Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea4047a852 | ||
|
|
41ff2ab114 | ||
|
|
df692ef214 | ||
|
|
88f7d0a5fa | ||
|
|
5833a73541 | ||
|
|
36b975ff5b | ||
|
|
b2201c5da1 | ||
|
|
5e78465d34 | ||
|
|
6d5c14f778 |
@@ -40,6 +40,10 @@ export MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
|
||||
# and always MEALQUEST_SESSION_MAX after sign-in (default 4320h).
|
||||
# export MEALQUEST_SESSION_IDLE=720h
|
||||
# export MEALQUEST_SESSION_MAX=4320h
|
||||
# Optional: comma-separated IPs/CIDRs of reverse proxies whose
|
||||
# X-Forwarded-For/-Proto headers are honoured (production: Traefik). Unset
|
||||
# trusts no peer, which is right for local development.
|
||||
# export MEALQUEST_TRUSTED_PROXY=10.60.1.33
|
||||
make dev
|
||||
```
|
||||
|
||||
@@ -47,7 +51,8 @@ make dev
|
||||
(which proxies `/api`, `/auth` and `/media` to `:8080`). Open
|
||||
http://localhost:5173. Configuration is environment-only and fails fast:
|
||||
every variable above is required except the commented-out session
|
||||
lifetimes (when set they must be positive, with MAX at least IDLE), and
|
||||
lifetimes (when set they must be positive, with MAX at least IDLE) and
|
||||
trusted proxy list (every entry must be an IP or CIDR), and
|
||||
secrets are read only from files named by `*_FILE` variables. `MEALQUEST_PUBLIC_URL`, `MEALQUEST_OIDC_ISSUER` and the
|
||||
endpoints the issuer advertises must be `https://`; plain `http://` is
|
||||
accepted only for `localhost`, `127.0.0.1` and `[::1]`.
|
||||
|
||||
+115
-7
@@ -3,15 +3,21 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
@@ -24,6 +30,46 @@ func useBootMigrations(t *testing.T, m fstest.MapFS) {
|
||||
t.Cleanup(func() { bootMigrations = saved })
|
||||
}
|
||||
|
||||
// loadConfig loads a configuration through config.Load whose secret key file
|
||||
// holds keyFile. Tests that call it must not run in parallel.
|
||||
func loadConfig(t *testing.T, keyFile string) config.Config {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
keyPath := filepath.Join(dir, "secret_key")
|
||||
if err := os.WriteFile(keyPath, []byte(keyFile), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clientSecretPath := filepath.Join(dir, "oidc_client_secret")
|
||||
if err := os.WriteFile(clientSecretPath, []byte("client-secret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(config.EnvHTTPAddr, "127.0.0.1:0")
|
||||
t.Setenv(config.EnvPublicURL, "http://localhost")
|
||||
t.Setenv(config.EnvDataDir, t.TempDir())
|
||||
t.Setenv(config.EnvShutdownTimeout, "1s")
|
||||
t.Setenv(config.EnvSecretKeyFile, keyPath)
|
||||
t.Setenv(config.EnvOIDCIssuer, "https://auth.example.com/application/o/mealquest/")
|
||||
t.Setenv(config.EnvOIDCClientID, "mealquest")
|
||||
t.Setenv(config.EnvOIDCClientSecretFile, clientSecretPath)
|
||||
t.Setenv(config.EnvOIDCAllowedGroups, "mealquest-admin")
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// validKeyFile returns secret key file contents as written by the README:
|
||||
// base64 of 32 random bytes followed by a newline.
|
||||
func validKeyFile(t *testing.T) string {
|
||||
t.Helper()
|
||||
key := make([]byte, secretbox.KeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(key) + "\n"
|
||||
}
|
||||
|
||||
var brokenMigrations = fstest.MapFS{
|
||||
"0000_good.sql": {Data: []byte("CREATE TABLE good (id INTEGER PRIMARY KEY);")},
|
||||
"0001_broken.sql": {Data: []byte("CREATE TABLE broken (id INTEGER PRIMARY KEY;")},
|
||||
@@ -73,12 +119,7 @@ func TestRunMigrationFailureReturnsBeforeListening(t *testing.T) {
|
||||
useBootMigrations(t, brokenMigrations)
|
||||
|
||||
var logs bytes.Buffer
|
||||
cfg := config.Config{
|
||||
HTTPAddr: "127.0.0.1:0",
|
||||
PublicURL: &url.URL{Scheme: "http", Host: "localhost"},
|
||||
DataDir: t.TempDir(),
|
||||
ShutdownTimeout: time.Second,
|
||||
}
|
||||
cfg := loadConfig(t, validKeyFile(t))
|
||||
errc := make(chan error, 1)
|
||||
go func() { errc <- run(cfg, slog.New(slog.NewJSONHandler(&logs, nil))) }()
|
||||
|
||||
@@ -94,3 +135,70 @@ func TestRunMigrationFailureReturnsBeforeListening(t *testing.T) {
|
||||
t.Errorf("run started listening despite the migration failure: %s", logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenSecretBoxAcceptsReadmeKey(t *testing.T) {
|
||||
cfg := loadConfig(t, validKeyFile(t))
|
||||
box, err := openSecretBox(cfg.SecretKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, err := box.Encrypt([]byte("x"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := box.Decrypt(token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBadSecretKeyStopsBoot(t *testing.T) {
|
||||
short := base64.StdEncoding.EncodeToString(make([]byte, 31))
|
||||
tests := []struct {
|
||||
name string
|
||||
keyFile string
|
||||
want error
|
||||
}{
|
||||
{"not base64", "this is not a base64 key!\n", secretbox.ErrKeyEncoding},
|
||||
{"wrong length", short + "\n", secretbox.ErrKeyLength},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := loadConfig(t, tt.keyFile)
|
||||
var logs bytes.Buffer
|
||||
errc := make(chan error, 1)
|
||||
go func() { errc <- run(cfg, slog.New(slog.NewJSONHandler(&logs, nil))) }()
|
||||
|
||||
var err error
|
||||
select {
|
||||
case err = <-errc:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("run kept going with a bad secret key")
|
||||
}
|
||||
if !errors.Is(err, tt.want) {
|
||||
t.Fatalf("run error = %v, want %v", err, tt.want)
|
||||
}
|
||||
if !strings.Contains(err.Error(), config.EnvSecretKeyFile) {
|
||||
t.Errorf("error does not name %s: %v", config.EnvSecretKeyFile, err)
|
||||
}
|
||||
if key := strings.TrimSpace(tt.keyFile); strings.Contains(err.Error(), key) {
|
||||
t.Errorf("error leaks the key: %v", err)
|
||||
}
|
||||
if strings.Contains(logs.String(), `"listening"`) {
|
||||
t.Errorf("run started listening despite the bad key: %s", logs.String())
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(cfg.DataDir, store.FileName)); !errors.Is(statErr, fs.ErrNotExist) {
|
||||
t.Errorf("run opened the database before rejecting the key (stat: %v)", statErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMissingSecretKeyFileStopsBoot(t *testing.T) {
|
||||
loadConfig(t, validKeyFile(t)) // sets every other variable
|
||||
t.Setenv(config.EnvSecretKeyFile, filepath.Join(t.TempDir(), "missing"))
|
||||
_, err := config.Load()
|
||||
var cfgErr *config.Error
|
||||
if !errors.As(err, &cfgErr) || !strings.Contains(err.Error(), config.EnvSecretKeyFile) {
|
||||
t.Fatalf("Load error = %v, want a config error naming %s", err, config.EnvSecretKeyFile)
|
||||
}
|
||||
}
|
||||
|
||||
+25
-7
@@ -15,7 +15,9 @@ import (
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
@@ -31,7 +33,7 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(os.Stdout, nil)))
|
||||
if err := run(cfg, logger); err != nil {
|
||||
logger.Error("server stopped", "error", err)
|
||||
os.Exit(1)
|
||||
@@ -42,6 +44,10 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
if _, err := openSecretBox(cfg.SecretKey); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
db, err := openStore(ctx, cfg.DataDir)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -57,17 +63,18 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
authFlow, err := auth.New(ctx, auth.Config{
|
||||
Issuer: cfg.OIDCIssuer.String(),
|
||||
ClientID: cfg.OIDCClientID,
|
||||
ClientSecret: cfg.OIDCClientSecret.Reveal(),
|
||||
PublicURL: cfg.PublicURL,
|
||||
SecretKey: []byte(cfg.SecretKey.Reveal()),
|
||||
Issuer: cfg.OIDCIssuer.String(),
|
||||
ClientID: cfg.OIDCClientID,
|
||||
ClientSecret: cfg.OIDCClientSecret.Reveal(),
|
||||
PublicURL: cfg.PublicURL,
|
||||
SecretKey: []byte(cfg.SecretKey.Reveal()),
|
||||
AllowedGroups: cfg.OIDCAllowedGroups,
|
||||
}, sessions, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
router, err := server.NewRouter(logger, version, authFlow, frontend.Assets())
|
||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, frontend.Assets())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -102,6 +109,16 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// openSecretBox builds the secret box from the configured key, naming the
|
||||
// variable whose file holds a bad key. The key itself is never reported.
|
||||
func openSecretBox(key config.Secret) (*secretbox.Box, error) {
|
||||
box, err := secretbox.New(key.Reveal())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", config.EnvSecretKeyFile, err)
|
||||
}
|
||||
return box, nil
|
||||
}
|
||||
|
||||
// bootMigrations is the migration set applied at boot. Tests replace it to
|
||||
// prove a failing migration stops the boot.
|
||||
var bootMigrations fs.FS = migrations.FS
|
||||
@@ -125,6 +142,7 @@ func logListening(logger *slog.Logger, cfg config.Config) {
|
||||
logger.Info("listening",
|
||||
"addr", cfg.HTTPAddr,
|
||||
"public_origin", cfg.PublicOrigin(),
|
||||
"trusted_proxy", cfg.TrustedProxy,
|
||||
"data_dir", cfg.DataDir,
|
||||
"version", version,
|
||||
)
|
||||
|
||||
@@ -29,6 +29,15 @@ docker compose project "mealquest" — 10.60.1.14
|
||||
One Go service serves the API, the auth flow, media and the PWA. Its SQLite
|
||||
database is replicated by Litestream to the NAS.
|
||||
|
||||
Traefik terminates TLS. The service honours `X-Forwarded-For` and
|
||||
`X-Forwarded-Proto` only from peers listed in `MEALQUEST_TRUSTED_PROXY`
|
||||
(comma-separated IPs/CIDRs; production lists `10.60.1.33`); from any other
|
||||
peer they are ignored. The client is the rightmost `X-Forwarded-For` address
|
||||
that is not a trusted proxy. The resolved client IP is added as `client_ip`
|
||||
to every log line written while handling a request. The OIDC redirect URI
|
||||
and the Secure cookies are fixed by `MEALQUEST_PUBLIC_URL`, never by request
|
||||
headers, so sign-in works whichever peer the request arrives from.
|
||||
|
||||
## Repository layout
|
||||
|
||||
| Path | Purpose |
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// Actor is the signed-in household member a request acts for. Handlers
|
||||
// record Actor.Subject as updated_by on edits.
|
||||
type Actor struct {
|
||||
Subject string
|
||||
Email string
|
||||
Name string
|
||||
Groups []string
|
||||
}
|
||||
|
||||
// Actor returns the household member s belongs to.
|
||||
func (s Session) Actor() Actor {
|
||||
return Actor{Subject: s.Subject, Email: s.Email, Name: s.Name, Groups: slices.Clone(s.Groups)}
|
||||
}
|
||||
|
||||
type sessionKey struct{}
|
||||
|
||||
// WithSession returns a copy of ctx carrying s. The session middleware calls
|
||||
// it for every authenticated request.
|
||||
func WithSession(ctx context.Context, s Session) context.Context {
|
||||
return context.WithValue(ctx, sessionKey{}, s)
|
||||
}
|
||||
|
||||
// SessionFrom returns the session WithSession stored in ctx. ok is false when
|
||||
// ctx has none, which behind the session middleware means a wiring bug.
|
||||
func SessionFrom(ctx context.Context) (s Session, ok bool) {
|
||||
s, ok = ctx.Value(sessionKey{}).(Session)
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// ActorFrom returns the actor of the session WithSession stored in ctx. ok is
|
||||
// false when ctx has none.
|
||||
func ActorFrom(ctx context.Context) (actor Actor, ok bool) {
|
||||
s, ok := SessionFrom(ctx)
|
||||
if !ok {
|
||||
return Actor{}, false
|
||||
}
|
||||
return s.Actor(), true
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestActorContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, ok := ActorFrom(context.Background()); ok {
|
||||
t.Error("ActorFrom found an actor in an empty context")
|
||||
}
|
||||
if _, ok := SessionFrom(context.Background()); ok {
|
||||
t.Error("SessionFrom found a session in an empty context")
|
||||
}
|
||||
s := Session{Subject: "user-123", Email: "cook@example.com", Name: "Home Cook", Groups: []string{"mealquest-admin"}, CSRFToken: "t"}
|
||||
ctx := WithSession(context.Background(), s)
|
||||
gotSession, ok := SessionFrom(ctx)
|
||||
if !ok || gotSession.Subject != s.Subject || gotSession.CSRFToken != s.CSRFToken {
|
||||
t.Errorf("SessionFrom = %+v (ok=%v), want %+v", gotSession, ok, s)
|
||||
}
|
||||
got, ok := ActorFrom(ctx)
|
||||
if !ok || got.Subject != s.Subject || got.Email != s.Email || got.Name != s.Name || !slices.Equal(got.Groups, s.Groups) {
|
||||
t.Errorf("ActorFrom = %+v (ok=%v), want the session's actor", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionActor(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := Session{Subject: "user-123", Email: "cook@example.com", Name: "Home Cook", Groups: []string{"mealquest-admin"}, CSRFToken: "t"}
|
||||
a := s.Actor()
|
||||
if a.Subject != s.Subject || a.Email != s.Email || a.Name != s.Name || !slices.Equal(a.Groups, s.Groups) {
|
||||
t.Errorf("Actor = %+v, want the session's identity", a)
|
||||
}
|
||||
a.Groups[0] = "changed"
|
||||
if s.Groups[0] != "mealquest-admin" {
|
||||
t.Error("Actor shares the session's groups slice")
|
||||
}
|
||||
}
|
||||
+119
-5
@@ -12,6 +12,8 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
@@ -65,15 +67,20 @@ type Config struct {
|
||||
PublicURL *url.URL
|
||||
// SecretKey is the server key the login cookie's signing key derives from.
|
||||
SecretKey []byte
|
||||
// AllowedGroups are the household's groups; a sign-in is refused unless
|
||||
// the account is in at least one of them.
|
||||
AllowedGroups []string
|
||||
}
|
||||
|
||||
// Auth serves the login and callback routes.
|
||||
type Auth struct {
|
||||
oauth oauth2.Config
|
||||
verifier *oidc.IDTokenVerifier
|
||||
provider *oidc.Provider
|
||||
client *http.Client
|
||||
cookie loginCookie
|
||||
sessions Sessions
|
||||
allowed []string
|
||||
// endSessionURL is the provider's end_session_endpoint; logout ends
|
||||
// there so the provider's own session ends too.
|
||||
endSessionURL string
|
||||
@@ -92,6 +99,8 @@ func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.Logger
|
||||
return nil, errors.New("auth: secret key is required")
|
||||
case sessions == nil:
|
||||
return nil, errors.New("auth: session layer is required")
|
||||
case len(cfg.AllowedGroups) == 0:
|
||||
return nil, errors.New("auth: at least one allowed group is required")
|
||||
}
|
||||
|
||||
if err := checkEndpoint("issuer", cfg.Issuer); err != nil {
|
||||
@@ -99,9 +108,12 @@ func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.Logger
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: providerTimeout, CheckRedirect: checkRedirect}
|
||||
provider, err := oidc.NewProvider(oidc.ClientContext(ctx, client), cfg.Issuer)
|
||||
discovery := *client
|
||||
recorder := &statusRecorder{next: http.DefaultTransport}
|
||||
discovery.Transport = recorder
|
||||
provider, err := oidc.NewProvider(oidc.ClientContext(ctx, &discovery), cfg.Issuer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth: OIDC discovery: %w", err)
|
||||
return nil, discoveryError(err, int(recorder.status.Load()))
|
||||
}
|
||||
endpoint := provider.Endpoint()
|
||||
var discovered struct {
|
||||
@@ -118,6 +130,9 @@ func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.Logger
|
||||
{"token endpoint", endpoint.TokenURL},
|
||||
{"JWKS endpoint", discovered.JWKSURI},
|
||||
{"end session endpoint", discovered.EndSessionURI},
|
||||
// Groups come from userinfo when the ID token carries none, and the
|
||||
// access token is sent there.
|
||||
{"userinfo endpoint", provider.UserInfoEndpoint()},
|
||||
} {
|
||||
if err := checkEndpoint(e.name, e.raw); err != nil {
|
||||
return nil, err
|
||||
@@ -138,7 +153,9 @@ func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.Logger
|
||||
ClientID: cfg.ClientID,
|
||||
SupportedSigningAlgs: []string{oidc.RS256},
|
||||
}),
|
||||
provider: provider,
|
||||
client: client,
|
||||
allowed: slices.Clone(cfg.AllowedGroups),
|
||||
cookie: newLoginCookie(cfg.SecretKey),
|
||||
sessions: sessions,
|
||||
endSessionURL: discovered.EndSessionURI,
|
||||
@@ -146,6 +163,32 @@ func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.Logger
|
||||
}, nil
|
||||
}
|
||||
|
||||
// statusRecorder records the status of the last response it carries.
|
||||
type statusRecorder struct {
|
||||
next http.RoundTripper
|
||||
status atomic.Int32
|
||||
}
|
||||
|
||||
func (s *statusRecorder) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
resp, err := s.next.RoundTrip(req)
|
||||
if err == nil {
|
||||
s.status.Store(int32(resp.StatusCode))
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// discoveryError reports a failed discovery. go-oidc puts the provider's
|
||||
// response body in its error for a non-200 discovery response, so that
|
||||
// error is replaced by the status alone. Transport errors carry no response
|
||||
// content and keep their cause.
|
||||
func discoveryError(err error, status int) error {
|
||||
var urlErr *url.Error
|
||||
if !errors.As(err, &urlErr) && status != 0 && status != http.StatusOK {
|
||||
return fmt.Errorf("auth: OIDC discovery: provider answered HTTP %d", status)
|
||||
}
|
||||
return fmt.Errorf("auth: OIDC discovery: %w", err)
|
||||
}
|
||||
|
||||
// checkEndpoint applies secureurl.Check to the provider URL raw, naming the
|
||||
// endpoint in the error.
|
||||
func checkEndpoint(name, raw string) error {
|
||||
@@ -218,7 +261,9 @@ func (a *Auth) login(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// callback completes the flow: it checks state against the login cookie,
|
||||
// exchanges the code, verifies the ID token and nonce, then starts a session.
|
||||
// exchanges the code, verifies the ID token and nonce, then starts a session
|
||||
// if the account is in an allowed group. Membership is checked on every
|
||||
// login, so an account removed from the group is refused at its next one.
|
||||
func (a *Auth) callback(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
ctx := r.Context()
|
||||
@@ -256,6 +301,22 @@ func (a *Auth) callback(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "sign-in failed", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if !a.member(claims.Groups) {
|
||||
if claims.Groups == nil {
|
||||
a.logger.WarnContext(ctx, "sign-in refused: no groups claim in the ID token or userinfo", "subject", claims.Subject)
|
||||
} else {
|
||||
a.logger.WarnContext(ctx, "sign-in refused: not in an allowed group", "subject", claims.Subject)
|
||||
}
|
||||
// A session this browser already holds ends too, so a refused
|
||||
// sign-in never leaves it signed in.
|
||||
if err := a.sessions.EndSession(w, r); err != nil {
|
||||
a.logger.ErrorContext(ctx, "end session", "error", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
a.refuse(w, r)
|
||||
return
|
||||
}
|
||||
if err := a.sessions.StartSession(w, r, claims); err != nil {
|
||||
a.logger.ErrorContext(ctx, "start session", "error", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
@@ -278,7 +339,8 @@ func (a *Auth) logout(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// verify exchanges code for tokens and returns the verified ID token claims.
|
||||
// The verifier enforces RS256, issuer, audience and expiry; nonce is checked
|
||||
// here.
|
||||
// here. When the ID token has no groups claim, Groups comes from userinfo;
|
||||
// it stays nil when neither has one.
|
||||
func (a *Auth) verify(ctx context.Context, code string, state loginState) (Claims, error) {
|
||||
token, err := a.oauth.Exchange(context.WithValue(ctx, oauth2.HTTPClient, a.client), code,
|
||||
oauth2.VerifierOption(state.Verifier))
|
||||
@@ -291,7 +353,7 @@ func (a *Auth) verify(ctx context.Context, code string, state loginState) (Claim
|
||||
}
|
||||
idToken, err := a.verifier.Verify(ctx, raw)
|
||||
if err != nil {
|
||||
return Claims{}, fmt.Errorf("verify ID token: %w", err)
|
||||
return Claims{}, verifyError(err)
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(idToken.Nonce), []byte(state.Nonce)) != 1 {
|
||||
return Claims{}, errors.New("ID token nonce mismatch")
|
||||
@@ -303,9 +365,61 @@ func (a *Auth) verify(ctx context.Context, code string, state loginState) (Claim
|
||||
if claims.Subject == "" {
|
||||
return Claims{}, errors.New("ID token has no subject")
|
||||
}
|
||||
if claims.Groups == nil {
|
||||
groups, err := a.userinfoGroups(ctx, token, claims.Subject)
|
||||
if err != nil {
|
||||
return Claims{}, err
|
||||
}
|
||||
claims.Groups = groups
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// userinfoGroups returns the groups claim from the userinfo endpoint, nil if
|
||||
// it has none. The userinfo subject must match the ID token's subject.
|
||||
func (a *Auth) userinfoGroups(ctx context.Context, token *oauth2.Token, subject string) ([]string, error) {
|
||||
info, err := a.provider.UserInfo(oidc.ClientContext(ctx, a.client), oauth2.StaticTokenSource(token))
|
||||
if err != nil {
|
||||
return nil, userinfoError(err)
|
||||
}
|
||||
if info.Subject != subject {
|
||||
return nil, errors.New("userinfo subject does not match the ID token")
|
||||
}
|
||||
var claims struct {
|
||||
Groups []string `json:"groups"`
|
||||
}
|
||||
if err := info.Claims(&claims); err != nil {
|
||||
return nil, fmt.Errorf("decode userinfo claims: %w", err)
|
||||
}
|
||||
return claims.Groups, nil
|
||||
}
|
||||
|
||||
// userinfoError reduces a failed userinfo request to a fixed category, like
|
||||
// exchangeError: go-oidc's error text can carry the response body.
|
||||
func userinfoError(err error) error {
|
||||
var transport *url.Error
|
||||
if errors.As(err, &transport) {
|
||||
return fmt.Errorf("userinfo: request failed: %s: %w", transport.Op, transport.Err)
|
||||
}
|
||||
return errors.New("userinfo: endpoint returned an unusable response")
|
||||
}
|
||||
|
||||
// verifyError reduces a failed ID token verification to a fixed category.
|
||||
// go-oidc's error text can carry the JWKS endpoint's response body or
|
||||
// token-supplied values, so only the expiry of an expired token is kept.
|
||||
func verifyError(err error) error {
|
||||
var expired *oidc.TokenExpiredError
|
||||
if errors.As(err, &expired) {
|
||||
return fmt.Errorf("verify ID token: token expired at %s", expired.Expiry.UTC().Format(time.RFC3339))
|
||||
}
|
||||
return errors.New("verify ID token: token or provider key set rejected")
|
||||
}
|
||||
|
||||
// member reports whether groups contains an allowed group.
|
||||
func (a *Auth) member(groups []string) bool {
|
||||
return slices.ContainsFunc(groups, func(g string) bool { return slices.Contains(a.allowed, g) })
|
||||
}
|
||||
|
||||
// exchangeError reduces a failed code exchange to a fixed category and safe
|
||||
// metadata. The oauth2 error text can carry the token endpoint's response
|
||||
// body or error_description, which may echo credentials, so it is dropped.
|
||||
|
||||
+192
-31
@@ -11,6 +11,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"maps"
|
||||
@@ -36,6 +37,9 @@ const (
|
||||
|
||||
var testPublicURL = &url.URL{Scheme: "https", Host: "meals.example.com"}
|
||||
|
||||
// testAllowedGroups are the household's groups in tests.
|
||||
var testAllowedGroups = []string{"mealquest-admin", "mealquest-family"}
|
||||
|
||||
// fakeProvider is an OIDC provider serving discovery, JWKS and a token
|
||||
// endpoint. Each issued code maps to the ID token the token endpoint returns.
|
||||
type fakeProvider struct {
|
||||
@@ -50,6 +54,14 @@ type fakeProvider struct {
|
||||
// tokenReply, when set, is returned by the token endpoint instead of a
|
||||
// token.
|
||||
tokenReply *tokenReply
|
||||
// userinfo is the userinfo endpoint's response; userinfoHits counts
|
||||
// requests to it.
|
||||
userinfo map[string]any
|
||||
userinfoHits int
|
||||
// userinfoReply, when set, is returned by the userinfo endpoint instead.
|
||||
userinfoReply *tokenReply
|
||||
// jwksReply, when set, is returned by the JWKS endpoint instead.
|
||||
jwksReply *tokenReply
|
||||
}
|
||||
|
||||
type tokenReply struct {
|
||||
@@ -73,6 +85,7 @@ func newFakeProvider(t *testing.T) *fakeProvider {
|
||||
mux.HandleFunc("GET /.well-known/openid-configuration", p.discovery)
|
||||
mux.HandleFunc("GET /jwks", p.jwks)
|
||||
mux.HandleFunc("POST /token", p.token)
|
||||
mux.HandleFunc("GET /userinfo", p.userinfoEndpoint)
|
||||
p.srv = httptest.NewServer(mux)
|
||||
t.Cleanup(p.srv.Close)
|
||||
return p
|
||||
@@ -86,6 +99,7 @@ func (p *fakeProvider) discovery(w http.ResponseWriter, _ *http.Request) {
|
||||
"authorization_endpoint": p.issuer() + "/authorize",
|
||||
"token_endpoint": p.issuer() + "/token",
|
||||
"jwks_uri": p.issuer() + "/jwks",
|
||||
"userinfo_endpoint": p.issuer() + "/userinfo",
|
||||
"end_session_endpoint": p.issuer() + "/end-session/",
|
||||
"response_types_supported": []string{"code"},
|
||||
"subject_types_supported": []string{"public"},
|
||||
@@ -98,6 +112,15 @@ func (p *fakeProvider) discovery(w http.ResponseWriter, _ *http.Request) {
|
||||
}
|
||||
|
||||
func (p *fakeProvider) jwks(w http.ResponseWriter, _ *http.Request) {
|
||||
p.mu.Lock()
|
||||
reply := p.jwksReply
|
||||
p.mu.Unlock()
|
||||
if reply != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(reply.status)
|
||||
io.WriteString(w, reply.body)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"keys": []map[string]string{{
|
||||
"kty": "RSA",
|
||||
"kid": testKID,
|
||||
@@ -148,6 +171,41 @@ func (p *fakeProvider) token(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// userinfoEndpoint checks the access token and returns p.userinfo, which
|
||||
// defaults to the test user without a groups claim.
|
||||
func (p *fakeProvider) userinfoEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer access-token" {
|
||||
http.Error(w, `{"error":"invalid_token"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.userinfoHits++
|
||||
info, reply := p.userinfo, p.userinfoReply
|
||||
p.mu.Unlock()
|
||||
if reply != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(reply.status)
|
||||
io.WriteString(w, reply.body)
|
||||
return
|
||||
}
|
||||
if info == nil {
|
||||
info = map[string]any{"sub": "user-123", "email": "cook@example.com"}
|
||||
}
|
||||
writeJSON(w, info)
|
||||
}
|
||||
|
||||
func (p *fakeProvider) setUserinfo(info map[string]any) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.userinfo = info
|
||||
}
|
||||
|
||||
func (p *fakeProvider) userinfoRequests() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.userinfoHits
|
||||
}
|
||||
|
||||
// authorize stands in for the user signing in at the provider: it records a
|
||||
// code bound to the PKCE challenge and returns it.
|
||||
func (p *fakeProvider) authorize(challenge, idToken string) string {
|
||||
@@ -261,6 +319,7 @@ func (m *memorySessions) started() []Claims {
|
||||
}
|
||||
|
||||
type harness struct {
|
||||
auth *Auth
|
||||
provider *fakeProvider
|
||||
sessions *memorySessions
|
||||
router http.Handler
|
||||
@@ -291,18 +350,19 @@ func newHarness(t *testing.T) *harness {
|
||||
sessions := &memorySessions{}
|
||||
logs := &syncBuffer{}
|
||||
a, err := New(context.Background(), Config{
|
||||
Issuer: p.issuer(),
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("server-secret-key"),
|
||||
Issuer: p.issuer(),
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("server-secret-key"),
|
||||
AllowedGroups: testAllowedGroups,
|
||||
}, sessions, slog.New(slog.NewTextHandler(logs, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := chi.NewRouter()
|
||||
a.Mount(r)
|
||||
return &harness{provider: p, sessions: sessions, router: r, logs: logs}
|
||||
return &harness{auth: a, provider: p, sessions: sessions, router: r, logs: logs}
|
||||
}
|
||||
|
||||
func (h *harness) get(target string, cookies ...*http.Cookie) *http.Response {
|
||||
@@ -680,6 +740,66 @@ func TestCallbackExchangeFailureDoesNotLogProviderResponse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackKeySetFailureDoesNotLogProviderResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
const sentinel = "sentinel-jwks-body-5c1e"
|
||||
tests := []struct {
|
||||
name string
|
||||
reply tokenReply
|
||||
}{
|
||||
{"error response", tokenReply{http.StatusInternalServerError, sentinel}},
|
||||
{"malformed success", tokenReply{http.StatusOK, `{"keys":"` + sentinel + `"`}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
h.provider.mu.Lock()
|
||||
h.provider.jwksReply = &tc.reply
|
||||
h.provider.mu.Unlock()
|
||||
|
||||
// The returned error, checked through verify directly.
|
||||
l := h.login(t, LoginPath)
|
||||
req := httptest.NewRequest(http.MethodGet, CallbackPath, nil)
|
||||
req.AddCookie(l.cookie)
|
||||
state, err := h.auth.cookie.read(req, time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code := h.provider.authorize(l.query.Get("code_challenge"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce"))))
|
||||
_, err = h.auth.verify(context.Background(), code, state)
|
||||
if err == nil {
|
||||
t.Fatal("verify succeeded with an unusable key set")
|
||||
}
|
||||
if strings.Contains(err.Error(), sentinel) {
|
||||
t.Errorf("verify error contains the JWKS response: %v", err)
|
||||
}
|
||||
|
||||
// The logs and response, through the callback.
|
||||
l = h.login(t, LoginPath)
|
||||
resp := h.callback(l, l.query.Get("state"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce"))))
|
||||
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("callback status = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if strings.Contains(string(body), sentinel) {
|
||||
t.Errorf("response contains the JWKS response: %s", body)
|
||||
}
|
||||
logs := h.logs.String()
|
||||
if strings.Contains(logs, sentinel) {
|
||||
t.Errorf("logs contain the JWKS response: %s", logs)
|
||||
}
|
||||
if want := "token or provider key set rejected"; !strings.Contains(logs, want) {
|
||||
t.Errorf("logs = %q, want %q", logs, want)
|
||||
}
|
||||
if len(h.sessions.started()) != 0 {
|
||||
t.Error("a session started")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginCookieExpires(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := newLoginCookie([]byte("server-secret-key"))
|
||||
@@ -730,28 +850,65 @@ func TestNewFailsWhenDiscoveryFails(t *testing.T) {
|
||||
srv := httptest.NewServer(http.NotFoundHandler())
|
||||
t.Cleanup(srv.Close)
|
||||
_, err := New(context.Background(), Config{
|
||||
Issuer: srv.URL,
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("k"),
|
||||
Issuer: srv.URL,
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("k"),
|
||||
AllowedGroups: testAllowedGroups,
|
||||
}, &memorySessions{}, slog.New(slog.DiscardHandler))
|
||||
if err == nil {
|
||||
t.Fatal("New succeeded without a discovery document")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDiscoveryErrorOmitsProviderBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
const sentinel = "sentinel-discovery-body-7f3a"
|
||||
for _, status := range []int{http.StatusNotFound, http.StatusInternalServerError} {
|
||||
t.Run(http.StatusText(status), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(status)
|
||||
io.WriteString(w, sentinel)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
logs := &syncBuffer{}
|
||||
_, err := New(context.Background(), Config{
|
||||
Issuer: srv.URL,
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("k"),
|
||||
AllowedGroups: testAllowedGroups,
|
||||
}, &memorySessions{}, slog.New(slog.NewTextHandler(logs, nil)))
|
||||
if err == nil {
|
||||
t.Fatal("New succeeded without a discovery document")
|
||||
}
|
||||
// cmd/server logs the returned error at startup.
|
||||
slog.New(slog.NewTextHandler(logs, nil)).Error("startup failed", "err", err)
|
||||
if strings.Contains(logs.String(), sentinel) {
|
||||
t.Errorf("logs contain the provider response body: %s", logs)
|
||||
}
|
||||
if want := fmt.Sprintf("auth: OIDC discovery: provider answered HTTP %d", status); err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewFailsOnIssuerMismatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := newFakeProvider(t)
|
||||
// Discovery trims the trailing slash, so this fetches the provider's
|
||||
// document, which reports the issuer without it.
|
||||
_, err := New(context.Background(), Config{
|
||||
Issuer: p.issuer() + "/",
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("k"),
|
||||
Issuer: p.issuer() + "/",
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("k"),
|
||||
AllowedGroups: testAllowedGroups,
|
||||
}, &memorySessions{}, slog.New(slog.DiscardHandler))
|
||||
if err == nil {
|
||||
t.Fatal("New accepted a provider reporting a different issuer")
|
||||
@@ -772,11 +929,12 @@ func TestNewRejectsRemoteHTTPIssuer(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, issuer := range []string{"http://auth.example.com/application/o/mealquest/", "http://192.0.2.10"} {
|
||||
_, err := New(context.Background(), Config{
|
||||
Issuer: issuer,
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("k"),
|
||||
Issuer: issuer,
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("k"),
|
||||
AllowedGroups: testAllowedGroups,
|
||||
}, &memorySessions{}, slog.New(slog.DiscardHandler))
|
||||
if err == nil || !strings.Contains(err.Error(), "issuer") {
|
||||
t.Errorf("New(issuer %q) error = %v, want issuer rejected", issuer, err)
|
||||
@@ -793,6 +951,7 @@ func TestNewRejectsRemoteHTTPEndpoint(t *testing.T) {
|
||||
{"token_endpoint", "token endpoint"},
|
||||
{"jwks_uri", "JWKS endpoint"},
|
||||
{"end_session_endpoint", "end session endpoint"},
|
||||
{"userinfo_endpoint", "userinfo endpoint"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.field, func(t *testing.T) {
|
||||
@@ -800,11 +959,12 @@ func TestNewRejectsRemoteHTTPEndpoint(t *testing.T) {
|
||||
p := newFakeProvider(t)
|
||||
p.discoveryOverrides = map[string]any{tc.field: "http://auth.example.com/" + tc.field}
|
||||
_, err := New(context.Background(), Config{
|
||||
Issuer: p.issuer(),
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("k"),
|
||||
Issuer: p.issuer(),
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("k"),
|
||||
AllowedGroups: testAllowedGroups,
|
||||
}, &memorySessions{}, slog.New(slog.DiscardHandler))
|
||||
if err == nil || !strings.Contains(err.Error(), tc.name) {
|
||||
t.Errorf("New error = %v, want %s rejected", err, tc.name)
|
||||
@@ -840,11 +1000,12 @@ func TestCallbackRefusesTokenRedirectToHTTP(t *testing.T) {
|
||||
sessions := &memorySessions{}
|
||||
logs := &syncBuffer{}
|
||||
a, err := New(context.Background(), Config{
|
||||
Issuer: p.issuer(),
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("server-secret-key"),
|
||||
Issuer: p.issuer(),
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("server-secret-key"),
|
||||
AllowedGroups: testAllowedGroups,
|
||||
}, sessions, slog.New(slog.NewTextHandler(logs, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// signInWith completes a login whose ID token has claims edited by edit.
|
||||
func (h *harness) signInWith(t *testing.T, edit func(claims map[string]any)) *http.Response {
|
||||
t.Helper()
|
||||
l := h.login(t, LoginPath)
|
||||
c := h.provider.claims(l.query.Get("nonce"))
|
||||
edit(c)
|
||||
return h.callback(l, l.query.Get("state"), h.provider.signRS256(c))
|
||||
}
|
||||
|
||||
// assertRefused checks resp is the refused-sign-in page and starts no
|
||||
// session.
|
||||
func assertRefused(t *testing.T, resp *http.Response) {
|
||||
t.Helper()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("callback status = %d, want 403", resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); ct != "text/html; charset=utf-8" {
|
||||
t.Errorf("Content-Type = %q, want text/html; charset=utf-8", ct)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"This kitchen is for the household only",
|
||||
`<form method="post" action="/auth/logout">`,
|
||||
"Sign out",
|
||||
} {
|
||||
if !strings.Contains(string(body), want) {
|
||||
t.Errorf("refused page lacks %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
for _, c := range resp.Cookies() {
|
||||
// Clearing a session cookie (negative Max-Age) is expected.
|
||||
if (c.Name == SessionCookieName || c.Name == "test_session") && c.MaxAge >= 0 {
|
||||
t.Errorf("refused sign-in set session cookie %s", c.Name)
|
||||
}
|
||||
}
|
||||
assertLoginCookieCleared(t, resp)
|
||||
}
|
||||
|
||||
func TestGroupGateMember(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Any one allowed group is enough, alongside groups that are not allowed.
|
||||
h := newHarness(t)
|
||||
resp := h.signInWith(t, func(c map[string]any) { c["groups"] = []string{"guests", "mealquest-family"} })
|
||||
if resp.StatusCode != http.StatusSeeOther {
|
||||
t.Fatalf("callback status = %d, want 303", resp.StatusCode)
|
||||
}
|
||||
if started := h.sessions.started(); len(started) != 1 || !slices.Equal(started[0].Groups, []string{"guests", "mealquest-family"}) {
|
||||
t.Errorf("sessions started = %+v, want one with the ID token groups", started)
|
||||
}
|
||||
if n := h.provider.userinfoRequests(); n != 0 {
|
||||
t.Errorf("userinfo requested %d times, want 0 when the ID token has groups", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupGateRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
groups any // ID token groups claim; nil removes it
|
||||
userinfo map[string]any
|
||||
}{
|
||||
{name: "non-member", groups: []string{"guests", "MEALQUEST-ADMIN"}},
|
||||
{name: "empty groups claim", groups: []string{}},
|
||||
{name: "groups claim missing from ID token and userinfo", groups: nil},
|
||||
{
|
||||
name: "non-member in userinfo",
|
||||
groups: nil,
|
||||
userinfo: map[string]any{"sub": "user-123", "groups": []string{"guests"}},
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
if tc.userinfo != nil {
|
||||
h.provider.setUserinfo(tc.userinfo)
|
||||
}
|
||||
resp := h.signInWith(t, func(c map[string]any) {
|
||||
if tc.groups == nil {
|
||||
delete(c, "groups")
|
||||
} else {
|
||||
c["groups"] = tc.groups
|
||||
}
|
||||
})
|
||||
assertRefused(t, resp)
|
||||
if started := h.sessions.started(); len(started) != 0 {
|
||||
t.Errorf("sessions started = %+v, want none", started)
|
||||
}
|
||||
if !strings.Contains(h.logs.String(), "sign-in refused") {
|
||||
t.Errorf("refusal not logged:\n%s", h.logs.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupGateUsesUserinfoWhenIDTokenHasNoGroups(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
h.provider.setUserinfo(map[string]any{"sub": "user-123", "groups": []string{"mealquest-admin"}})
|
||||
resp := h.signInWith(t, func(c map[string]any) { delete(c, "groups") })
|
||||
if resp.StatusCode != http.StatusSeeOther {
|
||||
t.Fatalf("callback status = %d, want 303", resp.StatusCode)
|
||||
}
|
||||
if started := h.sessions.started(); len(started) != 1 || !slices.Equal(started[0].Groups, []string{"mealquest-admin"}) {
|
||||
t.Errorf("sessions started = %+v, want one with the userinfo groups", started)
|
||||
}
|
||||
if n := h.provider.userinfoRequests(); n != 1 {
|
||||
t.Errorf("userinfo requested %d times, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupGateUserinfoFailures(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
userinfo map[string]any
|
||||
}{
|
||||
{"subject mismatch", map[string]any{"sub": "someone-else", "groups": []string{"mealquest-admin"}}},
|
||||
{"malformed groups", map[string]any{"sub": "user-123", "groups": "mealquest-admin"}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
h.provider.setUserinfo(tc.userinfo)
|
||||
resp := h.signInWith(t, func(c map[string]any) { delete(c, "groups") })
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("callback status = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
if started := h.sessions.started(); len(started) != 0 {
|
||||
t.Errorf("sessions started = %+v, want none", started)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A failed userinfo request fails the sign-in without logging the
|
||||
// endpoint's response, which may echo credentials.
|
||||
func TestGroupGateUserinfoErrorNotLogged(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
h.provider.mu.Lock()
|
||||
h.provider.userinfoReply = &tokenReply{status: http.StatusInternalServerError, body: `{"error":"leaked-s3cret"}`}
|
||||
h.provider.mu.Unlock()
|
||||
resp := h.signInWith(t, func(c map[string]any) { delete(c, "groups") })
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("callback status = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
if started := h.sessions.started(); len(started) != 0 {
|
||||
t.Errorf("sessions started = %+v, want none", started)
|
||||
}
|
||||
logs := h.logs.String()
|
||||
if !strings.Contains(logs, "userinfo") || strings.Contains(logs, "leaked-s3cret") {
|
||||
t.Errorf("logs = %s, want the userinfo failure without the response body", logs)
|
||||
}
|
||||
}
|
||||
|
||||
// Membership is checked at every login: after removal from the group, the
|
||||
// next login is refused and starts no session.
|
||||
func TestGroupGateRemovedMemberRefusedAtNextLogin(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newSessionHarness(t)
|
||||
h.signIn(t)
|
||||
if n := len(h.rows(t)); n != 1 {
|
||||
t.Fatalf("sessions after first login = %d, want 1", n)
|
||||
}
|
||||
|
||||
resp := h.signInWith(t, func(c map[string]any) { c["groups"] = []string{"former-household"} })
|
||||
assertRefused(t, resp)
|
||||
if n := len(h.rows(t)); n != 1 {
|
||||
t.Errorf("sessions after refused login = %d, want still 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// A refused sign-in from a browser that still holds a session ends that
|
||||
// session and clears its cookie.
|
||||
func TestGroupGateRefusalEndsExistingSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newSessionHarness(t)
|
||||
_, cookie := h.signIn(t)
|
||||
|
||||
l := h.login(t, LoginPath)
|
||||
c := h.provider.claims(l.query.Get("nonce"))
|
||||
c["groups"] = []string{"former-household"}
|
||||
code := h.provider.authorize(l.query.Get("code_challenge"), h.provider.signRS256(c))
|
||||
q := url.Values{"code": {code}, "state": {l.query.Get("state")}}
|
||||
resp := h.get(CallbackPath+"?"+q.Encode(), l.cookie, cookie)
|
||||
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("callback status = %d, want 403", resp.StatusCode)
|
||||
}
|
||||
if n := len(h.rows(t)); n != 0 {
|
||||
t.Errorf("sessions after refused login = %d, want 0", n)
|
||||
}
|
||||
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
|
||||
t.Errorf("old session lookup error = %v, want ErrNoSession", err)
|
||||
}
|
||||
cleared := false
|
||||
for _, c := range resp.Cookies() {
|
||||
cleared = cleared || (c.Name == SessionCookieName && c.MaxAge < 0)
|
||||
}
|
||||
if !cleared {
|
||||
t.Error("refused sign-in did not clear the session cookie")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupGateRefusalEndSessionError(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
h.sessions.err = errors.New("database down")
|
||||
resp := h.signInWith(t, func(c map[string]any) { c["groups"] = []string{"guests"} })
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Errorf("callback status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRequiresAllowedGroups(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := newFakeProvider(t)
|
||||
_, err := New(context.Background(), Config{
|
||||
Issuer: p.issuer(),
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("k"),
|
||||
}, &memorySessions{}, slog.New(slog.DiscardHandler))
|
||||
if err == nil || !strings.Contains(err.Error(), "allowed group") {
|
||||
t.Errorf("New error = %v, want missing allowed groups named", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRequiresUserinfoEndpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := newFakeProvider(t)
|
||||
p.discoveryOverrides = map[string]any{"userinfo_endpoint": nil}
|
||||
_, err := New(context.Background(), Config{
|
||||
Issuer: p.issuer(),
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("k"),
|
||||
AllowedGroups: testAllowedGroups,
|
||||
}, &memorySessions{}, slog.New(slog.DiscardHandler))
|
||||
if err == nil || !strings.Contains(err.Error(), "userinfo endpoint") {
|
||||
t.Errorf("New error = %v, want the missing userinfo endpoint named", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// refusedPage is shown when a sign-in succeeds at the provider but the
|
||||
// account is not in the household's group. Signing out ends the provider
|
||||
// session too, so another account can sign in.
|
||||
const refusedPage = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MealQuest</title>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>This kitchen is for the household only</h1>
|
||||
<p>The account you signed in with is not part of our kitchen.</p>
|
||||
<form method="post" action="` + LogoutPath + `">
|
||||
<button type="submit">Sign out</button>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
// refuse writes the refused-sign-in page with 403.
|
||||
func (a *Auth) refuse(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
if _, err := io.WriteString(w, refusedPage); err != nil {
|
||||
a.logger.WarnContext(r.Context(), "write refused sign-in page", "error", err)
|
||||
}
|
||||
}
|
||||
@@ -62,11 +62,12 @@ func newSessionHarness(t *testing.T) *sessionHarness {
|
||||
p := newFakeProvider(t)
|
||||
logs := &syncBuffer{}
|
||||
a, err := New(ctx, Config{
|
||||
Issuer: p.issuer(),
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("server-secret-key"),
|
||||
Issuer: p.issuer(),
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("server-secret-key"),
|
||||
AllowedGroups: testAllowedGroups,
|
||||
}, sessions, slog.New(slog.NewTextHandler(logs, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -490,11 +491,12 @@ func TestNewRequiresEndSessionEndpoint(t *testing.T) {
|
||||
p := newFakeProvider(t)
|
||||
p.discoveryOverrides = map[string]any{"end_session_endpoint": nil}
|
||||
_, err := New(context.Background(), Config{
|
||||
Issuer: p.issuer(),
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("k"),
|
||||
Issuer: p.issuer(),
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("k"),
|
||||
AllowedGroups: testAllowedGroups,
|
||||
}, &memorySessions{}, slog.New(slog.DiscardHandler))
|
||||
if err == nil || !strings.Contains(err.Error(), "end session endpoint") {
|
||||
t.Errorf("New error = %v, want the missing end session endpoint named", err)
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secureurl"
|
||||
)
|
||||
|
||||
@@ -30,6 +31,8 @@ const (
|
||||
|
||||
EnvSessionIdle = "MEALQUEST_SESSION_IDLE"
|
||||
EnvSessionMax = "MEALQUEST_SESSION_MAX"
|
||||
|
||||
EnvTrustedProxy = "MEALQUEST_TRUSTED_PROXY"
|
||||
)
|
||||
|
||||
// Session lifetime defaults (D-004): long enough that the kitchen tablet
|
||||
@@ -58,6 +61,10 @@ type Config struct {
|
||||
SessionIdle time.Duration
|
||||
// SessionMax ends every session this long after sign-in, used or not.
|
||||
SessionMax time.Duration
|
||||
|
||||
// TrustedProxy lists the reverse proxies whose X-Forwarded-For and
|
||||
// X-Forwarded-Proto headers are honoured. Empty trusts no peer.
|
||||
TrustedProxy forwarded.Trusted
|
||||
}
|
||||
|
||||
// PublicOrigin returns the scheme and host of PublicURL, the only parts of it
|
||||
@@ -88,6 +95,8 @@ func load(lookup func(string) (string, bool)) (Config, error) {
|
||||
|
||||
SessionIdle: r.sessionDuration(EnvSessionIdle, DefaultSessionIdle),
|
||||
SessionMax: r.sessionDuration(EnvSessionMax, DefaultSessionMax),
|
||||
|
||||
TrustedProxy: r.trustedProxy(EnvTrustedProxy),
|
||||
}
|
||||
if cfg.SessionIdle > 0 && cfg.SessionMax > 0 && cfg.SessionMax < cfg.SessionIdle {
|
||||
r.fail(EnvSessionMax, "must be at least %s (%s)", EnvSessionIdle, cfg.SessionIdle)
|
||||
@@ -177,6 +186,18 @@ func (r *reader) list(name string) []string {
|
||||
return items
|
||||
}
|
||||
|
||||
// trustedProxy parses an optional comma-separated list of IP addresses and
|
||||
// CIDR prefixes with forwarded.ParseTrusted. Unset or blank trusts no peer.
|
||||
func (r *reader) trustedProxy(name string) forwarded.Trusted {
|
||||
v, _ := r.lookup(name)
|
||||
t, err := forwarded.ParseTrusted(v)
|
||||
if err != nil {
|
||||
r.fail(name, "%v", err)
|
||||
return nil
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func (r *reader) listenAddr(name string) string {
|
||||
v, ok := r.required(name)
|
||||
if !ok {
|
||||
|
||||
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -11,6 +12,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
)
|
||||
|
||||
func TestLoad(t *testing.T) {
|
||||
@@ -293,6 +296,48 @@ func TestLoad(t *testing.T) {
|
||||
env: with(map[string]string{EnvSessionMax: "719h"}),
|
||||
badVars: []string{EnvSessionMax},
|
||||
},
|
||||
{
|
||||
name: "trusted proxy IP",
|
||||
env: with(map[string]string{EnvTrustedProxy: "10.60.1.33"}),
|
||||
},
|
||||
{
|
||||
name: "trusted proxy IPs and CIDRs",
|
||||
env: with(map[string]string{EnvTrustedProxy: "10.60.1.33, 172.16.0.0/12,2001:db8::/32,::1"}),
|
||||
},
|
||||
{
|
||||
name: "blank trusted proxy trusts no peer",
|
||||
env: with(map[string]string{EnvTrustedProxy: " "}),
|
||||
},
|
||||
{
|
||||
name: "trusted proxy not an IP",
|
||||
env: with(map[string]string{EnvTrustedProxy: "traefik"}),
|
||||
badVars: []string{EnvTrustedProxy},
|
||||
},
|
||||
{
|
||||
name: "trusted proxy invalid CIDR",
|
||||
env: with(map[string]string{EnvTrustedProxy: "10.60.1.0/33"}),
|
||||
badVars: []string{EnvTrustedProxy},
|
||||
},
|
||||
{
|
||||
name: "trusted proxy CIDR with host bits",
|
||||
env: with(map[string]string{EnvTrustedProxy: "10.60.1.33/24"}),
|
||||
badVars: []string{EnvTrustedProxy},
|
||||
},
|
||||
{
|
||||
name: "trusted proxy empty entry",
|
||||
env: with(map[string]string{EnvTrustedProxy: "10.60.1.33,,10.60.1.34"}),
|
||||
badVars: []string{EnvTrustedProxy},
|
||||
},
|
||||
{
|
||||
name: "trusted proxy trusting everyone",
|
||||
env: with(map[string]string{EnvTrustedProxy: "0.0.0.0/0"}),
|
||||
badVars: []string{EnvTrustedProxy},
|
||||
},
|
||||
{
|
||||
name: "trusted proxy with port",
|
||||
env: with(map[string]string{EnvTrustedProxy: "10.60.1.33:443"}),
|
||||
badVars: []string{EnvTrustedProxy},
|
||||
},
|
||||
{
|
||||
name: "several invalid values are all named",
|
||||
env: with(map[string]string{
|
||||
@@ -402,8 +447,13 @@ func TestLoadValues(t *testing.T) {
|
||||
t.Errorf("SessionIdle, SessionMax = %v, %v, want defaults 720h, 4320h", cfg.SessionIdle, cfg.SessionMax)
|
||||
}
|
||||
|
||||
if cfg.TrustedProxy != nil {
|
||||
t.Errorf("TrustedProxy = %v, want none when unset", cfg.TrustedProxy)
|
||||
}
|
||||
|
||||
env[EnvSessionIdle] = "12h"
|
||||
env[EnvSessionMax] = "36h"
|
||||
env[EnvTrustedProxy] = "10.60.1.33, 172.16.0.0/12"
|
||||
cfg, err = load(func(k string) (string, bool) { v, ok := env[k]; return v, ok })
|
||||
if err != nil {
|
||||
t.Fatalf("load() error = %v", err)
|
||||
@@ -411,6 +461,10 @@ func TestLoadValues(t *testing.T) {
|
||||
if cfg.SessionIdle != 12*time.Hour || cfg.SessionMax != 36*time.Hour {
|
||||
t.Errorf("SessionIdle, SessionMax = %v, %v, want 12h, 36h", cfg.SessionIdle, cfg.SessionMax)
|
||||
}
|
||||
want := forwarded.Trusted{netip.MustParsePrefix("10.60.1.33/32"), netip.MustParsePrefix("172.16.0.0/12")}
|
||||
if !slices.Equal(cfg.TrustedProxy, want) {
|
||||
t.Errorf("TrustedProxy = %v, want %v", cfg.TrustedProxy, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretRedacted(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
// Package forwarded resolves where a request really came from. The service
|
||||
// runs behind a reverse proxy (Traefik), so the TCP peer is usually the proxy;
|
||||
// the X-Forwarded-For and X-Forwarded-Proto headers it adds are believed only
|
||||
// when the peer is a trusted proxy. From anyone else they are ignored, so a
|
||||
// client cannot spoof its address or scheme.
|
||||
package forwarded
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Header names honoured from trusted proxies.
|
||||
const (
|
||||
HeaderFor = "X-Forwarded-For"
|
||||
HeaderProto = "X-Forwarded-Proto"
|
||||
)
|
||||
|
||||
// ErrInvalidHeader marks a malformed forwarded header from a trusted peer.
|
||||
var ErrInvalidHeader = errors.New("invalid forwarded header")
|
||||
|
||||
// Trusted is the set of proxy addresses whose forwarded headers are honoured.
|
||||
// The zero value trusts no peer.
|
||||
type Trusted []netip.Prefix
|
||||
|
||||
// ParseTrusted parses a comma-separated list of IP addresses and CIDR
|
||||
// prefixes. A blank list trusts no peer. Each entry must be a plain address
|
||||
// or a prefix with no host bits set; zones, IPv4-mapped IPv6 forms and
|
||||
// zero-length prefixes, which would trust every peer, are rejected.
|
||||
func ParseTrusted(s string) (Trusted, error) {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var t Trusted
|
||||
for _, entry := range strings.Split(s, ",") {
|
||||
entry = strings.TrimSpace(entry)
|
||||
if entry == "" {
|
||||
return nil, fmt.Errorf("empty entry in comma-separated list %q", s)
|
||||
}
|
||||
p, err := parseEntry(entry)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t = append(t, p)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func parseEntry(entry string) (netip.Prefix, error) {
|
||||
var p netip.Prefix
|
||||
if strings.Contains(entry, "/") {
|
||||
var err error
|
||||
p, err = netip.ParsePrefix(entry)
|
||||
if err != nil {
|
||||
return netip.Prefix{}, fmt.Errorf("invalid CIDR %q", entry)
|
||||
}
|
||||
if p != p.Masked() {
|
||||
return netip.Prefix{}, fmt.Errorf("CIDR %q has host bits set (did you mean %s?)", entry, p.Masked())
|
||||
}
|
||||
} else {
|
||||
a, err := netip.ParseAddr(entry)
|
||||
if err != nil {
|
||||
return netip.Prefix{}, fmt.Errorf("invalid IP address %q", entry)
|
||||
}
|
||||
if a.Zone() != "" {
|
||||
return netip.Prefix{}, fmt.Errorf("IP address %q must not have a zone", entry)
|
||||
}
|
||||
p = netip.PrefixFrom(a, a.BitLen())
|
||||
}
|
||||
switch {
|
||||
case p.Addr().Is4In6():
|
||||
return netip.Prefix{}, fmt.Errorf("%q is IPv4-mapped IPv6; write the IPv4 form", entry)
|
||||
case p.Bits() == 0:
|
||||
return netip.Prefix{}, fmt.Errorf("%q would trust every peer", entry)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Contains reports whether a is a trusted proxy address. IPv4-mapped IPv6
|
||||
// addresses, as a dual-stack listener reports IPv4 peers, match their IPv4
|
||||
// form.
|
||||
func (t Trusted) Contains(a netip.Addr) bool {
|
||||
a = normalize(a)
|
||||
for _, p := range t {
|
||||
if p.Contains(a) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalize(a netip.Addr) netip.Addr { return a.Unmap().WithZone("") }
|
||||
|
||||
// Origin is where a request came from.
|
||||
type Origin struct {
|
||||
// ClientIP is the client's address.
|
||||
ClientIP netip.Addr
|
||||
// Scheme is "https" or "http": the scheme the client used.
|
||||
Scheme string
|
||||
}
|
||||
|
||||
// Resolve works out r's Origin. When the TCP peer (r.RemoteAddr) is not in
|
||||
// trusted, both forwarded headers are ignored: the client is the peer and the
|
||||
// scheme is that of the connection.
|
||||
//
|
||||
// When the peer is trusted:
|
||||
// - X-Forwarded-For entries (across all its header lines, in order) are
|
||||
// walked right to left, skipping trusted proxy addresses; the first
|
||||
// untrusted address is the client. Entries left of it are client-supplied
|
||||
// and not examined. If every entry is trusted the leftmost is the client;
|
||||
// with no X-Forwarded-For the peer is the client.
|
||||
// - X-Forwarded-Proto, if present, must be a single "http" or "https" and
|
||||
// is the scheme; without it the connection's scheme is used.
|
||||
//
|
||||
// A malformed header from a trusted peer is an error wrapping
|
||||
// ErrInvalidHeader; any other error means RemoteAddr is not an IP and port.
|
||||
func Resolve(r *http.Request, trusted Trusted) (Origin, error) {
|
||||
peer, err := netip.ParseAddrPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return Origin{}, fmt.Errorf("peer address %q is not an IP and port", r.RemoteAddr)
|
||||
}
|
||||
o := Origin{ClientIP: normalize(peer.Addr()), Scheme: "http"}
|
||||
if r.TLS != nil {
|
||||
o.Scheme = "https"
|
||||
}
|
||||
if !trusted.Contains(o.ClientIP) {
|
||||
return o, nil
|
||||
}
|
||||
if values := r.Header.Values(HeaderFor); len(values) > 0 {
|
||||
o.ClientIP, err = clientFromChain(strings.Split(strings.Join(values, ","), ","), trusted)
|
||||
if err != nil {
|
||||
return Origin{}, err
|
||||
}
|
||||
}
|
||||
switch values := r.Header.Values(HeaderProto); len(values) {
|
||||
case 0:
|
||||
case 1:
|
||||
switch scheme := strings.ToLower(strings.TrimSpace(values[0])); scheme {
|
||||
case "http", "https":
|
||||
o.Scheme = scheme
|
||||
default:
|
||||
return Origin{}, fmt.Errorf("%w: %s %q is not http or https", ErrInvalidHeader, HeaderProto, values[0])
|
||||
}
|
||||
default:
|
||||
return Origin{}, fmt.Errorf("%w: %d %s headers, want one", ErrInvalidHeader, len(values), HeaderProto)
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
|
||||
// clientFromChain returns the rightmost untrusted address in chain, or the
|
||||
// leftmost address when all are trusted.
|
||||
func clientFromChain(chain []string, trusted Trusted) (netip.Addr, error) {
|
||||
var client netip.Addr
|
||||
for i := len(chain) - 1; i >= 0; i-- {
|
||||
a, err := netip.ParseAddr(strings.TrimSpace(chain[i]))
|
||||
if err != nil {
|
||||
return netip.Addr{}, fmt.Errorf("%w: %s entry %q is not an IP address", ErrInvalidHeader, HeaderFor, strings.TrimSpace(chain[i]))
|
||||
}
|
||||
client = normalize(a)
|
||||
if !trusted.Contains(client) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
type contextKey struct{}
|
||||
|
||||
// NewContext returns ctx carrying o.
|
||||
func NewContext(ctx context.Context, o Origin) context.Context {
|
||||
return context.WithValue(ctx, contextKey{}, o)
|
||||
}
|
||||
|
||||
// FromContext returns the Origin ctx carries; ok is false when the request
|
||||
// was not resolved.
|
||||
func FromContext(ctx context.Context) (o Origin, ok bool) {
|
||||
o, ok = ctx.Value(contextKey{}).(Origin)
|
||||
return o, ok
|
||||
}
|
||||
|
||||
// ClientIP returns the resolved client address ctx carries.
|
||||
func ClientIP(ctx context.Context) (netip.Addr, bool) {
|
||||
o, ok := FromContext(ctx)
|
||||
return o.ClientIP, ok
|
||||
}
|
||||
|
||||
// Scheme returns the resolved request scheme ctx carries.
|
||||
func Scheme(ctx context.Context) (string, bool) {
|
||||
o, ok := FromContext(ctx)
|
||||
return o.Scheme, ok
|
||||
}
|
||||
|
||||
// LogAttr is the log attribute key for the resolved client address.
|
||||
const LogAttr = "client_ip"
|
||||
|
||||
// LogHandler wraps h so every record logged with a resolved request's
|
||||
// context carries the client address as LogAttr.
|
||||
func LogHandler(h slog.Handler) slog.Handler { return logHandler{h} }
|
||||
|
||||
type logHandler struct{ slog.Handler }
|
||||
|
||||
func (h logHandler) Handle(ctx context.Context, rec slog.Record) error {
|
||||
if ip, ok := ClientIP(ctx); ok {
|
||||
rec = rec.Clone()
|
||||
rec.AddAttrs(slog.String(LogAttr, ip.String()))
|
||||
}
|
||||
return h.Handler.Handle(ctx, rec)
|
||||
}
|
||||
|
||||
func (h logHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
return logHandler{h.Handler.WithAttrs(attrs)}
|
||||
}
|
||||
|
||||
func (h logHandler) WithGroup(name string) slog.Handler {
|
||||
return logHandler{h.Handler.WithGroup(name)}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package forwarded
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseTrusted(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
in string
|
||||
want []string
|
||||
wantErr string
|
||||
}{
|
||||
{in: "", want: nil},
|
||||
{in: " ", want: nil},
|
||||
{in: "10.60.1.33", want: []string{"10.60.1.33/32"}},
|
||||
{in: "10.60.1.33, 172.16.0.0/12", want: []string{"10.60.1.33/32", "172.16.0.0/12"}},
|
||||
{in: "2001:db8::1,2001:db8::/32", want: []string{"2001:db8::1/128", "2001:db8::/32"}},
|
||||
{in: "traefik", wantErr: "invalid IP address"},
|
||||
{in: "10.60.1.33:443", wantErr: "invalid IP address"},
|
||||
{in: "10.60.1.0/33", wantErr: "invalid CIDR"},
|
||||
{in: "10.60.1.33/24", wantErr: "did you mean 10.60.1.0/24"},
|
||||
{in: "10.60.1.33,,10.60.1.34", wantErr: "empty entry"},
|
||||
{in: "10.60.1.33,", wantErr: "empty entry"},
|
||||
{in: "fe80::1%eth0", wantErr: "zone"},
|
||||
{in: "::ffff:10.60.1.33", wantErr: "IPv4-mapped"},
|
||||
{in: "0.0.0.0/0", wantErr: "every peer"},
|
||||
{in: "::/0", wantErr: "every peer"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got, err := ParseTrusted(tt.in)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Errorf("ParseTrusted(%q) error = %v, want one containing %q", tt.in, err, tt.wantErr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("ParseTrusted(%q) error = %v", tt.in, err)
|
||||
continue
|
||||
}
|
||||
var gotStr []string
|
||||
for _, p := range got {
|
||||
gotStr = append(gotStr, p.String())
|
||||
}
|
||||
if !slices.Equal(gotStr, tt.want) {
|
||||
t.Errorf("ParseTrusted(%q) = %v, want %v", tt.in, gotStr, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustedContainsMappedIPv4(t *testing.T) {
|
||||
t.Parallel()
|
||||
trusted := mustTrusted(t, "10.60.1.33")
|
||||
if !trusted.Contains(netip.MustParseAddr("::ffff:10.60.1.33")) {
|
||||
t.Error("IPv4-mapped form of a trusted address is not trusted")
|
||||
}
|
||||
if Trusted(nil).Contains(netip.MustParseAddr("10.60.1.33")) {
|
||||
t.Error("an empty set trusts a peer")
|
||||
}
|
||||
}
|
||||
|
||||
func mustTrusted(t *testing.T, s string) Trusted {
|
||||
t.Helper()
|
||||
tr, err := ParseTrusted(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tr
|
||||
}
|
||||
|
||||
func TestResolve(t *testing.T) {
|
||||
t.Parallel()
|
||||
// 10.60.1.33 is Traefik; 10.60.2.0/24 is an inner proxy tier.
|
||||
trusted := mustTrusted(t, "10.60.1.33,10.60.2.0/24")
|
||||
tests := []struct {
|
||||
name string
|
||||
peer string
|
||||
tls bool
|
||||
xff []string
|
||||
xfp []string
|
||||
wantIP string
|
||||
wantScheme string
|
||||
}{
|
||||
{
|
||||
name: "untrusted peer, no headers",
|
||||
peer: "198.51.100.9:5000", wantIP: "198.51.100.9", wantScheme: "http",
|
||||
},
|
||||
{
|
||||
name: "untrusted peer over TLS",
|
||||
peer: "198.51.100.9:5000", tls: true, wantIP: "198.51.100.9", wantScheme: "https",
|
||||
},
|
||||
{
|
||||
name: "untrusted peer spoofing both headers is ignored",
|
||||
peer: "198.51.100.9:5000", xff: []string{"203.0.113.7"}, xfp: []string{"https"},
|
||||
wantIP: "198.51.100.9", wantScheme: "http",
|
||||
},
|
||||
{
|
||||
name: "untrusted peer's malformed headers are ignored, not rejected",
|
||||
peer: "198.51.100.9:5000", xff: []string{"not-an-ip"}, xfp: []string{"gopher"},
|
||||
wantIP: "198.51.100.9", wantScheme: "http",
|
||||
},
|
||||
{
|
||||
name: "trusted peer: client from X-Forwarded-For, scheme from X-Forwarded-Proto",
|
||||
peer: "10.60.1.33:41000", xff: []string{"203.0.113.7"}, xfp: []string{"https"},
|
||||
wantIP: "203.0.113.7", wantScheme: "https",
|
||||
},
|
||||
{
|
||||
name: "trusted peer seen as IPv4-mapped IPv6",
|
||||
peer: "[::ffff:10.60.1.33]:41000", xff: []string{"203.0.113.7"}, xfp: []string{"https"},
|
||||
wantIP: "203.0.113.7", wantScheme: "https",
|
||||
},
|
||||
{
|
||||
name: "trusted peer without headers is itself the client",
|
||||
peer: "10.60.1.33:41000", wantIP: "10.60.1.33", wantScheme: "http",
|
||||
},
|
||||
{
|
||||
name: "rightmost untrusted address wins over a client-supplied prefix",
|
||||
peer: "10.60.1.33:41000", xff: []string{"1.2.3.4, 203.0.113.7"}, xfp: []string{"https"},
|
||||
wantIP: "203.0.113.7", wantScheme: "https",
|
||||
},
|
||||
{
|
||||
name: "trusted proxies in the chain are skipped",
|
||||
peer: "10.60.1.33:41000", xff: []string{"1.2.3.4, 203.0.113.7, 10.60.2.5"}, xfp: []string{"https"},
|
||||
wantIP: "203.0.113.7", wantScheme: "https",
|
||||
},
|
||||
{
|
||||
name: "several header lines form one chain",
|
||||
peer: "10.60.1.33:41000", xff: []string{"1.2.3.4", "203.0.113.7, 10.60.2.5"},
|
||||
wantIP: "203.0.113.7", wantScheme: "http",
|
||||
},
|
||||
{
|
||||
name: "garbage left of the client is not examined",
|
||||
peer: "10.60.1.33:41000", xff: []string{"spoofed, 203.0.113.7"},
|
||||
wantIP: "203.0.113.7", wantScheme: "http",
|
||||
},
|
||||
{
|
||||
name: "all trusted: leftmost is the client",
|
||||
peer: "10.60.1.33:41000", xff: []string{"10.60.2.5, 10.60.2.6"},
|
||||
wantIP: "10.60.2.5", wantScheme: "http",
|
||||
},
|
||||
{
|
||||
name: "IPv6 client",
|
||||
peer: "10.60.1.33:41000", xff: []string{"2001:db8::7"}, xfp: []string{"HTTPS"},
|
||||
wantIP: "2001:db8::7", wantScheme: "https",
|
||||
},
|
||||
{
|
||||
name: "trusted peer may downgrade to http",
|
||||
peer: "10.60.1.33:41000", tls: true, xfp: []string{"http"},
|
||||
wantIP: "10.60.1.33", wantScheme: "http",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := request(tt.peer, tt.xff, tt.xfp)
|
||||
if tt.tls {
|
||||
r.TLS = &tls.ConnectionState{}
|
||||
}
|
||||
o, err := Resolve(r, trusted)
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve error = %v", err)
|
||||
}
|
||||
if o.ClientIP.String() != tt.wantIP || o.Scheme != tt.wantScheme {
|
||||
t.Errorf("Resolve = %s %s, want %s %s", o.ClientIP, o.Scheme, tt.wantIP, tt.wantScheme)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRejectsMalformedHeadersFromTrustedPeer(t *testing.T) {
|
||||
t.Parallel()
|
||||
trusted := mustTrusted(t, "10.60.1.33")
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
xff, xfp []string
|
||||
}{
|
||||
{"X-Forwarded-For not an IP", []string{"not-an-ip"}, nil},
|
||||
{"X-Forwarded-For with a port", []string{"203.0.113.7:5000"}, nil},
|
||||
{"X-Forwarded-For empty entry", []string{"203.0.113.7, "}, nil},
|
||||
{"X-Forwarded-For empty", []string{""}, nil},
|
||||
{"X-Forwarded-Proto unknown scheme", nil, []string{"ftp"}},
|
||||
{"X-Forwarded-Proto list", nil, []string{"https, http"}},
|
||||
{"X-Forwarded-Proto repeated", nil, []string{"https", "https"}},
|
||||
} {
|
||||
_, err := Resolve(request("10.60.1.33:41000", tt.xff, tt.xfp), trusted)
|
||||
if !errors.Is(err, ErrInvalidHeader) {
|
||||
t.Errorf("%s: Resolve error = %v, want ErrInvalidHeader", tt.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveBadPeerAddress(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := request("@", nil, nil)
|
||||
_, err := Resolve(r, nil)
|
||||
if err == nil || errors.Is(err, ErrInvalidHeader) {
|
||||
t.Errorf("Resolve error = %v, want a peer address error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func request(peer string, xff, xfp []string) *http.Request {
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.RemoteAddr = peer
|
||||
for _, v := range xff {
|
||||
r.Header.Add(HeaderFor, v)
|
||||
}
|
||||
for _, v := range xfp {
|
||||
r.Header.Add(HeaderProto, v)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func TestContextHelpers(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, ok := ClientIP(context.Background()); ok {
|
||||
t.Error("ClientIP ok on an unresolved context")
|
||||
}
|
||||
if _, ok := Scheme(context.Background()); ok {
|
||||
t.Error("Scheme ok on an unresolved context")
|
||||
}
|
||||
ctx := NewContext(context.Background(), Origin{ClientIP: netip.MustParseAddr("203.0.113.7"), Scheme: "https"})
|
||||
if ip, ok := ClientIP(ctx); !ok || ip.String() != "203.0.113.7" {
|
||||
t.Errorf("ClientIP = %s %v, want 203.0.113.7 true", ip, ok)
|
||||
}
|
||||
if s, ok := Scheme(ctx); !ok || s != "https" {
|
||||
t.Errorf("Scheme = %q %v, want https true", s, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogHandler(t *testing.T) {
|
||||
t.Parallel()
|
||||
var buf bytes.Buffer
|
||||
logger := slog.New(LogHandler(slog.NewTextHandler(&buf, nil))).With("component", "test")
|
||||
ctx := NewContext(context.Background(), Origin{ClientIP: netip.MustParseAddr("203.0.113.7"), Scheme: "https"})
|
||||
|
||||
logger.InfoContext(ctx, "resolved")
|
||||
logger.InfoContext(context.Background(), "unresolved")
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(buf.String()), "\n")
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("logged %d lines, want 2: %q", len(lines), buf.String())
|
||||
}
|
||||
if !strings.Contains(lines[0], "client_ip=203.0.113.7") || !strings.Contains(lines[0], "component=test") {
|
||||
t.Errorf("resolved line = %q, want client_ip and the With attribute", lines[0])
|
||||
}
|
||||
if strings.Contains(lines[1], "client_ip") {
|
||||
t.Errorf("unresolved line = %q, want no client_ip", lines[1])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// Package household owns household-local calendar and clock logic. Every
|
||||
// function works in the household's IANA zone, passed explicitly, and never
|
||||
// in the process zone (time.Local).
|
||||
//
|
||||
// Weeks are ISO weeks: Monday to Sunday, numbered by ISO 8601, so the period
|
||||
// key of 2026-12-28 is "2026-W53" and of 2027-01-04 is "2027-W01".
|
||||
//
|
||||
// Calendar arithmetic is done on UTC midnights, which have no DST, so a date
|
||||
// plus seven days is always the same weekday a week later.
|
||||
package household
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// Today returns the household-local date at instant now in zone loc.
|
||||
func Today(now time.Time, loc *time.Location) store.LocalDate {
|
||||
return store.LocalDateOf(now.In(loc))
|
||||
}
|
||||
|
||||
// WeekStart returns the ISO Monday of the week containing d. It panics if d is
|
||||
// the zero LocalDate.
|
||||
func WeekStart(d store.LocalDate) store.LocalDate {
|
||||
t := midnightUTC(d)
|
||||
// Weekday counts from Sunday = 0; shift so Monday = 0 and Sunday = 6.
|
||||
sinceMonday := (int(t.Weekday()) + 6) % 7
|
||||
return store.LocalDateOf(t.AddDate(0, 0, -sinceMonday))
|
||||
}
|
||||
|
||||
// WeekDays returns the seven dates Monday to Sunday of the week starting on
|
||||
// weekStart. It is an error if weekStart is not a Monday.
|
||||
func WeekDays(weekStart store.LocalDate) ([7]store.LocalDate, error) {
|
||||
t, err := monday(weekStart)
|
||||
if err != nil {
|
||||
return [7]store.LocalDate{}, err
|
||||
}
|
||||
var days [7]store.LocalDate
|
||||
for i := range days {
|
||||
days[i] = store.LocalDateOf(t.AddDate(0, 0, i))
|
||||
}
|
||||
return days, nil
|
||||
}
|
||||
|
||||
// WeekLabel returns the display label of the week starting on weekStart, such
|
||||
// as "Week 41 · Oct 5 – 11", or "Week 40 · Sep 28 – Oct 4" when the week
|
||||
// spans two months. It is an error if weekStart is not a Monday.
|
||||
func WeekLabel(weekStart store.LocalDate) (string, error) {
|
||||
start, err := monday(weekStart)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, week := start.ISOWeek()
|
||||
end := start.AddDate(0, 0, 6)
|
||||
if end.Month() == start.Month() {
|
||||
return fmt.Sprintf("Week %d · %s – %d", week, start.Format("Jan 2"), end.Day()), nil
|
||||
}
|
||||
return fmt.Sprintf("Week %d · %s – %s", week, start.Format("Jan 2"), end.Format("Jan 2")), nil
|
||||
}
|
||||
|
||||
// PeriodKey returns the ISO week key of the week containing d, such as
|
||||
// "2026-W41". The year is the ISO week-numbering year, which differs from the
|
||||
// calendar year around New Year. It panics if d is the zero LocalDate.
|
||||
func PeriodKey(d store.LocalDate) string {
|
||||
year, week := midnightUTC(d).ISOWeek()
|
||||
return fmt.Sprintf("%04d-W%02d", year, week)
|
||||
}
|
||||
|
||||
// LocalTime returns the instant at which household clocks in zone loc read
|
||||
// hhmm (24-hour "HH:MM") on date d.
|
||||
//
|
||||
// DST rule: a wall time that does not exist (spring forward) or occurs twice
|
||||
// (fall back) is read with the UTC offset in effect before the transition. A
|
||||
// skipped time therefore moves forward by the length of the gap (02:30 on a
|
||||
// day clocks jump 02:00→03:00 is 03:30), and a repeated time is its first
|
||||
// occurrence. The rule assumes at most one offset change in the 48 hours
|
||||
// around the wall time, which holds for every real zone.
|
||||
func LocalTime(loc *time.Location, d store.LocalDate, hhmm string) (time.Time, error) {
|
||||
if d.IsZero() {
|
||||
return time.Time{}, errors.New("household: zero date")
|
||||
}
|
||||
clock, err := time.Parse("15:04", hhmm)
|
||||
if err != nil || clock.Format("15:04") != hhmm {
|
||||
return time.Time{}, fmt.Errorf("household: invalid time %q: want HH:MM", hhmm)
|
||||
}
|
||||
y, m, day := d.Date()
|
||||
// wall is the wall-clock reading expressed as if it were UTC; the real
|
||||
// instant is wall minus the zone's UTC offset at that instant.
|
||||
wall := time.Date(y, m, day, clock.Hour(), clock.Minute(), 0, 0, time.UTC)
|
||||
before := offset(wall.Add(-24*time.Hour), loc)
|
||||
after := offset(wall.Add(24*time.Hour), loc)
|
||||
early := wall.Add(-before)
|
||||
late := wall.Add(-after)
|
||||
// Use the post-transition offset only when the pre-transition reading is
|
||||
// not a real wall time and the post-transition one is.
|
||||
if offset(early, loc) != before && offset(late, loc) == after {
|
||||
return late.In(loc), nil
|
||||
}
|
||||
return early.In(loc), nil
|
||||
}
|
||||
|
||||
// offset returns the UTC offset of zone loc at instant t.
|
||||
func offset(t time.Time, loc *time.Location) time.Duration {
|
||||
_, secs := t.In(loc).Zone()
|
||||
return time.Duration(secs) * time.Second
|
||||
}
|
||||
|
||||
// midnightUTC returns d as midnight UTC. It panics if d is the zero LocalDate.
|
||||
func midnightUTC(d store.LocalDate) time.Time {
|
||||
if d.IsZero() {
|
||||
panic("household: zero date")
|
||||
}
|
||||
y, m, day := d.Date()
|
||||
return time.Date(y, m, day, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
// monday returns weekStart as midnight UTC, or an error if it is not a Monday.
|
||||
func monday(weekStart store.LocalDate) (time.Time, error) {
|
||||
if weekStart.IsZero() {
|
||||
return time.Time{}, errors.New("household: zero week start")
|
||||
}
|
||||
t := midnightUTC(weekStart)
|
||||
if t.Weekday() != time.Monday {
|
||||
return time.Time{}, fmt.Errorf("household: week start %s is a %s, want Monday", weekStart, t.Weekday())
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package household
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// TestMain runs the package with the process zone set to UTC, so any use of
|
||||
// time.Local in place of the household zone shows up as a failure.
|
||||
func TestMain(m *testing.M) {
|
||||
time.Local = time.UTC
|
||||
m.Run()
|
||||
}
|
||||
|
||||
func loadZone(t *testing.T, name string) *time.Location {
|
||||
t.Helper()
|
||||
loc, err := time.LoadLocation(name)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLocation(%q): %v", name, err)
|
||||
}
|
||||
return loc
|
||||
}
|
||||
|
||||
func date(t *testing.T, s string) store.LocalDate {
|
||||
t.Helper()
|
||||
d, err := store.ParseLocalDate(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func utc(t *testing.T, s string) time.Time {
|
||||
t.Helper()
|
||||
ts, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ts
|
||||
}
|
||||
|
||||
func TestTodayUsesHouseholdZone(t *testing.T) {
|
||||
chicago := loadZone(t, "America/Chicago")
|
||||
auckland := loadZone(t, "Pacific/Auckland")
|
||||
for _, tc := range []struct {
|
||||
now string
|
||||
loc *time.Location
|
||||
want string
|
||||
}{
|
||||
// 2026-10-05T04:30Z is Sunday 23:30 in Chicago (CDT, UTC−5) and
|
||||
// Monday 17:30 in Auckland (NZDT, UTC+13).
|
||||
{"2026-10-05T04:30:00Z", chicago, "2026-10-04"},
|
||||
{"2026-10-05T04:30:00Z", auckland, "2026-10-05"},
|
||||
// 2026-10-04T11:30Z is Sunday 06:30 in Chicago and already Monday
|
||||
// 00:30 in Auckland.
|
||||
{"2026-10-04T11:30:00Z", chicago, "2026-10-04"},
|
||||
{"2026-10-04T11:30:00Z", auckland, "2026-10-05"},
|
||||
} {
|
||||
if got := Today(utc(t, tc.now), tc.loc).String(); got != tc.want {
|
||||
t.Errorf("Today(%s, %s) = %s, want %s", tc.now, tc.loc, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSundayNightMondayMorningWeekAssignment(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
zone, now string
|
||||
wantDay string
|
||||
wantWeekStart string
|
||||
wantPeriodKey string
|
||||
wantWeekday time.Weekday
|
||||
}{
|
||||
// Sunday 23:59 local belongs to the week that started the previous
|
||||
// Monday; one minute later, Monday 00:00 starts a new week.
|
||||
{"America/Chicago", "2026-10-12T04:59:00Z", "2026-10-11", "2026-10-05", "2026-W41", time.Sunday},
|
||||
{"America/Chicago", "2026-10-12T05:00:00Z", "2026-10-12", "2026-10-12", "2026-W42", time.Monday},
|
||||
{"Pacific/Auckland", "2026-10-11T10:59:00Z", "2026-10-11", "2026-10-05", "2026-W41", time.Sunday},
|
||||
{"Pacific/Auckland", "2026-10-11T11:00:00Z", "2026-10-12", "2026-10-12", "2026-W42", time.Monday},
|
||||
} {
|
||||
loc := loadZone(t, tc.zone)
|
||||
now := utc(t, tc.now)
|
||||
if wd := now.In(loc).Weekday(); wd != tc.wantWeekday {
|
||||
t.Fatalf("%s in %s is %s, want %s", tc.now, tc.zone, wd, tc.wantWeekday)
|
||||
}
|
||||
today := Today(now, loc)
|
||||
if today.String() != tc.wantDay {
|
||||
t.Errorf("Today(%s, %s) = %s, want %s", tc.now, tc.zone, today, tc.wantDay)
|
||||
}
|
||||
if got := WeekStart(today).String(); got != tc.wantWeekStart {
|
||||
t.Errorf("WeekStart(%s) = %s, want %s", today, got, tc.wantWeekStart)
|
||||
}
|
||||
if got := PeriodKey(today); got != tc.wantPeriodKey {
|
||||
t.Errorf("PeriodKey(%s) = %s, want %s", today, got, tc.wantPeriodKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeekStartEveryWeekday(t *testing.T) {
|
||||
for _, d := range []string{
|
||||
"2026-10-05", "2026-10-06", "2026-10-07", "2026-10-08",
|
||||
"2026-10-09", "2026-10-10", "2026-10-11",
|
||||
} {
|
||||
if got := WeekStart(date(t, d)).String(); got != "2026-10-05" {
|
||||
t.Errorf("WeekStart(%s) = %s, want 2026-10-05", d, got)
|
||||
}
|
||||
}
|
||||
if got := WeekStart(date(t, "2026-10-12")).String(); got != "2026-10-12" {
|
||||
t.Errorf("WeekStart(2026-10-12) = %s, want 2026-10-12", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeekDays(t *testing.T) {
|
||||
days, err := WeekDays(date(t, "2026-09-28"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{
|
||||
"2026-09-28", "2026-09-29", "2026-09-30", "2026-10-01",
|
||||
"2026-10-02", "2026-10-03", "2026-10-04",
|
||||
}
|
||||
for i, d := range days {
|
||||
if d.String() != want[i] {
|
||||
t.Errorf("WeekDays[%d] = %s, want %s", i, d, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeekDaysAcrossDSTChange(t *testing.T) {
|
||||
// Chicago falls back on Sunday 2026-11-01 and Auckland springs forward on
|
||||
// Sunday 2026-09-27; neither shifts the dates of those weeks.
|
||||
for _, tc := range []struct{ start, last string }{
|
||||
{"2026-10-26", "2026-11-01"},
|
||||
{"2026-09-21", "2026-09-27"},
|
||||
{"2026-03-02", "2026-03-08"},
|
||||
} {
|
||||
days, err := WeekDays(date(t, tc.start))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if days[0].String() != tc.start || days[6].String() != tc.last {
|
||||
t.Errorf("WeekDays(%s) = %s … %s, want %s … %s", tc.start, days[0], days[6], tc.start, tc.last)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestISOWeekYearBoundary(t *testing.T) {
|
||||
for _, tc := range []struct{ day, weekStart, key string }{
|
||||
{"2026-12-27", "2026-12-21", "2026-W52"},
|
||||
{"2026-12-28", "2026-12-28", "2026-W53"},
|
||||
{"2026-12-31", "2026-12-28", "2026-W53"},
|
||||
{"2027-01-01", "2026-12-28", "2026-W53"},
|
||||
{"2027-01-03", "2026-12-28", "2026-W53"},
|
||||
{"2027-01-04", "2027-01-04", "2027-W01"},
|
||||
{"2027-01-10", "2027-01-04", "2027-W01"},
|
||||
{"2026-01-01", "2025-12-29", "2026-W01"},
|
||||
{"2026-10-07", "2026-10-05", "2026-W41"},
|
||||
} {
|
||||
d := date(t, tc.day)
|
||||
if got := WeekStart(d).String(); got != tc.weekStart {
|
||||
t.Errorf("WeekStart(%s) = %s, want %s", tc.day, got, tc.weekStart)
|
||||
}
|
||||
if got := PeriodKey(d); got != tc.key {
|
||||
t.Errorf("PeriodKey(%s) = %s, want %s", tc.day, got, tc.key)
|
||||
}
|
||||
}
|
||||
days, err := WeekDays(date(t, "2026-12-28"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if days[6].String() != "2027-01-03" {
|
||||
t.Errorf("W53 Sunday = %s, want 2027-01-03", days[6])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeekLabel(t *testing.T) {
|
||||
for _, tc := range []struct{ start, want string }{
|
||||
{"2026-10-05", "Week 41 · Oct 5 – 11"},
|
||||
{"2026-09-28", "Week 40 · Sep 28 – Oct 4"},
|
||||
{"2026-12-28", "Week 53 · Dec 28 – Jan 3"},
|
||||
{"2027-01-04", "Week 1 · Jan 4 – 10"},
|
||||
} {
|
||||
got, err := WeekLabel(date(t, tc.start))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("WeekLabel(%s) = %q, want %q", tc.start, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeekStartRequired(t *testing.T) {
|
||||
for _, d := range []store.LocalDate{date(t, "2026-10-07"), date(t, "2026-10-11"), {}} {
|
||||
if _, err := WeekDays(d); err == nil {
|
||||
t.Errorf("WeekDays(%s) succeeded, want error", d)
|
||||
}
|
||||
if _, err := WeekLabel(d); err == nil {
|
||||
t.Errorf("WeekLabel(%s) succeeded, want error", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestZeroDatePanics(t *testing.T) {
|
||||
for name, fn := range map[string]func(){
|
||||
"WeekStart": func() { WeekStart(store.LocalDate{}) },
|
||||
"PeriodKey": func() { PeriodKey(store.LocalDate{}) },
|
||||
} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%s(zero) did not panic", name)
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalTime(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
zone, day, hhmm string
|
||||
want string // UTC instant
|
||||
wantWall string // local wall clock of the result
|
||||
}{
|
||||
// Ordinary days.
|
||||
{"America/Chicago", "2026-10-05", "18:30", "2026-10-05T23:30:00Z", "2026-10-05 18:30 CDT"},
|
||||
{"America/Chicago", "2026-12-28", "00:00", "2026-12-28T06:00:00Z", "2026-12-28 00:00 CST"},
|
||||
{"Pacific/Auckland", "2026-10-05", "18:30", "2026-10-05T05:30:00Z", "2026-10-05 18:30 NZDT"},
|
||||
{"Pacific/Auckland", "2026-07-01", "23:59", "2026-07-01T11:59:00Z", "2026-07-01 23:59 NZST"},
|
||||
|
||||
// Chicago spring forward, Sunday 2026-03-08: 02:00 CST → 03:00 CDT.
|
||||
{"America/Chicago", "2026-03-08", "01:59", "2026-03-08T07:59:00Z", "2026-03-08 01:59 CST"},
|
||||
{"America/Chicago", "2026-03-08", "02:00", "2026-03-08T08:00:00Z", "2026-03-08 03:00 CDT"},
|
||||
{"America/Chicago", "2026-03-08", "02:30", "2026-03-08T08:30:00Z", "2026-03-08 03:30 CDT"},
|
||||
{"America/Chicago", "2026-03-08", "03:00", "2026-03-08T08:00:00Z", "2026-03-08 03:00 CDT"},
|
||||
{"America/Chicago", "2026-03-08", "03:30", "2026-03-08T08:30:00Z", "2026-03-08 03:30 CDT"},
|
||||
|
||||
// Chicago fall back, Sunday 2026-11-01: 02:00 CDT → 01:00 CST, so
|
||||
// 01:00–01:59 happens twice; the first (CDT) occurrence is chosen.
|
||||
{"America/Chicago", "2026-11-01", "00:59", "2026-11-01T05:59:00Z", "2026-11-01 00:59 CDT"},
|
||||
{"America/Chicago", "2026-11-01", "01:00", "2026-11-01T06:00:00Z", "2026-11-01 01:00 CDT"},
|
||||
{"America/Chicago", "2026-11-01", "01:30", "2026-11-01T06:30:00Z", "2026-11-01 01:30 CDT"},
|
||||
{"America/Chicago", "2026-11-01", "02:00", "2026-11-01T08:00:00Z", "2026-11-01 02:00 CST"},
|
||||
|
||||
// Auckland spring forward, Sunday 2026-09-27: 02:00 NZST → 03:00 NZDT.
|
||||
{"Pacific/Auckland", "2026-09-27", "01:59", "2026-09-26T13:59:00Z", "2026-09-27 01:59 NZST"},
|
||||
{"Pacific/Auckland", "2026-09-27", "02:30", "2026-09-26T14:30:00Z", "2026-09-27 03:30 NZDT"},
|
||||
{"Pacific/Auckland", "2026-09-27", "03:00", "2026-09-26T14:00:00Z", "2026-09-27 03:00 NZDT"},
|
||||
|
||||
// Auckland fall back, Sunday 2026-04-05: 03:00 NZDT → 02:00 NZST, so
|
||||
// 02:00–02:59 happens twice; the first (NZDT) occurrence is chosen.
|
||||
{"Pacific/Auckland", "2026-04-05", "01:59", "2026-04-04T12:59:00Z", "2026-04-05 01:59 NZDT"},
|
||||
{"Pacific/Auckland", "2026-04-05", "02:30", "2026-04-04T13:30:00Z", "2026-04-05 02:30 NZDT"},
|
||||
{"Pacific/Auckland", "2026-04-05", "03:00", "2026-04-04T15:00:00Z", "2026-04-05 03:00 NZST"},
|
||||
} {
|
||||
loc := loadZone(t, tc.zone)
|
||||
got, err := LocalTime(loc, date(t, tc.day), tc.hhmm)
|
||||
if err != nil {
|
||||
t.Fatalf("LocalTime(%s, %s, %s): %v", tc.zone, tc.day, tc.hhmm, err)
|
||||
}
|
||||
if !got.Equal(utc(t, tc.want)) {
|
||||
t.Errorf("LocalTime(%s, %s, %s) = %s, want %s", tc.zone, tc.day, tc.hhmm, got.UTC().Format(time.RFC3339), tc.want)
|
||||
}
|
||||
if wall := got.Format("2006-01-02 15:04 MST"); wall != tc.wantWall {
|
||||
t.Errorf("LocalTime(%s, %s, %s) wall = %s, want %s", tc.zone, tc.day, tc.hhmm, wall, tc.wantWall)
|
||||
}
|
||||
if got.Location() != loc {
|
||||
t.Errorf("LocalTime(%s, …) location = %s, want %s", tc.zone, got.Location(), loc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalTimeDeterministic(t *testing.T) {
|
||||
// The same ambiguous or skipped input always resolves to the same instant.
|
||||
chicago := loadZone(t, "America/Chicago")
|
||||
for _, tc := range []struct{ day, hhmm string }{{"2026-03-08", "02:30"}, {"2026-11-01", "01:30"}} {
|
||||
first, err := LocalTime(chicago, date(t, tc.day), tc.hhmm)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for range 100 {
|
||||
again, err := LocalTime(chicago, date(t, tc.day), tc.hhmm)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !again.Equal(first) {
|
||||
t.Fatalf("LocalTime(%s, %s) = %s then %s", tc.day, tc.hhmm, first, again)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalTimeRejectsInvalid(t *testing.T) {
|
||||
chicago := loadZone(t, "America/Chicago")
|
||||
d := date(t, "2026-10-05")
|
||||
for _, hhmm := range []string{
|
||||
"", "9:30", "09:3", "24:00", "23:60", "09:30:00", "0930", " 09:30", "09:30 ", "9am", "-1:00",
|
||||
} {
|
||||
if got, err := LocalTime(chicago, d, hhmm); err == nil {
|
||||
t.Errorf("LocalTime(%q) = %s, want error", hhmm, got)
|
||||
} else if !strings.Contains(err.Error(), "HH:MM") {
|
||||
t.Errorf("LocalTime(%q) error %q does not name the HH:MM form", hhmm, err)
|
||||
}
|
||||
}
|
||||
if _, err := LocalTime(chicago, store.LocalDate{}, "09:30"); err == nil {
|
||||
t.Error("LocalTime(zero date) succeeded")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Package secretbox encrypts secrets at rest with AES-256-GCM. It is the only
|
||||
// owner of secret encryption: every _ct column holds a token produced by
|
||||
// Box.Encrypt, laid out as nonce ‖ ciphertext.
|
||||
//
|
||||
// Errors from this package never contain plaintext, ciphertext or key
|
||||
// material.
|
||||
package secretbox
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// KeySize is the decoded key length in bytes (AES-256).
|
||||
const KeySize = 32
|
||||
|
||||
var (
|
||||
// ErrKeyEncoding means the key is not valid standard base64.
|
||||
ErrKeyEncoding = errors.New("secretbox: key is not valid standard base64")
|
||||
// ErrKeyLength means the decoded key is not exactly KeySize bytes.
|
||||
ErrKeyLength = errors.New("secretbox: key must decode to exactly 32 bytes")
|
||||
// ErrMalformed means a token is too short to hold a nonce and tag.
|
||||
ErrMalformed = errors.New("secretbox: token is too short")
|
||||
// ErrDecrypt means a token failed authentication: it was corrupted or
|
||||
// sealed under a different key.
|
||||
ErrDecrypt = errors.New("secretbox: token failed authentication")
|
||||
)
|
||||
|
||||
// Box seals and opens secrets under one key. It is safe for concurrent use.
|
||||
type Box struct {
|
||||
aead cipher.AEAD
|
||||
}
|
||||
|
||||
// New returns a Box for encodedKey, the standard-base64 encoding of a
|
||||
// KeySize-byte key.
|
||||
func New(encodedKey string) (*Box, error) {
|
||||
key, err := base64.StdEncoding.Strict().DecodeString(encodedKey)
|
||||
if err != nil {
|
||||
// The decoder's error is not wrapped: it describes the key's content.
|
||||
return nil, ErrKeyEncoding
|
||||
}
|
||||
if len(key) != KeySize {
|
||||
return nil, fmt.Errorf("%w, got %d", ErrKeyLength, len(key))
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("secretbox: %w", err)
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("secretbox: %w", err)
|
||||
}
|
||||
return &Box{aead: aead}, nil
|
||||
}
|
||||
|
||||
// Encrypt seals plaintext under a fresh random nonce and returns
|
||||
// nonce ‖ ciphertext.
|
||||
func (b *Box) Encrypt(plaintext []byte) ([]byte, error) {
|
||||
nonce := make([]byte, b.aead.NonceSize(), b.aead.NonceSize()+len(plaintext)+b.aead.Overhead())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return nil, fmt.Errorf("secretbox: generate nonce: %w", err)
|
||||
}
|
||||
return b.aead.Seal(nonce, nonce, plaintext, nil), nil
|
||||
}
|
||||
|
||||
// Decrypt authenticates and opens a token produced by Encrypt.
|
||||
func (b *Box) Decrypt(token []byte) ([]byte, error) {
|
||||
n := b.aead.NonceSize()
|
||||
if len(token) < n+b.aead.Overhead() {
|
||||
return nil, ErrMalformed
|
||||
}
|
||||
plaintext, err := b.aead.Open(nil, token[:n], token[n:], nil)
|
||||
if err != nil {
|
||||
return nil, ErrDecrypt
|
||||
}
|
||||
return plaintext, nil
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package secretbox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newKey returns a fresh random key and its standard-base64 encoding.
|
||||
func newKey(t *testing.T) ([]byte, string) {
|
||||
t.Helper()
|
||||
key := make([]byte, KeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return key, base64.StdEncoding.EncodeToString(key)
|
||||
}
|
||||
|
||||
func newBox(t *testing.T) (*Box, []byte, string) {
|
||||
t.Helper()
|
||||
key, encoded := newKey(t)
|
||||
box, err := New(encoded)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return box, key, encoded
|
||||
}
|
||||
|
||||
// assertNoLeak fails if err's message contains any of the secrets, either
|
||||
// raw or base64-encoded.
|
||||
func assertNoLeak(t *testing.T, err error, secrets ...[]byte) {
|
||||
t.Helper()
|
||||
msg := err.Error()
|
||||
for _, s := range secrets {
|
||||
if len(s) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, form := range []string{string(s), base64.StdEncoding.EncodeToString(s), base64.RawStdEncoding.EncodeToString(s)} {
|
||||
if strings.Contains(msg, form) {
|
||||
t.Errorf("error %q leaks secret material %q", msg, form)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
box, _, _ := newBox(t)
|
||||
for _, plaintext := range [][]byte{
|
||||
[]byte("ya29.oauth-refresh-token"),
|
||||
{},
|
||||
bytes.Repeat([]byte{0x00, 0xff}, 4096),
|
||||
} {
|
||||
token, err := box.Encrypt(plaintext)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(plaintext) > 0 && bytes.Contains(token, plaintext) {
|
||||
t.Errorf("token contains the plaintext")
|
||||
}
|
||||
got, err := box.Decrypt(token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, plaintext) {
|
||||
t.Errorf("Decrypt = %q, want %q", got, plaintext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenLayout(t *testing.T) {
|
||||
box, _, _ := newBox(t)
|
||||
plaintext := []byte("secret")
|
||||
token, err := box.Encrypt(plaintext)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// nonce (12) ‖ ciphertext (len(plaintext)) ‖ GCM tag (16).
|
||||
if want := 12 + len(plaintext) + 16; len(token) != want {
|
||||
t.Errorf("token length = %d, want %d", len(token), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptUsesFreshNonce(t *testing.T) {
|
||||
box, _, _ := newBox(t)
|
||||
plaintext := []byte("same plaintext")
|
||||
a, err := box.Encrypt(plaintext)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := box.Encrypt(plaintext)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Equal(a, b) {
|
||||
t.Fatal("two encryptions of the same plaintext are identical")
|
||||
}
|
||||
if bytes.Equal(a[:12], b[:12]) {
|
||||
t.Error("two encryptions reused the same nonce")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptRejectsFlippedByte(t *testing.T) {
|
||||
box, key, encoded := newBox(t)
|
||||
plaintext := []byte("do-not-leak-me")
|
||||
token, err := box.Encrypt(plaintext)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Flip one bit in every position: nonce, ciphertext and tag.
|
||||
for i := range token {
|
||||
tampered := bytes.Clone(token)
|
||||
tampered[i] ^= 0x01
|
||||
got, err := box.Decrypt(tampered)
|
||||
if !errors.Is(err, ErrDecrypt) {
|
||||
t.Fatalf("byte %d flipped: Decrypt = %q, %v; want ErrDecrypt", i, got, err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Errorf("byte %d flipped: Decrypt returned plaintext alongside the error", i)
|
||||
}
|
||||
assertNoLeak(t, err, plaintext, key, []byte(encoded), tampered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptRejectsWrongKey(t *testing.T) {
|
||||
box, key, encoded := newBox(t)
|
||||
other, otherKey, otherEncoded := newBox(t)
|
||||
plaintext := []byte("do-not-leak-me")
|
||||
token, err := box.Encrypt(plaintext)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := other.Decrypt(token)
|
||||
if !errors.Is(err, ErrDecrypt) {
|
||||
t.Fatalf("Decrypt with wrong key = %q, %v; want ErrDecrypt", got, err)
|
||||
}
|
||||
assertNoLeak(t, err, plaintext, key, []byte(encoded), otherKey, []byte(otherEncoded), token)
|
||||
}
|
||||
|
||||
func TestDecryptRejectsShortToken(t *testing.T) {
|
||||
box, _, _ := newBox(t)
|
||||
for _, n := range []int{0, 1, 12, 12 + 15} {
|
||||
token := bytes.Repeat([]byte{0xab}, n)
|
||||
if _, err := box.Decrypt(token); !errors.Is(err, ErrMalformed) {
|
||||
t.Errorf("Decrypt(%d bytes) error = %v, want ErrMalformed", n, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsBadKey(t *testing.T) {
|
||||
key, _ := newKey(t)
|
||||
tests := []struct {
|
||||
name string
|
||||
encoded string
|
||||
want error
|
||||
}{
|
||||
{"empty", "", ErrKeyLength},
|
||||
{"not base64", "not*base64!" + strings.Repeat("A", 33), ErrKeyEncoding},
|
||||
{"url-safe alphabet", strings.Repeat("_-", 22), ErrKeyEncoding},
|
||||
{"unpadded", base64.RawStdEncoding.EncodeToString(key), ErrKeyEncoding},
|
||||
{"raw key bytes", string(key), ErrKeyEncoding},
|
||||
{"too short", base64.StdEncoding.EncodeToString(key[:31]), ErrKeyLength},
|
||||
{"too long", base64.StdEncoding.EncodeToString(append(bytes.Clone(key), 0x00)), ErrKeyLength},
|
||||
{"AES-128 length", base64.StdEncoding.EncodeToString(key[:16]), ErrKeyLength},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
box, err := New(tt.encoded)
|
||||
if !errors.Is(err, tt.want) {
|
||||
t.Fatalf("New error = %v, want %v", err, tt.want)
|
||||
}
|
||||
if box != nil {
|
||||
t.Error("New returned a Box alongside the error")
|
||||
}
|
||||
assertNoLeak(t, err, key, []byte(tt.encoded))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAcceptsBase64Key(t *testing.T) {
|
||||
key, encoded := newKey(t)
|
||||
box, err := New(encoded)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A Box built from the same key opens tokens sealed by the first.
|
||||
token, err := box.Encrypt([]byte("x"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := New(base64.StdEncoding.EncodeToString(key))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := again.Decrypt(token); err != nil {
|
||||
t.Errorf("same key failed to decrypt: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||
)
|
||||
|
||||
// CSRFHeader carries the session's CSRF token, which the client reads from
|
||||
// GET /api/v1/me.
|
||||
const CSRFHeader = "X-MealQuest-CSRF"
|
||||
|
||||
// requireCSRF passes GET requests to next; every other
|
||||
// request must carry CSRFHeader equal to its session's CSRF token or get 403
|
||||
// csrf. It runs behind requireSession, which puts the session in the context.
|
||||
func requireCSRF(logger *slog.Logger) func(http.Handler) http.Handler {
|
||||
forbidden := apiErrorHandler(logger, http.StatusForbidden, "csrf", "missing or invalid CSRF token")
|
||||
internal := apiErrorHandler(logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
s, ok := auth.SessionFrom(r.Context())
|
||||
if !ok || s.CSRFToken == "" {
|
||||
// An empty token would match a missing header.
|
||||
logger.ErrorContext(r.Context(), "csrf check without a session token", "session", ok)
|
||||
internal(w, r)
|
||||
return
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(r.Header.Get(CSRFHeader)), []byte(s.CSRFToken)) != 1 {
|
||||
forbidden(w, r)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||
)
|
||||
|
||||
const (
|
||||
csrfJSON = `{"error":{"code":"csrf","message":"missing or invalid CSRF token","retryable":false}}`
|
||||
methodNotAllowedJSON = `{"error":{"code":"method_not_allowed","message":"method not allowed","retryable":false}}`
|
||||
)
|
||||
|
||||
func TestCSRFThroughRouter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router := newTestRouter(t, "dev")
|
||||
for _, tc := range []struct {
|
||||
name, method, path string
|
||||
header []string // nil sends no CSRF header
|
||||
wantStatus int
|
||||
wantBody string
|
||||
}{
|
||||
{"POST without header", http.MethodPost, "/api/v1/me", nil, http.StatusForbidden, csrfJSON},
|
||||
{"POST with wrong header", http.MethodPost, "/api/v1/me", []string{"wrong-token"}, http.StatusForbidden, csrfJSON},
|
||||
{"POST with empty header", http.MethodPost, "/api/v1/me", []string{""}, http.StatusForbidden, csrfJSON},
|
||||
{"POST with token prefix", http.MethodPost, "/api/v1/me", []string{"csrf-token"}, http.StatusForbidden, csrfJSON},
|
||||
{"PATCH without header", http.MethodPatch, "/api/v1/settings/household", nil, http.StatusForbidden, csrfJSON},
|
||||
{"DELETE with wrong header", http.MethodDelete, "/api/v1/me", []string{"wrong-token"}, http.StatusForbidden, csrfJSON},
|
||||
// The right token passes the check and reaches the router, which has
|
||||
// no POST /api/v1/me.
|
||||
{"POST with right header", http.MethodPost, "/api/v1/me", []string{testSession.CSRFToken}, http.StatusMethodNotAllowed, methodNotAllowedJSON},
|
||||
{"PATCH with right header", http.MethodPatch, "/api/v1/settings/household", []string{testSession.CSRFToken}, http.StatusNotFound, notFoundJSON},
|
||||
{"GET without header", http.MethodGet, "/api/v1/me", nil, http.StatusOK, ""},
|
||||
// Only GET is exempt; HEAD and OPTIONS need the header too.
|
||||
{"HEAD without header", http.MethodHead, "/api/v1/me", nil, http.StatusForbidden, csrfJSON},
|
||||
{"OPTIONS without header", http.MethodOptions, "/api/v1/me", nil, http.StatusForbidden, csrfJSON},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
req := httptest.NewRequest(tc.method, tc.path, nil)
|
||||
req.AddCookie(validSessionCookie)
|
||||
if tc.header != nil {
|
||||
req.Header.Set(CSRFHeader, tc.header[0])
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != tc.wantStatus {
|
||||
t.Errorf("status = %d, want %d (body %s)", rec.Code, tc.wantStatus, rec.Body.String())
|
||||
}
|
||||
if tc.wantBody != "" && rec.Body.String() != tc.wantBody {
|
||||
t.Errorf("body = %s, want %s", rec.Body.String(), tc.wantBody)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFUnauthenticatedGets401(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/me", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
newTestRouter(t, "dev").ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthenticatedJSON {
|
||||
t.Errorf("response = %d %s, want 401 %s", rec.Code, rec.Body.String(), unauthenticatedJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireCSRF(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
method string
|
||||
session *auth.Session
|
||||
header string
|
||||
wantStatus int
|
||||
}{
|
||||
{"right header reaches handler", http.MethodPost, &testSession, testSession.CSRFToken, http.StatusNoContent},
|
||||
{"wrong header", http.MethodPut, &testSession, "nope", http.StatusForbidden},
|
||||
{"GET needs no header", http.MethodGet, &testSession, "", http.StatusNoContent},
|
||||
{"HEAD needs the header", http.MethodHead, &testSession, "", http.StatusForbidden},
|
||||
{"OPTIONS needs the header", http.MethodOptions, &testSession, "", http.StatusForbidden},
|
||||
{"no session in context", http.MethodPost, nil, "", http.StatusInternalServerError},
|
||||
{"session without token", http.MethodPost, &auth.Session{Subject: "user-123"}, "", http.StatusInternalServerError},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
called := false
|
||||
h := requireCSRF(logger)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
ctx := context.Background()
|
||||
if tc.session != nil {
|
||||
ctx = auth.WithSession(ctx, *tc.session)
|
||||
}
|
||||
req := httptest.NewRequestWithContext(ctx, tc.method, "/api/v1/anything", nil)
|
||||
if tc.header != "" {
|
||||
req.Header.Set(CSRFHeader, tc.header)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != tc.wantStatus {
|
||||
t.Errorf("status = %d, want %d", rec.Code, tc.wantStatus)
|
||||
}
|
||||
if called != (tc.wantStatus == http.StatusNoContent) {
|
||||
t.Errorf("handler called = %v with status %d", called, rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMe(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
|
||||
req.AddCookie(validSessionCookie)
|
||||
rec := httptest.NewRecorder()
|
||||
newTestRouter(t, "dev").ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body %s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
||||
t.Errorf("Content-Type = %q, want application/json", ct)
|
||||
}
|
||||
if cc := rec.Header().Get("Cache-Control"); cc != "no-store" {
|
||||
t.Errorf("Cache-Control = %q, want no-store", cc)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode %s: %v", rec.Body.String(), err)
|
||||
}
|
||||
want := map[string]any{
|
||||
"name": "Home Cook",
|
||||
"email": "cook@example.com",
|
||||
"household": map[string]any{"label": "Our kitchen"},
|
||||
"csrfToken": "csrf-token-123",
|
||||
}
|
||||
gotJSON, _ := json.Marshal(got)
|
||||
wantJSON, _ := json.Marshal(want)
|
||||
if string(gotJSON) != string(wantJSON) {
|
||||
t.Errorf("body = %s, want %s", gotJSON, wantJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeNeedsSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
newTestRouter(t, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/me", nil))
|
||||
if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthenticatedJSON {
|
||||
t.Errorf("response = %d %s, want 401 %s", rec.Code, rec.Body.String(), unauthenticatedJSON)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
// newDiscoveryProvider serves the OIDC discovery document auth.New needs;
|
||||
// login only redirects to its authorization endpoint.
|
||||
func newDiscoveryProvider(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
var srv *httptest.Server
|
||||
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/.well-known/openid-configuration" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"issuer": srv.URL,
|
||||
"authorization_endpoint": srv.URL + "/authorize",
|
||||
"token_endpoint": srv.URL + "/token",
|
||||
"jwks_uri": srv.URL + "/jwks",
|
||||
"userinfo_endpoint": srv.URL + "/userinfo",
|
||||
"end_session_endpoint": srv.URL + "/end-session/",
|
||||
"response_types_supported": []string{"code"},
|
||||
"subject_types_supported": []string{"public"},
|
||||
"id_token_signing_alg_values_supported": []string{"RS256"},
|
||||
})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
// newLoginRouter wires the production router as cmd/server does: the real
|
||||
// auth flow and session manager, trusting Traefik only.
|
||||
func newLoginRouter(t *testing.T, issuer string) http.Handler {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
db, err := store.Open(ctx, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err := store.Migrate(ctx, db.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sessions, err := auth.NewSessionManager(db, 720*time.Hour, 4320*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(forwarded.LogHandler(slog.NewTextHandler(io.Discard, nil)))
|
||||
flow, err := auth.New(ctx, auth.Config{
|
||||
Issuer: issuer,
|
||||
ClientID: "mealquest",
|
||||
ClientSecret: "client-s3cret",
|
||||
PublicURL: &url.URL{Scheme: "https", Host: "meals.example.com"},
|
||||
SecretKey: []byte("server-secret-key"),
|
||||
AllowedGroups: []string{"mealquest-admin"},
|
||||
}, sessions, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
trusted, err := forwarded.ParseTrusted(traefik)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := NewRouter(logger, "dev", trusted, flow, sessions,
|
||||
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// TestLoginThroughRouter starts a login through the production router. The
|
||||
// redirect URI and the Secure login cookie come from MEALQUEST_PUBLIC_URL
|
||||
// whatever the direct peer: forwarded headers from an untrusted peer are
|
||||
// ignored but never fail sign-in.
|
||||
func TestLoginThroughRouter(t *testing.T) {
|
||||
t.Parallel()
|
||||
provider := newDiscoveryProvider(t)
|
||||
router := newLoginRouter(t, provider.URL)
|
||||
// Traefik reaches the service over plain http on its internal address;
|
||||
// the host headers must not leak into the redirect URI.
|
||||
headers := map[string]string{
|
||||
forwarded.HeaderFor: "203.0.113.7",
|
||||
forwarded.HeaderProto: "https",
|
||||
"X-Forwarded-Host": "evil.example.com",
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
peer string
|
||||
}{
|
||||
{"trusted proxy", traefik + ":41000"},
|
||||
{"untrusted peer", "172.17.0.1:5000"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, originRequest("http://mealquest:8080"+auth.LoginPath, tt.peer, headers))
|
||||
resp := rec.Result()
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
t.Fatalf("login status = %d, want 302", resp.StatusCode)
|
||||
}
|
||||
loc, err := url.Parse(resp.Header.Get("Location"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := loc.Scheme+"://"+loc.Host+loc.Path, provider.URL+"/authorize"; got != want {
|
||||
t.Errorf("redirect to %s, want %s", got, want)
|
||||
}
|
||||
if got, want := loc.Query().Get("redirect_uri"), "https://meals.example.com/auth/callback"; got != want {
|
||||
t.Errorf("redirect_uri = %q, want %q from the public URL", got, want)
|
||||
}
|
||||
cookies := resp.Cookies()
|
||||
if len(cookies) != 1 || cookies[0].Name != "__Host-mealquest_login" || !cookies[0].Secure || !cookies[0].HttpOnly {
|
||||
t.Errorf("login cookies = %v, want one Secure, HttpOnly __Host-mealquest_login", cookies)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||
)
|
||||
|
||||
// householdLabel is the single shared household identity.
|
||||
const householdLabel = "Our kitchen"
|
||||
|
||||
type meResponse struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Household householdInfo `json:"household"`
|
||||
CSRFToken string `json:"csrfToken"`
|
||||
}
|
||||
|
||||
type householdInfo struct {
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
// me answers GET /api/v1/me with the signed-in member, the household and the
|
||||
// session's CSRF token for CSRFHeader.
|
||||
func me(logger *slog.Logger) http.HandlerFunc {
|
||||
internal := apiErrorHandler(logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
s, ok := auth.SessionFrom(r.Context())
|
||||
if !ok {
|
||||
logger.ErrorContext(r.Context(), "GET /api/v1/me without a session")
|
||||
internal(w, r)
|
||||
return
|
||||
}
|
||||
body, err := json.Marshal(meResponse{
|
||||
Name: s.Name,
|
||||
Email: s.Email,
|
||||
Household: householdInfo{Label: householdLabel},
|
||||
CSRFToken: s.CSRFToken,
|
||||
})
|
||||
if err != nil {
|
||||
panic(err) // string fields cannot fail to marshal
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, err := w.Write(body); err != nil {
|
||||
logger.WarnContext(r.Context(), "write me response", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
)
|
||||
|
||||
// resolveOrigin resolves every request's client address and scheme with
|
||||
// forwarded.Resolve and puts them in the context (read them with
|
||||
// forwarded.ClientIP and forwarded.Scheme). Forwarded headers are honoured
|
||||
// only from a peer in trusted. A malformed forwarded header from a trusted
|
||||
// peer gets 400 bad_request.
|
||||
func resolveOrigin(logger *slog.Logger, trusted forwarded.Trusted) func(http.Handler) http.Handler {
|
||||
badRequest := apiErrorHandler(logger, http.StatusBadRequest, "bad_request", "invalid forwarded headers")
|
||||
internal := apiErrorHandler(logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
o, err := forwarded.Resolve(r, trusted)
|
||||
switch {
|
||||
case errors.Is(err, forwarded.ErrInvalidHeader):
|
||||
logger.WarnContext(r.Context(), "rejected forwarded headers", "peer", r.RemoteAddr, "error", err)
|
||||
badRequest(w, r)
|
||||
case err != nil:
|
||||
logger.ErrorContext(r.Context(), "resolve request origin", "error", err)
|
||||
internal(w, r)
|
||||
default:
|
||||
next.ServeHTTP(w, r.WithContext(forwarded.NewContext(r.Context(), o)))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
)
|
||||
|
||||
// traefik is the production proxy's address.
|
||||
const traefik = "10.60.1.33"
|
||||
|
||||
// probeAuth mounts GET /auth/probe, which reports the request's resolved
|
||||
// origin, in place of the real auth flow.
|
||||
type probeAuth struct{}
|
||||
|
||||
func (probeAuth) Mount(r chi.Router) {
|
||||
r.Get("/auth/probe", func(w http.ResponseWriter, r *http.Request) {
|
||||
ip, ipOK := forwarded.ClientIP(r.Context())
|
||||
scheme, schemeOK := forwarded.Scheme(r.Context())
|
||||
if !ipOK || !schemeOK {
|
||||
http.Error(w, "origin not resolved", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "%s %s", ip, scheme)
|
||||
})
|
||||
}
|
||||
|
||||
// newOriginRouter builds the production router trusting Traefik, logging
|
||||
// through forwarded.LogHandler as cmd/server does.
|
||||
func newOriginRouter(t *testing.T, logs io.Writer) http.Handler {
|
||||
t.Helper()
|
||||
trusted, err := forwarded.ParseTrusted(traefik)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
|
||||
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{},
|
||||
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func originRequest(target, peer string, headers map[string]string) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, target, nil)
|
||||
req.RemoteAddr = peer
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func TestRouterResolvesOrigin(t *testing.T) {
|
||||
t.Parallel()
|
||||
spoofed := map[string]string{forwarded.HeaderFor: "203.0.113.7", forwarded.HeaderProto: "https"}
|
||||
tests := []struct {
|
||||
name string
|
||||
peer string
|
||||
want string
|
||||
}{
|
||||
{"trusted proxy's forwarded headers are honoured", traefik + ":41000", "203.0.113.7 https"},
|
||||
{"untrusted peer spoofing them is ignored", "198.51.100.9:5000", "198.51.100.9 http"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := httptest.NewRecorder()
|
||||
newOriginRouter(t, io.Discard).ServeHTTP(rec, originRequest("/auth/probe", tt.peer, spoofed))
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != tt.want {
|
||||
t.Errorf("probe = %d %q, want 200 %q", rec.Code, rec.Body.String(), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterLogsResolvedClientIP(t *testing.T) {
|
||||
t.Parallel()
|
||||
spoofed := map[string]string{forwarded.HeaderFor: "203.0.113.7", forwarded.HeaderProto: "https"}
|
||||
tests := []struct {
|
||||
name string
|
||||
peer string
|
||||
want string
|
||||
}{
|
||||
{"trusted proxy", traefik + ":41000", "203.0.113.7"},
|
||||
{"untrusted peer spoofing", "198.51.100.9:5000", "198.51.100.9"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var logs bytes.Buffer
|
||||
// A failed session lookup is logged with the request's context.
|
||||
req := originRequest("/api/v1/me", tt.peer, spoofed)
|
||||
req.AddCookie(brokenSessionCookie)
|
||||
rec := httptest.NewRecorder()
|
||||
newOriginRouter(t, &logs).ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", rec.Code)
|
||||
}
|
||||
var line map[string]any
|
||||
if err := json.Unmarshal(logs.Bytes(), &line); err != nil {
|
||||
t.Fatalf("log %q: %v", logs.String(), err)
|
||||
}
|
||||
if line["msg"] != "look up session" || line[forwarded.LogAttr] != tt.want {
|
||||
t.Errorf("log = %v, want the session error with %s=%s", line, forwarded.LogAttr, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterRejectsMalformedForwardedHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
const badRequestJSON = `{"error":{"code":"bad_request","message":"invalid forwarded headers","retryable":false}}`
|
||||
for _, headers := range []map[string]string{
|
||||
{forwarded.HeaderFor: "not-an-ip"},
|
||||
{forwarded.HeaderProto: "ftp"},
|
||||
} {
|
||||
var logs bytes.Buffer
|
||||
rec := httptest.NewRecorder()
|
||||
newOriginRouter(t, &logs).ServeHTTP(rec, originRequest("/health", traefik+":41000", headers))
|
||||
if rec.Code != http.StatusBadRequest || rec.Body.String() != badRequestJSON {
|
||||
t.Errorf("%v: response = %d %s, want 400 %s", headers, rec.Code, rec.Body.String(), badRequestJSON)
|
||||
}
|
||||
if !bytes.Contains(logs.Bytes(), []byte("rejected forwarded headers")) {
|
||||
t.Errorf("%v: logs = %q, want the rejection logged", headers, logs.String())
|
||||
}
|
||||
|
||||
// The same headers from an untrusted peer are not examined at all.
|
||||
rec = httptest.NewRecorder()
|
||||
newOriginRouter(t, io.Discard).ServeHTTP(rec, originRequest("/health", "198.51.100.9:5000", headers))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("%v from an untrusted peer: status = %d, want 200", headers, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
||||
)
|
||||
|
||||
@@ -21,10 +22,13 @@ type RouteMounter interface {
|
||||
Mount(r chi.Router)
|
||||
}
|
||||
|
||||
// NewRouter returns the HTTP handler for the service. version is reported by
|
||||
// GET /health; auth mounts the /auth routes; assets is the web build served
|
||||
// for every other GET.
|
||||
func NewRouter(logger *slog.Logger, version string, auth RouteMounter, assets fs.FS) (http.Handler, error) {
|
||||
// NewRouter returns the HTTP handler for the service. Every request first
|
||||
// has its client address and scheme resolved, honouring forwarded headers
|
||||
// only from a peer in trusted (see resolveOrigin). version is reported by
|
||||
// GET /health; auth mounts the /auth routes; sessions guards /api/v1 and
|
||||
// /media; assets is the web build served for every other GET. /health, /auth
|
||||
// and the web build are public. Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
|
||||
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, assets fs.FS) (http.Handler, error) {
|
||||
notFound := apiErrorHandler(logger, http.StatusNotFound, "not_found", "not found")
|
||||
methodNotAllowed := apiErrorHandler(logger, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||
|
||||
@@ -34,18 +38,31 @@ func NewRouter(logger *slog.Logger, version string, auth RouteMounter, assets fs
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(resolveOrigin(logger, trusted))
|
||||
r.MethodNotAllowed(methodNotAllowed)
|
||||
r.NotFound(notFound)
|
||||
|
||||
r.Get("/health", health(logger, version))
|
||||
auth.Mount(r)
|
||||
// The session middleware wraps the whole /api/v1 and /media subrouters
|
||||
// rather than being added with Use: chi skips a subrouter's middleware
|
||||
// when it has no routes, which would leave it unguarded.
|
||||
signedIn := requireSession(logger, sessions)
|
||||
csrf := requireCSRF(logger)
|
||||
apiV1, media := chi.NewRouter(), chi.NewRouter()
|
||||
for _, sub := range []*chi.Mux{apiV1, media} {
|
||||
sub.MethodNotAllowed(methodNotAllowed)
|
||||
sub.NotFound(notFound)
|
||||
}
|
||||
apiV1.Get("/me", me(logger))
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.MethodNotAllowed(methodNotAllowed)
|
||||
r.NotFound(notFound)
|
||||
r.Mount("/v1", signedIn(csrf(apiV1)))
|
||||
})
|
||||
// Catch-all for the PWA. Unknown /auth paths and /media (no routes yet)
|
||||
// reach the frontend handler, which passes them to notFound rather than
|
||||
// the SPA fallback.
|
||||
r.Mount("/media", signedIn(media))
|
||||
// Catch-all for the PWA. Unknown /auth paths reach the frontend handler,
|
||||
// which passes them to notFound rather than the SPA fallback.
|
||||
r.Method(http.MethodGet, "/*", web)
|
||||
r.Method(http.MethodHead, "/*", web)
|
||||
return r, nil
|
||||
|
||||
@@ -27,7 +27,7 @@ func newTestRouter(t *testing.T, version string) http.Handler {
|
||||
"index.html": {Data: []byte(fixtureIndex)},
|
||||
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
|
||||
}
|
||||
r, err := NewRouter(logger, version, stubAuth{}, assets)
|
||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -126,6 +126,8 @@ func TestReservedPathsNotFound(t *testing.T) {
|
||||
}{
|
||||
{http.MethodGet, "/api"},
|
||||
{http.MethodGet, "/api/unknown"},
|
||||
{http.MethodGet, "/api/v1/unknown"},
|
||||
{http.MethodPost, "/api/v1/unknown"},
|
||||
{http.MethodPost, "/api/unknown"},
|
||||
{http.MethodGet, "/auth/unknown"},
|
||||
{http.MethodGet, "/media/photo.jpg"},
|
||||
@@ -137,8 +139,13 @@ func TestReservedPathsNotFound(t *testing.T) {
|
||||
t.Run(tt.method+" "+tt.path, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Signed in with the CSRF token, so /api/v1 and /media reach
|
||||
// their routers.
|
||||
req := httptest.NewRequest(tt.method, tt.path, nil)
|
||||
req.AddCookie(validSessionCookie)
|
||||
req.Header.Set(CSRFHeader, testSession.CSRFToken)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(tt.method, tt.path, nil))
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want 404", rec.Code)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||
)
|
||||
|
||||
// SessionLookup finds the live session a request carries; it returns
|
||||
// auth.ErrNoSession when there is none. *auth.SessionManager implements it.
|
||||
type SessionLookup interface {
|
||||
SessionFor(r *http.Request) (auth.Session, error)
|
||||
}
|
||||
|
||||
// requireSession is the RequireSession middleware. It passes requests with a
|
||||
// live session to next with the session in the context (read it with
|
||||
// auth.SessionFrom or auth.ActorFrom); others get 401 unauthenticated.
|
||||
func requireSession(logger *slog.Logger, sessions SessionLookup) func(http.Handler) http.Handler {
|
||||
unauthenticated := apiErrorHandler(logger, http.StatusUnauthorized, "unauthenticated", "sign in required")
|
||||
internal := apiErrorHandler(logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
s, err := sessions.SessionFor(r)
|
||||
switch {
|
||||
case errors.Is(err, auth.ErrNoSession):
|
||||
unauthenticated(w, r)
|
||||
case err != nil:
|
||||
logger.ErrorContext(r.Context(), "look up session", "error", err)
|
||||
internal(w, r)
|
||||
default:
|
||||
next.ServeHTTP(w, r.WithContext(auth.WithSession(r.Context(), s)))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||
)
|
||||
|
||||
const (
|
||||
unauthenticatedJSON = `{"error":{"code":"unauthenticated","message":"sign in required","retryable":false}}`
|
||||
internalJSON = `{"error":{"code":"internal","message":"internal error","retryable":false}}`
|
||||
)
|
||||
|
||||
var (
|
||||
validSessionCookie = &http.Cookie{Name: auth.SessionCookieName, Value: "valid"}
|
||||
brokenSessionCookie = &http.Cookie{Name: auth.SessionCookieName, Value: "broken"}
|
||||
testSession = auth.Session{
|
||||
Subject: "user-123",
|
||||
Email: "cook@example.com",
|
||||
Name: "Home Cook",
|
||||
Groups: []string{"mealquest-admin"},
|
||||
CSRFToken: "csrf-token-123",
|
||||
}
|
||||
)
|
||||
|
||||
// stubSessions knows one valid session cookie; the broken cookie fails the
|
||||
// lookup as a database error would.
|
||||
type stubSessions struct{}
|
||||
|
||||
func (stubSessions) SessionFor(r *http.Request) (auth.Session, error) {
|
||||
c, err := r.Cookie(auth.SessionCookieName)
|
||||
switch {
|
||||
case err != nil:
|
||||
return auth.Session{}, auth.ErrNoSession
|
||||
case c.Value == validSessionCookie.Value:
|
||||
return testSession, nil
|
||||
case c.Value == brokenSessionCookie.Value:
|
||||
return auth.Session{}, errors.New("database down")
|
||||
default:
|
||||
return auth.Session{}, auth.ErrNoSession
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtectedPathsRequireSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router := newTestRouter(t, "dev")
|
||||
for _, tc := range []struct {
|
||||
method, path string
|
||||
cookie *http.Cookie
|
||||
}{
|
||||
{http.MethodGet, "/api/v1/settings/household", nil},
|
||||
{http.MethodPatch, "/api/v1/settings/household", nil},
|
||||
{http.MethodGet, "/api/v1", nil},
|
||||
{http.MethodGet, "/media/photo.jpg", nil},
|
||||
{http.MethodGet, "/api/v1/settings/household", &http.Cookie{Name: auth.SessionCookieName, Value: "unknown"}},
|
||||
} {
|
||||
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
req := httptest.NewRequest(tc.method, tc.path, nil)
|
||||
if tc.cookie != nil {
|
||||
req.AddCookie(tc.cookie)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
||||
t.Errorf("Content-Type = %q, want application/json", ct)
|
||||
}
|
||||
if got := rec.Body.String(); got != unauthenticatedJSON {
|
||||
t.Errorf("body = %s, want %s", got, unauthenticatedJSON)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicPathsNeedNoSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router := newTestRouter(t, "dev")
|
||||
for _, path := range []string{"/health", "/auth/login", "/", "/assets/index-AbC123.js", "/quests/42"} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("GET %s without a cookie = %d, want 200", path, rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionLookupFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings/household", nil)
|
||||
req.AddCookie(brokenSessionCookie)
|
||||
rec := httptest.NewRecorder()
|
||||
newTestRouter(t, "dev").ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusInternalServerError || rec.Body.String() != internalJSON {
|
||||
t.Errorf("response = %d %s, want 500 %s", rec.Code, rec.Body.String(), internalJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireSessionAttachesActor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var got auth.Actor
|
||||
var ok bool
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
got, ok = auth.ActorFrom(r.Context())
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
h := requireSession(slog.New(slog.NewTextHandler(io.Discard, nil)), stubSessions{})(next)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings/household", nil)
|
||||
req.AddCookie(validSessionCookie)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want the handler's 204", rec.Code)
|
||||
}
|
||||
if !ok || got.Subject != "user-123" || got.Email != "cook@example.com" || got.Name != "Home Cook" ||
|
||||
!slices.Equal(got.Groups, []string{"mealquest-admin"}) {
|
||||
t.Errorf("actor = %+v (ok=%v), want the session's household member", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireSessionStopsUnauthenticated(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
called := false
|
||||
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })
|
||||
h := requireSession(slog.New(slog.NewTextHandler(io.Discard, nil)), stubSessions{})(next)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/settings/household", nil))
|
||||
|
||||
if called || rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("handler called = %v, status = %d; want not called and 401", called, rec.Code)
|
||||
}
|
||||
}
|
||||
+16
-1
@@ -86,6 +86,17 @@ func ParseLocalDate(s string) (LocalDate, error) {
|
||||
return LocalDate{t.Year(), t.Month(), t.Day()}, nil
|
||||
}
|
||||
|
||||
// LocalDateOf returns the calendar date of t in t's own location.
|
||||
func LocalDateOf(t time.Time) LocalDate {
|
||||
y, m, d := t.Date()
|
||||
return LocalDate{y, m, d}
|
||||
}
|
||||
|
||||
// Date returns the year, month and day of d.
|
||||
func (d LocalDate) Date() (year int, month time.Month, day int) {
|
||||
return d.year, d.month, d.day
|
||||
}
|
||||
|
||||
// String returns the YYYY-MM-DD form.
|
||||
func (d LocalDate) String() string {
|
||||
return fmt.Sprintf("%04d-%02d-%02d", d.year, int(d.month), d.day)
|
||||
@@ -96,11 +107,15 @@ func (d LocalDate) IsZero() bool {
|
||||
return d == LocalDate{}
|
||||
}
|
||||
|
||||
// Value implements driver.Valuer. The zero LocalDate is an error.
|
||||
// Value implements driver.Valuer. The zero LocalDate is an error, as is a
|
||||
// year outside 0000–9999, which the four-digit stored form cannot represent.
|
||||
func (d LocalDate) Value() (driver.Value, error) {
|
||||
if d.IsZero() {
|
||||
return nil, errors.New("store: zero LocalDate")
|
||||
}
|
||||
if d.year < 0 || d.year > 9999 {
|
||||
return nil, fmt.Errorf("store: LocalDate year %d outside 0000-9999", d.year)
|
||||
}
|
||||
return d.String(), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -102,6 +102,25 @@ func TestLocalDateRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalDateOf(t *testing.T) {
|
||||
auckland, err := time.LoadLocation("Pacific/Auckland")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 2026-10-03T20:00Z is already 2026-10-04 in Auckland (UTC+13).
|
||||
instant := time.Date(2026, time.October, 3, 20, 0, 0, 0, time.UTC)
|
||||
if got := LocalDateOf(instant).String(); got != "2026-10-03" {
|
||||
t.Errorf("LocalDateOf(UTC) = %s, want 2026-10-03", got)
|
||||
}
|
||||
d := LocalDateOf(instant.In(auckland))
|
||||
if got := d.String(); got != "2026-10-04" {
|
||||
t.Errorf("LocalDateOf(Auckland) = %s, want 2026-10-04", got)
|
||||
}
|
||||
if y, m, day := d.Date(); y != 2026 || m != time.October || day != 4 {
|
||||
t.Errorf("Date() = %d-%d-%d, want 2026-10-4", y, m, day)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalDateRejectsInvalid(t *testing.T) {
|
||||
for _, str := range []string{
|
||||
"", "2026-02-30", "2026-02-29", "2026-13-01", "2026-00-10", "2026-10-00",
|
||||
@@ -119,6 +138,12 @@ func TestLocalDateRejectsInvalid(t *testing.T) {
|
||||
if _, err := (LocalDate{}).Value(); err == nil {
|
||||
t.Error("zero LocalDate Value succeeded")
|
||||
}
|
||||
for _, y := range []int{-1, 10000} {
|
||||
d := LocalDateOf(time.Date(y, time.January, 1, 0, 0, 0, 0, time.UTC))
|
||||
if _, err := d.Value(); err == nil {
|
||||
t.Errorf("LocalDate year %d Value succeeded", y)
|
||||
}
|
||||
}
|
||||
var d LocalDate
|
||||
if err := d.Scan(nil); err == nil {
|
||||
t.Error("Scan(nil) succeeded")
|
||||
|
||||
Reference in New Issue
Block a user