95 lines
4.4 KiB
Markdown
95 lines
4.4 KiB
Markdown
# 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.
|