Files
familienarchiv/docs/adr/020-stateful-auth-via-spring-session-jdbc.md
2026-05-17 19:15:51 +02:00

4.4 KiB

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 <base64(email:password)>) — 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

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.

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.