Add server-side sessions, session cookie and logout
CI / ci (push) Successful in 1m25s
CI / ci (pull_request) Successful in 1m24s
Independent review Codex gpt-6-sol high: CHANGES_REQUIRED (1 medium)

A successful OIDC callback now creates a sessions row through the new
auth.SessionManager: a random 256-bit ID whose SHA-256 hash is the only
stored form, the subject, email, name and groups claims, a separate random
CSRF token and created/last_seen/expires timestamps. The ID travels only in
the __Host-mealquest_session cookie (HttpOnly, Secure, SameSite=Lax, Path=/,
no Domain).

SessionFor rejects a session unused for MEALQUEST_SESSION_IDLE or older
than MEALQUEST_SESSION_MAX, deletes it, and otherwise slides last_seen_at.
POST /auth/logout deletes the row, clears the cookie and redirects to the
provider's end_session_endpoint, which discovery now requires.

The session lifetimes are optional configuration (defaults 720h and 4320h,
per D-004); set values must be positive with MAX at least IDLE.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 10:02:17 -04:00
co-authored by Claude Opus 5.5
parent 99c8f1625f
commit 1dfba7b9db
8 changed files with 851 additions and 27 deletions
+5 -9
View File
@@ -52,13 +52,17 @@ func run(cfg config.Config, logger *slog.Logger) error {
}
}()
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()),
}, sessionsPending{}, logger)
}, sessions, logger)
if err != nil {
return err
}
@@ -115,14 +119,6 @@ func openStore(ctx context.Context, dataDir string) (*store.Store, error) {
return db, nil
}
// sessionsPending refuses every login until the session layer (Feature 03
// task 2) replaces it.
type sessionsPending struct{}
func (sessionsPending) StartSession(http.ResponseWriter, *http.Request, auth.Claims) error {
return errors.New("session layer not implemented")
}
// logListening logs the startup line. Only the public URL's origin is logged
// so no credential-bearing URL component can reach the log.
func logListening(logger *slog.Logger, cfg config.Config) {