diff --git a/README.md b/README.md index c764516..d374368 100644 --- a/README.md +++ b/README.md @@ -30,14 +30,32 @@ export MEALQUEST_PUBLIC_URL=http://localhost:5173 export MEALQUEST_DATA_DIR="$HOME/.mealquest-dev" export MEALQUEST_SHUTDOWN_TIMEOUT=10s export MEALQUEST_SECRET_KEY_FILE="$HOME/.mealquest-dev/secret_key" +# Authentik OAuth2/OpenID provider for MealQuest (redirect URI: +# $MEALQUEST_PUBLIC_URL/auth/callback). Save its client secret to the file. +export MEALQUEST_OIDC_ISSUER=https://auth.example.com/application/o/mealquest/ +export MEALQUEST_OIDC_CLIENT_ID=mealquest +export MEALQUEST_OIDC_CLIENT_SECRET_FILE="$HOME/.mealquest-dev/oidc_client_secret" +export MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin +# Optional: sessions end after MEALQUEST_SESSION_IDLE unused (default 720h) +# 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 ``` `make dev` runs the Go server on `:8080` and the Vite dev server on `:5173` (which proxies `/api`, `/auth` and `/media` to `:8080`). Open http://localhost:5173. Configuration is environment-only and fails fast: -every variable above is required, and secrets are read only from files named -by `*_FILE` variables. +every variable above is required except the commented-out session +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]`. ## Common commands diff --git a/cmd/server/main.go b/cmd/server/main.go index f98335e..c937169 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -13,7 +13,9 @@ import ( "syscall" "time" + "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/server" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" @@ -30,7 +32,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) @@ -51,7 +53,23 @@ func run(cfg config.Config, logger *slog.Logger) error { } }() - router, err := server.NewRouter(logger, version, frontend.Assets()) + sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax) + if err != nil { + 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()), + AllowedGroups: cfg.OIDCAllowedGroups, + }, sessions, logger) + if err != nil { + return err + } + + router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, frontend.Assets()) if err != nil { return err } @@ -109,6 +127,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, ) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4beae8c..6f9eaab 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 | diff --git a/go.mod b/go.mod index a517797..39a6ce9 100644 --- a/go.mod +++ b/go.mod @@ -3,13 +3,16 @@ module code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest go 1.25.0 require ( + github.com/coreos/go-oidc/v3 v3.21.0 github.com/go-chi/chi/v5 v5.3.1 github.com/oklog/ulid/v2 v2.1.2 + golang.org/x/oauth2 v0.36.0 modernc.org/sqlite v1.59.0 ) require ( github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/google/uuid v1.6.0 // indirect github.com/mattn/go-isatty v0.0.24 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect diff --git a/go.sum b/go.sum index 3a07785..ba3f090 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,11 @@ +github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM= +github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -19,6 +23,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= diff --git a/internal/auth/actor.go b/internal/auth/actor.go new file mode 100644 index 0000000..c781091 --- /dev/null +++ b/internal/auth/actor.go @@ -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 +} diff --git a/internal/auth/actor_test.go b/internal/auth/actor_test.go new file mode 100644 index 0000000..fff57cb --- /dev/null +++ b/internal/auth/actor_test.go @@ -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") + } +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..d82d33d --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,439 @@ +// Package auth signs the household in with Authentik over OpenID Connect. +// The service is a confidential client: the authorization code, tokens and +// client secret stay on the server and never reach JavaScript. +package auth + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "errors" + "fmt" + "log/slog" + "net/http" + "net/url" + "slices" + "sync/atomic" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/go-chi/chi/v5" + "golang.org/x/oauth2" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secureurl" +) + +// Routes owned by this package. +const ( + LoginPath = "/auth/login" + CallbackPath = "/auth/callback" + LogoutPath = "/auth/logout" +) + +// providerTimeout bounds every request to the identity provider. +const providerTimeout = 10 * time.Second + +// scopes requested at login. +var scopes = []string{oidc.ScopeOpenID, "profile", "email", "groups", oidc.ScopeOfflineAccess} + +// Claims are the verified ID token claims handed to the session layer. +type Claims struct { + Subject string `json:"sub"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Name string `json:"name"` + PreferredUsername string `json:"preferred_username"` + Groups []string `json:"groups"` +} + +// Sessions is the session layer. StartSession is called once per successful +// login with claims from a verified ID token; it must set the session cookie +// on w. EndSession is called on logout; it must end the session r carries, +// if any, and clear the cookie on w. A returned error fails the request with +// 500. +type Sessions interface { + StartSession(w http.ResponseWriter, r *http.Request, claims Claims) error + EndSession(w http.ResponseWriter, r *http.Request) error +} + +// Config is what New needs to reach the identity provider. +type Config struct { + // Issuer is the exact issuer URL; discovery runs against it. + Issuer string + ClientID string + ClientSecret string + // PublicURL is the service's public URL; its origin prefixes CallbackPath + // to form the redirect URI. + 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 + logger *slog.Logger +} + +// New discovers the provider at cfg.Issuer and returns the login flow. It +// fails if discovery fails or the issuer the provider reports differs. +func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.Logger) (*Auth, error) { + switch { + case cfg.Issuer == "", cfg.ClientID == "", cfg.ClientSecret == "": + return nil, errors.New("auth: issuer, client ID and client secret are required") + case cfg.PublicURL == nil: + return nil, errors.New("auth: public URL is required") + case len(cfg.SecretKey) == 0: + 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 { + return nil, err + } + + client := &http.Client{Timeout: providerTimeout, CheckRedirect: checkRedirect} + 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, discoveryError(err, int(recorder.status.Load())) + } + endpoint := provider.Endpoint() + var discovered struct { + JWKSURI string `json:"jwks_uri"` + EndSessionURI string `json:"end_session_endpoint"` + } + if err := provider.Claims(&discovered); err != nil { + return nil, fmt.Errorf("auth: OIDC discovery: %w", err) + } + // The token endpoint receives the client secret and the key set decides + // which tokens verify, so every endpoint used must pass the same rule. + for _, e := range []struct{ name, raw string }{ + {"authorization endpoint", endpoint.AuthURL}, + {"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 + } + } + // client_secret_basic, the OIDC default; no auto-detection retries. + endpoint.AuthStyle = oauth2.AuthStyleInHeader + + return &Auth{ + oauth: oauth2.Config{ + ClientID: cfg.ClientID, + ClientSecret: cfg.ClientSecret, + Endpoint: endpoint, + RedirectURL: cfg.PublicURL.Scheme + "://" + cfg.PublicURL.Host + CallbackPath, + Scopes: scopes, + }, + verifier: provider.Verifier(&oidc.Config{ + 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, + logger: 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 { + u, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("auth: OIDC %s %q is not a valid URL", name, raw) + } + if err := secureurl.Check(u); err != nil { + return fmt.Errorf("auth: OIDC %s %q: %w", name, raw, err) + } + return nil +} + +// maxRedirects matches net/http's default redirect limit. +const maxRedirects = 10 + +// checkRedirect is the provider client's redirect policy. On a 307 or 308 to +// the same host Go resends the body and Authorization header, which carry the +// code and client secret, so every target must pass secureurl.Check and an +// https:// request never follows a redirect to plain http://, even loopback. +func checkRedirect(req *http.Request, via []*http.Request) error { + if len(via) >= maxRedirects { + return fmt.Errorf("stopped after %d redirects", maxRedirects) + } + if err := secureurl.Check(req.URL); err != nil { + return fmt.Errorf("redirect refused: %w", err) + } + if via[len(via)-1].URL.Scheme == "https" && req.URL.Scheme != "https" { + return errors.New("redirect refused: https:// to plain http:// downgrade") + } + return nil +} + +// Mount registers GET LoginPath, GET CallbackPath and POST LogoutPath on r. +func (a *Auth) Mount(r chi.Router) { + r.Get(LoginPath, a.login) + r.Get(CallbackPath, a.callback) + r.Post(LogoutPath, a.logout) +} + +// EndSessionURL is the provider's end_session_endpoint. +func (a *Auth) EndSessionURL() string { return a.endSessionURL } + +// login starts the authorization code flow with PKCE (S256). The optional +// "return" query parameter must be a same-origin path; anything else fails +// the login with 400. +func (a *Auth) login(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + + returnTo, err := returnPath(r.URL.Query().Get("return")) + if err != nil { + http.Error(w, "invalid return path", http.StatusBadRequest) + return + } + state := loginState{ + State: rand.Text(), + Nonce: rand.Text(), + Verifier: oauth2.GenerateVerifier(), + Return: returnTo, + } + if err := a.cookie.set(w, state, time.Now()); err != nil { + a.logger.ErrorContext(r.Context(), "set login cookie", "error", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + http.Redirect(w, r, a.oauth.AuthCodeURL(state.State, + oidc.Nonce(state.Nonce), + oauth2.S256ChallengeOption(state.Verifier), + ), http.StatusFound) +} + +// callback completes the flow: it checks state against the login cookie, +// 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() + // The login cookie is single-use. + a.cookie.clear(w) + + state, err := a.cookie.read(r, time.Now()) + if err != nil { + a.logger.WarnContext(ctx, "login callback without a valid login cookie", "error", err) + http.Error(w, "sign-in expired; start again", http.StatusBadRequest) + return + } + q := r.URL.Query() + if subtle.ConstantTimeCompare([]byte(q.Get("state")), []byte(state.State)) != 1 { + a.logger.WarnContext(ctx, "login callback state mismatch") + http.Error(w, "sign-in state mismatch; start again", http.StatusBadRequest) + return + } + if q.Get("error") != "" { + // error and error_description are provider-supplied text; only the + // fixed category is logged. + a.logger.WarnContext(ctx, "identity provider returned an authorization error") + http.Error(w, "sign-in was refused", http.StatusUnauthorized) + return + } + code := q.Get("code") + if code == "" { + http.Error(w, "missing authorization code", http.StatusBadRequest) + return + } + + claims, err := a.verify(ctx, code, state) + if err != nil { + a.logger.WarnContext(ctx, "sign-in failed", "error", err) + 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) + return + } + http.Redirect(w, r, state.Return, http.StatusSeeOther) +} + +// logout ends the session, clears the cookie and sends the browser to the +// provider's end_session_endpoint to end the provider session as well. +func (a *Auth) logout(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + if err := a.sessions.EndSession(w, r); err != nil { + a.logger.ErrorContext(r.Context(), "end session", "error", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + http.Redirect(w, r, a.endSessionURL, http.StatusSeeOther) +} + +// verify exchanges code for tokens and returns the verified ID token claims. +// The verifier enforces RS256, issuer, audience and expiry; nonce is checked +// 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)) + if err != nil { + return Claims{}, exchangeError(err) + } + raw, ok := token.Extra("id_token").(string) + if !ok || raw == "" { + return Claims{}, errors.New("token response has no id_token") + } + idToken, err := a.verifier.Verify(ctx, raw) + if err != nil { + return Claims{}, verifyError(err) + } + if subtle.ConstantTimeCompare([]byte(idToken.Nonce), []byte(state.Nonce)) != 1 { + return Claims{}, errors.New("ID token nonce mismatch") + } + var claims Claims + if err := idToken.Claims(&claims); err != nil { + return Claims{}, fmt.Errorf("decode ID token claims: %w", err) + } + 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. +func exchangeError(err error) error { + var retrieve *oauth2.RetrieveError + var transport *url.Error + switch { + case errors.As(err, &retrieve): + return fmt.Errorf("exchange code: token endpoint rejected the request (HTTP %d)", retrieve.Response.StatusCode) + case errors.As(err, &transport): + // Request failures never carry the response. The URL is dropped: after + // a refused redirect it is the provider-supplied Location. + return fmt.Errorf("exchange code: token request failed: %s: %w", transport.Op, transport.Err) + default: + return errors.New("exchange code: token endpoint returned an unusable response") + } +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..407bbb8 --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,1075 @@ +package auth + +import ( + "bytes" + "context" + "crypto" + "crypto/hmac" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "maps" + "math/big" + "net/http" + "net/http/httptest" + "net/url" + "slices" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/go-chi/chi/v5" +) + +const ( + testClientID = "mealquest" + testClientSecret = "client-s3cret" + testKID = "test-key" +) + +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 { + t *testing.T + srv *httptest.Server + key *rsa.PrivateKey + + mu sync.Mutex + codes map[string]issuedCode + // discoveryOverrides replace fields of the discovery document. + discoveryOverrides map[string]any + // 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 { + status int + body string +} + +type issuedCode struct { + challenge string + idToken string +} + +func newFakeProvider(t *testing.T) *fakeProvider { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + p := &fakeProvider{t: t, key: key, codes: map[string]issuedCode{}} + mux := http.NewServeMux() + 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 +} + +func (p *fakeProvider) issuer() string { return p.srv.URL } + +func (p *fakeProvider) discovery(w http.ResponseWriter, _ *http.Request) { + doc := map[string]any{ + "issuer": p.issuer(), + "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"}, + "id_token_signing_alg_values_supported": []string{"RS256", "HS256"}, + } + p.mu.Lock() + maps.Copy(doc, p.discoveryOverrides) + p.mu.Unlock() + writeJSON(w, doc) +} + +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, + "use": "sig", + "alg": "RS256", + "n": b64(p.key.N.Bytes()), + "e": b64(big.NewInt(int64(p.key.E)).Bytes()), + }}}) +} + +// token checks client authentication, the code and the PKCE verifier before +// returning the code's ID token. +func (p *fakeProvider) token(w http.ResponseWriter, r *http.Request) { + p.mu.Lock() + reply := p.tokenReply + p.mu.Unlock() + if reply != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(reply.status) + io.WriteString(w, reply.body) + return + } + id, secret, ok := r.BasicAuth() + if !ok || id != testClientID || secret != testClientSecret { + http.Error(w, `{"error":"invalid_client"}`, http.StatusUnauthorized) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, `{"error":"invalid_request"}`, http.StatusBadRequest) + return + } + p.mu.Lock() + issued, ok := p.codes[r.PostForm.Get("code")] + delete(p.codes, r.PostForm.Get("code")) + p.mu.Unlock() + if r.PostForm.Get("grant_type") != "authorization_code" || !ok || + challengeS256(r.PostForm.Get("code_verifier")) != issued.challenge { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + io.WriteString(w, `{"error":"invalid_grant"}`) + return + } + writeJSON(w, map[string]any{ + "access_token": "access-token", + "token_type": "Bearer", + "expires_in": 3600, + "id_token": issued.idToken, + }) +} + +// 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 { + code := rand.Text() + p.mu.Lock() + p.codes[code] = issuedCode{challenge: challenge, idToken: idToken} + p.mu.Unlock() + return code +} + +// claims returns valid ID token claims for nonce. +func (p *fakeProvider) claims(nonce string) map[string]any { + now := time.Now() + return map[string]any{ + "iss": p.issuer(), + "sub": "user-123", + "aud": testClientID, + "iat": now.Unix(), + "exp": now.Add(5 * time.Minute).Unix(), + "nonce": nonce, + "email": "cook@example.com", + "email_verified": true, + "name": "Home Cook", + "preferred_username": "cook", + "groups": []string{"mealquest-admin"}, + } +} + +func (p *fakeProvider) signRS256(claims map[string]any) string { + return signRS256(p.t, p.key, claims) +} + +func signRS256(t *testing.T, key *rsa.PrivateKey, claims map[string]any) string { + t.Helper() + signing := jwtSigningInput(t, "RS256", claims) + digest := sha256.Sum256([]byte(signing)) + sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:]) + if err != nil { + t.Fatal(err) + } + return signing + "." + b64(sig) +} + +// signHS256 signs with the client secret, as in an algorithm-confusion attack. +func (p *fakeProvider) signHS256(claims map[string]any) string { + signing := jwtSigningInput(p.t, "HS256", claims) + mac := hmac.New(sha256.New, []byte(testClientSecret)) + mac.Write([]byte(signing)) + return signing + "." + b64(mac.Sum(nil)) +} + +func jwtSigningInput(t *testing.T, alg string, claims map[string]any) string { + t.Helper() + header, err := json.Marshal(map[string]string{"alg": alg, "typ": "JWT", "kid": testKID}) + if err != nil { + t.Fatal(err) + } + body, err := json.Marshal(claims) + if err != nil { + t.Fatal(err) + } + return b64(header) + "." + b64(body) +} + +func challengeS256(verifier string) string { + sum := sha256.Sum256([]byte(verifier)) + return b64(sum[:]) +} + +func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(v) +} + +// memorySessions is an in-memory Sessions that records the claims it was +// handed and how many sessions were ended. +type memorySessions struct { + mu sync.Mutex + claims []Claims + ended int + err error +} + +func (m *memorySessions) StartSession(w http.ResponseWriter, _ *http.Request, claims Claims) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.err != nil { + return m.err + } + m.claims = append(m.claims, claims) + http.SetCookie(w, &http.Cookie{Name: "test_session", Value: claims.Subject}) + return nil +} + +func (m *memorySessions) EndSession(http.ResponseWriter, *http.Request) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.err != nil { + return m.err + } + m.ended++ + return nil +} + +func (m *memorySessions) started() []Claims { + m.mu.Lock() + defer m.mu.Unlock() + return slices.Clone(m.claims) +} + +type harness struct { + auth *Auth + provider *fakeProvider + sessions *memorySessions + router http.Handler + logs *syncBuffer +} + +// syncBuffer is a bytes.Buffer safe for concurrent log writes. +type syncBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *syncBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *syncBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +func newHarness(t *testing.T) *harness { + t.Helper() + p := newFakeProvider(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"), + AllowedGroups: testAllowedGroups, + }, sessions, slog.New(slog.NewTextHandler(logs, nil))) + if err != nil { + t.Fatal(err) + } + r := chi.NewRouter() + a.Mount(r) + return &harness{auth: a, provider: p, sessions: sessions, router: r, logs: logs} +} + +func (h *harness) get(target string, cookies ...*http.Cookie) *http.Response { + req := httptest.NewRequest(http.MethodGet, target, nil) + for _, c := range cookies { + req.AddCookie(c) + } + rec := httptest.NewRecorder() + h.router.ServeHTTP(rec, req) + return rec.Result() +} + +// login is a started login: the authorize URL's query and the login cookie. +type login struct { + query url.Values + cookie *http.Cookie +} + +func (h *harness) login(t *testing.T, target string) login { + t.Helper() + resp := h.get(target) + if resp.StatusCode != http.StatusFound { + t.Fatalf("GET %s status = %d, want 302", target, 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, h.provider.issuer()+"/authorize"; got != want { + t.Fatalf("redirect to %s, want %s", got, want) + } + var cookie *http.Cookie + for _, c := range resp.Cookies() { + if c.Name == loginCookieName { + cookie = c + } + } + if cookie == nil { + t.Fatalf("login set no %s cookie", loginCookieName) + } + return login{query: loc.Query(), cookie: cookie} +} + +// callback completes l at the provider with idToken and calls the callback. +func (h *harness) callback(l login, state, idToken string) *http.Response { + code := h.provider.authorize(l.query.Get("code_challenge"), idToken) + q := url.Values{"code": {code}, "state": {state}} + return h.get(CallbackPath+"?"+q.Encode(), l.cookie) +} + +func TestLoginRedirect(t *testing.T) { + t.Parallel() + h := newHarness(t) + l := h.login(t, LoginPath+"?return=/plan") + + want := map[string]string{ + "client_id": testClientID, + "response_type": "code", + "scope": "openid profile email groups offline_access", + "redirect_uri": "https://meals.example.com/auth/callback", + "code_challenge_method": "S256", + } + for k, v := range want { + if got := l.query.Get(k); got != v { + t.Errorf("authorize %s = %q, want %q", k, got, v) + } + } + for _, k := range []string{"state", "nonce", "code_challenge"} { + if l.query.Get(k) == "" { + t.Errorf("authorize URL has no %s", k) + } + } + + c := l.cookie + if !c.HttpOnly || !c.Secure || c.SameSite != http.SameSiteLaxMode || c.Path != "/" { + t.Errorf("login cookie HttpOnly=%v Secure=%v SameSite=%v Path=%q, want HttpOnly, Secure, Lax, /", + c.HttpOnly, c.Secure, c.SameSite, c.Path) + } + if c.MaxAge <= 0 || c.MaxAge > 600 { + t.Errorf("login cookie MaxAge = %d, want 1-600 seconds", c.MaxAge) + } +} + +func TestLoginStateIsFreshEachTime(t *testing.T) { + t.Parallel() + h := newHarness(t) + a, b := h.login(t, LoginPath), h.login(t, LoginPath) + for _, k := range []string{"state", "nonce", "code_challenge"} { + if a.query.Get(k) == b.query.Get(k) { + t.Errorf("%s repeated across logins", k) + } + } +} + +func TestCallbackSuccess(t *testing.T) { + t.Parallel() + h := newHarness(t) + l := h.login(t, LoginPath+"?return="+url.QueryEscape("/plan?week=2026-W40")) + + resp := h.callback(l, l.query.Get("state"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce")))) + + if resp.StatusCode != http.StatusSeeOther { + t.Fatalf("callback status = %d, want 303", resp.StatusCode) + } + if got := resp.Header.Get("Location"); got != "/plan?week=2026-W40" { + t.Errorf("callback redirect = %q, want the return path", got) + } + started := h.sessions.started() + want := Claims{ + Subject: "user-123", + Email: "cook@example.com", + EmailVerified: true, + Name: "Home Cook", + PreferredUsername: "cook", + Groups: []string{"mealquest-admin"}, + } + if len(started) != 1 || started[0].Subject != want.Subject || started[0].Email != want.Email || + started[0].Name != want.Name || started[0].PreferredUsername != want.PreferredUsername || + !started[0].EmailVerified || !slices.Equal(started[0].Groups, want.Groups) { + t.Errorf("session started with %+v, want [%+v]", started, want) + } + assertLoginCookieCleared(t, resp) +} + +func TestCallbackDefaultReturnPath(t *testing.T) { + t.Parallel() + h := newHarness(t) + 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.StatusSeeOther || resp.Header.Get("Location") != "/" { + t.Errorf("callback = %d to %q, want 303 to /", resp.StatusCode, resp.Header.Get("Location")) + } +} + +func TestCallbackRejected(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + // state returns the state sent to the callback. + state func(l login) string + // token returns the ID token the provider issues. + token func(p *fakeProvider, l login) string + wantStatus int + }{ + { + name: "mismatched state", + state: func(login) string { return "attacker-state" }, + wantStatus: http.StatusBadRequest, + }, + { + name: "wrong nonce", + token: func(p *fakeProvider, _ login) string { + return p.signRS256(p.claims("other-nonce")) + }, + wantStatus: http.StatusUnauthorized, + }, + { + name: "missing nonce", + token: func(p *fakeProvider, _ login) string { + c := p.claims("") + delete(c, "nonce") + return p.signRS256(c) + }, + wantStatus: http.StatusUnauthorized, + }, + { + name: "HS256-signed token", + token: func(p *fakeProvider, l login) string { + return p.signHS256(p.claims(l.query.Get("nonce"))) + }, + wantStatus: http.StatusUnauthorized, + }, + { + name: "expired token", + token: func(p *fakeProvider, l login) string { + c := p.claims(l.query.Get("nonce")) + c["iat"] = time.Now().Add(-2 * time.Hour).Unix() + c["exp"] = time.Now().Add(-time.Hour).Unix() + return p.signRS256(c) + }, + wantStatus: http.StatusUnauthorized, + }, + { + name: "wrong issuer", + token: func(p *fakeProvider, l login) string { + c := p.claims(l.query.Get("nonce")) + c["iss"] = "https://evil.example.com" + return p.signRS256(c) + }, + wantStatus: http.StatusUnauthorized, + }, + { + name: "wrong audience", + token: func(p *fakeProvider, l login) string { + c := p.claims(l.query.Get("nonce")) + c["aud"] = "another-client" + return p.signRS256(c) + }, + wantStatus: http.StatusUnauthorized, + }, + { + name: "signed by an unknown key", + token: func(p *fakeProvider, l login) string { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + p.t.Fatal(err) + } + return signRS256(p.t, key, p.claims(l.query.Get("nonce"))) + }, + wantStatus: http.StatusUnauthorized, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + h := newHarness(t) + l := h.login(t, LoginPath) + state := l.query.Get("state") + if tc.state != nil { + state = tc.state(l) + } + token := h.provider.signRS256(h.provider.claims(l.query.Get("nonce"))) + if tc.token != nil { + token = tc.token(h.provider, l) + } + + resp := h.callback(l, state, token) + + if resp.StatusCode != tc.wantStatus { + t.Errorf("callback status = %d, want %d", resp.StatusCode, tc.wantStatus) + } + if n := len(h.sessions.started()); n != 0 { + t.Errorf("%d sessions started, want none", n) + } + assertLoginCookieCleared(t, resp) + }) + } +} + +func TestCallbackWithoutLoginCookie(t *testing.T) { + t.Parallel() + h := newHarness(t) + l := h.login(t, LoginPath) + l.cookie = &http.Cookie{Name: "unrelated", Value: "x"} + resp := h.callback(l, l.query.Get("state"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce")))) + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("callback status = %d, want 400", resp.StatusCode) + } + if n := len(h.sessions.started()); n != 0 { + t.Errorf("%d sessions started, want none", n) + } +} + +func TestCallbackTamperedLoginCookie(t *testing.T) { + t.Parallel() + h := newHarness(t) + l := h.login(t, LoginPath) + payload, sig, _ := strings.Cut(l.cookie.Value, ".") + data, err := base64.RawURLEncoding.DecodeString(payload) + if err != nil { + t.Fatal(err) + } + var s loginState + if err := json.Unmarshal(data, &s); err != nil { + t.Fatal(err) + } + s.Return = "/somewhere-else" + data, err = json.Marshal(s) + if err != nil { + t.Fatal(err) + } + l.cookie = &http.Cookie{Name: loginCookieName, Value: b64(data) + "." + sig} + + resp := h.callback(l, l.query.Get("state"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce")))) + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("callback status = %d, want 400", resp.StatusCode) + } +} + +// The provider's error and error_description are untrusted text and never +// reach the logs; only the fixed category does. +func TestCallbackProviderError(t *testing.T) { + t.Parallel() + const ( + errorMarker = "provider-error-marker" + descriptionMarker = "description-marker-" + testClientSecret + ) + h := newHarness(t) + l := h.login(t, LoginPath) + q := url.Values{ + "error": {errorMarker}, + "error_description": {descriptionMarker}, + "state": {l.query.Get("state")}, + } + resp := h.get(CallbackPath+"?"+q.Encode(), l.cookie) + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("callback status = %d, want 401", resp.StatusCode) + } + assertLoginCookieCleared(t, resp) + if n := len(h.sessions.started()); n != 0 { + t.Errorf("started %d sessions, want 0", n) + } + logs := h.logs.String() + for _, marker := range []string{errorMarker, descriptionMarker} { + if strings.Contains(logs, marker) { + t.Errorf("logs contain provider text %q: %s", marker, logs) + } + } + if !strings.Contains(logs, "identity provider returned an authorization error") { + t.Errorf("logs = %q, want the authorization error category", logs) + } +} + +func TestCallbackSessionError(t *testing.T) { + t.Parallel() + h := newHarness(t) + h.sessions.err = errors.New("database down") + 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.StatusInternalServerError { + t.Errorf("callback status = %d, want 500", resp.StatusCode) + } +} + +// A failed code exchange is logged by category and status only: the token +// endpoint's body and error_description may echo credentials. +func TestCallbackExchangeFailureDoesNotLogProviderResponse(t *testing.T) { + t.Parallel() + const leaked = "leaked-" + testClientSecret + tests := []struct { + name string + reply tokenReply + wantLogs string + }{ + { + name: "error response", + reply: tokenReply{http.StatusBadRequest, `{"error":"invalid_grant","error_description":"` + leaked + `"}`}, + wantLogs: "HTTP 400", + }, + { + name: "unparseable error response", + reply: tokenReply{http.StatusInternalServerError, leaked}, + wantLogs: "HTTP 500", + }, + { + name: "success without access token", + reply: tokenReply{http.StatusOK, `{"id_token":"` + leaked + `"}`}, + wantLogs: "unusable response", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + h := newHarness(t) + h.provider.mu.Lock() + h.provider.tokenReply = &tc.reply + h.provider.mu.Unlock() + l := h.login(t, LoginPath) + + resp := h.callback(l, l.query.Get("state"), "unused") + + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("callback status = %d, want 401", resp.StatusCode) + } + logs := h.logs.String() + if strings.Contains(logs, leaked) { + t.Errorf("logs contain the provider response: %s", logs) + } + if !strings.Contains(logs, tc.wantLogs) { + t.Errorf("logs = %q, want %q", logs, tc.wantLogs) + } + }) + } +} + +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")) + now := time.Now() + rec := httptest.NewRecorder() + if err := c.set(rec, loginState{State: "s", Nonce: "n", Verifier: "v", Return: "/"}, now); err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodGet, CallbackPath, nil) + req.AddCookie(rec.Result().Cookies()[0]) + + if _, err := c.read(req, now.Add(loginCookieTTL-time.Second)); err != nil { + t.Errorf("read before expiry: %v", err) + } + if _, err := c.read(req, now.Add(loginCookieTTL)); err == nil { + t.Error("read at expiry succeeded") + } +} + +func TestLoginRejectsOffOriginReturn(t *testing.T) { + t.Parallel() + h := newHarness(t) + for _, ret := range []string{ + "https://evil.example.com/", + "http://meals.example.com/plan", + "//evil.example.com/", + "/\\evil.example.com", + "\\\\evil.example.com", + "javascript:alert(1)", + "plan", + "/plan\r\nSet-Cookie: x=y", + } { + resp := h.get(LoginPath + "?return=" + url.QueryEscape(ret)) + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("return=%q: status = %d, want 400", ret, resp.StatusCode) + } + if loc := resp.Header.Get("Location"); loc != "" { + t.Errorf("return=%q: redirected to %q", ret, loc) + } + if len(resp.Cookies()) != 0 { + t.Errorf("return=%q: set cookies %v", ret, resp.Cookies()) + } + } +} + +func TestNewFailsWhenDiscoveryFails(t *testing.T) { + t.Parallel() + 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"), + 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"), + AllowedGroups: testAllowedGroups, + }, &memorySessions{}, slog.New(slog.DiscardHandler)) + if err == nil { + t.Fatal("New accepted a provider reporting a different issuer") + } +} + +func assertLoginCookieCleared(t *testing.T, resp *http.Response) { + t.Helper() + for _, c := range resp.Cookies() { + if c.Name == loginCookieName && c.MaxAge < 0 { + return + } + } + t.Errorf("callback did not clear the %s cookie", loginCookieName) +} + +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"), + 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) + } + } +} + +func TestNewRejectsRemoteHTTPEndpoint(t *testing.T) { + t.Parallel() + tests := []struct { + field, name string + }{ + {"authorization_endpoint", "authorization endpoint"}, + {"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) { + t.Parallel() + 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"), + 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) + } + }) + } +} + +// A token endpoint on https:// that redirects the code exchange to plain +// http:// must not receive a follow-up request: Go would resend the client +// secret in the Authorization header. The plaintext target is loopback, so +// only the downgrade rule stops it. +func TestCallbackRefusesTokenRedirectToHTTP(t *testing.T) { + t.Parallel() + for _, status := range []int{http.StatusTemporaryRedirect, http.StatusPermanentRedirect} { + t.Run(http.StatusText(status), func(t *testing.T) { + t.Parallel() + var plaintextHits atomic.Int32 + plaintext := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + plaintextHits.Add(1) + http.Error(w, "unreachable", http.StatusTeapot) + })) + t.Cleanup(plaintext.Close) + var tlsHits atomic.Int32 + tlsToken := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tlsHits.Add(1) + http.Redirect(w, r, plaintext.URL+"/token", status) + })) + t.Cleanup(tlsToken.Close) + + p := newFakeProvider(t) + p.discoveryOverrides = map[string]any{"token_endpoint": tlsToken.URL + "/token"} + sessions := &memorySessions{} + logs := &syncBuffer{} + a, err := New(context.Background(), Config{ + 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) + } + // Trust the test server's certificate; the redirect policy stays. + a.client.Transport = tlsToken.Client().Transport + r := chi.NewRouter() + a.Mount(r) + h := &harness{provider: p, sessions: sessions, router: r, logs: logs} + l := h.login(t, LoginPath) + + resp := h.callback(l, l.query.Get("state"), "unused") + + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("callback status = %d, want 401", resp.StatusCode) + } + if n := tlsHits.Load(); n != 1 { + t.Errorf("https token endpoint hit %d times, want 1", n) + } + if n := plaintextHits.Load(); n != 0 { + t.Errorf("plaintext endpoint hit %d times, want 0", n) + } + if n := len(sessions.started()); n != 0 { + t.Errorf("started %d sessions, want 0", n) + } + if got := logs.String(); !strings.Contains(got, "downgrade") { + t.Errorf("logs = %q, want the downgrade refusal", got) + } + }) + } +} + +func TestCheckRedirect(t *testing.T) { + t.Parallel() + request := func(raw string) *http.Request { + req, err := http.NewRequest(http.MethodPost, raw, nil) + if err != nil { + t.Fatal(err) + } + return req + } + tests := []struct { + from, to string + ok bool + }{ + {"https://auth.example.com/token", "https://auth2.example.com/token", true}, + {"http://127.0.0.1:9000/token", "http://localhost:9001/token", true}, + {"http://127.0.0.1:9000/token", "https://auth.example.com/token", true}, + {"https://auth.example.com/token", "http://auth.example.com/token", false}, + {"https://127.0.0.1:9000/token", "http://127.0.0.1:9001/token", false}, + {"http://127.0.0.1:9000/token", "http://auth.example.com/token", false}, + {"https://auth.example.com/token", "ftp://auth.example.com/token", false}, + } + for _, tc := range tests { + err := checkRedirect(request(tc.to), []*http.Request{request(tc.from)}) + if (err == nil) != tc.ok { + t.Errorf("redirect %s -> %s: error = %v, want ok=%v", tc.from, tc.to, err, tc.ok) + } + } + via := make([]*http.Request, maxRedirects) + for i := range via { + via[i] = request("https://auth.example.com/token") + } + if err := checkRedirect(request("https://auth.example.com/token"), via); err == nil { + t.Errorf("redirect after %d hops: error = nil, want limit", maxRedirects) + } +} diff --git a/internal/auth/cookie.go b/internal/auth/cookie.go new file mode 100644 index 0000000..4be2d7d --- /dev/null +++ b/internal/auth/cookie.go @@ -0,0 +1,124 @@ +package auth + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + loginCookieName = "__Host-mealquest_login" + loginCookieTTL = 10 * time.Minute + // loginCookieKeyLabel separates the login cookie's signing key from other + // keys derived from the same server secret. + loginCookieKeyLabel = "mealquest auth login cookie v1" +) + +// loginState travels from login to callback in the signed login cookie. +type loginState struct { + State string `json:"s"` + Nonce string `json:"n"` + Verifier string `json:"v"` + Return string `json:"r"` + Expires int64 `json:"e"` // Unix seconds +} + +// loginCookie signs and verifies the login cookie with HMAC-SHA256. +type loginCookie struct { + key []byte +} + +func newLoginCookie(secretKey []byte) loginCookie { + mac := hmac.New(sha256.New, secretKey) + mac.Write([]byte(loginCookieKeyLabel)) + return loginCookie{key: mac.Sum(nil)} +} + +func (c loginCookie) sign(payload string) string { + mac := hmac.New(sha256.New, c.key) + mac.Write([]byte(payload)) + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) +} + +func (c loginCookie) set(w http.ResponseWriter, s loginState, now time.Time) error { + s.Expires = now.Add(loginCookieTTL).Unix() + data, err := json.Marshal(s) + if err != nil { + return err + } + payload := base64.RawURLEncoding.EncodeToString(data) + http.SetCookie(w, &http.Cookie{ + Name: loginCookieName, + Value: payload + "." + c.sign(payload), + Path: "/", + MaxAge: int(loginCookieTTL / time.Second), + Secure: true, + HttpOnly: true, + // Lax so the cookie rides the provider's top-level redirect back. + SameSite: http.SameSiteLaxMode, + }) + return nil +} + +func (c loginCookie) clear(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: loginCookieName, + Path: "/", + MaxAge: -1, + Secure: true, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) +} + +func (c loginCookie) read(r *http.Request, now time.Time) (loginState, error) { + cookie, err := r.Cookie(loginCookieName) + if err != nil { + return loginState{}, errors.New("login cookie missing") + } + payload, sig, ok := strings.Cut(cookie.Value, ".") + if !ok || !hmac.Equal([]byte(sig), []byte(c.sign(payload))) { + return loginState{}, errors.New("login cookie signature invalid") + } + data, err := base64.RawURLEncoding.DecodeString(payload) + if err != nil { + return loginState{}, fmt.Errorf("login cookie encoding: %w", err) + } + var s loginState + if err := json.Unmarshal(data, &s); err != nil { + return loginState{}, fmt.Errorf("login cookie payload: %w", err) + } + if now.Unix() >= s.Expires { + return loginState{}, errors.New("login cookie expired") + } + return s, nil +} + +// returnPath validates the optional post-login return path. Empty means "/". +// Only same-origin absolute paths are allowed: no scheme, host, protocol- +// relative "//" prefix, backslashes or control characters. +func returnPath(raw string) (string, error) { + if raw == "" { + return "/", nil + } + if !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") || strings.ContainsRune(raw, '\\') { + return "", errors.New("return path must be a same-origin path") + } + for _, c := range raw { + if c < 0x20 || c == 0x7f { + return "", errors.New("return path contains a control character") + } + } + u, err := url.Parse(raw) + if err != nil || u.Scheme != "" || u.Host != "" || u.User != nil { + return "", errors.New("return path must be a same-origin path") + } + return raw, nil +} diff --git a/internal/auth/gate_test.go b/internal/auth/gate_test.go new file mode 100644 index 0000000..0f18362 --- /dev/null +++ b/internal/auth/gate_test.go @@ -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", + `