From de7053644b325060736136cec61c984cc092301f Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 17 May 2026 19:15:51 +0200 Subject: [PATCH] =?UTF-8?q?docs(adr):=20ADR-020=20=E2=80=94=20stateful=20a?= =?UTF-8?q?uth=20via=20Spring=20Session=20JDBC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- ...0-stateful-auth-via-spring-session-jdbc.md | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 docs/adr/020-stateful-auth-via-spring-session-jdbc.md diff --git a/docs/adr/020-stateful-auth-via-spring-session-jdbc.md b/docs/adr/020-stateful-auth-via-spring-session-jdbc.md new file mode 100644 index 00000000..78b62397 --- /dev/null +++ b/docs/adr/020-stateful-auth-via-spring-session-jdbc.md @@ -0,0 +1,94 @@ +# 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.