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