# ADR-020 — Stateful Authentication via Spring Session JDBC **Date:** 2026-05-17 **Status:** Accepted **Issue:** #523 --- ## Context PR #521 (closing #520) introduced `AuthTokenCookieFilter` to unblock a production deploy. The filter promotes an `auth_token` cookie — which contains the full HTTP Basic credential (`Basic `) — to an `Authorization` header so browser-direct `/api/*` calls authenticate correctly behind Caddy. This model has three concrete problems: 1. **Cookie = credential.** A stolen `auth_token` cookie leaks the user's password in base64-encoded plaintext. No decode step is needed; the cookie value is directly usable as a credential forever. 2. **No server-side revocation.** Logout deletes the local cookie but the credential remains valid until the 24 h `Max-Age` elapses. An attacker who copied the cookie before logout retains access. 3. **No audit signal.** There is no server-side record of login or logout events. Observability and compliance tooling cannot reconstruct "who was logged in when". Additionally, Nora flagged that `url.protocol === 'https:'` in `login/+page.server.ts` is incorrect behind Caddy: SvelteKit sees `http`, so `Secure=false` was set on the credential cookie in production, transmitting it in cleartext from Caddy to the browser on any network path without TLS. --- ## Decision Replace the `auth_token` / `AuthTokenCookieFilter` model with **Spring Session JDBC**: - A `POST /api/auth/login` endpoint in a new `auth` package authenticates with `email + password`, creates a server-side session record in PostgreSQL, and returns the `AppUser` JSON in the response body. - The response sets an **opaque** `fa_session` cookie (`HttpOnly`, `SameSite=Strict`, `Secure` in non-dev profiles, `Max-Age=28800` — 8 h idle timeout) that contains only the session ID, never a credential. - A `POST /api/auth/logout` endpoint invalidates the session record immediately. Subsequent requests carrying the same cookie return 401. - `AuthTokenCookieFilter` is deleted in the same PR. No transitional coexistence period. - Cookie name `fa_session` (not the default `SESSION`) minimises framework fingerprinting. Session storage uses the canonical `spring_session` / `spring_session_attributes` tables, re-introduced via `V67__recreate_spring_session_tables.sql` (dropped by V2 when the dependency was previously removed as unused). **Idle timeout:** 8 h (`MaxInactiveIntervalInSeconds = 28800`). No 24 h absolute cap is implemented in Phase 1 — the 8 h idle bound contains the risk to one workday. A weekend-long active session is acceptable given the family-archive threat model. The absolute cap and additional revocation paths (password-change, admin force-logout) land in Phase 2 (#524). --- ## Alternatives Considered ### Stay on Basic cookie + add a server-side revocation table Keeps the credential-in-cookie problem. Implementing a revocation table would re-invent Spring Session badly — we'd write bespoke session storage that already exists and is well-tested upstream. ### JWT (stateless) Opaque revocation is simpler than JWT revocation (token introspection or short-lived tokens + refresh). The cluster is single-node; session affinity is not a constraint. Stateless tokens buy complexity without benefit here. JWKS infrastructure and refresh-token rotation are unnecessary for a family archive with < 50 concurrent users. ### Keep `auth_token` cookie but add `AuthTokenCookieFilter` improvements The root problem is that the cookie contains the credential. No amount of filter hardening fixes that. Nora's P1 flag stands until the credential leaves the cookie. --- ## Consequences - **One breaking deploy.** All existing sessions (the `auth_token` cookies) become inert on the next request after the deploy. The SvelteKit `handleAuth` hook redirects to `/login?reason=expired`; a banner renders. Users re-login. No data loss. - **~2 KB per active session** in PostgreSQL (`spring_session_attributes` stores the serialised `SecurityContext`). With < 50 family members, this is immaterial. - **Session cleanup task** runs on the default Spring Session JDBC schedule (every 10 min). No custom job needed. - **Caddy / infrastructure unchanged.** `forward-headers-strategy: native` already ensures `Secure` cookies work correctly behind the reverse proxy. - **Dev profile:** `application-dev.yaml` sets `secure: false` on the session cookie so local HTTP dev (port 5173 → 8080) works without TLS.