diff --git a/README.md b/README.md index d08ebbf..6ff2bd3 100644 --- a/README.md +++ b/README.md @@ -36,14 +36,19 @@ 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 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. `MEALQUEST_PUBLIC_URL`, `MEALQUEST_OIDC_ISSUER` and the +every variable above is required except the commented-out session +lifetimes (when set they must be positive, with MAX at least IDLE), 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]`. diff --git a/cmd/server/main.go b/cmd/server/main.go index 23d8738..89f6b9d 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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) { diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 8256c88..629ed19 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -25,6 +25,7 @@ import ( const ( LoginPath = "/auth/login" CallbackPath = "/auth/callback" + LogoutPath = "/auth/logout" ) // providerTimeout bounds every request to the identity provider. @@ -43,11 +44,14 @@ type Claims struct { Groups []string `json:"groups"` } -// SessionStarter is the session layer's entry point. StartSession is called -// once per successful login with claims from a verified ID token; it must set -// the session cookie on w. A returned error aborts the login with 500. -type SessionStarter interface { +// 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. @@ -69,13 +73,16 @@ type Auth struct { verifier *oidc.IDTokenVerifier client *http.Client cookie loginCookie - sessions SessionStarter - logger *slog.Logger + sessions Sessions + // 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 SessionStarter, logger *slog.Logger) (*Auth, error) { +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") @@ -84,7 +91,7 @@ func New(ctx context.Context, cfg Config, sessions SessionStarter, logger *slog. case len(cfg.SecretKey) == 0: return nil, errors.New("auth: secret key is required") case sessions == nil: - return nil, errors.New("auth: session starter is required") + return nil, errors.New("auth: session layer is required") } if err := checkEndpoint("issuer", cfg.Issuer); err != nil { @@ -98,7 +105,8 @@ func New(ctx context.Context, cfg Config, sessions SessionStarter, logger *slog. } endpoint := provider.Endpoint() var discovered struct { - JWKSURI string `json:"jwks_uri"` + 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) @@ -109,6 +117,7 @@ func New(ctx context.Context, cfg Config, sessions SessionStarter, logger *slog. {"authorization endpoint", endpoint.AuthURL}, {"token endpoint", endpoint.TokenURL}, {"JWKS endpoint", discovered.JWKSURI}, + {"end session endpoint", discovered.EndSessionURI}, } { if err := checkEndpoint(e.name, e.raw); err != nil { return nil, err @@ -129,10 +138,11 @@ func New(ctx context.Context, cfg Config, sessions SessionStarter, logger *slog. ClientID: cfg.ClientID, SupportedSigningAlgs: []string{oidc.RS256}, }), - client: client, - cookie: newLoginCookie(cfg.SecretKey), - sessions: sessions, - logger: logger, + client: client, + cookie: newLoginCookie(cfg.SecretKey), + sessions: sessions, + endSessionURL: discovered.EndSessionURI, + logger: logger, }, nil } @@ -169,12 +179,16 @@ func checkRedirect(req *http.Request, via []*http.Request) error { return nil } -// Mount registers GET LoginPath and GET CallbackPath on r. +// 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. @@ -250,6 +264,18 @@ func (a *Auth) callback(w http.ResponseWriter, r *http.Request) { 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. diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 66e4379..a9d83e6 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -86,6 +86,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", + "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"}, @@ -223,11 +224,12 @@ func writeJSON(w http.ResponseWriter, v any) { json.NewEncoder(w).Encode(v) } -// memorySessions is an in-memory SessionStarter that records the claims it -// was handed. +// 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 } @@ -242,6 +244,16 @@ func (m *memorySessions) StartSession(w http.ResponseWriter, _ *http.Request, cl 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() @@ -780,6 +792,7 @@ func TestNewRejectsRemoteHTTPEndpoint(t *testing.T) { {"authorization_endpoint", "authorization endpoint"}, {"token_endpoint", "token endpoint"}, {"jwks_uri", "JWKS endpoint"}, + {"end_session_endpoint", "end session endpoint"}, } for _, tc := range tests { t.Run(tc.field, func(t *testing.T) { diff --git a/internal/auth/session.go b/internal/auth/session.go new file mode 100644 index 0000000..89d061e --- /dev/null +++ b/internal/auth/session.go @@ -0,0 +1,238 @@ +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// SessionCookieName is the session cookie. The __Host- prefix makes the +// browser insist on Secure, Path=/ and no Domain. +const SessionCookieName = "__Host-mealquest_session" + +// sessionIDBytes is the size of the random session ID: 256 bits. +const sessionIDBytes = 32 + +// ErrNoSession means the request carries no live session: the cookie is +// missing or malformed, or its session is unknown, ended or expired. +var ErrNoSession = errors.New("auth: no valid session") + +// Session is a signed-in household member's server-side session. +type Session struct { + Subject string + Email string + Name string + Groups []string + CSRFToken string + CreatedAt time.Time + // LastSeenAt is the last time the session was used. + LastSeenAt time.Time + // ExpiresAt is when the session ends unless used again first. + ExpiresAt time.Time +} + +// SessionManager keeps sessions in the sessions table. The cookie holds only +// the random session ID; the table holds only its SHA-256 hash. A session +// ends after idle without use and always max after it was created. +type SessionManager struct { + db *store.Store + idle time.Duration + max time.Duration + // now is the clock; tests replace it. + now func() time.Time +} + +// NewSessionManager returns a SessionManager over db. idle and max must be +// positive with max >= idle. +func NewSessionManager(db *store.Store, idle, max time.Duration) (*SessionManager, error) { + switch { + case db == nil: + return nil, errors.New("auth: session store is required") + case idle <= 0 || max <= 0: + return nil, errors.New("auth: session idle and max lifetimes must be positive") + case max < idle: + return nil, errors.New("auth: session max lifetime must be at least the idle lifetime") + } + return &SessionManager{db: db, idle: idle, max: max, now: time.Now}, nil +} + +// StartSession creates a session for claims and sets the session cookie. +func (m *SessionManager) StartSession(w http.ResponseWriter, r *http.Request, claims Claims) error { + if claims.Subject == "" { + return errors.New("auth: session needs a subject") + } + id, csrf := randomToken(), randomToken() + groups := claims.Groups + if groups == nil { + groups = []string{} + } + groupsJSON, err := json.Marshal(groups) + if err != nil { + return fmt.Errorf("auth: encode session groups: %w", err) + } + + now := m.timestamp() + if _, err := m.db.Writer.ExecContext(r.Context(), ` + INSERT INTO sessions (id_hash, subject, email, name, groups, csrf_token, created_at, last_seen_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + hashSessionID(id), claims.Subject, claims.Email, claims.Name, string(groupsJSON), csrf, + now, now, m.expiry(now.Time, now.Time), + ); err != nil { + return fmt.Errorf("auth: create session: %w", err) + } + + // The cookie outlives idle gaps so a returning device presents its ID; + // the server decides whether the session is still live. Max-Age is whole + // seconds, rounded up so the cookie never ends before the absolute limit. + http.SetCookie(w, sessionCookie(id, cookieMaxAge(m.max))) + return nil +} + +// cookieMaxAge is lifetime in whole seconds, rounded up. It rounds the +// quotient rather than adding to lifetime, which would overflow near the +// largest time.Duration. +func cookieMaxAge(lifetime time.Duration) int { + seconds := lifetime / time.Second + if lifetime%time.Second != 0 { + seconds++ + } + return int(seconds) +} + +// SessionFor returns the live session r carries and slides its last use to +// now. It returns ErrNoSession when there is none; an expired session is +// deleted. +func (m *SessionManager) SessionFor(r *http.Request) (Session, error) { + idHash, err := sessionIDHash(r) + if err != nil { + return Session{}, err + } + now := m.timestamp() + + var s Session + live := false + err = m.db.WithTx(r.Context(), func(tx *sql.Tx) error { + var groupsJSON string + var created, lastSeen, expires store.Timestamp + err := tx.QueryRowContext(r.Context(), ` + SELECT subject, email, name, groups, csrf_token, created_at, last_seen_at, expires_at + FROM sessions WHERE id_hash = ?`, idHash, + ).Scan(&s.Subject, &s.Email, &s.Name, &groupsJSON, &s.CSRFToken, &created, &lastSeen, &expires) + if errors.Is(err, sql.ErrNoRows) { + return nil + } + if err != nil { + return fmt.Errorf("auth: read session: %w", err) + } + + if !now.Before(expires.Time) || now.Sub(lastSeen.Time) >= m.idle || now.Sub(created.Time) >= m.max { + if _, err := tx.ExecContext(r.Context(), `DELETE FROM sessions WHERE id_hash = ?`, idHash); err != nil { + return fmt.Errorf("auth: delete expired session: %w", err) + } + return nil + } + + if err := json.Unmarshal([]byte(groupsJSON), &s.Groups); err != nil { + return fmt.Errorf("auth: decode session groups: %w", err) + } + expiresAt := m.expiry(created.Time, now.Time) + if _, err := tx.ExecContext(r.Context(), ` + UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE id_hash = ?`, + now, expiresAt, idHash, + ); err != nil { + return fmt.Errorf("auth: slide session: %w", err) + } + s.CreatedAt, s.LastSeenAt, s.ExpiresAt = created.Time, now.Time, expiresAt.Time + live = true + return nil + }) + if err != nil { + return Session{}, err + } + if !live { + return Session{}, ErrNoSession + } + return s, nil +} + +// EndSession deletes the session r carries, if any, and clears the cookie. +func (m *SessionManager) EndSession(w http.ResponseWriter, r *http.Request) error { + http.SetCookie(w, sessionCookie("", -1)) + idHash, err := sessionIDHash(r) + if errors.Is(err, ErrNoSession) { + return nil + } + if err != nil { + return err + } + if _, err := m.db.Writer.ExecContext(r.Context(), `DELETE FROM sessions WHERE id_hash = ?`, idHash); err != nil { + return fmt.Errorf("auth: delete session: %w", err) + } + return nil +} + +// timestamp is the clock's current instant at stored precision. +func (m *SessionManager) timestamp() store.Timestamp { + return store.Timestamp{Time: m.now().UTC().Truncate(time.Millisecond)} +} + +// expiry is when a session created at created and last used at lastSeen +// ends: idle after lastSeen, but never later than max after created. +func (m *SessionManager) expiry(created, lastSeen time.Time) store.Timestamp { + idle, absolute := lastSeen.Add(m.idle), created.Add(m.max) + if absolute.Before(idle) { + return store.Timestamp{Time: absolute} + } + return store.Timestamp{Time: idle} +} + +// sessionCookie is the session cookie carrying value, with maxAge seconds; +// a negative maxAge clears it. +func sessionCookie(value string, maxAge int) *http.Cookie { + return &http.Cookie{ + Name: SessionCookieName, + Value: value, + Path: "/", + MaxAge: maxAge, + Secure: true, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + } +} + +// sessionIDHash returns the stored hash of r's session ID, or ErrNoSession +// when the cookie is missing or is not a well-formed ID. +func sessionIDHash(r *http.Request) (string, error) { + c, err := r.Cookie(SessionCookieName) + if err != nil { + return "", ErrNoSession + } + raw, err := base64.RawURLEncoding.DecodeString(c.Value) + if err != nil || len(raw) != sessionIDBytes { + return "", ErrNoSession + } + return hashSessionID(c.Value), nil +} + +// hashSessionID is the SHA-256 hex digest of the cookie value. +func hashSessionID(id string) string { + sum := sha256.Sum256([]byte(id)) + return hex.EncodeToString(sum[:]) +} + +// randomToken returns 256 random bits, base64url-encoded without padding. +// crypto/rand.Read never returns an error; it crashes the program instead. +func randomToken() string { + b := make([]byte, sessionIDBytes) + rand.Read(b) + return base64.RawURLEncoding.EncodeToString(b) +} diff --git a/internal/auth/session_test.go b/internal/auth/session_test.go new file mode 100644 index 0000000..20e03fe --- /dev/null +++ b/internal/auth/session_test.go @@ -0,0 +1,521 @@ +package auth + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "slices" + "strings" + "testing" + "time" + + "github.com/go-chi/chi/v5" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" +) + +const ( + testIdle = 30 * 24 * time.Hour + testMax = 180 * 24 * time.Hour +) + +// testEpoch is the fake clock's starting instant. +var testEpoch = time.Date(2026, 10, 3, 18, 30, 0, 0, time.UTC) + +// sessionHarness runs the real login flow against a fake provider with a +// SessionManager over a migrated database and a fake clock. +type sessionHarness struct { + *harness + db *store.Store + sessions *SessionManager + auth *Auth + clock time.Time +} + +func newSessionHarness(t *testing.T) *sessionHarness { + 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 := NewSessionManager(db, testIdle, testMax) + if err != nil { + t.Fatal(err) + } + h := &sessionHarness{db: db, sessions: sessions, clock: testEpoch} + sessions.now = func() time.Time { return h.clock } + + p := newFakeProvider(t) + logs := &syncBuffer{} + a, err := New(ctx, Config{ + Issuer: p.issuer(), + ClientID: testClientID, + ClientSecret: testClientSecret, + PublicURL: testPublicURL, + SecretKey: []byte("server-secret-key"), + }, sessions, slog.New(slog.NewTextHandler(logs, nil))) + if err != nil { + t.Fatal(err) + } + r := chi.NewRouter() + a.Mount(r) + h.harness = &harness{provider: p, router: r, logs: logs} + h.auth = a + return h +} + +func (h *sessionHarness) advance(d time.Duration) { h.clock = h.clock.Add(d) } + +// signIn completes a login and returns the callback response and the +// session cookie it set. +func (h *sessionHarness) signIn(t *testing.T) (*http.Response, *http.Cookie) { + t.Helper() + 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 { + t.Fatalf("callback status = %d, want 303", resp.StatusCode) + } + for _, c := range resp.Cookies() { + if c.Name == SessionCookieName { + return resp, c + } + } + t.Fatalf("callback set no %s cookie", SessionCookieName) + return nil, nil +} + +// use looks up the session cookie c names. +func (h *sessionHarness) use(c *http.Cookie) (Session, error) { + req := httptest.NewRequest(http.MethodGet, "/api/anything", nil) + if c != nil { + req.AddCookie(c) + } + return h.sessions.SessionFor(req) +} + +type sessionRow struct { + idHash, subject, email, name, groups, csrf string + created, lastSeen, expires store.Timestamp +} + +func (h *sessionHarness) rows(t *testing.T) []sessionRow { + t.Helper() + rs, err := h.db.Reader.Query(`SELECT id_hash, subject, email, name, groups, csrf_token, + created_at, last_seen_at, expires_at FROM sessions`) + if err != nil { + t.Fatal(err) + } + defer rs.Close() + var out []sessionRow + for rs.Next() { + var r sessionRow + if err := rs.Scan(&r.idHash, &r.subject, &r.email, &r.name, &r.groups, &r.csrf, + &r.created, &r.lastSeen, &r.expires); err != nil { + t.Fatal(err) + } + out = append(out, r) + } + if err := rs.Err(); err != nil { + t.Fatal(err) + } + return out +} + +// sessionSetCookie returns the raw Set-Cookie header for the session cookie. +func sessionSetCookie(t *testing.T, resp *http.Response) string { + t.Helper() + for _, v := range resp.Header.Values("Set-Cookie") { + if strings.HasPrefix(v, SessionCookieName+"=") { + return v + } + } + t.Fatalf("no Set-Cookie for %s in %q", SessionCookieName, resp.Header.Values("Set-Cookie")) + return "" +} + +// assertSessionCookieAttributes checks the attributes the __Host- prefix +// and D-004 require, on the raw header so nothing is lost in parsing. +func assertSessionCookieAttributes(t *testing.T, raw string) { + t.Helper() + attrs := map[string]bool{} + for _, a := range strings.Split(raw, ";")[1:] { + name, _, _ := strings.Cut(strings.TrimSpace(a), "=") + attrs[strings.ToLower(name)] = true + } + for _, want := range []string{"Path=/", "HttpOnly", "Secure", "SameSite=Lax"} { + if !slices.Contains(strings.Split(raw, "; "), want) { + t.Errorf("session Set-Cookie %q lacks %s", raw, want) + } + } + if attrs["domain"] { + t.Errorf("session Set-Cookie %q has a Domain", raw) + } +} + +func sha256Hex(s string) string { + sum := sha256.Sum256([]byte(s)) + return hex.EncodeToString(sum[:]) +} + +func TestCallbackStartsSession(t *testing.T) { + t.Parallel() + h := newSessionHarness(t) + resp, cookie := h.signIn(t) + + assertSessionCookieAttributes(t, sessionSetCookie(t, resp)) + if cookie.MaxAge != int(testMax/time.Second) { + t.Errorf("session cookie MaxAge = %d, want %d", cookie.MaxAge, int(testMax/time.Second)) + } + + rows := h.rows(t) + if len(rows) != 1 { + t.Fatalf("sessions has %d rows, want 1", len(rows)) + } + r := rows[0] + if len(cookie.Value) != 43 { + t.Errorf("session ID %q is %d chars, want 43 (256 bits, base64url)", cookie.Value, len(cookie.Value)) + } + if r.idHash != sha256Hex(cookie.Value) { + t.Errorf("id_hash = %q, want the SHA-256 of the cookie value", r.idHash) + } + if r.subject != "user-123" || r.email != "cook@example.com" || r.name != "Home Cook" || + r.groups != `["mealquest-admin"]` { + t.Errorf("session row = %+v, want the ID token claims", r) + } + if len(r.csrf) != 43 || r.csrf == cookie.Value { + t.Errorf("csrf_token = %q, want a separate 256-bit token", r.csrf) + } + if !r.created.Equal(testEpoch) || !r.lastSeen.Equal(testEpoch) || !r.expires.Equal(testEpoch.Add(testIdle)) { + t.Errorf("created, last_seen, expires = %v, %v, %v, want %v, %v, %v", + r.created, r.lastSeen, r.expires, testEpoch, testEpoch, testEpoch.Add(testIdle)) + } + + s, err := h.use(cookie) + if err != nil { + t.Fatalf("SessionFor = %v, want the new session", err) + } + if s.Subject != "user-123" || s.CSRFToken != r.csrf || !slices.Equal(s.Groups, []string{"mealquest-admin"}) { + t.Errorf("SessionFor = %+v", s) + } +} + +func TestStartSessionStoresOnlyTheHash(t *testing.T) { + t.Parallel() + h := newSessionHarness(t) + _, cookie := h.signIn(t) + var n int + err := h.db.Reader.QueryRow(`SELECT count(*) FROM sessions WHERE + id_hash = ?1 OR subject = ?1 OR email = ?1 OR name = ?1 OR groups = ?1 OR csrf_token = ?1`, + cookie.Value).Scan(&n) + if err != nil { + t.Fatal(err) + } + if n != 0 { + t.Error("the raw session ID is stored in the sessions table") + } +} + +func TestSessionLastSeenSlides(t *testing.T) { + t.Parallel() + h := newSessionHarness(t) + _, cookie := h.signIn(t) + + // Used every 20 days, the session outlives several idle periods. + for range 4 { + h.advance(20 * 24 * time.Hour) + s, err := h.use(cookie) + if err != nil { + t.Fatalf("at %v SessionFor = %v, want the session", h.clock, err) + } + if !s.LastSeenAt.Equal(h.clock) || !s.ExpiresAt.Equal(h.clock.Add(testIdle)) { + t.Errorf("at %v session last seen %v, expires %v, want %v, %v", + h.clock, s.LastSeenAt, s.ExpiresAt, h.clock, h.clock.Add(testIdle)) + } + r := h.rows(t)[0] + if !r.lastSeen.Equal(h.clock) || !r.expires.Equal(h.clock.Add(testIdle)) || !r.created.Equal(testEpoch) { + t.Errorf("at %v row created %v, last seen %v, expires %v", h.clock, r.created, r.lastSeen, r.expires) + } + } +} + +func TestSessionIdleExpiry(t *testing.T) { + t.Parallel() + h := newSessionHarness(t) + _, cookie := h.signIn(t) + + h.advance(testIdle - time.Millisecond) + if _, err := h.use(cookie); err != nil { + t.Fatalf("just inside the idle limit SessionFor = %v, want the session", err) + } + h.advance(testIdle) + if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) { + t.Fatalf("after idle SessionFor = %v, want ErrNoSession", err) + } + if n := len(h.rows(t)); n != 0 { + t.Errorf("expired session left %d rows, want 0", n) + } + // Turning the clock back cannot revive it. + h.clock = testEpoch + if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) { + t.Errorf("after deletion SessionFor = %v, want ErrNoSession", err) + } +} + +func TestSessionAbsoluteExpiry(t *testing.T) { + t.Parallel() + h := newSessionHarness(t) + _, cookie := h.signIn(t) + + for h.clock.Before(testEpoch.Add(testMax - 20*24*time.Hour)) { + h.advance(20 * 24 * time.Hour) + if _, err := h.use(cookie); err != nil { + t.Fatalf("at %v SessionFor = %v, want the session", h.clock, err) + } + } + // Last used 20 days before the absolute limit: the idle window would run + // past it, so expiry is capped there. + if r := h.rows(t)[0]; !r.expires.Equal(testEpoch.Add(testMax)) { + t.Errorf("expires_at = %v, want the absolute limit %v", r.expires, testEpoch.Add(testMax)) + } + + h.clock = testEpoch.Add(testMax - time.Millisecond) + if _, err := h.use(cookie); err != nil { + t.Fatalf("just inside the absolute limit SessionFor = %v, want the session", err) + } + h.clock = testEpoch.Add(testMax) + if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) { + t.Fatalf("at the absolute limit, used 1ms ago, SessionFor = %v, want ErrNoSession", err) + } + if n := len(h.rows(t)); n != 0 { + t.Errorf("expired session left %d rows, want 0", n) + } +} + +// A max with fractional seconds rounds the cookie Max-Age up, so the browser +// keeps the cookie until the server-side absolute limit. +func TestSessionCookieOutlivesFractionalMax(t *testing.T) { + t.Parallel() + h := newSessionHarness(t) + h.sessions.idle, h.sessions.max = 1500*time.Millisecond, 2500*time.Millisecond + _, cookie := h.signIn(t) + if cookie.MaxAge != 3 { + t.Errorf("session cookie MaxAge = %d, want 3", cookie.MaxAge) + } + if r := h.rows(t)[0]; !r.expires.Equal(testEpoch.Add(1500 * time.Millisecond)) { + t.Errorf("expires_at = %v, want %v", r.expires, testEpoch.Add(1500*time.Millisecond)) + } + + h.advance(time.Second) + if _, err := h.use(cookie); err != nil { + t.Fatalf("at 1s SessionFor = %v, want the session", err) + } + h.clock = testEpoch.Add(2500*time.Millisecond - time.Millisecond) + if _, err := h.use(cookie); err != nil { + t.Fatalf("just inside the absolute limit SessionFor = %v, want the session", err) + } + h.clock = testEpoch.Add(2500 * time.Millisecond) + if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) { + t.Errorf("at the absolute limit SessionFor = %v, want ErrNoSession", err) + } +} + +// A lifetime near the largest accepted duration still yields a positive, +// rounded-up cookie Max-Age rather than overflowing. +func TestSessionCookieMaxAgeNearLargestMax(t *testing.T) { + t.Parallel() + largest, err := time.ParseDuration("2562047h47m16.854s") + if err != nil { + t.Fatal(err) + } + h := newSessionHarness(t) + h.sessions.max = largest + _, cookie := h.signIn(t) + if want := 9223372037; cookie.MaxAge != want { + t.Errorf("session cookie MaxAge = %d, want %d", cookie.MaxAge, want) + } +} + +// A lifetime shortened by configuration applies to existing sessions even +// though their stored expires_at is later. +func TestSessionLifetimeChecksUseCurrentConfig(t *testing.T) { + t.Parallel() + h := newSessionHarness(t) + _, cookie := h.signIn(t) + h.sessions.idle = 24 * time.Hour + h.advance(24 * time.Hour) + if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) { + t.Errorf("past the shortened idle SessionFor = %v, want ErrNoSession", err) + } + + _, cookie = h.signIn(t) + h.sessions.idle, h.sessions.max = 24*time.Hour, 48*time.Hour + for range 3 { + h.advance(12 * time.Hour) + if _, err := h.use(cookie); err != nil { + t.Fatalf("SessionFor = %v, want the session", err) + } + } + h.advance(12 * time.Hour) + if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) { + t.Errorf("past the shortened max SessionFor = %v, want ErrNoSession", err) + } + + // Used recently and with expires_at still ahead, but older than a max + // shortened after that use: only the absolute check ends it. + h.sessions.idle, h.sessions.max = testIdle, testMax + _, cookie = h.signIn(t) + h.advance(10 * 24 * time.Hour) + if _, err := h.use(cookie); err != nil { + t.Fatalf("SessionFor = %v, want the session", err) + } + h.sessions.max = 10 * 24 * time.Hour + h.advance(time.Hour) + if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) { + t.Errorf("past a max shortened after the last use SessionFor = %v, want ErrNoSession", err) + } +} + +func TestSessionForRejectsBadCookies(t *testing.T) { + t.Parallel() + h := newSessionHarness(t) + h.signIn(t) + for name, c := range map[string]*http.Cookie{ + "missing": nil, + "empty": {Name: SessionCookieName, Value: ""}, + "not base64": {Name: SessionCookieName, Value: strings.Repeat("!", 43)}, + "short": {Name: SessionCookieName, Value: "AAAA"}, + "unknown ID": {Name: SessionCookieName, Value: randomToken()}, + "other cookie": {Name: "mealquest_session", Value: randomToken()}, + } { + if _, err := h.use(c); !errors.Is(err, ErrNoSession) { + t.Errorf("%s: SessionFor = %v, want ErrNoSession", name, err) + } + } + if n := len(h.rows(t)); n != 1 { + t.Errorf("sessions has %d rows, want the signed-in session only", n) + } +} + +func (h *sessionHarness) logout(cookies ...*http.Cookie) *http.Response { + req := httptest.NewRequest(http.MethodPost, LogoutPath, nil) + for _, c := range cookies { + req.AddCookie(c) + } + rec := httptest.NewRecorder() + h.router.ServeHTTP(rec, req) + return rec.Result() +} + +func TestLogout(t *testing.T) { + t.Parallel() + h := newSessionHarness(t) + _, cookie := h.signIn(t) + _, other := h.signIn(t) + + resp := h.logout(cookie) + if resp.StatusCode != http.StatusSeeOther { + t.Fatalf("logout status = %d, want 303", resp.StatusCode) + } + want := h.provider.issuer() + "/end-session/" + if got := resp.Header.Get("Location"); got != want || h.auth.EndSessionURL() != want { + t.Errorf("logout redirect = %q, EndSessionURL = %q, want %q", got, h.auth.EndSessionURL(), want) + } + raw := sessionSetCookie(t, resp) + assertSessionCookieAttributes(t, raw) + if !strings.HasPrefix(raw, SessionCookieName+"=;") || !strings.Contains(raw, "Max-Age=0") { + t.Errorf("logout Set-Cookie = %q, want the cookie cleared", raw) + } + + rows := h.rows(t) + if len(rows) != 1 || rows[0].idHash != sha256Hex(other.Value) { + t.Errorf("after logout sessions = %+v, want only the other session", rows) + } + if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) { + t.Errorf("logged-out cookie: SessionFor = %v, want ErrNoSession", err) + } + if _, err := h.use(other); err != nil { + t.Errorf("other session: SessionFor = %v, want it untouched", err) + } +} + +func TestLogoutWithoutSession(t *testing.T) { + t.Parallel() + h := newSessionHarness(t) + resp := h.logout() + if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != h.provider.issuer()+"/end-session/" { + t.Errorf("logout = %d to %q, want 303 to the end session endpoint", resp.StatusCode, resp.Header.Get("Location")) + } + assertSessionCookieAttributes(t, sessionSetCookie(t, resp)) +} + +func TestLogoutIsPostOnly(t *testing.T) { + t.Parallel() + h := newSessionHarness(t) + _, cookie := h.signIn(t) + if resp := h.get(LogoutPath, cookie); resp.StatusCode != http.StatusMethodNotAllowed { + t.Errorf("GET %s status = %d, want 405", LogoutPath, resp.StatusCode) + } + if _, err := h.use(cookie); err != nil { + t.Errorf("after GET %s SessionFor = %v, want the session", LogoutPath, err) + } +} + +func TestLogoutSessionError(t *testing.T) { + t.Parallel() + h := newHarness(t) + h.sessions.err = errors.New("database down") + rec := httptest.NewRecorder() + h.router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, LogoutPath, nil)) + if rec.Code != http.StatusInternalServerError { + t.Errorf("logout status = %d, want 500", rec.Code) + } +} + +func TestNewRequiresEndSessionEndpoint(t *testing.T) { + t.Parallel() + 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"), + }, &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) + } +} + +func TestNewSessionManagerValidates(t *testing.T) { + t.Parallel() + db := &store.Store{} + for _, tc := range []struct { + name string + db *store.Store + idle, max time.Duration + }{ + {"nil store", nil, time.Hour, time.Hour}, + {"zero idle", db, 0, time.Hour}, + {"negative max", db, time.Hour, -time.Hour}, + {"max below idle", db, 2 * time.Hour, time.Hour}, + } { + if _, err := NewSessionManager(tc.db, tc.idle, tc.max); err == nil { + t.Errorf("%s: NewSessionManager succeeded", tc.name) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 113c855..699003b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -27,6 +27,16 @@ const ( EnvOIDCClientID = "MEALQUEST_OIDC_CLIENT_ID" EnvOIDCClientSecretFile = "MEALQUEST_OIDC_CLIENT_SECRET_FILE" EnvOIDCAllowedGroups = "MEALQUEST_OIDC_ALLOWED_GROUPS" + + EnvSessionIdle = "MEALQUEST_SESSION_IDLE" + EnvSessionMax = "MEALQUEST_SESSION_MAX" +) + +// Session lifetime defaults (D-004): long enough that the kitchen tablet +// stays signed in. +const ( + DefaultSessionIdle = 720 * time.Hour // 30 days + DefaultSessionMax = 4320 * time.Hour // 180 days ) // Config is the validated service configuration. @@ -43,6 +53,11 @@ type Config struct { OIDCClientSecret Secret // OIDCAllowedGroups lists the groups whose members may sign in. OIDCAllowedGroups []string + + // SessionIdle ends a session left unused this long. + SessionIdle time.Duration + // SessionMax ends every session this long after sign-in, used or not. + SessionMax time.Duration } // PublicOrigin returns the scheme and host of PublicURL, the only parts of it @@ -70,6 +85,12 @@ func load(lookup func(string) (string, bool)) (Config, error) { OIDCClientID: r.str(EnvOIDCClientID), OIDCClientSecret: r.secretFile(EnvOIDCClientSecretFile), OIDCAllowedGroups: r.list(EnvOIDCAllowedGroups), + + SessionIdle: r.sessionDuration(EnvSessionIdle, DefaultSessionIdle), + SessionMax: r.sessionDuration(EnvSessionMax, DefaultSessionMax), + } + if cfg.SessionIdle > 0 && cfg.SessionMax > 0 && cfg.SessionMax < cfg.SessionIdle { + r.fail(EnvSessionMax, "must be at least %s (%s)", EnvSessionIdle, cfg.SessionIdle) } if len(r.problems) > 0 { return Config{}, &Error{Problems: r.problems} @@ -243,6 +264,32 @@ func (r *reader) duration(name string) time.Duration { if !ok { return 0 } + return r.parseDuration(name, v) +} + +// optionalDuration returns def when name is unset. A set value, even an empty +// one, must be a valid positive duration. +func (r *reader) optionalDuration(name string, def time.Duration) time.Duration { + v, ok := r.lookup(name) + if !ok { + return def + } + return r.parseDuration(name, v) +} + +// sessionDuration is optionalDuration restricted to whole milliseconds: +// session expiry is stored as a store.Timestamp, which keeps millisecond +// precision, so a finer duration could not be honoured. +func (r *reader) sessionDuration(name string, def time.Duration) time.Duration { + d := r.optionalDuration(name, def) + if d%time.Millisecond != 0 { + r.fail(name, "duration must be a whole number of milliseconds, got %s", d) + return 0 + } + return d +} + +func (r *reader) parseDuration(name, v string) time.Duration { d, err := time.ParseDuration(v) if err != nil { r.fail(name, "invalid duration %q (want e.g. 10s, 1m)", v) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index c00286b..4dafec1 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -246,6 +246,53 @@ func TestLoad(t *testing.T) { env: with(map[string]string{EnvOIDCClientID: " "}), badVars: []string{EnvOIDCClientID}, }, + { + name: "session lifetimes set", + env: with(map[string]string{EnvSessionIdle: "24h", EnvSessionMax: "48h"}), + }, + { + name: "session max equal to idle", + env: with(map[string]string{EnvSessionIdle: "24h", EnvSessionMax: "24h"}), + }, + { + name: "invalid session idle", + env: with(map[string]string{EnvSessionIdle: "a month"}), + badVars: []string{EnvSessionIdle}, + }, + { + name: "empty session idle", + env: with(map[string]string{EnvSessionIdle: ""}), + badVars: []string{EnvSessionIdle}, + }, + { + name: "non-positive session max", + env: with(map[string]string{EnvSessionMax: "-1h"}), + badVars: []string{EnvSessionMax}, + }, + { + name: "whole-millisecond session lifetimes", + env: with(map[string]string{EnvSessionIdle: "1ms", EnvSessionMax: "2ms"}), + }, + { + name: "sub-millisecond session idle", + env: with(map[string]string{EnvSessionIdle: "1500us", EnvSessionMax: "2ms"}), + badVars: []string{EnvSessionIdle}, + }, + { + name: "sub-millisecond session max", + env: with(map[string]string{EnvSessionIdle: "1ms", EnvSessionMax: "1ms500us"}), + badVars: []string{EnvSessionMax}, + }, + { + name: "session max below idle", + env: with(map[string]string{EnvSessionIdle: "48h", EnvSessionMax: "24h"}), + badVars: []string{EnvSessionMax}, + }, + { + name: "session max below default idle", + env: with(map[string]string{EnvSessionMax: "719h"}), + badVars: []string{EnvSessionMax}, + }, { name: "several invalid values are all named", env: with(map[string]string{ @@ -351,6 +398,19 @@ func TestLoadValues(t *testing.T) { if want := []string{"mealquest-admin", "family"}; !slices.Equal(cfg.OIDCAllowedGroups, want) { t.Errorf("OIDCAllowedGroups = %q, want %q", cfg.OIDCAllowedGroups, want) } + if cfg.SessionIdle != 720*time.Hour || cfg.SessionMax != 4320*time.Hour { + t.Errorf("SessionIdle, SessionMax = %v, %v, want defaults 720h, 4320h", cfg.SessionIdle, cfg.SessionMax) + } + + env[EnvSessionIdle] = "12h" + env[EnvSessionMax] = "36h" + cfg, err = load(func(k string) (string, bool) { v, ok := env[k]; return v, ok }) + if err != nil { + t.Fatalf("load() error = %v", err) + } + if cfg.SessionIdle != 12*time.Hour || cfg.SessionMax != 36*time.Hour { + t.Errorf("SessionIdle, SessionMax = %v, %v, want 12h, 36h", cfg.SessionIdle, cfg.SessionMax) + } } func TestSecretRedacted(t *testing.T) {