docs(adr): ADR-020 — stateful auth via Spring Session JDBC

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-17 19:15:51 +02:00
parent f1e0b92f47
commit de7053644b

View File

@@ -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 <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
### 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.