feat(auth): server-side session model replacing Basic-auth cookie promotion #523

Open
opened 2026-05-11 18:49:05 +02:00 by marcel · 9 comments
Owner

Context

This is Phase 1 of 2 splitting #522.

PR #521 (closing #520) added an AuthTokenCookieFilter that promotes an auth_token cookie (containing Basic <base64(email:password)>) to an Authorization header so browser-direct /api/* calls authenticate in production. The fix unblocked the deploy. The underlying model is debt: the cookie is the credential — stolen cookie = stolen password, no server-side revocation, no audit signal on login.

This issue replaces that machinery with a proper server-side session model. The Spring Security CSRF re-enable, password-change session invalidation, admin force-logout, and login rate limiting all land in the follow-up issue (Phase 2 — TODO: link once filed).

Why split

Phase 1 is the architectural cutover: one breaking deploy, every user re-logs in. Phase 2 is purely additive on top of a working session model. Folding them into one PR doubles review surface and forces the same risky cutover decisions to be made simultaneously. Independently, each phase is shippable and reviewable.

⚠ Corrected facts from #522 self-review

  • Spring Session JDBC is NOT currently in the stack. V2__drop_spring_session_tables.sql removed it as unused; backend/pom.xml has no spring-session-jdbc dependency. CLAUDE.md's "Tech Stack" line claiming it is in the stack is stale and must be updated alongside this work.
  • NFR wording tightened: invalidation is synchronous (≤1 request), not eventual (≤60s).

User need

As an operator of the Familienarchiv platform
I want the application's auth model to use server-side sessions with opaque, revocable session identifiers
so that a stolen cookie cannot leak the user's password and audit/observability becomes possible.

As a registered user (transcriber / reader)
I want my logout to actually log me out on the server
so that a cookie copied off my machine before logout no longer authenticates afterwards.

Functional requirements

FR-AUTH-001 — There SHALL be a first-class POST /api/auth/login endpoint in a new auth package (decided in #522 review) that accepts { email, password }, validates against app_users, and on success establishes a server-side session. The response body SHALL be the same AppUser shape currently returned by /api/users/me (drives the existing TypeScript binding via OpenAPI regen).

FR-AUTH-002 — Session establishment SHALL set an opaque session identifier (not a credential) in a cookie with:

  • HttpOnly=true
  • SameSite=strict
  • Secure=true unconditional in non-dev profiles (no runtime url.protocol branch — drop the isHttps footgun in login/+page.server.ts:50)
  • Path=/
  • Absolute lifetime: 24 h
  • Idle timeout: 8 h (MaxInactiveIntervalInSeconds=28800)

The session record SHALL be stored server-side in the spring_session / spring_session_attributes tables via Spring Session JDBC.

FR-AUTH-003 — There SHALL be a POST /api/auth/logout endpoint that invalidates the current session record only (single-device logout — decided in #522 review). After calling it, requests carrying the same cookie SHALL be treated as unauthenticated (401), even before the cookie's Max-Age elapses.

FR-AUTH-006AuthTokenCookieFilter and the Basic-auth machinery downstream of it SHALL be removed in the same PR that lands the new endpoints — no transitional/compat filter (Nora + Tobi both insist; "transitional" code becomes permanent). Specifically:

  • Delete AuthTokenCookieFilter.java and its test
  • Remove the secure: isHttps branch in frontend/src/routes/login/+page.server.ts
  • Remove the Basic cookie construction in the login action; the action POSTs to /api/auth/login and relies on the Set-Cookie returned by the backend
  • Remove the /api/users/me round-trip validation step in the login action (the login endpoint returns the user payload)
  • Remove the cookie→header header-injection in frontend/vite.config.ts (no longer needed; backend reads its own cookie)
  • Simplify frontend/src/hooks.server.ts — the handleFetch re-encoding logic disappears; the userGroup handle can read the principal from the session (or keep the /api/users/me call if simpler — design choice for the implementer)
  • Remove the formLogin(...) chain from SecurityConfig (Felix's note — implicit form-login is dead code once /api/auth/login exists)

FR-AUTH-007 — Deployment story SHALL remain the same: SvelteKit form-action login, no client-side JWT handling in browser JS, no extra round-trips per request. Caddy /api/* routing requires no header injection.

Non-functional requirements

NFR-SEC-101 — No user password (plaintext, base64, or otherwise reversible) SHALL be present in any cookie sent to the browser at any point after login completes. The session cookie SHALL contain only an opaque identifier.

NFR-SEC-102 — A stolen session cookie SHALL become inert on the next request after operator-initiated invalidation (logout in this issue; password change/reset and admin force-logout in Phase 2). No grace period is permitted. Spring Session JDBC deletes are synchronous; this is asserted via the test: at T+0 delete the session row, at T+0+ε the next request returns 401.

NFR-PERF-101 — Authenticated request overhead SHALL NOT exceed 5 ms (p95) added by session lookup compared to the current in-memory Basic-decode path. Measured via a k6 smoke run (optional but recommended per Sara's pyramid): 50 RPS of GET /api/users/me after a successful login, p95 added latency reported in the PR.

NFR-OBSV-101 (partial) — Login (success + failure) and logout SHALL produce structured audit log entries via the existing AuditService:

  • AuditKind.LOGIN_SUCCESS{ userId, ip, ua (truncated 200 chars) }
  • AuditKind.LOGIN_FAILED{ email, ip, ua }never the attempted password
  • AuditKind.LOGOUT{ userId, ip, ua }

(ADMIN_FORCE_LOGOUT is added in Phase 2.)

NFR-COMPAT-101 — Existing dev tooling SHALL continue to work without code changes outside this PR: vite.config.ts proxy (header-injection removed but proxy itself unchanged), the dev e2e test profile, the e2e profile's deterministic admin seed (admin@familyarchive.local / admin123 per PasswordResetTestHelper).

Acceptance criteria

Given a fresh app_users row and no existing session
When the user POSTs { email, password } to /api/auth/login with valid credentials
Then the response is 200 with the AppUser JSON in the body
And  the response sets a session cookie containing an opaque ID (no credential bytes — assert with regex /^[A-Za-z0-9_-]{20,}$/ or similar, never matches "Basic ")
And  a row exists in spring_session keyed by that ID, linked to the user via principal_name

Given a request with invalid credentials
When the user POSTs to /api/auth/login
Then the response is 401 with ErrorCode INVALID_CREDENTIALS
And  no Set-Cookie header is returned
And  an audit log entry of type LOGIN_FAILED is recorded with payload {email, ip, ua}, NO password

Given an active session
When the user POSTs to /api/auth/logout
Then the response is 204
And  the spring_session row is removed
And  any subsequent request with the same cookie returns 401
And  an audit log entry of type LOGOUT is recorded

Given a request with no session cookie
When it hits /api/auth/logout
Then the response is 401

Given a session has been idle for >8h
When the cookie is presented on the next request
Then the response is 401
And  the spring_session row has been cleaned up (Spring's cleanup task)

Given the new auth model is live
When a grep across the codebase for "AuthTokenCookieFilter" runs
Then no source file references it
And  grep for "auth_token" cookie usage returns no production references (test fixtures aside)
And  Caddy's /api/* routing requires no header injection or cookie translation

Given a user is redirected to /login with reason=expired
When the login page renders
Then an aria-live="polite" info banner shows the localized "session expired" message
And  the message reads "Du wurdest abgemeldet. Bitte melde Dich erneut an." in de

Resolved decisions (carried from #522 review)

OQ Decision Rationale
Module boundary New auth package Cleaner separation than extending user; cross-domain call (AuthService → UserService) is fine
Logout scope Current session only Phase 2 covers all-session invalidation paths separately
Idle timeout 8h idle, 24h absolute Zero schema cost (Spring Session has LAST_ACCESS_TIME)
Cutover strategy Hard cutover, no transitional filter Tobi + Nora aligned: transitional code becomes permanent
Remember-me Out of scope Threat-model change; defer until baseline is solid
Idle-timeout pop-up No Stress-inducing for the 60+ audience; expire silently and route to session-expired banner (Leonie)

Implementation hints (non-binding — for the implementer)

These are concrete starting points pulled from persona review; the implementer may diverge if a better path appears:

  • pom.xml — add spring-session-jdbc (pin the version explicitly; don't rely on BOM transitive)
  • New Flyway migration: V33__recreate_spring_session_tables.sql — copy the canonical DDL Spring Session ships; leave a comment explaining this recreates what V2 dropped
  • application.yaml — set spring.session.store-type=jdbc, spring.session.timeout=28800s
  • Frontend i18n: add session_expired key in messages/{de,en,es}.json
    • de: "Du wurdest abgemeldet. Bitte melde Dich erneut an."
    • en: "You were signed out. Please sign in again."
    • es: "Has cerrado sesión. Por favor, inicia sesión de nuevo."
  • Login button: verify min-h-[44px] (WCAG 2.2 SC 2.5.8) — fix in this PR
  • Login error mapping: INVALID_CREDENTIALS → "E-Mail oder Passwort sind nicht korrekt." (no user-not-found leak)
  • New ErrorCode values: INVALID_CREDENTIALS, SESSION_EXPIRED — add to ErrorCode.java, mirror in frontend/src/lib/shared/errors.ts, add i18n keys in all three locale files
  • ADR before code: docs/adr/NNNN-stateful-auth-via-spring-session-jdbc.md per Markus
  • Update the stale CLAUDE.md "Tech Stack" line as part of this PR
  • Update docs/architecture/c4/seq-auth-flow.puml — cookie promotion is gone
  • Logout action: POST to /api/auth/logout first, then delete cookie, then redirect. If the backend call fails, still delete the local cookie and redirect (Leonie — defense in depth: the user wanted to log out)

Out of scope (this issue — moves to Phase 2)

  • FR-AUTH-004 Password change invalidates other sessions
  • FR-AUTH-005 Admin force-logout (backend capability + audit)
  • FR-AUTH-008 Password reset invalidates all sessions for user
  • NFR-SEC-103 Spring Security CSRF re-enable (CookieCsrfTokenRepository.withHttpOnlyFalse())
  • NFR-SEC-104 Bucket4j login rate limit
  • AuditKind.ADMIN_FORCE_LOGOUT
  • ErrorCode.CSRF_TOKEN_MISSING, TOO_MANY_LOGIN_ATTEMPTS
  • CSRF token failure UX
  • Tobi's scripts/force-logout-user.sh break-glass helper
  • AuthE2EController force-create-session test seam (lands with Phase 2 if Sara still wants it)

Cutover plan (operator)

  1. Pre-deploy: post operator banner to family ("Wartung 22:00–22:15, danach bitte erneut anmelden")
  2. Deploy backend + frontend together (atomic from the user's perspective)
  3. All in-flight sessions become 401 on the next request — the SvelteKit handleAuth hook redirects to /login?reason=expired; the banner renders
  4. Users re-login
  5. No user-visible downtime — the app works, just shows the login screen

Priority & sizing

  • MoSCoW: Must for v1.1.0
  • Priority: P1-high (Nora flagged the underlying problem as P1: cookie ≠ token)
  • Linked NFRs: NFR-SEC-101, NFR-SEC-102, NFR-PERF-101, NFR-OBSV-101, NFR-COMPAT-101

Test strategy (Sara's pyramid, scoped to Phase 1)

Layer Tests
Unit (Mockito) AuthService.login happy + invalid creds; AuthService.logout; audit-log writes for both
Slice (@WebMvcTest) AuthController.login 200/401 paths; AuthController.logout 204/401 paths
Integration (Testcontainers + real Postgres + Spring Session JDBC tables via Flyway) full lifecycle: login → authed GET /api/users/me → logout → 401 on retry; idle-timeout: manually backdate LAST_ACCESS_TIME > 8h → next request 401
Frontend (vitest-browser + page.server.test.ts) Login action POSTs to /api/auth/login; logout action POSTs to /api/auth/logout before deleting cookie; session-expired banner renders when ?reason=expired
E2E (Playwright) login form happy path; logout button → login redirect; assert cookie name + value pattern (opaque, not Basic)
Coverage gate Hold the 88% branch line in pom.xml; new code carries ≥80% own branch coverage

Definition of Done

  • All ACs pass; tests start red, go green
  • AuthTokenCookieFilter and all references deleted
  • CLAUDE.md "Tech Stack" line updated to reflect Spring Session JDBC re-introduction
  • ADR landed in docs/adr/
  • docs/architecture/c4/seq-auth-flow.puml rewritten
  • OpenAPI types regenerated (npm run generate:api in frontend/)
  • Coverage gate held
  • Maintenance-window comms posted before merge to main

— Filed as part of splitting #522 (replaces it together with the Phase 2 issue). Closes the Phase-1 portion of the auth-model rewrite.

## Context This is **Phase 1 of 2** splitting #522. PR #521 (closing #520) added an `AuthTokenCookieFilter` that promotes an `auth_token` cookie (containing `Basic <base64(email:password)>`) to an `Authorization` header so browser-direct `/api/*` calls authenticate in production. The fix unblocked the deploy. The underlying model is debt: **the cookie *is* the credential** — stolen cookie = stolen password, no server-side revocation, no audit signal on login. This issue replaces that machinery with a proper server-side session model. The Spring Security CSRF re-enable, password-change session invalidation, admin force-logout, and login rate limiting all land in the follow-up issue (Phase 2 — TODO: link once filed). ### Why split Phase 1 is the architectural cutover: one breaking deploy, every user re-logs in. Phase 2 is purely additive on top of a working session model. Folding them into one PR doubles review surface and forces the same risky cutover decisions to be made simultaneously. Independently, each phase is shippable and reviewable. ## ⚠ Corrected facts from #522 self-review - **Spring Session JDBC is NOT currently in the stack.** `V2__drop_spring_session_tables.sql` removed it as unused; `backend/pom.xml` has no `spring-session-jdbc` dependency. `CLAUDE.md`'s "Tech Stack" line claiming it is in the stack is stale and must be updated alongside this work. - **NFR wording tightened:** invalidation is synchronous (≤1 request), not eventual (≤60s). ## User need > **As an** operator of the Familienarchiv platform > **I want** the application's auth model to use server-side sessions with opaque, revocable session identifiers > **so that** a stolen cookie cannot leak the user's password and audit/observability becomes possible. > **As a** registered user (transcriber / reader) > **I want** my logout to actually log me out on the server > **so that** a cookie copied off my machine before logout no longer authenticates afterwards. ## Functional requirements **FR-AUTH-001** — There SHALL be a first-class `POST /api/auth/login` endpoint in a new `auth` package (decided in #522 review) that accepts `{ email, password }`, validates against `app_users`, and on success establishes a server-side session. The response body SHALL be the same `AppUser` shape currently returned by `/api/users/me` (drives the existing TypeScript binding via OpenAPI regen). **FR-AUTH-002** — Session establishment SHALL set an opaque session identifier (not a credential) in a cookie with: - `HttpOnly=true` - `SameSite=strict` - `Secure=true` **unconditional in non-dev profiles** (no runtime `url.protocol` branch — drop the `isHttps` footgun in `login/+page.server.ts:50`) - `Path=/` - Absolute lifetime: 24 h - Idle timeout: 8 h (`MaxInactiveIntervalInSeconds=28800`) The session record SHALL be stored server-side in the `spring_session` / `spring_session_attributes` tables via Spring Session JDBC. **FR-AUTH-003** — There SHALL be a `POST /api/auth/logout` endpoint that invalidates the **current** session record only (single-device logout — decided in #522 review). After calling it, requests carrying the same cookie SHALL be treated as unauthenticated (401), even before the cookie's `Max-Age` elapses. **FR-AUTH-006** — `AuthTokenCookieFilter` and the Basic-auth machinery downstream of it SHALL be removed in the same PR that lands the new endpoints — no transitional/compat filter (Nora + Tobi both insist; "transitional" code becomes permanent). Specifically: - Delete `AuthTokenCookieFilter.java` and its test - Remove the `secure: isHttps` branch in `frontend/src/routes/login/+page.server.ts` - Remove the `Basic` cookie construction in the login action; the action POSTs to `/api/auth/login` and relies on the `Set-Cookie` returned by the backend - Remove the `/api/users/me` round-trip validation step in the login action (the login endpoint returns the user payload) - Remove the cookie→header header-injection in `frontend/vite.config.ts` (no longer needed; backend reads its own cookie) - Simplify `frontend/src/hooks.server.ts` — the `handleFetch` re-encoding logic disappears; the `userGroup` handle can read the principal from the session (or keep the `/api/users/me` call if simpler — design choice for the implementer) - Remove the `formLogin(...)` chain from `SecurityConfig` (Felix's note — implicit form-login is dead code once `/api/auth/login` exists) **FR-AUTH-007** — Deployment story SHALL remain the same: SvelteKit form-action login, no client-side JWT handling in browser JS, no extra round-trips per request. Caddy `/api/*` routing requires no header injection. ## Non-functional requirements **NFR-SEC-101** — No user password (plaintext, base64, or otherwise reversible) SHALL be present in any cookie sent to the browser at any point after login completes. The session cookie SHALL contain only an opaque identifier. **NFR-SEC-102** — A stolen session cookie SHALL become inert **on the next request** after operator-initiated invalidation (logout in this issue; password change/reset and admin force-logout in Phase 2). No grace period is permitted. Spring Session JDBC deletes are synchronous; this is asserted via the test: at T+0 delete the session row, at T+0+ε the next request returns 401. **NFR-PERF-101** — Authenticated request overhead SHALL NOT exceed 5 ms (p95) added by session lookup compared to the current in-memory Basic-decode path. Measured via a k6 smoke run (optional but recommended per Sara's pyramid): 50 RPS of `GET /api/users/me` after a successful login, p95 added latency reported in the PR. **NFR-OBSV-101 (partial)** — Login (success + failure) and logout SHALL produce structured audit log entries via the existing `AuditService`: - `AuditKind.LOGIN_SUCCESS` — `{ userId, ip, ua (truncated 200 chars) }` - `AuditKind.LOGIN_FAILED` — `{ email, ip, ua }` — **never** the attempted password - `AuditKind.LOGOUT` — `{ userId, ip, ua }` (`ADMIN_FORCE_LOGOUT` is added in Phase 2.) **NFR-COMPAT-101** — Existing dev tooling SHALL continue to work without code changes outside this PR: `vite.config.ts` proxy (header-injection removed but proxy itself unchanged), the dev e2e test profile, the `e2e` profile's deterministic admin seed (`admin@familyarchive.local` / `admin123` per `PasswordResetTestHelper`). ## Acceptance criteria ``` Given a fresh app_users row and no existing session When the user POSTs { email, password } to /api/auth/login with valid credentials Then the response is 200 with the AppUser JSON in the body And the response sets a session cookie containing an opaque ID (no credential bytes — assert with regex /^[A-Za-z0-9_-]{20,}$/ or similar, never matches "Basic ") And a row exists in spring_session keyed by that ID, linked to the user via principal_name Given a request with invalid credentials When the user POSTs to /api/auth/login Then the response is 401 with ErrorCode INVALID_CREDENTIALS And no Set-Cookie header is returned And an audit log entry of type LOGIN_FAILED is recorded with payload {email, ip, ua}, NO password Given an active session When the user POSTs to /api/auth/logout Then the response is 204 And the spring_session row is removed And any subsequent request with the same cookie returns 401 And an audit log entry of type LOGOUT is recorded Given a request with no session cookie When it hits /api/auth/logout Then the response is 401 Given a session has been idle for >8h When the cookie is presented on the next request Then the response is 401 And the spring_session row has been cleaned up (Spring's cleanup task) Given the new auth model is live When a grep across the codebase for "AuthTokenCookieFilter" runs Then no source file references it And grep for "auth_token" cookie usage returns no production references (test fixtures aside) And Caddy's /api/* routing requires no header injection or cookie translation Given a user is redirected to /login with reason=expired When the login page renders Then an aria-live="polite" info banner shows the localized "session expired" message And the message reads "Du wurdest abgemeldet. Bitte melde Dich erneut an." in de ``` ## Resolved decisions (carried from #522 review) | OQ | Decision | Rationale | |---|---|---| | Module boundary | New `auth` package | Cleaner separation than extending `user`; cross-domain call (`AuthService → UserService`) is fine | | Logout scope | Current session only | Phase 2 covers all-session invalidation paths separately | | Idle timeout | 8h idle, 24h absolute | Zero schema cost (Spring Session has `LAST_ACCESS_TIME`) | | Cutover strategy | Hard cutover, no transitional filter | Tobi + Nora aligned: transitional code becomes permanent | | Remember-me | **Out of scope** | Threat-model change; defer until baseline is solid | | Idle-timeout pop-up | **No** | Stress-inducing for the 60+ audience; expire silently and route to session-expired banner (Leonie) | ## Implementation hints (non-binding — for the implementer) These are concrete starting points pulled from persona review; the implementer may diverge if a better path appears: - `pom.xml` — add `spring-session-jdbc` (pin the version explicitly; don't rely on BOM transitive) - New Flyway migration: `V33__recreate_spring_session_tables.sql` — copy the canonical DDL Spring Session ships; leave a comment explaining this recreates what `V2` dropped - `application.yaml` — set `spring.session.store-type=jdbc`, `spring.session.timeout=28800s` - Frontend i18n: add `session_expired` key in `messages/{de,en,es}.json` - de: "Du wurdest abgemeldet. Bitte melde Dich erneut an." - en: "You were signed out. Please sign in again." - es: "Has cerrado sesión. Por favor, inicia sesión de nuevo." - Login button: verify `min-h-[44px]` (WCAG 2.2 SC 2.5.8) — fix in this PR - Login error mapping: `INVALID_CREDENTIALS` → "E-Mail oder Passwort sind nicht korrekt." (no user-not-found leak) - New `ErrorCode` values: `INVALID_CREDENTIALS`, `SESSION_EXPIRED` — add to `ErrorCode.java`, mirror in `frontend/src/lib/shared/errors.ts`, add i18n keys in all three locale files - ADR before code: `docs/adr/NNNN-stateful-auth-via-spring-session-jdbc.md` per Markus - Update the stale `CLAUDE.md` "Tech Stack" line as part of this PR - Update `docs/architecture/c4/seq-auth-flow.puml` — cookie promotion is gone - Logout action: POST to `/api/auth/logout` first, then delete cookie, then redirect. If the backend call fails, still delete the local cookie and redirect (Leonie — defense in depth: the user *wanted* to log out) ## Out of scope (this issue — moves to Phase 2) - **FR-AUTH-004** Password change invalidates other sessions - **FR-AUTH-005** Admin force-logout (backend capability + audit) - **FR-AUTH-008** Password reset invalidates all sessions for user - **NFR-SEC-103** Spring Security CSRF re-enable (`CookieCsrfTokenRepository.withHttpOnlyFalse()`) - **NFR-SEC-104** Bucket4j login rate limit - `AuditKind.ADMIN_FORCE_LOGOUT` - `ErrorCode.CSRF_TOKEN_MISSING`, `TOO_MANY_LOGIN_ATTEMPTS` - CSRF token failure UX - Tobi's `scripts/force-logout-user.sh` break-glass helper - `AuthE2EController` force-create-session test seam (lands with Phase 2 if Sara still wants it) ## Cutover plan (operator) 1. **Pre-deploy:** post operator banner to family ("Wartung 22:00–22:15, danach bitte erneut anmelden") 2. Deploy backend + frontend together (atomic from the user's perspective) 3. All in-flight sessions become 401 on the next request — the SvelteKit `handleAuth` hook redirects to `/login?reason=expired`; the banner renders 4. Users re-login 5. No user-visible downtime — the app works, just shows the login screen ## Priority & sizing - MoSCoW: **Must** for v1.1.0 - Priority: **P1-high** (Nora flagged the underlying problem as P1: cookie ≠ token) - Linked NFRs: NFR-SEC-101, NFR-SEC-102, NFR-PERF-101, NFR-OBSV-101, NFR-COMPAT-101 ## Test strategy (Sara's pyramid, scoped to Phase 1) | Layer | Tests | |---|---| | Unit (Mockito) | `AuthService.login` happy + invalid creds; `AuthService.logout`; audit-log writes for both | | Slice (`@WebMvcTest`) | `AuthController.login` 200/401 paths; `AuthController.logout` 204/401 paths | | Integration (Testcontainers + real Postgres + Spring Session JDBC tables via Flyway) | full lifecycle: login → authed GET `/api/users/me` → logout → 401 on retry; idle-timeout: manually backdate `LAST_ACCESS_TIME` > 8h → next request 401 | | Frontend (vitest-browser + page.server.test.ts) | Login action POSTs to `/api/auth/login`; logout action POSTs to `/api/auth/logout` before deleting cookie; session-expired banner renders when `?reason=expired` | | E2E (Playwright) | login form happy path; logout button → login redirect; assert cookie name + value pattern (opaque, not `Basic`) | | Coverage gate | Hold the 88% branch line in `pom.xml`; new code carries ≥80% own branch coverage | ## Definition of Done - [ ] All ACs pass; tests start red, go green - [ ] `AuthTokenCookieFilter` and all references deleted - [ ] `CLAUDE.md` "Tech Stack" line updated to reflect Spring Session JDBC re-introduction - [ ] ADR landed in `docs/adr/` - [ ] `docs/architecture/c4/seq-auth-flow.puml` rewritten - [ ] OpenAPI types regenerated (`npm run generate:api` in `frontend/`) - [ ] Coverage gate held - [ ] Maintenance-window comms posted before merge to `main` — Filed as part of splitting #522 (replaces it together with the Phase 2 issue). Closes the Phase-1 portion of the auth-model rewrite.
marcel added this to the v1.1.0 milestone 2026-05-11 18:49:05 +02:00
marcel added the P1-highfeaturesecurity labels 2026-05-11 18:50:26 +02:00
Author
Owner

Phase 2 follow-up is filed: #524 (CSRF re-enable, session revocation on password change/reset, admin force-logout, login rate-limit). Replace the "TODO: link once filed" reference in the Context section with #524.

**Phase 2 follow-up is filed: #524** (CSRF re-enable, session revocation on password change/reset, admin force-logout, login rate-limit). Replace the "TODO: link once filed" reference in the Context section with #524.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Observations

  • The latest migration on disk is V65__tbmp_promote_unique_to_pk.sql. The issue's hint says V33__recreate_spring_session_tables.sql — that's wrong. The recreate migration must be V66 (or later if other branches land first).
  • SecurityConfig.java:80 is .httpBasic(Customizer.withDefaults()).formLogin(form -> form.usernameParameter("email")). The DoD removes formLogin, but httpBasic also goes — once /api/auth/login exists nothing else issues WWW-Authenticate: Basic. Removing both also removes the dev "browser pops a native dialog" footgun.
  • frontend/src/hooks.server.ts:50-67 ("userGroup" handle) still reads auth_token and calls /api/users/me. After cutover, that whole block must change: forward the SESSION cookie (or whatever cookie name we pick) on the call to /api/users/me, not an Authorization header.
  • handleFetch in hooks.server.ts:70-113 currently rebuilds requests with Authorization: <auth_token>. With server-side sessions, SvelteKit's fetch must forward the cookie header instead — the backend reads the session cookie itself.
  • Today's logout (routes/logout/+page.server.ts) only deletes the local cookie. ACK that this PR adds a backend POST /api/auth/logout call before cookie deletion.
  • ErrorCode.java already has UNAUTHORIZED and FORBIDDEN. The new INVALID_CREDENTIALS and SESSION_EXPIRED codes also need the 3-place propagation (ErrorCode.java, frontend/src/lib/shared/errors.ts, messages/{de,en,es}.json).
  • application.yaml:45 already sets forward-headers-strategy: native, so Secure cookies behind Caddy work without extra config. ✓
  • AuthController lives in the user package today. The resolved decision moves it to a new auth package — that's a file move, not just a new class. Move PasswordResetService + InviteService endpoints? NoAuthController (login/logout) is new; the existing password-reset/invite endpoints stay where they are. The auth package owns only login/logout/session lifecycle.

Recommendations

  • Migration number: name it V66__recreate_spring_session_tables.sql. Copy the canonical DDL from Spring Session 3.x's org/springframework/session/jdbc/schema-postgresql.sql verbatim. Add a header comment: -- Re-introduces tables dropped by V2. See ADR-012.
  • Set-Cookie propagation from the form action — this is the part the issue hint glosses over. SvelteKit's cookies.set on the form-action event only sets a cookie on the SvelteKit→browser response. The backend will return its own Set-Cookie: SESSION=<opaque> on the inner /api/auth/login fetch. The cleanest approach: parse the inner response's Set-Cookie header(s) with set-cookie-parser, then re-emit each one via cookies.set(name, value, opts). Don't try to construct the cookie value on the SvelteKit side — the backend is authoritative on the session ID.
  • Old auth_token cookies during cutover: browsers will still send the old auth_token cookie for a while after deploy. Add a single line to handleAuth (or userGroup) that deletes any auth_token cookie it sees — purely defensive. One commit, two-line change, prevents a stale-cookie support ticket.
  • SecurityConfig matcher: add /api/auth/login and /api/auth/logout to the permitAll chain (login is unauthenticated by definition; logout returns 401 if no session, but Spring Security must let the request reach the controller to return that 401 explicitly). Drop /api/auth/reset-token-for-test from the permitAll chain only if AuthE2EController moves with Phase 2 (issue confirms it does not — leave it).
  • Cookie name: keep the Spring Session default cookie name SESSION. Don't rename — SESSION is the recognizable, framework-standard name; every developer touching this code in the next 5 years will know to look for it. Set server.servlet.session.cookie.name: SESSION explicitly in application.yaml so a future Spring upgrade doesn't change it silently.
  • AC regex: Spring Session uses URL-safe base64 with = padding by default (DefaultCookieSerializer.setUseBase64Encoding(true)). The proposed regex ^[A-Za-z0-9_-]{20,}$ is close but wrong — it must allow = (or end-anchor before padding). Use ^[A-Za-z0-9_=-]{20,}$ and add the explicit not matching "Basic " assertion as a second test.
  • @RequirePermission doesn't apply to /api/auth/login — it's unauthenticated by design. /api/auth/logout doesn't need it either; the requirement is "authenticated", not "has a specific permission". Comment this in the controller so the next reviewer doesn't add @RequirePermission reflexively.
  • TDD red phase first for both endpoints. For login: @WebMvcTest(AuthController.class) slice → 200 on valid creds, 401 with INVALID_CREDENTIALS on wrong creds, no Set-Cookie on failure. The audit-log writes go in AuthServiceTest with mocked AuditService.

Open Decisions

  • Absolute 24h timeout: Spring Session JDBC natively supports only idle timeout (LAST_ACCESS_TIME). The 24h absolute lifetime needs custom logic — typically a session attribute createdAt checked by a Spring Security filter. Option A: implement the filter now (adds ~30 LOC + tests). Option B: drop "24h absolute" from FR-AUTH-002 and rely on idle only — simpler, fewer moving parts, but a session held active by a polling bot lives forever. Option C: defer the absolute cap to Phase 2 alongside the rest of the revocation work. The spec asserts "Absolute lifetime: 24h" but the ACs only test idle. This needs an explicit pick.
## 👨‍💻 Felix Brandt — Senior Fullstack Developer ### Observations - The latest migration on disk is `V65__tbmp_promote_unique_to_pk.sql`. The issue's hint says `V33__recreate_spring_session_tables.sql` — that's wrong. The recreate migration must be **V66** (or later if other branches land first). - `SecurityConfig.java:80` is `.httpBasic(Customizer.withDefaults()).formLogin(form -> form.usernameParameter("email"))`. The DoD removes `formLogin`, but `httpBasic` also goes — once `/api/auth/login` exists nothing else issues `WWW-Authenticate: Basic`. Removing both also removes the dev "browser pops a native dialog" footgun. - `frontend/src/hooks.server.ts:50-67` ("userGroup" handle) still reads `auth_token` and calls `/api/users/me`. After cutover, that whole block must change: forward the `SESSION` cookie (or whatever cookie name we pick) on the call to `/api/users/me`, not an `Authorization` header. - `handleFetch` in `hooks.server.ts:70-113` currently rebuilds requests with `Authorization: <auth_token>`. With server-side sessions, SvelteKit's fetch must forward the **cookie** header instead — the backend reads the session cookie itself. - Today's logout (`routes/logout/+page.server.ts`) only deletes the local cookie. ACK that this PR adds a backend `POST /api/auth/logout` call before cookie deletion. - `ErrorCode.java` already has `UNAUTHORIZED` and `FORBIDDEN`. The new `INVALID_CREDENTIALS` and `SESSION_EXPIRED` codes also need the 3-place propagation (`ErrorCode.java`, `frontend/src/lib/shared/errors.ts`, `messages/{de,en,es}.json`). - `application.yaml:45` already sets `forward-headers-strategy: native`, so `Secure` cookies behind Caddy work without extra config. ✓ - `AuthController` lives in the `user` package today. The resolved decision moves it to a new `auth` package — that's a file move, not just a new class. Move `PasswordResetService` + `InviteService` endpoints? **No** — `AuthController` (login/logout) is new; the existing password-reset/invite endpoints stay where they are. The `auth` package owns only login/logout/session lifecycle. ### Recommendations - **Migration number:** name it `V66__recreate_spring_session_tables.sql`. Copy the canonical DDL from Spring Session 3.x's `org/springframework/session/jdbc/schema-postgresql.sql` verbatim. Add a header comment: `-- Re-introduces tables dropped by V2. See ADR-012.` - **Set-Cookie propagation from the form action** — this is the part the issue hint glosses over. SvelteKit's `cookies.set` on the form-action `event` only sets a cookie on the SvelteKit→browser response. The backend will return its own `Set-Cookie: SESSION=<opaque>` on the inner `/api/auth/login` fetch. The cleanest approach: parse the inner response's `Set-Cookie` header(s) with `set-cookie-parser`, then re-emit each one via `cookies.set(name, value, opts)`. Don't try to construct the cookie value on the SvelteKit side — the backend is authoritative on the session ID. - **Old `auth_token` cookies during cutover:** browsers will still send the old `auth_token` cookie for a while after deploy. Add a single line to `handleAuth` (or `userGroup`) that **deletes** any `auth_token` cookie it sees — purely defensive. One commit, two-line change, prevents a stale-cookie support ticket. - **`SecurityConfig` matcher:** add `/api/auth/login` and `/api/auth/logout` to the `permitAll` chain (login is unauthenticated by definition; logout returns 401 if no session, but Spring Security must let the request reach the controller to return that 401 explicitly). Drop `/api/auth/reset-token-for-test` from the permitAll chain only if `AuthE2EController` moves with Phase 2 (issue confirms it does not — leave it). - **Cookie name:** keep the Spring Session default cookie name `SESSION`. Don't rename — `SESSION` is the recognizable, framework-standard name; every developer touching this code in the next 5 years will know to look for it. Set `server.servlet.session.cookie.name: SESSION` explicitly in `application.yaml` so a future Spring upgrade doesn't change it silently. - **AC regex:** Spring Session uses URL-safe base64 with `=` padding by default (`DefaultCookieSerializer.setUseBase64Encoding(true)`). The proposed regex `^[A-Za-z0-9_-]{20,}$` is **close but wrong** — it must allow `=` (or end-anchor before padding). Use `^[A-Za-z0-9_=-]{20,}$` and add the explicit `not matching "Basic "` assertion as a second test. - **`@RequirePermission` doesn't apply to `/api/auth/login`** — it's unauthenticated by design. `/api/auth/logout` doesn't need it either; the requirement is "authenticated", not "has a specific permission". Comment this in the controller so the next reviewer doesn't add `@RequirePermission` reflexively. - **TDD red phase first** for both endpoints. For login: `@WebMvcTest(AuthController.class)` slice → 200 on valid creds, 401 with `INVALID_CREDENTIALS` on wrong creds, no `Set-Cookie` on failure. The audit-log writes go in `AuthServiceTest` with mocked `AuditService`. ### Open Decisions - **Absolute 24h timeout:** Spring Session JDBC natively supports only idle timeout (`LAST_ACCESS_TIME`). The 24h absolute lifetime needs custom logic — typically a session attribute `createdAt` checked by a Spring Security filter. Option A: implement the filter now (adds ~30 LOC + tests). Option B: drop "24h absolute" from FR-AUTH-002 and rely on idle only — simpler, fewer moving parts, but a session held active by a polling bot lives forever. Option C: defer the absolute cap to Phase 2 alongside the rest of the revocation work. _The spec asserts "Absolute lifetime: 24h" but the ACs only test idle. This needs an explicit pick._
Author
Owner

🏛️ Markus Keller — Application Architect

Observations

  • Module boundary decision (new auth package) is correct. AuthService → UserService is a clean cross-domain call. Resist the temptation to expand the user package — login/session lifecycle is its own concern, distinct from "user CRUD".
  • docs/architecture/c4/seq-auth-flow.puml already exists in the repo. The doc-update table makes this a blocker, not a concern: the diagram must be rewritten in the same PR (cookie-promotion arrow gone, server session round-trip in).
  • application.yaml:45 has forward-headers-strategy: native — Secure cookies behind Caddy will respect X-Forwarded-Proto. No new infra config needed for that.
  • ADRs in docs/adr/ run up to 011 (011-single-tenant-gitea-runner.md). The new ADR is 012.
  • The latest migration is V65. The issue says V33__recreate_spring_session_tables.sql — that was the placeholder number from a stale plan. The actual migration is V66.
  • Both CLAUDE.md files (root + backend/CLAUDE.md) list "Spring Session JDBC" in the tech stack. Root CLAUDE.md is already accurate-ish (it lists it once the PR lands); backend/CLAUDE.md already lists it stale — both must be touched.

Recommendations

  • ADR-012-stateful-auth-via-spring-session-jdbc.md before any code. Sections: Context (cookie-as-credential debt from #520), Decision (Spring Session JDBC, opaque SESSION cookie, single-device logout), Alternatives (stay with cookie promotion + add server-side revocation table — rejected: re-implements Spring Session badly; JWT — rejected: opaque revocation is simpler and our cluster is single-node), Consequences (one Flyway migration, ~2 KB per active session, idle-cleanup task runs in-app). Number it 012.
  • Keep the boundary tight. AuthController calls AuthService.login(email, password) which returns the AppUser. AuthService injects UserService (for the lookup) and Spring Security's AuthenticationManager (for password verification). AuthService does NOT inject AppUserRepository directly — that's the existing rule.
  • Push session cleanup down to the framework. Don't write a custom cleanup job; configure Spring Session's built-in scheduled task via spring.session.jdbc.cleanup-cron or accept the default. Application code doesn't own this responsibility.
  • spring.session.timeout: 28800s is correct for the idle timeout, but understand the semantics — this is MaxInactiveIntervalInSeconds, not absolute. The hint in the issue body and the FR are aligned, but document this in the ADR consequences section so future-Markus doesn't expect absolute-cap behavior from a single config line.
  • Boundary lint. I'd ordinarily ask for an ArchUnit rule banning AuthController from touching AppUserRepository. Since this is one new class and the rule for *Service boundaries already exists, an ArchUnit assertion AuthController_should_only_call_AuthService is overkill for a single endpoint. Skip the rule.

Open Decisions

  • Absolute 24h cap — keep or drop? (Same ground Felix flagged.) From an architecture lens: the ADR should pick a position rather than leaving the FR and AC inconsistent. My weak preference is drop the 24h absolute cap for Phase 1 and revisit in Phase 2 when the rest of the revocation surface (password-change, admin force-logout) lands. Reason: the 8h idle timeout already bounds risk to one workday; the 24h cap is belt-and-braces that adds a custom filter and a test surface for marginal gain. But if the threat model says "a logged-in workstation left running over the weekend is unacceptable", keep the 24h cap and implement the filter. State the threat-model assumption in the ADR either way.
## 🏛️ Markus Keller — Application Architect ### Observations - Module boundary decision (new `auth` package) is correct. `AuthService → UserService` is a clean cross-domain call. Resist the temptation to expand the `user` package — login/session lifecycle is its own concern, distinct from "user CRUD". - `docs/architecture/c4/seq-auth-flow.puml` already exists in the repo. The doc-update table makes this a blocker, not a concern: the diagram must be rewritten in the same PR (cookie-promotion arrow gone, server session round-trip in). - `application.yaml:45` has `forward-headers-strategy: native` — Secure cookies behind Caddy will respect `X-Forwarded-Proto`. No new infra config needed for that. - ADRs in `docs/adr/` run up to **011** (`011-single-tenant-gitea-runner.md`). The new ADR is **012**. - The latest migration is V65. The issue says `V33__recreate_spring_session_tables.sql` — that was the placeholder number from a stale plan. The actual migration is **V66**. - Both `CLAUDE.md` files (root + `backend/CLAUDE.md`) list "Spring Session JDBC" in the tech stack. Root CLAUDE.md is already accurate-ish (it lists it once the PR lands); backend/CLAUDE.md already lists it stale — both must be touched. ### Recommendations - **ADR-012-stateful-auth-via-spring-session-jdbc.md** before any code. Sections: Context (cookie-as-credential debt from #520), Decision (Spring Session JDBC, opaque SESSION cookie, single-device logout), Alternatives (stay with cookie promotion + add server-side revocation table — rejected: re-implements Spring Session badly; JWT — rejected: opaque revocation is simpler and our cluster is single-node), Consequences (one Flyway migration, ~2 KB per active session, idle-cleanup task runs in-app). Number it 012. - **Keep the boundary tight.** `AuthController` calls `AuthService.login(email, password)` which returns the `AppUser`. `AuthService` injects `UserService` (for the lookup) and Spring Security's `AuthenticationManager` (for password verification). `AuthService` does NOT inject `AppUserRepository` directly — that's the existing rule. - **Push session cleanup down to the framework.** Don't write a custom cleanup job; configure Spring Session's built-in scheduled task via `spring.session.jdbc.cleanup-cron` or accept the default. Application code doesn't own this responsibility. - **`spring.session.timeout: 28800s`** is correct for the idle timeout, but understand the semantics — this is `MaxInactiveIntervalInSeconds`, not absolute. The hint in the issue body and the FR are aligned, but document this in the ADR consequences section so future-Markus doesn't expect absolute-cap behavior from a single config line. - **Boundary lint.** I'd ordinarily ask for an ArchUnit rule banning `AuthController` from touching `AppUserRepository`. Since this is one new class and the rule for `*Service` boundaries already exists, an ArchUnit assertion `AuthController_should_only_call_AuthService` is overkill for a single endpoint. Skip the rule. ### Open Decisions - **Absolute 24h cap — keep or drop?** (Same ground Felix flagged.) From an architecture lens: the ADR should pick a position rather than leaving the FR and AC inconsistent. My weak preference is **drop the 24h absolute cap for Phase 1** and revisit in Phase 2 when the rest of the revocation surface (password-change, admin force-logout) lands. Reason: the 8h idle timeout already bounds risk to one workday; the 24h cap is belt-and-braces that adds a custom filter and a test surface for marginal gain. But if the threat model says "a logged-in workstation left running over the weekend is unacceptable", keep the 24h cap and implement the filter. State the threat-model assumption in the ADR either way.
Author
Owner

🔐 Nora Steiner — Application Security Engineer

Observations

  • login/+page.server.ts:45 const isHttps = url.protocol === 'https:' is exactly the footgun I'd flag if I hadn't already. Behind Caddy, url.protocol reflects what SvelteKit sees from the proxy (http), so this currently writes Secure: false in production. The 24h Basic credential then rides the wire in plaintext on any HTTPS-stripping proxy. Killing this branch is correct and overdue — and that vulnerability is today's state, not theoretical.
  • SecurityConfig.java:54 disables CSRF with a long comment explaining that SameSite=strict is the load-bearing defense. That comment stays load-bearing for Phase 1 too. CSRF re-enable moves to #524, so the SameSite=strict + CORS-default combination is still the entire CSRF story for this release. Update the comment so it references the SESSION cookie, not the (now-deleted) auth_token cookie.
  • The secure: true requirement in FR-AUTH-002 is "unconditional in non-dev profiles". I want to add: the dev profile is the only legitimate exception, and application-dev.yaml is the only place a non-secure cookie is acceptable. Encode this in config, not a JS branch.
  • AppUser is the response body of /api/users/me today. Returning it from /api/auth/login (FR-AUTH-001) is fine — but the entity carries passwordHash. Verify the existing /api/users/me already strips it (it does in current behavior — confirm with a test before assuming).
  • Spring Security 6+ enables session fixation protection by default (migrateSession). Verify nothing in SecurityConfig overrides it. After successful authentication a brand-new session ID must be issued; the test for this is: log in twice in a row, capture the SESSION cookie each time, assert they differ.

Recommendations

  • Username enumeration: AuthService.login MUST return INVALID_CREDENTIALS for both "unknown email" and "wrong password" — and the timing must be similar enough that an attacker can't distinguish via response latency. Concretely: even on "user not found", run a BCrypt verify against a dummy hash. ~100ms either way. Add a regression test that records p50 latency for "unknown user" and "wrong password" and asserts the gap is <30ms.
  • Audit log payload — codify the privacy rule with a Semgrep guard. The spec correctly says LOGIN_FAILED stores {email, ip, ua}, NEVER the attempted password. Add a Semgrep rule:
    - id: auth-audit-no-password
      pattern: AuditKind.LOGIN_FAILED
      pattern-either:
        - pattern: $X.password
        - pattern: pwd
        - pattern: passwordAttempt
      message: "LOGIN_FAILED payload must not contain password fields"
      severity: ERROR
    
    This runs on every PR forever. Without it, the rule lives only in this issue body and rots.
  • Tests for both 401 paths: /api/auth/login with bad creds → 401, no Set-Cookie; arbitrary /api/* endpoint with no session cookie → 401, no auth challenge. The current httpBasic(Customizer.withDefaults()) issues WWW-Authenticate: Basic, which is the cause of the browser popup. Once removed, unauthenticated requests must return 401 with no WWW-Authenticate header. Test it.
  • Cookie attributes — defense in depth on the assertion: the test should assert all five: HttpOnly=true, Secure=true (in non-dev profile), SameSite=Strict, Path=/, Max-Age=28800. One missing attribute is a footgun; cover all five in one parameterized test.
  • Stale auth_token defense in depth: after deploy, browsers will keep sending the old auth_token cookie for a while. Worth explicitly deleting it server-side via Set-Cookie: auth_token=; Max-Age=0; Path=/. Felix called this out already — I'm seconding it from the security side, because a stale credential lying around in a browser is a credential we haven't revoked.
  • app.admin.password: admin123 default in application.yaml:77 — this is a known dev fallback, but Familienarchiv runs in a "self-hosted by a hobbyist" model. Add a startup guard: if spring.profiles.active does not contain dev or e2e AND app.admin.password == "admin123", log an ERROR and refuse to start. Not strictly in scope for this PR but the same surface area, same file. Worth adding while we're here.

Open Decisions

  • SESSION cookie name visibility: the default Spring Session cookie name SESSION is technology-disclosing — an attacker probing the app learns "Spring Session is in use" from the cookie name alone. Rename to fa_session? Or keep SESSION for developer clarity (Felix's argument)? Trade-off is real but small. My preference: rename to fa_session — disclosure-minimization is cheap and prevents one fingerprinting signal. But Felix's recognizability argument is also valid; I won't block on it.
## 🔐 Nora Steiner — Application Security Engineer ### Observations - `login/+page.server.ts:45` `const isHttps = url.protocol === 'https:'` is exactly the footgun I'd flag if I hadn't already. Behind Caddy, `url.protocol` reflects what SvelteKit sees from the proxy (`http`), so this currently writes `Secure: false` in production. The 24h Basic credential then rides the wire in plaintext on any HTTPS-stripping proxy. Killing this branch is correct and overdue — and that vulnerability is **today's** state, not theoretical. - `SecurityConfig.java:54` disables CSRF with a long comment explaining that `SameSite=strict` is the load-bearing defense. **That comment stays load-bearing for Phase 1 too.** CSRF re-enable moves to #524, so the SameSite=strict + CORS-default combination is still the entire CSRF story for this release. Update the comment so it references the SESSION cookie, not the (now-deleted) `auth_token` cookie. - The `secure: true` requirement in FR-AUTH-002 is "unconditional in non-dev profiles". I want to add: the dev profile is the only legitimate exception, and `application-dev.yaml` is the only place a non-secure cookie is acceptable. Encode this in config, not a JS branch. - `AppUser` is the response body of `/api/users/me` today. Returning it from `/api/auth/login` (FR-AUTH-001) is fine — but the entity carries `passwordHash`. Verify the existing `/api/users/me` already strips it (it does in current behavior — confirm with a test before assuming). - Spring Security 6+ enables session fixation protection by default (`migrateSession`). Verify nothing in `SecurityConfig` overrides it. After successful authentication a brand-new session ID must be issued; the test for this is: log in twice in a row, capture the SESSION cookie each time, assert they differ. ### Recommendations - **Username enumeration:** `AuthService.login` MUST return `INVALID_CREDENTIALS` for both "unknown email" and "wrong password" — and the timing must be similar enough that an attacker can't distinguish via response latency. Concretely: even on "user not found", run a BCrypt verify against a dummy hash. ~100ms either way. Add a regression test that records p50 latency for "unknown user" and "wrong password" and asserts the gap is <30ms. - **Audit log payload — codify the privacy rule with a Semgrep guard.** The spec correctly says LOGIN_FAILED stores `{email, ip, ua}`, NEVER the attempted password. Add a Semgrep rule: ```yaml - id: auth-audit-no-password pattern: AuditKind.LOGIN_FAILED pattern-either: - pattern: $X.password - pattern: pwd - pattern: passwordAttempt message: "LOGIN_FAILED payload must not contain password fields" severity: ERROR ``` This runs on every PR forever. Without it, the rule lives only in this issue body and rots. - **Tests for both 401 paths:** `/api/auth/login` with bad creds → 401, no `Set-Cookie`; arbitrary `/api/*` endpoint with no session cookie → 401, no auth challenge. The current `httpBasic(Customizer.withDefaults())` issues `WWW-Authenticate: Basic`, which is the cause of the browser popup. Once removed, unauthenticated requests must return 401 with **no** `WWW-Authenticate` header. Test it. - **Cookie attributes — defense in depth on the assertion:** the test should assert all five: `HttpOnly=true`, `Secure=true` (in non-dev profile), `SameSite=Strict`, `Path=/`, `Max-Age=28800`. One missing attribute is a footgun; cover all five in one parameterized test. - **Stale `auth_token` defense in depth:** after deploy, browsers will keep sending the old `auth_token` cookie for a while. Worth explicitly deleting it server-side via `Set-Cookie: auth_token=; Max-Age=0; Path=/`. Felix called this out already — I'm seconding it from the security side, because a stale credential lying around in a browser is a credential we haven't revoked. - **`app.admin.password: admin123` default in `application.yaml:77`** — this is a known dev fallback, but Familienarchiv runs in a "self-hosted by a hobbyist" model. Add a startup guard: if `spring.profiles.active` does not contain `dev` or `e2e` AND `app.admin.password == "admin123"`, log an `ERROR` and refuse to start. Not strictly in scope for this PR but the same surface area, same file. Worth adding while we're here. ### Open Decisions - **`SESSION` cookie name visibility:** the default Spring Session cookie name `SESSION` is technology-disclosing — an attacker probing the app learns "Spring Session is in use" from the cookie name alone. Rename to `fa_session`? Or keep `SESSION` for developer clarity (Felix's argument)? Trade-off is real but small. My preference: rename to `fa_session` — disclosure-minimization is cheap and prevents one fingerprinting signal. But Felix's recognizability argument is also valid; I won't block on it.
Author
Owner

🧪 Sara Holt — QA Engineer

Observations

  • The test-pyramid table in the issue is already well-scoped. I'm building on it, not replacing it.
  • AuditKind.java currently has 11 values — none for auth events. The PR adds three (LOGIN_SUCCESS, LOGIN_FAILED, LOGOUT). That makes them new code surface that the existing tests don't touch; coverage for them goes from zero.
  • The coverage gate is 88% branch (from backend/CLAUDE.md), not 80%. The DoD says "Hold the 88% branch line" — that means the new AuthService + AuthController need ≥88% own coverage to not pull the global number down.
  • vitest.config coverage thresholds are 80% lines/branches/functions/statements on the frontend server project. The new login form-action code lives in src/lib/shared/server/** (via the API client) and src/routes/login/+page.server.ts. Verify the latter is in the coverage include glob — currently the includes are narrow and login may slip through.

Recommendations

  • Test the regex against a real Spring Session cookie value, not a hypothesis. Run an integration test once locally, log the cookie value, and copy the actual character set into the test regex. Felix is right that = padding may appear; ASCII range may also include ~ in some configurations. Hard-coding a regex that fails at runtime is worse than no regex.
  • Idle-timeout test mechanic: the AC says "manually backdate LAST_ACCESS_TIME > 8h". Concrete approach:
    jdbcTemplate.update(
        "UPDATE spring_session SET last_access_time = ? WHERE session_id = ?",
        Instant.now().minus(9, ChronoUnit.HOURS).toEpochMilli(),
        sessionId);
    
    Then immediately fire a request — Spring Session evaluates idle on the next access, so no real sleep needed. Test stays deterministic and runs in <100ms.
  • LOGIN_FAILED payload guard at the test level (defense in depth on Nora's Semgrep rule):
    @Test
    void login_failed_audit_payload_does_not_contain_password() {
        // happy red-green path then:
        var entry = auditLogRepository.findFirstByKindOrderByCreatedAtDesc(LOGIN_FAILED);
        assertThat(entry.getPayload().toString())
            .doesNotContainIgnoringCase("password")
            .doesNotContainIgnoringCase("pwd");
    }
    
  • @WithMockUser doesn't work with server-side session tests@WithMockUser operates in Spring Security's SecurityContext, not Spring Session. For integration tests that need an authenticated session, use a TestRestTemplate that performs an actual POST /api/auth/login first, captures the cookie, and uses it on subsequent requests. This pattern reusable in AbstractAuthenticatedIntegrationTest.
  • Codify the AuthTokenCookieFilter deletion as a permanent test:
    @Test
    void no_references_to_deprecated_AuthTokenCookieFilter() {
        assertThat(Path.of("backend/src/main/java"))
            .doesNotContain(filesContaining("AuthTokenCookieFilter"));
    }
    
    Or simpler — ArchUnit rule banning any reference to the deleted class. Without this, someone resurrects it from git history in 6 months "because it solved a problem".
  • Playwright cookie-pattern assertion belongs in E2E exactly once. Don't replicate it across every E2E test. Make it part of the login flow Page Object's assertSessionEstablished() method.
  • k6 smoke run (NFR-PERF-101) — the issue marks this optional. I'd push for "actually do it once and report the number in the PR description," not committed as CI. 50 RPS for 30s of GET /api/users/me against the new session-lookup path, baseline against the current Basic-decode path. p95 added latency goes in the PR body as a real number, not "assumed <5ms".

Open Decisions

None from my angle — the test strategy in the issue is concrete enough to execute. The choices left are technical (regex shape, k6 yes/no in CI) and the implementer can pick within the bounds the spec already sets.

## 🧪 Sara Holt — QA Engineer ### Observations - The test-pyramid table in the issue is already well-scoped. I'm building on it, not replacing it. - `AuditKind.java` currently has 11 values — none for auth events. The PR adds three (`LOGIN_SUCCESS`, `LOGIN_FAILED`, `LOGOUT`). That makes them new code surface that the existing tests don't touch; coverage for them goes from zero. - The coverage gate is **88% branch** (from `backend/CLAUDE.md`), not 80%. The DoD says "Hold the 88% branch line" — that means the new `AuthService` + `AuthController` need ≥88% own coverage to not pull the global number down. - `vitest.config` coverage thresholds are 80% lines/branches/functions/statements on the frontend server project. The new login form-action code lives in `src/lib/shared/server/**` (via the API client) and `src/routes/login/+page.server.ts`. Verify the latter is in the coverage `include` glob — currently the includes are narrow and login may slip through. ### Recommendations - **Test the regex against a real Spring Session cookie value, not a hypothesis.** Run an integration test once locally, log the cookie value, and copy the actual character set into the test regex. Felix is right that `=` padding may appear; ASCII range may also include `~` in some configurations. Hard-coding a regex that fails at runtime is worse than no regex. - **Idle-timeout test mechanic:** the AC says "manually backdate `LAST_ACCESS_TIME` > 8h". Concrete approach: ```java jdbcTemplate.update( "UPDATE spring_session SET last_access_time = ? WHERE session_id = ?", Instant.now().minus(9, ChronoUnit.HOURS).toEpochMilli(), sessionId); ``` Then immediately fire a request — Spring Session evaluates idle on the next access, so no real sleep needed. Test stays deterministic and runs in <100ms. - **LOGIN_FAILED payload guard at the test level (defense in depth on Nora's Semgrep rule):** ```java @Test void login_failed_audit_payload_does_not_contain_password() { // happy red-green path then: var entry = auditLogRepository.findFirstByKindOrderByCreatedAtDesc(LOGIN_FAILED); assertThat(entry.getPayload().toString()) .doesNotContainIgnoringCase("password") .doesNotContainIgnoringCase("pwd"); } ``` - **`@WithMockUser` doesn't work with server-side session tests** — `@WithMockUser` operates in Spring Security's `SecurityContext`, not Spring Session. For integration tests that need an authenticated session, use a `TestRestTemplate` that performs an actual `POST /api/auth/login` first, captures the cookie, and uses it on subsequent requests. This pattern reusable in `AbstractAuthenticatedIntegrationTest`. - **Codify the AuthTokenCookieFilter deletion as a permanent test:** ```java @Test void no_references_to_deprecated_AuthTokenCookieFilter() { assertThat(Path.of("backend/src/main/java")) .doesNotContain(filesContaining("AuthTokenCookieFilter")); } ``` Or simpler — ArchUnit rule banning any reference to the deleted class. Without this, someone resurrects it from git history in 6 months "because it solved a problem". - **Playwright cookie-pattern assertion** belongs in E2E exactly once. Don't replicate it across every E2E test. Make it part of the login flow Page Object's `assertSessionEstablished()` method. - **k6 smoke run** (NFR-PERF-101) — the issue marks this optional. I'd push for "actually do it once and report the number in the PR description," not committed as CI. 50 RPS for 30s of `GET /api/users/me` against the new session-lookup path, baseline against the current Basic-decode path. p95 added latency goes in the PR body as a real number, not "assumed <5ms". ### Open Decisions None from my angle — the test strategy in the issue is concrete enough to execute. The choices left are technical (regex shape, k6 yes/no in CI) and the implementer can pick within the bounds the spec already sets.
Author
Owner

🔧 Tobias Wendt — DevOps & Platform Engineer

Observations

  • application.yaml:45 already has forward-headers-strategy: native. Behind Caddy, Spring sees the original https scheme and will set Secure cookies correctly. The cutover requires zero Caddy config changes. Confirmed by reading docs/infrastructure/production-compose.md patterns and the existing seq-auth-flow.
  • docker-compose.ci.yml already provisions a real Postgres + MinIO for CI. Spring Session JDBC tables get created by Flyway in CI on the first test run — no CI overlay change needed.
  • The migration in the issue body says V33__. Latest on disk is V65__. The new migration is V66. (Same correction Felix and Markus flagged.)
  • The current auth_token cookie has Max-Age=60*60*24 (24h). Old browsers will keep sending it after deploy. There's no Domain set so it's bound to the apex host — once the backend ignores it, it's a slowly-decaying artifact.
  • Operator banner copy in the cutover plan ("Wartung 22:00–22:15, danach bitte erneut anmelden") is good. The audience is 60+ family members; channel matters more than wording.

Recommendations

  • Migration: V66. Header comment: -- Re-introduces tables dropped by V2 (#523). See ADR-012. Copy the canonical DDL from Spring Session 3.x org/springframework/session/jdbc/schema-postgresql.sql — do not invent the schema.
  • Pin the Spring Session JDBC version in pom.xml. The BOM transitive will pick whatever Spring Boot 4 bundles, which is fine today but will silently shift on the next Boot bump. Explicit pin = reproducible build. Add a Renovate rule for the version so it stays current without being a moving target.
  • spring.session.timeout: 28800s in application.yaml (8h). Verify it doesn't conflict with server.servlet.session.timeout (default 30min) — Spring Session overrides but a stale server.servlet.session.timeout is a misleading config smell. Remove it if set.
  • Spring Session cleanup task runs at a default cron. In a quiet family-archive workload, this is fine. Don't tune it. If you ever care: the spring_session table will hold ≤ (active users × idle hours) rows = max a few hundred. Indexing is already canonical in Spring's DDL.
  • Backup posture: include spring_session and spring_session_attributes in pg_dump (default behavior). Document in docs/infrastructure/production-compose.md that on restore, all sessions are effectively invalidated — every user must re-log-in. That's correct behavior post-restore, no change needed.
  • Maintenance comms — the part the spec doesn't cover concretely:
    • The deploy window is short (5-10 min) so an in-app banner is useless — the app is being replaced.
    • Send the German message via the family WhatsApp group / email distribution list 24h before and again 1h before. The audience is older relatives who will panic if they hit a login screen unexpectedly.
    • Wording: "Hallo zusammen, am 12.05. zwischen 22:00 und 22:15 macht das Familienarchiv eine kurze Wartungspause. Danach müsst Ihr Euch einmal neu anmelden — das ist normal und Eure Inhalte sind nicht weg." Adjust for the actual deploy date.
  • Smoke test after deploy: add to the existing post-deploy script (or run by hand):
    curl -sf -c /tmp/cookies.txt -X POST https://prod/api/auth/login \
         -H 'Content-Type: application/json' \
         -d '{"email":"admin@…","password":"…"}' | jq .id
    curl -sf -b /tmp/cookies.txt https://prod/api/users/me | jq .email
    curl -sf -b /tmp/cookies.txt -X POST https://prod/api/auth/logout -o /dev/null -w "%{http_code}"  # expect 204
    curl -s  -b /tmp/cookies.txt https://prod/api/users/me -o /dev/null -w "%{http_code}"             # expect 401
    
    4 calls, full happy-path coverage of the new contract. Run it as the deploy verification step.
  • Optional Prometheus metric: spring_session_jdbc_active_sessions gauge via a @Scheduled query of SELECT COUNT(*) FROM spring_session. Useful for "is the cleanup task running?" observability. Defer to Phase 2 — not in this PR's scope.

Open Decisions

None from infrastructure. The deploy is a single backend+frontend rollout to the same Compose stack, no Caddy changes, no DNS, no firewall rules.

## 🔧 Tobias Wendt — DevOps & Platform Engineer ### Observations - `application.yaml:45` already has `forward-headers-strategy: native`. Behind Caddy, Spring sees the original `https` scheme and will set `Secure` cookies correctly. The cutover requires **zero** Caddy config changes. Confirmed by reading `docs/infrastructure/production-compose.md` patterns and the existing seq-auth-flow. - `docker-compose.ci.yml` already provisions a real Postgres + MinIO for CI. Spring Session JDBC tables get created by Flyway in CI on the first test run — no CI overlay change needed. - The migration in the issue body says `V33__`. Latest on disk is `V65__`. The new migration is **V66**. (Same correction Felix and Markus flagged.) - The current `auth_token` cookie has `Max-Age=60*60*24` (24h). Old browsers will keep sending it after deploy. There's no `Domain` set so it's bound to the apex host — once the backend ignores it, it's a slowly-decaying artifact. - Operator banner copy in the cutover plan ("Wartung 22:00–22:15, danach bitte erneut anmelden") is good. The audience is 60+ family members; channel matters more than wording. ### Recommendations - **Migration: V66.** Header comment: `-- Re-introduces tables dropped by V2 (#523). See ADR-012.` Copy the canonical DDL from Spring Session 3.x `org/springframework/session/jdbc/schema-postgresql.sql` — do not invent the schema. - **Pin the Spring Session JDBC version in `pom.xml`.** The BOM transitive will pick whatever Spring Boot 4 bundles, which is fine **today** but will silently shift on the next Boot bump. Explicit pin = reproducible build. Add a Renovate rule for the version so it stays current without being a moving target. - **`spring.session.timeout: 28800s`** in `application.yaml` (8h). Verify it doesn't conflict with `server.servlet.session.timeout` (default 30min) — Spring Session overrides but a stale `server.servlet.session.timeout` is a misleading config smell. Remove it if set. - **Spring Session cleanup task** runs at a default cron. In a quiet family-archive workload, this is fine. Don't tune it. If you ever care: the `spring_session` table will hold ≤ (active users × idle hours) rows = max a few hundred. Indexing is already canonical in Spring's DDL. - **Backup posture:** include `spring_session` and `spring_session_attributes` in `pg_dump` (default behavior). Document in `docs/infrastructure/production-compose.md` that on restore, all sessions are effectively invalidated — every user must re-log-in. That's correct behavior post-restore, no change needed. - **Maintenance comms — the part the spec doesn't cover concretely:** - The deploy window is short (5-10 min) so an in-app banner is useless — the app is being replaced. - Send the German message via the family WhatsApp group / email distribution list **24h before** and again **1h before**. The audience is older relatives who will panic if they hit a login screen unexpectedly. - Wording: "Hallo zusammen, am 12.05. zwischen 22:00 und 22:15 macht das Familienarchiv eine kurze Wartungspause. Danach müsst Ihr Euch einmal neu anmelden — das ist normal und Eure Inhalte sind nicht weg." Adjust for the actual deploy date. - **Smoke test after deploy:** add to the existing post-deploy script (or run by hand): ```bash curl -sf -c /tmp/cookies.txt -X POST https://prod/api/auth/login \ -H 'Content-Type: application/json' \ -d '{"email":"admin@…","password":"…"}' | jq .id curl -sf -b /tmp/cookies.txt https://prod/api/users/me | jq .email curl -sf -b /tmp/cookies.txt -X POST https://prod/api/auth/logout -o /dev/null -w "%{http_code}" # expect 204 curl -s -b /tmp/cookies.txt https://prod/api/users/me -o /dev/null -w "%{http_code}" # expect 401 ``` 4 calls, full happy-path coverage of the new contract. Run it as the deploy verification step. - **Optional Prometheus metric:** `spring_session_jdbc_active_sessions` gauge via a `@Scheduled` query of `SELECT COUNT(*) FROM spring_session`. Useful for "is the cleanup task running?" observability. Defer to Phase 2 — not in this PR's scope. ### Open Decisions None from infrastructure. The deploy is a single backend+frontend rollout to the same Compose stack, no Caddy changes, no DNS, no firewall rules.
Author
Owner

🎨 Leonie Voss — UX & Accessibility

Observations

  • The cutover logs every active user out — including the 60+ relatives who form the transcriber audience. The "session expired" banner is the only signal they'll get that anything happened. It carries a lot of weight.
  • The spec already calls out aria-live="polite", the German wording, and that there's no idle-timeout pop-up. All correct.
  • The hint mentions verifying min-h-[44px] on the login button (WCAG 2.2 SC 2.5.8). I checked the file — the login template is in src/routes/login/+page.svelte (not directly readable in this comment but worth confirming). Touch-target compliance is in scope for the same PR per the hint.
  • The audience split (60+ transcribers on laptop/tablet, younger readers on phones — per project memory) means the login page needs to work at 320px width with one hand. Many family WhatsApp links open inside the WhatsApp in-app browser.

Recommendations

  • Banner styling — informational, not error.
    <div role="status" aria-live="polite"
         class="rounded-sm border border-amber-400 bg-amber-50 text-ink p-4 mb-6 flex items-start gap-3">
      <svg class="w-5 h-5 mt-0.5 text-amber-600 shrink-0" aria-hidden="true"><!-- info icon --></svg>
      <div>
        <p class="font-semibold">{m.session_expired()}</p>
        <p class="text-sm text-ink-3 mt-1">{m.session_expired_explainer()}</p>
      </div>
    </div>
    
    Use amber (informational), not red (error). The user did nothing wrong. Pair the color with an icon + label per the "never color alone" rule.
  • Add an explainer line. "Du wurdest abgemeldet" alone may read as alarming. Add:
    • de: "Aus Sicherheitsgründen werden Sitzungen nach 8 Stunden Inaktivität automatisch beendet."
    • en: "For your security, sessions end automatically after 8 hours of inactivity."
    • es: "Por seguridad, las sesiones finalizan automáticamente tras 8 horas de inactividad."
  • Focus management on /login?reason=expired — move focus to the email input automatically on page load. Saves the senior user one Tab keystroke and signals "here's what to do next."
    <script>
      let emailInput;
      onMount(() => { if (page.url.searchParams.get('reason') === 'expired') emailInput?.focus(); });
    </script>
    
    Don't auto-focus on the normal /login path — that breaks keyboard-only landing on the first heading for screen readers. Only when reason=expired.
  • Touch targets on the login form:
    • Email + password inputs: min-h-[44px] (currently? verify — if not, fix).
    • Submit button: min-h-[44px] per the hint, plus w-full on small screens so it's a fat tap target without horizontal aim.
    • Show/hide password toggle (if present) must also be 44×44 — a common miss.
  • Dark mode: verify border-amber-400 bg-amber-50 remaps in dark mode. If the project uses semantic tokens for status colors, prefer border-status-info bg-status-info-soft and define the dark variants in layout.css. If only the brand tokens are dark-mode-aware today, define new status tokens in this PR — banner colors must work in both themes (axe will catch contrast failures).
  • Reduced motion: any banner entrance transition needs motion-safe:transition-*. No bounce/slide on first render — keep it calm. Senior audience + vestibular sensitivity considerations apply.
  • Logout button — make it not-easy-to-misclick on small screens. This isn't directly in scope but the issue touches logout flow: ensure the logout button in the header has a confirmation tap pattern OR sufficient touch-target separation from common-tap targets. If logout is one tap with no confirm on a 320px phone, a fat-finger relogin is exactly the kind of friction that erodes trust with the 60+ audience.
  • Do not auto-dismiss the banner. The spec is aria-live="polite". Some toast components auto-dismiss after 5s; this is a banner, not a toast. It dismisses when the user submits the form or navigates away.

Open Decisions

None from my angle — the visual + a11y decisions are concrete enough to execute. The trade-offs (banner color, explainer wording, focus on expired-only) all have a clear right answer for this audience.

## 🎨 Leonie Voss — UX & Accessibility ### Observations - The cutover logs every active user out — including the 60+ relatives who form the transcriber audience. The "session expired" banner is the only signal they'll get that anything happened. It carries a lot of weight. - The spec already calls out `aria-live="polite"`, the German wording, and that there's no idle-timeout pop-up. All correct. - The hint mentions verifying `min-h-[44px]` on the login button (WCAG 2.2 SC 2.5.8). I checked the file — the login template is in `src/routes/login/+page.svelte` (not directly readable in this comment but worth confirming). Touch-target compliance is in scope for the same PR per the hint. - The audience split (60+ transcribers on laptop/tablet, younger readers on phones — per project memory) means the login page needs to work at 320px width with one hand. Many family WhatsApp links open inside the WhatsApp in-app browser. ### Recommendations - **Banner styling — informational, not error.** ```svelte <div role="status" aria-live="polite" class="rounded-sm border border-amber-400 bg-amber-50 text-ink p-4 mb-6 flex items-start gap-3"> <svg class="w-5 h-5 mt-0.5 text-amber-600 shrink-0" aria-hidden="true"><!-- info icon --></svg> <div> <p class="font-semibold">{m.session_expired()}</p> <p class="text-sm text-ink-3 mt-1">{m.session_expired_explainer()}</p> </div> </div> ``` Use amber (informational), not red (error). The user did nothing wrong. Pair the color with an icon + label per the "never color alone" rule. - **Add an explainer line.** "Du wurdest abgemeldet" alone may read as alarming. Add: - de: "Aus Sicherheitsgründen werden Sitzungen nach 8 Stunden Inaktivität automatisch beendet." - en: "For your security, sessions end automatically after 8 hours of inactivity." - es: "Por seguridad, las sesiones finalizan automáticamente tras 8 horas de inactividad." - **Focus management on `/login?reason=expired`** — move focus to the email input automatically on page load. Saves the senior user one Tab keystroke and signals "here's what to do next." ```svelte <script> let emailInput; onMount(() => { if (page.url.searchParams.get('reason') === 'expired') emailInput?.focus(); }); </script> ``` Don't auto-focus on the normal `/login` path — that breaks keyboard-only landing on the first heading for screen readers. Only when `reason=expired`. - **Touch targets on the login form:** - Email + password inputs: `min-h-[44px]` (currently? verify — if not, fix). - Submit button: `min-h-[44px]` per the hint, **plus** `w-full` on small screens so it's a fat tap target without horizontal aim. - Show/hide password toggle (if present) must also be 44×44 — a common miss. - **Dark mode:** verify `border-amber-400 bg-amber-50` remaps in dark mode. If the project uses semantic tokens for status colors, prefer `border-status-info bg-status-info-soft` and define the dark variants in `layout.css`. If only the brand tokens are dark-mode-aware today, define new status tokens in this PR — banner colors must work in both themes (axe will catch contrast failures). - **Reduced motion:** any banner entrance transition needs `motion-safe:transition-*`. No bounce/slide on first render — keep it calm. Senior audience + vestibular sensitivity considerations apply. - **Logout button — make it not-easy-to-misclick on small screens.** This isn't directly in scope but the issue touches logout flow: ensure the logout button in the header has a confirmation tap pattern OR sufficient touch-target separation from common-tap targets. If logout is one tap with no confirm on a 320px phone, a fat-finger relogin is exactly the kind of friction that erodes trust with the 60+ audience. - **Do not auto-dismiss the banner.** The spec is `aria-live="polite"`. Some toast components auto-dismiss after 5s; this is a banner, not a toast. It dismisses when the user submits the form or navigates away. ### Open Decisions None from my angle — the visual + a11y decisions are concrete enough to execute. The trade-offs (banner color, explainer wording, focus on expired-only) all have a clear right answer for this audience.
Author
Owner

📋 Elicit — Requirements Engineer

Observations

  • This is a Brownfield Phase 1 split with a sharp scope boundary against Phase 2 (#524). The scope split is clean — every deferred item is named and linked.
  • FR-AUTH-001 through 003 + 006/007 are numbered, traceable, and testable. ACs are in Given-When-Then. NFRs have measurable thresholds (5 ms p95, 8 h idle, 24 h absolute). MoSCoW (Must for v1.1.0) is set. Linked NFR IDs are explicit.
  • Resolved decisions from #522 are recorded in a table — the audit trail of why those decisions were made survives the PR.
  • The "Corrected facts from #522 self-review" section is exemplary. Stating "Spring Session JDBC is NOT currently in the stack" against a stale CLAUDE.md prevents the implementer from operating on a wrong premise.

Recommendations

  • Inconsistency between FR-AUTH-002 and the ACs — flag for explicit reconciliation. FR-AUTH-002 lists "Absolute lifetime: 24 h" as a requirement, but the AC table only tests idle timeout (8 h). The AC for absolute cap (e.g. "Given a session created 24h ago, when a request arrives, then return 401") is missing. Either add the AC and the implementation (Markus's Option A) or drop the absolute lifetime from FR-AUTH-002 (Option B). Don't ship an untested FR — that's how untested FRs become permanent "we said we did this" debt.
  • NFR-OBSV-101 is marked "partial". That's fine, but the partial scope should name what it covers vs. defers. Suggest re-titling: "NFR-OBSV-101 (login/logout subset; admin/password-reset paths deferred to Phase 2)" so a future reader doesn't think this NFR is fully satisfied by Phase 1.
  • DoD: "Maintenance-window comms posted before merge to main" — this is a process gate, not a code criterion. Move it out of the DoD list into a separate "Pre-merge ops checklist" or rename the section so it's clear the comms are pre-merge external work, not "did I write the code". Otherwise it sits in the PR template and gets ticked without anyone actually sending the WhatsApp message.
  • Traceability — explicit map exists in prose but not as a table. For a P1-high security change, a one-glance traceability matrix helps future-you (and Phase 2):
    Goal Persona Story AC NFR
    Cookie ≠ credential Operator FR-AUTH-001/002 AC1-AC3 NFR-SEC-101
    Logout actually logs out Transcriber/Reader FR-AUTH-003 AC4-AC5 NFR-SEC-102
    No new perf debt Operator FR-AUTH-007 (none — measured) NFR-PERF-101
    Five lines. Worth adding to the issue body.
  • Open Questions register is empty in this issue — that's correct because the decision table absorbed all open questions from #522. Worth noting explicitly: "Open Questions: none — all resolved in #522 review."

Open Decisions

  • DoD vs. process checklist boundary. The current DoD mixes code criteria (tests pass, OpenAPI types regenerated, ADR landed, coverage held) with deployment process (maintenance-window comms posted). They have different reviewers (technical PR review vs. operator ack) and different gates (CI green vs. "did Marcel send the WhatsApp message"). Split them, or keep them mixed and add a process-owner annotation per line. Either is fine — the current form is just ambiguous.
## 📋 Elicit — Requirements Engineer ### Observations - This is a Brownfield Phase 1 split with a sharp scope boundary against Phase 2 (#524). The scope split is clean — every deferred item is named and linked. - FR-AUTH-001 through 003 + 006/007 are numbered, traceable, and testable. ACs are in Given-When-Then. NFRs have measurable thresholds (5 ms p95, 8 h idle, 24 h absolute). MoSCoW (Must for v1.1.0) is set. Linked NFR IDs are explicit. - Resolved decisions from #522 are recorded in a table — the audit trail of why those decisions were made survives the PR. - The "Corrected facts from #522 self-review" section is exemplary. Stating "Spring Session JDBC is NOT currently in the stack" against a stale CLAUDE.md prevents the implementer from operating on a wrong premise. ### Recommendations - **Inconsistency between FR-AUTH-002 and the ACs — flag for explicit reconciliation.** FR-AUTH-002 lists "Absolute lifetime: 24 h" as a requirement, but the AC table only tests idle timeout (8 h). The AC for absolute cap (e.g. "Given a session created 24h ago, when a request arrives, then return 401") is missing. Either add the AC and the implementation (Markus's Option A) or drop the absolute lifetime from FR-AUTH-002 (Option B). Don't ship an untested FR — that's how untested FRs become permanent "we said we did this" debt. - **NFR-OBSV-101 is marked "partial".** That's fine, but the partial scope should name what it covers vs. defers. Suggest re-titling: "NFR-OBSV-101 (login/logout subset; admin/password-reset paths deferred to Phase 2)" so a future reader doesn't think this NFR is fully satisfied by Phase 1. - **DoD: "Maintenance-window comms posted before merge to `main`"** — this is a process gate, not a code criterion. Move it out of the DoD list into a separate "Pre-merge ops checklist" or rename the section so it's clear the comms are pre-merge external work, not "did I write the code". Otherwise it sits in the PR template and gets ticked without anyone actually sending the WhatsApp message. - **Traceability — explicit map exists in prose but not as a table.** For a P1-high security change, a one-glance traceability matrix helps future-you (and Phase 2): | Goal | Persona | Story | AC | NFR | |---|---|---|---|---| | Cookie ≠ credential | Operator | FR-AUTH-001/002 | AC1-AC3 | NFR-SEC-101 | | Logout actually logs out | Transcriber/Reader | FR-AUTH-003 | AC4-AC5 | NFR-SEC-102 | | No new perf debt | Operator | FR-AUTH-007 | (none — measured) | NFR-PERF-101 | Five lines. Worth adding to the issue body. - **Open Questions register is empty in this issue** — that's correct because the decision table absorbed all open questions from #522. Worth noting explicitly: "Open Questions: none — all resolved in #522 review." ### Open Decisions - **DoD vs. process checklist boundary.** The current DoD mixes code criteria (tests pass, OpenAPI types regenerated, ADR landed, coverage held) with deployment process (maintenance-window comms posted). They have different reviewers (technical PR review vs. operator ack) and different gates (CI green vs. "did Marcel send the WhatsApp message"). Split them, or keep them mixed and add a process-owner annotation per line. Either is fine — the current form is just ambiguous.
Author
Owner

🗳️ Decision Queue — Action Required

3 decisions need your input before implementation starts.

Architecture / Security

  • Absolute 24 h session cap — keep, drop, or defer? Spring Session JDBC natively supports only idle timeout (8 h via LAST_ACCESS_TIME). FR-AUTH-002 promises "Absolute lifetime: 24 h" but the ACs only test idle. Options:
    • A. Implement now — custom Spring Security filter checks a createdAt session attribute on every request; ~30 LOC + tests. Closes the "session held alive by a polling tab forever" gap.
    • B. Drop the 24 h cap from FR-AUTH-002 — rely on 8 h idle only. Simpler, fewer moving parts. Threat model assumption: a workstation left logged in over the weekend is acceptable.
    • C. Defer to Phase 2 — bundle with the rest of the revocation surface (password-change, admin force-logout).
      Raised by: Felix, Markus. Markus's weak preference: B. Felix is neutral.

Security

  • SESSION cookie name — keep default or rename? Spring Session's default cookie name is SESSION, which fingerprints the framework. Options:
    • A. Rename to fa_session — minor disclosure-minimization win; one extra config line; not a recognized name for future developers.
    • B. Keep SESSION — framework-standard, instantly recognizable, no extra config to maintain.
      Raised by: Nora prefers A (will not block). Felix prefers B (recognizability).

Process / Spec

  • Definition of Done split — code criteria vs ops checklist? Current DoD mixes "tests pass / OpenAPI regenerated / ADR landed" (technical PR review gates) with "maintenance-window comms posted before merge" (operator process gate). Different reviewers, different signals. Options:
    • A. Split into two lists — "Code DoD" + "Pre-merge ops checklist". Clear ownership per item.
    • B. Keep mixed, annotate owner per line — less restructuring.
      Raised by: Elicit. Either is fine — current form is just ambiguous.
## 🗳️ Decision Queue — Action Required _3 decisions need your input before implementation starts._ ### Architecture / Security - **Absolute 24 h session cap — keep, drop, or defer?** Spring Session JDBC natively supports only idle timeout (8 h via `LAST_ACCESS_TIME`). FR-AUTH-002 promises "Absolute lifetime: 24 h" but the ACs only test idle. Options: - **A. Implement now** — custom Spring Security filter checks a `createdAt` session attribute on every request; ~30 LOC + tests. Closes the "session held alive by a polling tab forever" gap. - **B. Drop the 24 h cap from FR-AUTH-002** — rely on 8 h idle only. Simpler, fewer moving parts. Threat model assumption: a workstation left logged in over the weekend is acceptable. - **C. Defer to Phase 2** — bundle with the rest of the revocation surface (password-change, admin force-logout). _Raised by: Felix, Markus. Markus's weak preference: B. Felix is neutral._ ### Security - **SESSION cookie name — keep default or rename?** Spring Session's default cookie name is `SESSION`, which fingerprints the framework. Options: - **A. Rename to `fa_session`** — minor disclosure-minimization win; one extra config line; not a recognized name for future developers. - **B. Keep `SESSION`** — framework-standard, instantly recognizable, no extra config to maintain. _Raised by: Nora prefers A (will not block). Felix prefers B (recognizability)._ ### Process / Spec - **Definition of Done split — code criteria vs ops checklist?** Current DoD mixes "tests pass / OpenAPI regenerated / ADR landed" (technical PR review gates) with "maintenance-window comms posted before merge" (operator process gate). Different reviewers, different signals. Options: - **A. Split into two lists** — "Code DoD" + "Pre-merge ops checklist". Clear ownership per item. - **B. Keep mixed, annotate owner per line** — less restructuring. _Raised by: Elicit. Either is fine — current form is just ambiguous._
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#523