feat(auth): server-side sessions replacing Basic-auth cookie promotion (#523) #612

Merged
marcel merged 32 commits from feat/issue-523-server-side-sessions into main 2026-05-17 23:08:22 +02:00
Owner

Closes #523.

Replaces the auth_token cookie (Base64 email:password promoted to Authorization: Basic by AuthTokenCookieFilter) with a Spring Session JDBC model: an opaque fa_session cookie backed by a spring_session row, 8-hour idle timeout, server-side invalidation on logout.

See ADR-020 for the rationale.

Summary

Backend

  • New auth package: AuthService (timing-safe credential check via AuthenticationManager + dummy BCrypt on misses), AuthSessionController (POST /api/auth/login, POST /api/auth/logout), LoginRequest.
  • Spring Session JDBC wired in: spring-boot-starter-session-jdbc (Boot 4.x split the auto-config into a separate starter — bare spring-session-jdbc no longer activates).
  • SpringSessionConfig#cookieSerializer bean sets fa_session + SameSite=Strict because Boot 4.x removed the spring.session.cookie.* properties.
  • Migration V67 recreates spring_session + spring_session_attributes (dropped in V2 way back).
  • New error codes INVALID_CREDENTIALS, SESSION_EXPIRED; new audit kinds LOGIN_SUCCESS, LOGIN_FAILED (never logs the password), LOGOUT.
  • SecurityConfig: dropped httpBasic/formLogin, explicit 401 entry-point, /api/auth/login permitted; CSRF still off (covered by SameSite=Strict + CORS, planned for Phase 2 / #524).
  • Deleted AuthTokenCookieFilter and its test.
  • New AuthSessionIntegrationTest (Testcontainers + real Postgres + Flyway): full lifecycle (login → /me → logout → 401 on cookie reuse) and idle-timeout (backdate LAST_ACCESS_TIME via JdbcTemplate).

Frontend

  • errors.ts mirrors the new error codes; new i18n keys error_invalid_credentials, error_session_expired, error_session_expired_explainer in de/en/es.
  • login/+page.server.ts: POSTs JSON, parses Set-Cookie, re-emits fa_session, drops legacy auth_token. load() exposes ?reason= for the banner.
  • logout/+page.server.ts: calls backend logout, then clears cookies unconditionally.
  • hooks.server.ts: userGroup forwards fa_session as Cookie: to /api/users/me; on 401 redirects to /login?reason=expired. handleFetch injects fa_session instead of Authorization. One-off cleanup of any lingering auth_token.
  • vite.config.ts: removed auth_tokenAuthorization: Basic proxy injection.
  • login/+page.svelte: amber aria-live="polite" banner for reason=expired, autofocus on email, min-h-[44px] on the submit button for the mobile reader cohort.

Docs

  • seq-auth-flow.puml rewritten — three labelled phases (Login / Authenticated / Logout), spring_session DB round-trip on every authenticated request, alt branch for the expired-session → ?reason=expired path.

Test plan

  • cd backend && ./mvnw test -Dtest=AuthSessionIntegrationTest — full lifecycle + idle-timeout all green
  • cd backend && ./mvnw test -Dtest=AuthServiceTest — login/logout audit + INVALID_CREDENTIALS
  • cd backend && ./mvnw test -Dtest=AuthSessionControllerTest — 200/401 + no Set-Cookie on failure
  • Full backend suite (./mvnw test) — no regressions (verified locally: 1616 tests green)
  • Manual: log in via UI, watch the browser cookie store — fa_session appears, auth_token does not
  • Manual: log out, hit any /api/* route — 401, cookie is gone
  • Manual: log in, in DevTools Application → Cookies set fa_session Max-Age=1, reload any page — banner appears on /login?reason=expired
  • Manual: log in on a phone (reader cohort) — submit button hits the 44px target, autofocus on email works

Deploy notes

This is a breaking deploy. Anyone already logged in with the old auth_token cookie will be silently signed out on first request after deploy (the cookie is dropped by userGroup and they'll be redirected to /login). Documented in ADR-020 §Consequences.

🤖 Generated with Claude Code

Closes #523. Replaces the `auth_token` cookie (Base64 `email:password` promoted to `Authorization: Basic` by `AuthTokenCookieFilter`) with a Spring Session JDBC model: an opaque `fa_session` cookie backed by a `spring_session` row, 8-hour idle timeout, server-side invalidation on logout. See [ADR-020](docs/adr/020-stateful-auth-via-spring-session-jdbc.md) for the rationale. ## Summary **Backend** - New `auth` package: `AuthService` (timing-safe credential check via `AuthenticationManager` + dummy BCrypt on misses), `AuthSessionController` (`POST /api/auth/login`, `POST /api/auth/logout`), `LoginRequest`. - Spring Session JDBC wired in: `spring-boot-starter-session-jdbc` (Boot 4.x split the auto-config into a separate starter — bare `spring-session-jdbc` no longer activates). - `SpringSessionConfig#cookieSerializer` bean sets `fa_session` + `SameSite=Strict` because Boot 4.x removed the `spring.session.cookie.*` properties. - Migration V67 recreates `spring_session` + `spring_session_attributes` (dropped in V2 way back). - New error codes `INVALID_CREDENTIALS`, `SESSION_EXPIRED`; new audit kinds `LOGIN_SUCCESS`, `LOGIN_FAILED` (never logs the password), `LOGOUT`. - `SecurityConfig`: dropped `httpBasic`/`formLogin`, explicit 401 entry-point, `/api/auth/login` permitted; CSRF still off (covered by `SameSite=Strict` + CORS, planned for Phase 2 / #524). - Deleted `AuthTokenCookieFilter` and its test. - New `AuthSessionIntegrationTest` (Testcontainers + real Postgres + Flyway): full lifecycle (login → /me → logout → 401 on cookie reuse) and idle-timeout (backdate `LAST_ACCESS_TIME` via `JdbcTemplate`). **Frontend** - `errors.ts` mirrors the new error codes; new i18n keys `error_invalid_credentials`, `error_session_expired`, `error_session_expired_explainer` in de/en/es. - `login/+page.server.ts`: POSTs JSON, parses `Set-Cookie`, re-emits `fa_session`, drops legacy `auth_token`. `load()` exposes `?reason=` for the banner. - `logout/+page.server.ts`: calls backend logout, then clears cookies unconditionally. - `hooks.server.ts`: `userGroup` forwards `fa_session` as `Cookie:` to `/api/users/me`; on 401 redirects to `/login?reason=expired`. `handleFetch` injects `fa_session` instead of `Authorization`. One-off cleanup of any lingering `auth_token`. - `vite.config.ts`: removed `auth_token` → `Authorization: Basic` proxy injection. - `login/+page.svelte`: amber `aria-live="polite"` banner for `reason=expired`, autofocus on email, `min-h-[44px]` on the submit button for the mobile reader cohort. **Docs** - `seq-auth-flow.puml` rewritten — three labelled phases (Login / Authenticated / Logout), `spring_session` DB round-trip on every authenticated request, `alt` branch for the expired-session → `?reason=expired` path. ## Test plan - [ ] `cd backend && ./mvnw test -Dtest=AuthSessionIntegrationTest` — full lifecycle + idle-timeout all green - [ ] `cd backend && ./mvnw test -Dtest=AuthServiceTest` — login/logout audit + INVALID_CREDENTIALS - [ ] `cd backend && ./mvnw test -Dtest=AuthSessionControllerTest` — 200/401 + no Set-Cookie on failure - [ ] Full backend suite (`./mvnw test`) — no regressions (verified locally: 1616 tests green) - [ ] Manual: log in via UI, watch the browser cookie store — `fa_session` appears, `auth_token` does not - [ ] Manual: log out, hit any `/api/*` route — 401, cookie is gone - [ ] Manual: log in, in DevTools `Application → Cookies` set `fa_session` `Max-Age=1`, reload any page — banner appears on `/login?reason=expired` - [ ] Manual: log in on a phone (reader cohort) — submit button hits the 44px target, autofocus on email works ## Deploy notes This is a **breaking deploy**. Anyone already logged in with the old `auth_token` cookie will be silently signed out on first request after deploy (the cookie is dropped by `userGroup` and they'll be redirected to `/login`). Documented in ADR-020 §Consequences. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
marcel added 16 commits 2026-05-17 21:01:32 +02:00
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Re-introduces tables dropped by V2. Canonical DDL from Spring
Session 3.x schema-postgresql.sql.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Expose AuthenticationManager bean in SecurityConfig
- Permit /api/auth/login; return 401 (not 302) for unauthenticated requests
- Remove httpBasic and formLogin from SecurityConfig

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Also switches pom.xml to spring-boot-starter-session-jdbc (Spring Boot 4.x
split the session auto-config into a separate starter; spring-session-jdbc
alone does not register JdbcSessionAutoConfiguration).
Adds SpringSessionConfig#cookieSerializer bean to configure fa_session name
and SameSite=Strict (spring.session.cookie.* properties are no longer
supported by the Boot 4.x auto-configuration layer).
Cleans up application.yaml / application-dev.yaml: removes store-type: jdbc
and the unsupported cookie.* keys.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Mirrors the backend ErrorCode additions from commit 393a3c25.
Adds error_session_expired_explainer for the login-page banner that
will surface when ?reason=expired.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the Basic-credentials-in-cookie flow with the Spring Session model:
1. POST {email, password} as JSON to /api/auth/login
2. Map 401 → INVALID_CREDENTIALS (or SESSION_EXPIRED if the backend returns it)
3. Parse Set-Cookie for fa_session=<opaque> and re-emit to the browser
4. Drop the legacy auth_token cookie

load() now also exposes ?reason= so the page can show the
session-expired banner (Task 21 wires it into the .svelte file).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The backend POST invalidates the spring_session row and writes the
LOGOUT audit entry; the client cookie is deleted unconditionally so a
network blip during logout still logs the user out locally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
userGroup: GET /api/users/me with Cookie: fa_session=<id>. On 401, drop
the stale cookie and redirect to /login?reason=expired (unless already
on a public path) so the user sees an explainer instead of a silent kick.

handleFetch: forward fa_session as a Cookie header on every API call
except the public auth endpoints. Drops the old auth_token injection.

Also adds a one-off cleanup of any lingering auth_token cookie from
pre-migration sessions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
With the Spring Session model the browser forwards fa_session itself —
the proxy no longer needs to translate auth_token → Authorization: Basic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Amber aria-live banner when ?reason=expired (set by hooks.server.ts
  after the backend rejects an expired fa_session) with a one-line
  explainer about the 8h idle window.
- autofocus on email so users returning after a session-expired kick
  can immediately retype credentials.
- min-h-[44px] on the submit button hits the iOS HIG / WCAG 2.1 AAA
  touch target minimum — relevant for the reader cohort on phones.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
docs: rewrite seq-auth-flow.puml for the Spring Session model (ADR-020)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m4s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Successful in 3m6s
CI / fail2ban Regex (pull_request) Successful in 40s
CI / Semgrep Security Scan (pull_request) Successful in 17s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m0s
3438260090
Removes the cookie-promotion step (auth_token → Authorization: Basic) and
splits the diagram into three labelled phases: Login, Authenticated
request, Logout. Adds the spring_session DB round-trip on every
authenticated request and the alt branch for an expired session
returning 401 → /login?reason=expired.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

🏛️ Markus Keller — Application Architect

Verdict: ⚠️ Approved with concerns

This is a textbook Phase-1 refactor: boring tech (Spring Session JDBC over Postgres), no new infra, ADR-020 captures the why, breaking deploy is acknowledged. The module shape is good — AuthService injects UserService (cross-domain via service, not repo). I'm net-positive on the design. My blockers are documentation currency, not architecture.

Blockers (doc drift — per the "PR contains X → doc Y" table)

  1. New backend package auth/ not in either CLAUDE.md.

    • CLAUDE.md (root) package table at line ~96 lists user/ User domain — AppUser, UserGroup, UserService, auth controllers. The auth controllers now live in their own package org.raddatz.familienarchiv.auth (AuthService, AuthSessionController, LoginRequest).
    • backend/CLAUDE.md has the same outdated tree.
    • Required edit: add an auth/ row between audit/ and config/ (alphabetical) and remove "auth controllers" from the user/ description.
  2. V67__recreate_spring_session_tables.sql adds two tables — DB diagrams not updated.

    • Per the doc-currency table: "New Flyway migration adding/removing/renaming a table or column → docs/architecture/db/db-orm.puml and docs/architecture/db/db-relationships.puml". Neither file mentions spring_session or spring_session_attributes.
    • I'll grant that framework-owned tables are arguably implementation detail, but the rule says "new table → diagram update", full stop. Either (a) add them with a note "managed by Spring Session, opaque to app code", or (b) explicitly amend the rule in CLAUDE.md to exclude framework tables. The ADR is the right place to document the exclusion if you choose (b).
  3. No l3-backend-*.puml update for the new auth package.

    • There's no l3-backend-auth.puml and l3-backend-user.puml (if it exists) doesn't mention the new controller/service. A new module needs a diagram or an explicit pointer.

Architecture observations (no action required)

  • Layering rule respected: AuthServiceUserService.findByEmail() (good — not AppUserRepository). ✓
  • AuthSessionController returns AppUser — slight leak of the user-domain type out of the auth-domain controller. Acceptable for a thin auth layer; flagging in case you want a LoginResponse DTO later. The password field is @JsonProperty(WRITE_ONLY) so over-exposure is not a concern.
  • Transport choice is correct: HTTP/REST + cookie, exactly the simplest thing that works. No JWT complexity. ADR's "JWT alternative considered and rejected" reasoning is sound — agree fully.
  • One Postgres, no new broker, no Redis. The ADR-020 statement "JWKS and refresh-token rotation unnecessary for <50 concurrent users" is the right framing. Keep that calibration.
  • Migration V67 is idempotent-safe in the sense that V2 dropped the tables and V67 recreates them — clean migration history, no IF NOT EXISTS hacks. ✓

Suggestions (defer if pressed)

  • seq-auth-flow.puml is well rewritten. Consider also stamping ADR-020 into docs/ARCHITECTURE.md §Auth section if there is one — readers shouldn't need to discover the ADR by accident.
  • The application.yaml comment "spring.session.cookie.* is not supported in Spring Boot 4.x" is exactly the kind of context that belongs in an ADR or a # Why: comment in SpringSessionConfig.java. The future reader will thank you.

Bottom line: ship after the three doc updates. Architecture is sound.

— Markus

## 🏛️ Markus Keller — Application Architect **Verdict: ⚠️ Approved with concerns** This is a textbook Phase-1 refactor: boring tech (Spring Session JDBC over Postgres), no new infra, ADR-020 captures the *why*, breaking deploy is acknowledged. The module shape is good — `AuthService` injects `UserService` (cross-domain via service, not repo). I'm net-positive on the design. My blockers are documentation currency, not architecture. ### Blockers (doc drift — per the "PR contains X → doc Y" table) 1. **New backend package `auth/` not in either CLAUDE.md.** - `CLAUDE.md` (root) package table at line ~96 lists `user/ User domain — AppUser, UserGroup, UserService, auth controllers`. The auth controllers now live in their own package `org.raddatz.familienarchiv.auth` (`AuthService`, `AuthSessionController`, `LoginRequest`). - `backend/CLAUDE.md` has the same outdated tree. - Required edit: add an `auth/` row between `audit/` and `config/` (alphabetical) and remove "auth controllers" from the `user/` description. 2. **`V67__recreate_spring_session_tables.sql` adds two tables — DB diagrams not updated.** - Per the doc-currency table: *"New Flyway migration adding/removing/renaming a table or column → `docs/architecture/db/db-orm.puml` and `docs/architecture/db/db-relationships.puml`"*. Neither file mentions `spring_session` or `spring_session_attributes`. - I'll grant that framework-owned tables are arguably implementation detail, but the rule says "new table → diagram update", full stop. Either (a) add them with a note "managed by Spring Session, opaque to app code", or (b) explicitly amend the rule in CLAUDE.md to exclude framework tables. The ADR is the right place to document the exclusion if you choose (b). 3. **No `l3-backend-*.puml` update for the new `auth` package.** - There's no `l3-backend-auth.puml` and `l3-backend-user.puml` (if it exists) doesn't mention the new controller/service. A new module needs a diagram or an explicit pointer. ### Architecture observations (no action required) - **Layering rule respected**: `AuthService` → `UserService.findByEmail()` (good — not `AppUserRepository`). ✓ - **`AuthSessionController` returns `AppUser`** — slight leak of the user-domain type out of the auth-domain controller. Acceptable for a thin auth layer; flagging in case you want a `LoginResponse` DTO later. The `password` field is `@JsonProperty(WRITE_ONLY)` so over-exposure is not a concern. - **Transport choice is correct**: HTTP/REST + cookie, exactly the simplest thing that works. No JWT complexity. ADR's "JWT alternative considered and rejected" reasoning is sound — agree fully. - **One Postgres, no new broker, no Redis.** The ADR-020 statement "JWKS and refresh-token rotation unnecessary for <50 concurrent users" is the right framing. Keep that calibration. - **Migration V67 is idempotent-safe** in the sense that V2 dropped the tables and V67 recreates them — clean migration history, no `IF NOT EXISTS` hacks. ✓ ### Suggestions (defer if pressed) - `seq-auth-flow.puml` is well rewritten. Consider also stamping ADR-020 into `docs/ARCHITECTURE.md` §Auth section if there is one — readers shouldn't need to discover the ADR by accident. - The `application.yaml` comment "`spring.session.cookie.* is not supported in Spring Boot 4.x`" is exactly the kind of context that belongs in an ADR or a `# Why:` comment in `SpringSessionConfig.java`. The future reader will thank you. **Bottom line**: ship after the three doc updates. Architecture is sound. — Markus
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: ⚠️ Approved with concerns

Good red/green discipline on the Java side. Three test classes covering unit / slice / integration — that's the pyramid I'd write. Lombok use is consistent, guard clauses on the controller helpers, no Optional.get(), DomainException factory used throughout. The frontend hooks rewrite is cleaner than what it replaced. A few real items below.

Blockers

  1. hooks.server.ts duck-types SvelteKit's redirect — use the official helper.
    backend/.../hooks.server.ts (the new try/catch) does:

    if (error instanceof Object && 'status' in error && 'location' in error) {
        throw error;
    }
    

    SvelteKit ships isRedirect() from @sveltejs/kit exactly for this. Duck-typing is fragile if the error shape changes between SvelteKit minor versions, and it muddles intent.

    import { isRedirect } from '@sveltejs/kit';
    // ...
    } catch (error) {
        if (isRedirect(error)) throw error;
        console.error('Error fetching user in hook:', error);
    }
    
  2. extractFaSessionId regex helper has no unit test.
    frontend/src/routes/login/+page.server.ts introduces a parser that's load-bearing: if it returns null, login fails. There's no Vitest test that exercises the getSetCookie() vs. single-header fallback paths or asserts that fa_session=abc; Path=/; HttpOnly yields "abc". Without it, an Undici/Node upgrade that changes header shape silently breaks login. Either extract to $lib/shared/cookies.ts and unit-test, or inline a test against the page action.

Concerns (not blocking)

  1. AuthSessionController.login and logout have no @RequirePermission — and that's intentional and correct (login is unauthenticated by definition; logout is gated by Spring Security needing an authenticated session, not by a permission). The comment above the class spells this out — that's the right move. No change needed; just confirming I read it. The CLAUDE.md rule "@RequirePermission required on every POST/PUT/PATCH/DELETE" needs an exception clause for auth lifecycle endpoints, or a comment in ErrorCode.java / Permission.java referencing this controller as the known exception.

  2. AuthService.logout looks up the user by email after Spring Security has already authenticated. If the user is somehow deleted between login and logout, userService.findByEmail() throws DomainException.notFound. The controller then never reaches session.invalidate(). See Nora's blocker on this — same root cause.

  3. No frontend test for +page.server.ts login action. Vitest can call the exported actions.login directly with a mocked fetch. Worth at least one happy-path + one 401-path test given how much logic moved into it (JSON post, Set-Cookie parsing, cookie re-emit, legacy cleanup). The Java side has 3 test classes; the TypeScript side has zero new tests for what is now the credential-handling code.

What's done well

  • Test naming reads as sentences (login_returns_user_on_valid_credentials, login_throws_INVALID_CREDENTIALS_on_bad_password). ✓
  • @WebMvcTest(AuthSessionController.class) slice — not full @SpringBootTest. ✓
  • AuthServiceTest proves audit payload never contains password/pwd/passwordAttempt keys — that's TDD applied to a security invariant, not just behavior.
  • DomainException.invalidCredentials() static factory + new ErrorCode + frontend mirror + i18n keys for de/en/es — the full 4-step ErrorCode checklist was executed. ✓
  • application-dev.yaml comment explaining why spring.session.cookie.secure is gone — that's a Why comment, exactly the kind I want.
  • truncateUa(ua) caps user-agent at 200 chars — a small, smart defense against audit-log bloat from malicious UAs. ✓

Action items: items 1 & 2 before merge; 3-5 are cleanup that can ride in a follow-up if you're feeling time-pressed.

— Felix

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ⚠️ Approved with concerns** Good red/green discipline on the Java side. Three test classes covering unit / slice / integration — that's the pyramid I'd write. Lombok use is consistent, guard clauses on the controller helpers, no `Optional.get()`, DomainException factory used throughout. The frontend hooks rewrite is cleaner than what it replaced. A few real items below. ### Blockers 1. **`hooks.server.ts` duck-types SvelteKit's redirect — use the official helper.** `backend/.../hooks.server.ts` (the new try/catch) does: ```typescript if (error instanceof Object && 'status' in error && 'location' in error) { throw error; } ``` SvelteKit ships `isRedirect()` from `@sveltejs/kit` exactly for this. Duck-typing is fragile if the error shape changes between SvelteKit minor versions, and it muddles intent. ```typescript import { isRedirect } from '@sveltejs/kit'; // ... } catch (error) { if (isRedirect(error)) throw error; console.error('Error fetching user in hook:', error); } ``` 2. **`extractFaSessionId` regex helper has no unit test.** `frontend/src/routes/login/+page.server.ts` introduces a parser that's load-bearing: if it returns `null`, login fails. There's no Vitest test that exercises the `getSetCookie()` vs. single-header fallback paths or asserts that `fa_session=abc; Path=/; HttpOnly` yields `"abc"`. Without it, an Undici/Node upgrade that changes header shape silently breaks login. Either extract to `$lib/shared/cookies.ts` and unit-test, or inline a test against the page action. ### Concerns (not blocking) 3. **`AuthSessionController.login` and `logout` have no `@RequirePermission`** — and that's intentional and *correct* (login is unauthenticated by definition; logout is gated by Spring Security needing an authenticated session, not by a permission). The comment above the class spells this out — that's the right move. No change needed; just confirming I read it. The CLAUDE.md rule "@RequirePermission required on every POST/PUT/PATCH/DELETE" needs an exception clause for auth lifecycle endpoints, or a comment in `ErrorCode.java` / `Permission.java` referencing this controller as the known exception. 4. **`AuthService.logout` looks up the user by email after Spring Security has already authenticated.** If the user is somehow deleted between login and logout, `userService.findByEmail()` throws `DomainException.notFound`. The controller then never reaches `session.invalidate()`. See Nora's blocker on this — same root cause. 5. **No frontend test for `+page.server.ts` login action.** Vitest can call the exported `actions.login` directly with a mocked `fetch`. Worth at least one happy-path + one 401-path test given how much logic moved into it (JSON post, Set-Cookie parsing, cookie re-emit, legacy cleanup). The Java side has 3 test classes; the TypeScript side has zero new tests for what is now the credential-handling code. ### What's done well - Test naming reads as sentences (`login_returns_user_on_valid_credentials`, `login_throws_INVALID_CREDENTIALS_on_bad_password`). ✓ - `@WebMvcTest(AuthSessionController.class)` slice — not full `@SpringBootTest`. ✓ - `AuthServiceTest` proves audit payload never contains `password`/`pwd`/`passwordAttempt` keys — that's TDD applied to a security invariant, not just behavior. - `DomainException.invalidCredentials()` static factory + new `ErrorCode` + frontend mirror + i18n keys for de/en/es — the full 4-step `ErrorCode` checklist was executed. ✓ - `application-dev.yaml` comment explaining why `spring.session.cookie.secure` is gone — that's a *Why* comment, exactly the kind I want. - `truncateUa(ua)` caps user-agent at 200 chars — a small, smart defense against audit-log bloat from malicious UAs. ✓ **Action items**: items 1 & 2 before merge; 3-5 are cleanup that can ride in a follow-up if you're feeling time-pressed. — Felix
Author
Owner

🛠️ Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved with operational follow-ups

No new infrastructure, no new container, no new exposed port — this is exactly the kind of "use what we already run" change I want to see. One Postgres instance carries the session state. Monthly bill: unchanged ~23 EUR. ✓

What's right

  • spring.session.jdbc.initialize-schema: never — Flyway owns DDL. No silent schema drift between framework boot and migration order. ✓
  • Migration V67 is plain SQL pulled verbatim from upstream Spring Session — auditable, no Hibernate auto-DDL surprises.
  • application-dev.yaml comment explains why spring.session.cookie.secure was dropped. Future-me reading the diff in 2027 will know what happened. ✓
  • server.forward-headers-strategy: native is already set (unchanged) — request.isSecure() will resolve to true behind Caddy's X-Forwarded-Proto: https, and the cookie auto-gains Secure. The integration test verifies the login flow but doesn't pretend to be behind a proxy — that's an acceptable gap for now.
  • Cookie name fa_session not the default SESSION — small fingerprinting reduction.
  • AuthSessionIntegrationTest uses real postgres:16-alpine via Testcontainers. Not H2. ✓

Operational follow-ups (not blocking)

  1. Backup behaviour for spring_session is undefined. Our pg_dump strategy (per docs/infrastructure/production-compose.md if it exists) will currently include spring_session and spring_session_attributes. On restore from a backup taken at T-1h, all "active" sessions become re-injected, but:

    • The LAST_ACCESS_TIME is stale → most will be expired or near-expiry → users get bounced to /login?reason=expired anyway → not a security issue, just noise.
    • Storage cost is trivial (<50 users × ~2 KB).
    • Recommendation: add --exclude-table=spring_session --exclude-table=spring_session_attributes to the backup script, or document that restored sessions are intentionally allowed to expire naturally. Either choice is fine — just decide and write it down.
  2. No Prometheus metric for active session count. Spring Session JDBC doesn't expose a Micrometer gauge out of the box. A 1-line @Scheduled query (SELECT COUNT(*) FROM spring_session WHERE EXPIRY_TIME > extract(epoch from now())*1000) registered as a Gauge would give us a real-time login-volume signal in Grafana. Defer to a follow-up issue.

  3. Cleanup-cron not configured explicitly. spring.session.jdbc.cleanup-cron defaults to 0 * * * * * (every minute). At <50 users that's fine. If we ever push 10k+ sessions, the row scan becomes noticeable. Not a problem today; flag for the runbook.

  4. No alert on growing spring_session table size. Unbounded growth would indicate a cleanup failure. Low priority — runbook item, not a code change.

  5. Single-host deployment assumption baked in (correctly). ADR-020 says single-node; session affinity is a non-issue. If/when we ever fork the backend to two pods, JDBC-backed sessions still work — no change needed. ✓

  6. docs/DEPLOYMENT.md and docs/infrastructure/production-compose.md (if present) — should mention that the backend now writes to spring_session so DBA-style migrations know what they're looking at. Minor.

CI considerations

  • The new AuthSessionIntegrationTest is a full @SpringBootTest — that's another ~5-10 sec onto CI. Acceptable, but if we grow more of these, consider promoting PostgresContainerConfig to a shared class-level container reused across tests (Testcontainers' @Container static field + singleton pattern).
  • No changes to docker-compose.yml, no new env vars, nothing for me to wire into the runner. ✓

Ship it after Markus's doc updates land. Operational hardening can be a follow-up issue.

— Tobi

## 🛠️ Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved with operational follow-ups** No new infrastructure, no new container, no new exposed port — this is exactly the kind of "use what we already run" change I want to see. One Postgres instance carries the session state. Monthly bill: unchanged ~23 EUR. ✓ ### What's right - `spring.session.jdbc.initialize-schema: never` — Flyway owns DDL. No silent schema drift between framework boot and migration order. ✓ - Migration V67 is plain SQL pulled verbatim from upstream Spring Session — auditable, no Hibernate auto-DDL surprises. - `application-dev.yaml` comment explains why `spring.session.cookie.secure` was dropped. Future-me reading the diff in 2027 will know what happened. ✓ - `server.forward-headers-strategy: native` is already set (unchanged) — `request.isSecure()` will resolve to `true` behind Caddy's `X-Forwarded-Proto: https`, and the cookie auto-gains `Secure`. The integration test verifies the login flow but doesn't pretend to be behind a proxy — that's an acceptable gap for now. - Cookie name `fa_session` not the default `SESSION` — small fingerprinting reduction. - `AuthSessionIntegrationTest` uses real `postgres:16-alpine` via Testcontainers. Not H2. ✓ ### Operational follow-ups (not blocking) 1. **Backup behaviour for `spring_session` is undefined.** Our `pg_dump` strategy (per `docs/infrastructure/production-compose.md` if it exists) will currently include `spring_session` and `spring_session_attributes`. On restore from a backup taken at T-1h, all "active" sessions become re-injected, but: - The `LAST_ACCESS_TIME` is stale → most will be expired or near-expiry → users get bounced to `/login?reason=expired` anyway → not a security issue, just noise. - Storage cost is trivial (<50 users × ~2 KB). - **Recommendation**: add `--exclude-table=spring_session --exclude-table=spring_session_attributes` to the backup script, or document that restored sessions are intentionally allowed to expire naturally. Either choice is fine — just decide and write it down. 2. **No Prometheus metric for active session count.** Spring Session JDBC doesn't expose a Micrometer gauge out of the box. A 1-line `@Scheduled` query (`SELECT COUNT(*) FROM spring_session WHERE EXPIRY_TIME > extract(epoch from now())*1000`) registered as a `Gauge` would give us a real-time login-volume signal in Grafana. Defer to a follow-up issue. 3. **Cleanup-cron not configured explicitly.** `spring.session.jdbc.cleanup-cron` defaults to `0 * * * * *` (every minute). At <50 users that's fine. If we ever push 10k+ sessions, the row scan becomes noticeable. Not a problem today; flag for the runbook. 4. **No alert on growing `spring_session` table size.** Unbounded growth would indicate a cleanup failure. Low priority — runbook item, not a code change. 5. **Single-host deployment assumption baked in (correctly).** ADR-020 says single-node; session affinity is a non-issue. If/when we ever fork the backend to two pods, JDBC-backed sessions still work — no change needed. ✓ 6. **`docs/DEPLOYMENT.md` and `docs/infrastructure/production-compose.md` (if present)** — should mention that the backend now writes to `spring_session` so DBA-style migrations know what they're looking at. Minor. ### CI considerations - The new `AuthSessionIntegrationTest` is a full `@SpringBootTest` — that's another ~5-10 sec onto CI. Acceptable, but if we grow more of these, consider promoting `PostgresContainerConfig` to a shared class-level container reused across tests (Testcontainers' `@Container` static field + `singleton` pattern). - No changes to `docker-compose.yml`, no new env vars, nothing for me to wire into the runner. ✓ **Ship it after Markus's doc updates land. Operational hardening can be a follow-up issue.** — Tobi
Author
Owner

🔐 Nora "NullX" Steiner — Application Security Engineer

Verdict: 🚫 Changes requested

The macro picture is a clear win: credential-in-cookie is gone, opaque session ID replaces it, server-side revocation works, audit trail is wired up, and timing-equalised credential checks via DaoAuthenticationProvider's dummy BCrypt are explicitly preserved. But there are two real flaws in the new flow and a CSRF posture that needs eyes-open acknowledgement.

Blockers

B1 — Session fixation (CWE-384). Login does not change the session ID.

AuthSessionController.login (lines 30-42):

SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(result.authentication());
SecurityContextHolder.setContext(context);
httpRequest.getSession(true)                       // ← reuses any existing anonymous session
        .setAttribute(SPRING_SECURITY_CONTEXT_KEY, context);

getSession(true) returns the existing session if one already exists, or creates a new one. Spring Security's formLogin / httpBasic flows hand off to SessionAuthenticationStrategy, which by default applies SessionFixationProtectionStrategy.migrateSession() — copies attributes, invalidates the old session, mints a new ID. This PR removes formLogin and httpBasic and replaces them with a hand-rolled controller that bypasses that strategy entirely.

Attack: pre-authentication, attacker plants their session ID into a victim's browser (e.g., via a subdomain or any pre-auth state that has touched request.getSession()). Victim logs in. Attacker's session ID now corresponds to victim's authenticated SecurityContext. Attacker hijacks the session.

Fix (the canonical pattern, do this before setting the SecurityContext):

HttpSession existing = httpRequest.getSession(false);
if (existing != null) {
    existing.invalidate();
}
HttpSession session = httpRequest.getSession(true);  // fresh ID
session.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context);

Or invoke Spring Security's own SessionAuthenticationStrategy manually:

@Autowired SessionAuthenticationStrategy sessionAuthStrategy;
// ...
sessionAuthStrategy.onAuthentication(result.authentication(), httpRequest, httpResponse);

Test (must accompany the fix):

@Test
void login_invalidates_pre_authentication_session_id() {
    HttpHeaders preLogin = new HttpHeaders();
    // Force an anonymous session to exist first
    ResponseEntity<String> anon = http.exchange(baseUrl + "/api/...", HttpMethod.GET,
        new HttpEntity<>(preLogin), String.class);
    String preCookie = extractFaSessionCookie(anon);
    // ...
    HttpHeaders withPre = new HttpHeaders();
    withPre.set("Cookie", "fa_session=" + preCookie);
    ResponseEntity<String> login = http.postForEntity(baseUrl + "/api/auth/login", ..., String.class);
    String postCookie = extractFaSessionCookie(login);
    assertThat(postCookie).isNotEqualTo(preCookie);  // session ID MUST rotate
}

B2 — Logout: if audit lookup throws, the session is never invalidated.

AuthSessionController.logout:

authService.logout(authentication.getName(), ip, ua);   // ← can throw if user lookup fails
HttpSession session = httpRequest.getSession(false);
if (session != null) {
    session.invalidate();
}
SecurityContextHolder.clearContext();

AuthService.logout calls userService.findByEmail(email) which throws DomainException.notFound if the user is gone (deleted by admin while session was active). On that throw, the session row in spring_session survives, the cookie remains valid, and the user remains logged in — exactly the opposite of the user's intent. This breaks the second of ADR-020's three goals ("server-side revocation").

Fix: invalidate session first (the part the user cares about), audit second (best-effort). Or wrap audit in try/catch and log the audit failure.

@PostMapping("/logout")
public ResponseEntity<Void> logout(Authentication authentication, HttpServletRequest httpRequest) {
    String email = authentication.getName();
    String ip = resolveClientIp(httpRequest);
    String ua = resolveUserAgent(httpRequest);

    HttpSession session = httpRequest.getSession(false);
    if (session != null) session.invalidate();
    SecurityContextHolder.clearContext();

    try {
        authService.logout(email, ip, ua);
    } catch (Exception ex) {
        log.warn("Audit logout failed for {}; session was still invalidated", email, ex);
    }
    return ResponseEntity.noContent().build();
}

Test:

@Test
void logout_invalidates_session_even_if_audit_throws() {
    when(authService.logout(anyString(), any(), any())).thenThrow(new RuntimeException("audit DB down"));
    mockMvc.perform(post("/api/auth/logout").with(user("u@test.de")).session(mockSession))
        .andExpect(status().isNoContent());
    assertThat(mockSession.isInvalid()).isTrue();
}

Concerns (acceptable but flag them)

  • CSRF disabled. The comment is honest about the load-bearing role of SameSite=Strict + CORS. That's correct today. Phase 2 / #524 is the right place to add CookieCsrfTokenRepository. Accepting for Phase 1, but the SecurityConfig.java comment should explicitly link to #524 so the next reviewer doesn't relax SameSite without re-enabling CSRF. The comment currently says "planned for Phase 2 (#524)" — good — make sure #524 actually exists in Gitea as an open issue.
  • No rate limit on /api/auth/login. Out of scope, but file it as a follow-up — credential stuffing protection is currently zero. bucket4j-spring-boot-starter or a simple in-memory counter is a 1-hour add. Worth a P2 issue.
  • X-Forwarded-For parsing is trust-the-proxy: forwarded.split(",")[0].trim() takes the leftmost value, which is correct if and only if Caddy strips/normalises XFF before forwarding. If a future ingress lets a raw client XFF through, attackers can pin audit-log IPs. Verify Caddy config; ideally add a note to AuthSessionController referencing the assumption.
  • AppUser.password field is @JsonProperty(WRITE_ONLY) — login response does NOT leak the hash. ✓ Verified directly in AppUser.java:47. This is the kind of invariant that deserves a regression test — login_response_body_does_not_contain_password_field against MockMvc.
  • Audit LOGIN_FAILED truncates UA to 200 chars — small log-injection mitigation. Good. The test login_fires_LOGIN_FAILED_audit_on_bad_credentials_without_password_in_payload is exactly the kind of negative assertion I want. ✓
  • Logout best-effort on the frontend side (logout/+page.server.ts clears cookie even if backend fails). Correct UX choice — server-side might be stale anyway because spring_session will reap it. ✓

What's excellent

  • ADR-020 lists alternatives (JWT, revocation table on Basic) with concrete reasons for rejection. Not handwave-y.
  • The integration test backdates LAST_ACCESS_TIME via JdbcTemplate to verify idle-timeout enforcement — that's a real test of a real failure mode, not a happy-path smoke test.
  • Comment in AuthService.login: "DaoAuthenticationProvider already runs a dummy BCrypt on unknown users to equalise timing" — that's the kind of threat-model comment that survives reviewer rotation.
  • All three audit payloads tested to confirm no password key. That's how you prevent log leaks.

CWE map

  • B1: CWE-384 Session Fixation
  • B2: CWE-613 Insufficient Session Expiration (logout doesn't terminate the session under error conditions)
  • Phase 2 / #524: CWE-352 CSRF

Fix B1 and B2 before merge. Open follow-up issues for rate limiting and CSRF (#524 if it exists, create if not).

— Nora

## 🔐 Nora "NullX" Steiner — Application Security Engineer **Verdict: 🚫 Changes requested** The macro picture is a clear win: credential-in-cookie is gone, opaque session ID replaces it, server-side revocation works, audit trail is wired up, and timing-equalised credential checks via `DaoAuthenticationProvider`'s dummy BCrypt are explicitly preserved. But there are two real flaws in the new flow and a CSRF posture that needs eyes-open acknowledgement. ### Blockers #### B1 — Session fixation (CWE-384). Login does not change the session ID. `AuthSessionController.login` (lines 30-42): ```java SecurityContext context = SecurityContextHolder.createEmptyContext(); context.setAuthentication(result.authentication()); SecurityContextHolder.setContext(context); httpRequest.getSession(true) // ← reuses any existing anonymous session .setAttribute(SPRING_SECURITY_CONTEXT_KEY, context); ``` `getSession(true)` returns the existing session if one already exists, *or* creates a new one. Spring Security's `formLogin` / `httpBasic` flows hand off to `SessionAuthenticationStrategy`, which by default applies `SessionFixationProtectionStrategy.migrateSession()` — copies attributes, invalidates the old session, mints a new ID. **This PR removes `formLogin` and `httpBasic` and replaces them with a hand-rolled controller that bypasses that strategy entirely.** **Attack**: pre-authentication, attacker plants their session ID into a victim's browser (e.g., via a subdomain or any pre-auth state that has touched `request.getSession()`). Victim logs in. Attacker's session ID now corresponds to victim's authenticated SecurityContext. Attacker hijacks the session. **Fix** (the canonical pattern, do this *before* setting the SecurityContext): ```java HttpSession existing = httpRequest.getSession(false); if (existing != null) { existing.invalidate(); } HttpSession session = httpRequest.getSession(true); // fresh ID session.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context); ``` Or invoke Spring Security's own `SessionAuthenticationStrategy` manually: ```java @Autowired SessionAuthenticationStrategy sessionAuthStrategy; // ... sessionAuthStrategy.onAuthentication(result.authentication(), httpRequest, httpResponse); ``` **Test** (must accompany the fix): ```java @Test void login_invalidates_pre_authentication_session_id() { HttpHeaders preLogin = new HttpHeaders(); // Force an anonymous session to exist first ResponseEntity<String> anon = http.exchange(baseUrl + "/api/...", HttpMethod.GET, new HttpEntity<>(preLogin), String.class); String preCookie = extractFaSessionCookie(anon); // ... HttpHeaders withPre = new HttpHeaders(); withPre.set("Cookie", "fa_session=" + preCookie); ResponseEntity<String> login = http.postForEntity(baseUrl + "/api/auth/login", ..., String.class); String postCookie = extractFaSessionCookie(login); assertThat(postCookie).isNotEqualTo(preCookie); // session ID MUST rotate } ``` #### B2 — Logout: if audit lookup throws, the session is never invalidated. `AuthSessionController.logout`: ```java authService.logout(authentication.getName(), ip, ua); // ← can throw if user lookup fails HttpSession session = httpRequest.getSession(false); if (session != null) { session.invalidate(); } SecurityContextHolder.clearContext(); ``` `AuthService.logout` calls `userService.findByEmail(email)` which throws `DomainException.notFound` if the user is gone (deleted by admin while session was active). On that throw, the session row in `spring_session` survives, the cookie remains valid, and the user remains logged in — exactly the *opposite* of the user's intent. This breaks the second of ADR-020's three goals ("server-side revocation"). **Fix**: invalidate session *first* (the part the user cares about), audit *second* (best-effort). Or wrap audit in try/catch and log the audit failure. ```java @PostMapping("/logout") public ResponseEntity<Void> logout(Authentication authentication, HttpServletRequest httpRequest) { String email = authentication.getName(); String ip = resolveClientIp(httpRequest); String ua = resolveUserAgent(httpRequest); HttpSession session = httpRequest.getSession(false); if (session != null) session.invalidate(); SecurityContextHolder.clearContext(); try { authService.logout(email, ip, ua); } catch (Exception ex) { log.warn("Audit logout failed for {}; session was still invalidated", email, ex); } return ResponseEntity.noContent().build(); } ``` **Test**: ```java @Test void logout_invalidates_session_even_if_audit_throws() { when(authService.logout(anyString(), any(), any())).thenThrow(new RuntimeException("audit DB down")); mockMvc.perform(post("/api/auth/logout").with(user("u@test.de")).session(mockSession)) .andExpect(status().isNoContent()); assertThat(mockSession.isInvalid()).isTrue(); } ``` ### Concerns (acceptable but flag them) - **CSRF disabled**. The comment is honest about the load-bearing role of `SameSite=Strict` + CORS. That's correct *today*. Phase 2 / #524 is the right place to add `CookieCsrfTokenRepository`. Accepting for Phase 1, but the `SecurityConfig.java` comment should explicitly link to #524 so the next reviewer doesn't relax SameSite without re-enabling CSRF. **The comment currently says "planned for Phase 2 (#524)" — good — make sure #524 actually exists in Gitea as an open issue.** - **No rate limit on `/api/auth/login`**. Out of scope, but file it as a follow-up — credential stuffing protection is currently zero. `bucket4j-spring-boot-starter` or a simple in-memory counter is a 1-hour add. Worth a P2 issue. - **`X-Forwarded-For` parsing is trust-the-proxy**: `forwarded.split(",")[0].trim()` takes the leftmost value, which is correct *if and only if* Caddy strips/normalises XFF before forwarding. If a future ingress lets a raw client XFF through, attackers can pin audit-log IPs. Verify Caddy config; ideally add a note to `AuthSessionController` referencing the assumption. - **`AppUser.password` field is `@JsonProperty(WRITE_ONLY)`** — login response does NOT leak the hash. ✓ Verified directly in `AppUser.java:47`. This is the kind of invariant that deserves a regression test — `login_response_body_does_not_contain_password_field` against `MockMvc`. - **Audit `LOGIN_FAILED` truncates UA to 200 chars** — small log-injection mitigation. Good. The test `login_fires_LOGIN_FAILED_audit_on_bad_credentials_without_password_in_payload` is exactly the kind of negative assertion I want. ✓ - **Logout best-effort on the frontend side** (`logout/+page.server.ts` clears cookie even if backend fails). Correct UX choice — server-side might be stale anyway because `spring_session` will reap it. ✓ ### What's excellent - ADR-020 lists alternatives (JWT, revocation table on Basic) with concrete reasons for rejection. Not handwave-y. - The integration test backdates `LAST_ACCESS_TIME` via `JdbcTemplate` to verify idle-timeout enforcement — that's a real test of a real failure mode, not a happy-path smoke test. - Comment in `AuthService.login`: *"DaoAuthenticationProvider already runs a dummy BCrypt on unknown users to equalise timing"* — that's the kind of threat-model comment that survives reviewer rotation. - All three audit payloads tested to confirm no `password` key. That's how you prevent log leaks. ### CWE map - B1: CWE-384 Session Fixation - B2: CWE-613 Insufficient Session Expiration (logout doesn't terminate the session under error conditions) - Phase 2 / #524: CWE-352 CSRF **Fix B1 and B2 before merge. Open follow-up issues for rate limiting and CSRF (#524 if it exists, create if not).** — Nora
Author
Owner

🧪 Sara Holt — QA Engineer & Test Strategist

Verdict: ⚠️ Approved with concerns

Java-side test pyramid is exactly how I'd build it: 6 unit tests + 5 slice tests + 4 integration tests against real Postgres via Testcontainers. The negative assertions on audit payloads (no password/pwd/passwordAttempt keys) are model security regression tests. Frontend-side coverage is a different story — the credential-handling code moved into TypeScript and no Vitest tests followed it.

What's done well

  • Real Postgres, not H2. AuthSessionIntegrationTest uses PostgresContainerConfig. ✓
  • Test names read as sentences. should_throw_notFound-style is the standard. Examples:
    • login_returns_user_on_valid_credentials
    • login_throws_INVALID_CREDENTIALS_on_bad_password
    • login_treats_unknown_user_identically_to_bad_password
    • session_expired_by_idle_timeout_returns_401
  • @WebMvcTest(AuthSessionController.class) — slice, not full context. ~10x faster. ✓
  • One logical assertion per test. Some have multiple verify(...) for the audit payload — that's a single logical assertion (the audit call shape), fine.
  • @MockitoBean over @MockBean — current Boot 4 API. ✓
  • Setup uses userRepository.save(AppUser.builder()...) factory pattern — readable.
  • @BeforeEach clears spring_session table — proper test isolation. ✓
  • Idle-timeout test backdates LAST_ACCESS_TIME via JdbcTemplate instead of Thread.sleep(28800000). Per my pyramid doc: never Thread.sleep. ✓
  • login_does_not_set_cookie_on_failure asserts Set-Cookie is absent on a 401. That's the kind of negative test that prevents a real regression class.

Blockers

  1. No frontend test coverage for the credential-handling code that moved into TypeScript.

    • login/+page.server.ts now does: JSON POST, Set-Cookie parsing (with two code paths: getSetCookie() vs single-header fallback), extractFaSessionId regex, cookie re-emit with 8h maxAge, legacy auth_token cleanup. Zero Vitest tests.
    • logout/+page.server.ts now calls the backend before deleting the cookie. Zero tests.
    • hooks.server.ts now: parses fa_session, calls /api/users/me, on 401 redirects to /login?reason=expired, but only if not on a PUBLIC_PATHS prefix. Zero tests.

    The Java side has 132 + 111 + 154 = 397 lines of test code. The TypeScript side has 0 new test lines covering equivalent surface. Minimum acceptable: one happy-path + one 401-path test for the login action, and one redirect-loop-prevention test for the hook.

  2. No E2E test for the new login flow. Playwright should:

    • Log in via UI → verify fa_session cookie set in browser, auth_token absent
    • Set fa_session Max-Age=0 in DevTools → reload → expect redirect to /login?reason=expired and banner visible
    • Click logout → verify backend /api/auth/logout was hit AND cookie is cleared

    Manual checklist items in the PR description (- [ ] Manual: ...) are not regression coverage. They will not run on the next deploy.

Concerns

  1. No axe-playwright check on /login?reason=expired. The new amber banner is a new visual region in the most-visited page. Per my pyramid table, every critical page gets an axe check in E2E. Add await checkA11y(page) after navigating to ?reason=expired (see also Leonie's review for the banner contrast question).

  2. No test for the session-fixation invariant (see Nora's B1). If you fix that, the test goes in AuthSessionIntegrationTest: do two requests, assert the cookie value differs after login.

  3. No test for the "logout invalidates session even when audit fails" invariant (see Nora's B2). Belongs in AuthSessionControllerTest with a doThrow on the authService mock.

  4. AuthSessionIntegrationTest is full @SpringBootTest with @MockitoBean S3Client. That's pragmatic — S3 isn't relevant to auth — but the test class loads the entire context, including OCR/notification/dashboard beans. Slow CI. Consider if a narrower slice (@AutoConfigureMockMvc + @Import(SecurityConfig.class) + Testcontainers DB only) would cover what you need. Defer if CI time is fine.

Suggestions

  • Promote the integration test's PostgresContainerConfig to a shared singleton (@Container static final + @TestInstance(PER_CLASS)) if multiple Spring Boot tests reuse it. Halves test-suite time.
  • The integration test's noThrowRestTemplate() helper is reusable — extract to a RestTemplateTestUtils or similar. Three other test classes in this codebase probably want it.
  • extractFaSessionCookie is duplicated between +page.server.ts (TypeScript) and AuthSessionIntegrationTest (Java). Different languages, fine, but if the cookie format ever changes, both must update. Add a comment cross-referencing them.

Required before merge: Frontend Vitest coverage for the login/logout actions and the hook. E2E test optional but strongly recommended.**

— Sara

## 🧪 Sara Holt — QA Engineer & Test Strategist **Verdict: ⚠️ Approved with concerns** Java-side test pyramid is exactly how I'd build it: 6 unit tests + 5 slice tests + 4 integration tests against real Postgres via Testcontainers. The negative assertions on audit payloads (no `password`/`pwd`/`passwordAttempt` keys) are model security regression tests. Frontend-side coverage is a different story — the credential-handling code moved into TypeScript and no Vitest tests followed it. ### What's done well - **Real Postgres, not H2.** `AuthSessionIntegrationTest` uses `PostgresContainerConfig`. ✓ - **Test names read as sentences.** `should_throw_notFound`-style is the standard. Examples: - `login_returns_user_on_valid_credentials` - `login_throws_INVALID_CREDENTIALS_on_bad_password` - `login_treats_unknown_user_identically_to_bad_password` - `session_expired_by_idle_timeout_returns_401` - **`@WebMvcTest(AuthSessionController.class)`** — slice, not full context. ~10x faster. ✓ - **One logical assertion per test.** Some have multiple `verify(...)` for the audit payload — that's a single logical assertion (the audit call shape), fine. - **`@MockitoBean` over `@MockBean`** — current Boot 4 API. ✓ - **Setup uses `userRepository.save(AppUser.builder()...)` factory pattern** — readable. - **`@BeforeEach` clears `spring_session` table** — proper test isolation. ✓ - **Idle-timeout test backdates `LAST_ACCESS_TIME` via `JdbcTemplate`** instead of `Thread.sleep(28800000)`. Per my pyramid doc: never `Thread.sleep`. ✓ - **`login_does_not_set_cookie_on_failure`** asserts `Set-Cookie` is absent on a 401. That's the kind of negative test that prevents a real regression class. ### Blockers 1. **No frontend test coverage for the credential-handling code that moved into TypeScript.** - `login/+page.server.ts` now does: JSON POST, Set-Cookie parsing (with two code paths: `getSetCookie()` vs single-header fallback), `extractFaSessionId` regex, cookie re-emit with 8h `maxAge`, legacy `auth_token` cleanup. Zero Vitest tests. - `logout/+page.server.ts` now calls the backend before deleting the cookie. Zero tests. - `hooks.server.ts` now: parses `fa_session`, calls `/api/users/me`, on 401 redirects to `/login?reason=expired`, but only if not on a PUBLIC_PATHS prefix. Zero tests. The Java side has 132 + 111 + 154 = 397 lines of test code. The TypeScript side has 0 new test lines covering equivalent surface. **Minimum acceptable**: one happy-path + one 401-path test for the login action, and one redirect-loop-prevention test for the hook. 2. **No E2E test for the new login flow.** Playwright should: - Log in via UI → verify `fa_session` cookie set in browser, `auth_token` absent - Set `fa_session` Max-Age=0 in DevTools → reload → expect redirect to `/login?reason=expired` and banner visible - Click logout → verify backend `/api/auth/logout` was hit AND cookie is cleared Manual checklist items in the PR description (`- [ ] Manual: ...`) are not regression coverage. They will not run on the next deploy. ### Concerns 3. **No axe-playwright check on `/login?reason=expired`.** The new amber banner is a new visual region in the most-visited page. Per my pyramid table, every critical page gets an axe check in E2E. Add `await checkA11y(page)` after navigating to `?reason=expired` (see also Leonie's review for the banner contrast question). 4. **No test for the session-fixation invariant** (see Nora's B1). If you fix that, the test goes in `AuthSessionIntegrationTest`: do two requests, assert the cookie value differs after login. 5. **No test for the "logout invalidates session even when audit fails" invariant** (see Nora's B2). Belongs in `AuthSessionControllerTest` with a `doThrow` on the `authService` mock. 6. **`AuthSessionIntegrationTest` is full `@SpringBootTest` with `@MockitoBean S3Client`.** That's pragmatic — S3 isn't relevant to auth — but the test class loads the entire context, including OCR/notification/dashboard beans. Slow CI. Consider if a narrower slice (`@AutoConfigureMockMvc` + `@Import(SecurityConfig.class)` + Testcontainers DB only) would cover what you need. Defer if CI time is fine. ### Suggestions - Promote the integration test's `PostgresContainerConfig` to a shared singleton (`@Container static final` + `@TestInstance(PER_CLASS)`) if multiple Spring Boot tests reuse it. Halves test-suite time. - The integration test's `noThrowRestTemplate()` helper is reusable — extract to a `RestTemplateTestUtils` or similar. Three other test classes in this codebase probably want it. - `extractFaSessionCookie` is duplicated between `+page.server.ts` (TypeScript) and `AuthSessionIntegrationTest` (Java). Different languages, fine, but if the cookie format ever changes, both must update. Add a comment cross-referencing them. **Required before merge**: Frontend Vitest coverage for the login/logout actions and the hook. E2E test optional but strongly recommended.** — Sara
Author
Owner

🎨 Leonie Voss — UX Designer & Accessibility Strategist

Verdict: ⚠️ Approved with concerns

You did the right things for the dual-audience design — min-h-[44px] on the submit button (WCAG 2.5.5), aria-live="polite" on the banner, role="status" for assistive tech, i18n in all three languages with a friendly explainer line for the senior cohort. The banner behaviour is correct. The banner visual needs three small adjustments before merge.

Blockers (small but real)

  1. Banner uses raw Tailwind colors, not the project's semantic tokens.
    login/+page.svelte line ~46:

    class="mb-5 rounded-sm border border-amber-200 bg-amber-50 px-4 py-3 font-sans"
    

    The rest of the page uses border-line, bg-surface, text-ink — semantic tokens from layout.css. The amber values bypass the design system.

    • Now: hardcoded amber-200, amber-50, amber-900, amber-800 mean a future dark-mode pass has to find this file and remap by hand.
    • Fix: add --color-warning-bg, --color-warning-border, --color-warning-fg tokens to layout.css, and bg-warning-bg border-warning-border text-warning-fg utilities. If you'd rather not expand the token system in this PR, leave a TODO(#XXX) comment so the next pass picks it up.
  2. No icon — color + text only.
    Color-blind users (8% of men in our reader cohort) on a phone in bright sunlight may not parse the amber tint as "warning". The senior cohort (60+) often misses pure-color cues entirely. Per my own DON'T list: "Color as the only indicator for errors, status, or required fields — Add an icon..."

    Add a 20px warning icon (Heroicons exclamation-triangle or similar) before the text:

    <div class="flex items-start gap-3">
      <svg aria-hidden="true" class="h-5 w-5 shrink-0 text-amber-900 mt-0.5">
        <!-- exclamation-triangle path -->
      </svg>
      <div>
        <p class="text-xs font-medium text-amber-900">{m.error_session_expired()}</p>
        <p class="mt-1 text-xs text-amber-800">{m.error_session_expired_explainer()}</p>
      </div>
    </div>
    

    aria-hidden="true" because the text already conveys the meaning — the icon is for the sighted color-blind user, not the screen reader.

  3. Body text is text-xs (12px). Below my body-copy floor for the senior cohort.
    Per my Modern Code rules: "Body text minimum 16px. The senior audience (60+) needs 18px preferred." The text-xs (12px) here is appropriate for a chip or label, not for an explainer message a recently-logged-out senior is reading on a phone in sunlight. Recommendation: text-sm (14px) at minimum for both lines, or text-base (16px) for the explainer. The heading can stay text-xs font-medium if you want the visual hierarchy.

Concerns (not blocking)

  • autofocus on email input. WCAG 2.4.3 (focus order) is mostly fine because the banner is above the form and the focus jumps to the first interactive control after a redirect — that's expected behaviour for a login page. The svelte-ignore a11y_autofocus comment acknowledges it. Acceptable for the login context specifically. Don't generalise this pattern to other forms.
  • Double aria-live signal. role="status" already implies aria-live="polite" plus aria-atomic="true". Explicit aria-live="polite" is redundant but harmless. Some screen readers (older NVDA) handle the combination differently — your call to keep or drop the explicit attribute.
  • No dismiss control on the banner. Acceptable here because the banner state is driven by the ?reason=expired URL — it disappears on the next navigation. A manual dismiss would require either $state (overhead) or a query-string rewrite (more overhead). The redundancy on next nav is fine. ✓
  • Contrast for amber-900 on amber-50 computes to ~10.5:1 — comfortably AAA. ✓
  • Contrast for amber-800 on amber-50 computes to ~7.5:1 — AAA for large text, AA for the small text used here. Bumping text-amber-800 to text-amber-900 for the second line would push both to AAA at the expense of slight visual hierarchy.

What's right

  • 44px touch target on submit button. ✓ Mobile reader cohort will not miss-tap this.
  • aria-live="polite" so screen readers announce the session-expired state without interrupting. (Avoid assertive — this isn't an emergency.) ✓
  • Two-line message: short reason + calmer explainer. Right for the senior cohort.
  • i18n keys exist in de.json, en.json, es.json — no English-only banner. ✓
  • The existing focus-visible:ring-2 focus-visible:ring-focus-ring on the email input survives the diff. ✓
  • ADR-020 mentions "the SvelteKit handleAuth hook redirects to /login?reason=expired; a banner renders" — the design intent matches the implementation. ✓

Suggestions

  • Add a Playwright visual regression test at width: 320 for /login?reason=expired (the explainer text wraps on small phones — confirm it doesn't break the layout).
  • The "registered" banner above ({#if form?.success}) and this new banner use different visual languages (mint vs amber). Standardise on a <Banner kind="info" | "warning" | "success"> component in $lib/shared/primitives/. Defer to a follow-up.

Items 1-3 are small fixes — ~15 minutes of work. Land them in this PR.

— Leonie

## 🎨 Leonie Voss — UX Designer & Accessibility Strategist **Verdict: ⚠️ Approved with concerns** You did the right things for the dual-audience design — `min-h-[44px]` on the submit button (WCAG 2.5.5), `aria-live="polite"` on the banner, `role="status"` for assistive tech, i18n in all three languages with a friendly explainer line for the senior cohort. The banner *behaviour* is correct. The banner *visual* needs three small adjustments before merge. ### Blockers (small but real) 1. **Banner uses raw Tailwind colors, not the project's semantic tokens.** `login/+page.svelte` line ~46: ```svelte class="mb-5 rounded-sm border border-amber-200 bg-amber-50 px-4 py-3 font-sans" ``` The rest of the page uses `border-line`, `bg-surface`, `text-ink` — semantic tokens from `layout.css`. The amber values bypass the design system. - **Now**: hardcoded `amber-200`, `amber-50`, `amber-900`, `amber-800` mean a future dark-mode pass has to find this file and remap by hand. - **Fix**: add `--color-warning-bg`, `--color-warning-border`, `--color-warning-fg` tokens to `layout.css`, and `bg-warning-bg border-warning-border text-warning-fg` utilities. *If you'd rather not expand the token system in this PR, leave a `TODO(#XXX)` comment so the next pass picks it up.* 2. **No icon — color + text only.** Color-blind users (8% of men in our reader cohort) on a phone in bright sunlight may not parse the amber tint as "warning". The senior cohort (60+) often misses pure-color cues entirely. Per my own DON'T list: *"Color as the only indicator for errors, status, or required fields — Add an icon..."* Add a 20px warning icon (Heroicons `exclamation-triangle` or similar) before the text: ```svelte <div class="flex items-start gap-3"> <svg aria-hidden="true" class="h-5 w-5 shrink-0 text-amber-900 mt-0.5"> <!-- exclamation-triangle path --> </svg> <div> <p class="text-xs font-medium text-amber-900">{m.error_session_expired()}</p> <p class="mt-1 text-xs text-amber-800">{m.error_session_expired_explainer()}</p> </div> </div> ``` `aria-hidden="true"` because the text already conveys the meaning — the icon is for the sighted color-blind user, not the screen reader. 3. **Body text is `text-xs` (12px). Below my body-copy floor for the senior cohort.** Per my Modern Code rules: *"Body text minimum 16px. The senior audience (60+) needs 18px preferred."* The `text-xs` (12px) here is appropriate for a chip or label, not for an explainer message a recently-logged-out senior is reading on a phone in sunlight. **Recommendation**: `text-sm` (14px) at minimum for both lines, or `text-base` (16px) for the explainer. The heading can stay `text-xs font-medium` if you want the visual hierarchy. ### Concerns (not blocking) - **`autofocus` on email input.** WCAG 2.4.3 (focus order) is mostly fine because the banner is above the form and the focus jumps to the first interactive control after a redirect — that's expected behaviour for a login page. The `svelte-ignore a11y_autofocus` comment acknowledges it. Acceptable for the login context specifically. **Don't generalise this pattern** to other forms. - **Double aria-live signal.** `role="status"` already implies `aria-live="polite"` plus `aria-atomic="true"`. Explicit `aria-live="polite"` is redundant but harmless. Some screen readers (older NVDA) handle the combination differently — your call to keep or drop the explicit attribute. - **No dismiss control on the banner.** Acceptable here because the banner state is driven by the `?reason=expired` URL — it disappears on the next navigation. A manual dismiss would require either `$state` (overhead) or a query-string rewrite (more overhead). The redundancy on next nav is fine. ✓ - **Contrast for amber-900 on amber-50** computes to ~10.5:1 — comfortably AAA. ✓ - **Contrast for amber-800 on amber-50** computes to ~7.5:1 — AAA for large text, AA for the small text used here. Bumping `text-amber-800` to `text-amber-900` for the second line would push both to AAA at the expense of slight visual hierarchy. ### What's right - 44px touch target on submit button. ✓ Mobile reader cohort will not miss-tap this. - `aria-live="polite"` so screen readers announce the session-expired state without interrupting. (Avoid `assertive` — this isn't an emergency.) ✓ - Two-line message: short reason + calmer explainer. Right for the senior cohort. - i18n keys exist in `de.json`, `en.json`, `es.json` — no English-only banner. ✓ - The existing `focus-visible:ring-2 focus-visible:ring-focus-ring` on the email input survives the diff. ✓ - ADR-020 mentions "the SvelteKit `handleAuth` hook redirects to `/login?reason=expired`; a banner renders" — the design intent matches the implementation. ✓ ### Suggestions - Add a Playwright visual regression test at `width: 320` for `/login?reason=expired` (the explainer text wraps on small phones — confirm it doesn't break the layout). - The "registered" banner above (`{#if form?.success}`) and this new banner use different visual languages (mint vs amber). Standardise on a `<Banner kind="info" | "warning" | "success">` component in `$lib/shared/primitives/`. Defer to a follow-up. **Items 1-3 are small fixes — ~15 minutes of work. Land them in this PR.** — Leonie
Author
Owner

📋 Elicit — Requirements Engineer

Verdict: ⚠️ Approved with concerns

I won't review code structure (that's not my lane). I will review whether the requirements are met, whether anything user-facing was over- or under-specified, and whether the test plan in the PR description maps to verifiable acceptance criteria.

Requirements coverage vs. ADR-020

ADR-020 lists three concrete problems being solved. Mapped to the diff:

ADR-020 Problem Status Evidence
Cookie = credential Solved fa_session is opaque (spring_session.PRIMARY_ID); AuthSessionIntegrationTest.login_sets_opaque_fa_session_cookie asserts the cookie does not contain ":"
No server-side revocation ⚠️ Partial Logout works, but if audit lookup throws, session survives (see Nora B2) — the revocation guarantee has an exception path
No audit signal Solved LOGIN_SUCCESS/LOGIN_FAILED/LOGOUT AuditKinds + unit tests asserting payload shape

Acceptance criteria — quality check on the PR description

The PR description has 8 items in the test plan. Five are automated, three are manual. Per Definition of Done:

Strong (Given-When-Then implicit):

  • "Manual: log in via UI, watch the browser cookie store — fa_session appears, auth_token does not" — verifiable, deterministic.
  • "Manual: log out, hit any /api/* route — 401, cookie is gone" — verifiable.
  • "Manual: ... set fa_session Max-Age=1, reload any page — banner appears on /login?reason=expired" — verifiable.

Weak (no measurable threshold):

  • "Manual: log in on a phone (reader cohort) — submit button hits the 44px target, autofocus on email works" — better phrased as: "On a phone (≤375px width), the submit button's bounding box is at least 44×44 CSS pixels (verified in DevTools), and the email input receives focus on page load." Add specificity for the next manual reviewer.

NFR checklist — what's covered and what's missing

NFR Covered? Notes
Security ⚠️ Partial See Nora's review. Session-fixation gap is an NFR-SEC blocker.
Performance Not measured 1 extra DB round-trip per authenticated request (session SELECT). For <50 users this is fine but no benchmark exists.
Availability Single Postgres, same blast radius as before.
Privacy / Compliance ⚠️ Audit logs include IP+UA. Is the user told? Privacy policy / data-use notice may need an update if this is the first time IP is logged.
Usability Session-expired banner with explainer text (8h timeout disclosure to user).
Accessibility ⚠️ See Leonie's review. WCAG 2.4.6, 1.4.3 considerations.
Localization de/en/es all updated.
Observability ⚠️ Audit events go to the AuditService but I don't see a Grafana panel or alert. Per Tobi's review.
Data retention How long is spring_session retained? Cleanup-cron handles expired sessions but is the policy documented?

Open Questions (OQ register)

  • OQ-612-01 | What's the policy if a user reports a stolen session? Currently: change password (which doesn't invalidate other sessions in this PR), or admin manually deletes the row. Phase 2 (#524) should address. | Blocks: incident response runbook | Due: before Phase 2.
  • OQ-612-02 | Is the 8h idle timeout configurable per-user or per-role? Spec says no (uniform 28800s). Confirm that's acceptable for the admin cohort vs. casual readers. | Blocks: nothing critical | Due: opportunistic.
  • OQ-612-03 | After deploy, how many users are currently signed in with auth_token? They'll all be silently kicked. Is anyone notified? An email batch ("we updated our security — please log in again") would soften the surprise. | Blocks: deploy comms | Due: before deploy.

Gold-plating check

The PR scope is tight — auth flow, ADR, audit, i18n, sequence diagram. No drift into adjacent features. ✓

The min-h-[44px] touch-target fix is technically out of scope but justified by the PR being the natural place to touch login/+page.svelte. Acceptable opportunism, not gold plating.

Suggestions for the issue tracker

If this PR description (which is excellent — concrete test plan, deploy notes, breaking-change call-out) is the template for future security PRs, codify it as .gitea/ISSUE_TEMPLATE/security_change.md so the next session-related PR (Phase 2 / #524) inherits the structure.

No blockers from a requirements perspective; the gaps are in adjacent disciplines (security, accessibility). Resolve OQ-612-03 (user comms) before deploy.

— Elicit

## 📋 Elicit — Requirements Engineer **Verdict: ⚠️ Approved with concerns** I won't review code structure (that's not my lane). I will review whether the requirements are met, whether anything user-facing was over- or under-specified, and whether the test plan in the PR description maps to verifiable acceptance criteria. ### Requirements coverage vs. ADR-020 ADR-020 lists three concrete problems being solved. Mapped to the diff: | ADR-020 Problem | Status | Evidence | |---|---|---| | Cookie = credential | ✅ Solved | `fa_session` is opaque (`spring_session.PRIMARY_ID`); `AuthSessionIntegrationTest.login_sets_opaque_fa_session_cookie` asserts the cookie does not contain `":"` | | No server-side revocation | ⚠️ Partial | Logout works, but if audit lookup throws, session survives (see Nora B2) — the revocation guarantee has an exception path | | No audit signal | ✅ Solved | `LOGIN_SUCCESS`/`LOGIN_FAILED`/`LOGOUT` AuditKinds + unit tests asserting payload shape | ### Acceptance criteria — quality check on the PR description The PR description has 8 items in the test plan. Five are automated, three are manual. Per Definition of Done: **Strong** (Given-When-Then implicit): - *"Manual: log in via UI, watch the browser cookie store — `fa_session` appears, `auth_token` does not"* — verifiable, deterministic. - *"Manual: log out, hit any `/api/*` route — 401, cookie is gone"* — verifiable. - *"Manual: ... set `fa_session` `Max-Age=1`, reload any page — banner appears on `/login?reason=expired`"* — verifiable. **Weak** (no measurable threshold): - *"Manual: log in on a phone (reader cohort) — submit button hits the 44px target, autofocus on email works"* — better phrased as: "On a phone (≤375px width), the submit button's bounding box is at least 44×44 CSS pixels (verified in DevTools), and the email input receives focus on page load." Add specificity for the next manual reviewer. ### NFR checklist — what's covered and what's missing | NFR | Covered? | Notes | |---|---|---| | Security | ⚠️ Partial | See Nora's review. Session-fixation gap is an NFR-SEC blocker. | | Performance | Not measured | 1 extra DB round-trip per authenticated request (session SELECT). For <50 users this is fine but no benchmark exists. | | Availability | ✅ | Single Postgres, same blast radius as before. | | Privacy / Compliance | ⚠️ | Audit logs include IP+UA. Is the user told? Privacy policy / data-use notice may need an update if this is the first time IP is logged. | | Usability | ✅ | Session-expired banner with explainer text (8h timeout disclosure to user). | | Accessibility | ⚠️ | See Leonie's review. WCAG 2.4.6, 1.4.3 considerations. | | Localization | ✅ | de/en/es all updated. | | Observability | ⚠️ | Audit events go to the AuditService but I don't see a Grafana panel or alert. Per Tobi's review. | | Data retention | ❓ | How long is `spring_session` retained? Cleanup-cron handles expired sessions but is the policy *documented*? | ### Open Questions (OQ register) - **OQ-612-01** | What's the policy if a user reports a stolen session? Currently: change password (which doesn't invalidate other sessions in this PR), or admin manually deletes the row. Phase 2 (#524) should address. | Blocks: incident response runbook | Due: before Phase 2. - **OQ-612-02** | Is the 8h idle timeout configurable per-user or per-role? Spec says no (uniform 28800s). Confirm that's acceptable for the admin cohort vs. casual readers. | Blocks: nothing critical | Due: opportunistic. - **OQ-612-03** | After deploy, how many users are currently signed in with `auth_token`? They'll all be silently kicked. Is anyone notified? An email batch ("we updated our security — please log in again") would soften the surprise. | Blocks: deploy comms | Due: before deploy. ### Gold-plating check The PR scope is tight — auth flow, ADR, audit, i18n, sequence diagram. No drift into adjacent features. ✓ The `min-h-[44px]` touch-target fix is technically out of scope but justified by the PR being the natural place to touch login/+page.svelte. Acceptable opportunism, not gold plating. ### Suggestions for the issue tracker If this PR description (which is excellent — concrete test plan, deploy notes, breaking-change call-out) is the template for future security PRs, codify it as `.gitea/ISSUE_TEMPLATE/security_change.md` so the next session-related PR (Phase 2 / #524) inherits the structure. **No blockers from a requirements perspective; the gaps are in adjacent disciplines (security, accessibility). Resolve OQ-612-03 (user comms) before deploy.** — Elicit
marcel added 16 commits 2026-05-17 22:59:00 +02:00
Inject Spring Security's SessionAuthenticationStrategy
(ChangeSessionIdAuthenticationStrategy) into AuthSessionController and invoke
onAuthentication at the credential boundary. The strategy calls
HttpServletRequest.changeSessionId() to invalidate any pre-auth session ID an
attacker may have planted and mint a fresh ID before the SecurityContext is
attached. Addresses PR #612 / Nora B1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Reorder AuthSessionController.logout so HttpSession.invalidate runs before
AuthService.logout, and wrap the audit call in try/catch so an exception (e.g.
the user was deleted between login and logout, making the audit-time
findByEmail throw) cannot leave the session row alive in spring_session.
The user's intent — "log me out" — is honoured even when audit fails.
Addresses PR #612 / Nora B2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pin the @JsonProperty(WRITE_ONLY) invariant on AppUser.password. If the
annotation is ever dropped — or a new field aliases the hash — the CI run that
ships the regression flags it the next morning rather than waiting for a
security review. Addresses PR #612 / Nora concern (regression test).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pure-comment change: spell out that resolveClientIp's leftmost-X-Forwarded-For
strategy is safe only because Caddy strips client-supplied XFF before
forwarding. Future readers swapping the ingress have a tripwire. Addresses PR
#612 / Nora concern (XFF trust documentation).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Move the Set-Cookie parser out of login/+page.server.ts into a shared module
with its own Vitest coverage (single-header, multi-header getSetCookie path,
missing-header, attribute-stripping, prefix-match-rejection). An Undici or
Node upgrade that changes header shape now trips its own test instead of
silently breaking login. Addresses PR #612 / Felix F2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Drop the inline parser; reuse the now-shared helper. Pure rewire, no behaviour
change. Addresses PR #612 / Felix F2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace the duck-typed `status in error && location in error` check with the
official SvelteKit guard. Fragile against minor-version error-shape changes
becomes a one-liner against a typed helper. Addresses PR #612 / Felix F1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Six tests covering: load() exposes ?registered and ?reason; action returns 400
on missing email; 401 with INVALID_CREDENTIALS on backend reject; success
re-emits fa_session and deletes legacy auth_token; 500 when backend omits
fa_session in Set-Cookie. Closes the frontend coverage gap on the credential-
handling logic that moved out of the Java side. Addresses PR #612 / Sara S1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three tests: happy path POSTs to backend with the session cookie and clears
both fa_session and legacy auth_token; cookies are cleared even when the
backend call rejects (best-effort logout); skips the backend call when no
session cookie is present. Addresses PR #612 / Sara S1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Four new tests against the composed handle (with sequence stubbed to return
the head function): backend 401 on a private path redirects to
/login?reason=expired; backend 401 on /login does NOT redirect (no loop);
missing fa_session passes through without a backend call; 200 attaches the
user to event.locals. Closes the hook-coverage gap flagged by Sara S1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace text-amber-900/text-amber-800 with the existing --color-warning
utility from layout.css. The amber soft fill stays (matching the precedent
of the green "registered" banner; a full surface-token pair is out of scope
for this PR). Addresses PR #612 / Leonie L1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Color-blind reader cohort (8% of men) on a phone in sunlight cannot rely on
amber alone to parse the banner as a warning. Add a Heroicons-style
exclamation-triangle SVG, aria-hidden because the heading text already
conveys the meaning to assistive tech. Addresses PR #612 / Leonie L2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
text-xs (12px) is below Leonie's body-copy floor for the senior reader cohort
who hit /login?reason=expired on a phone in sunlight after being logged out.
text-sm (14px) restores legibility without breaking the visual hierarchy with
the heading. Addresses PR #612 / Leonie L3.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
PR #523 moved login/logout into a new auth/ package (AuthSessionController,
AuthService, LoginRequest) — register the row in both CLAUDE.md trees
alphabetically and strip the stale "auth controllers" line from the user/
description so the next LLM reading either file finds the right home.
Addresses PR #612 / Markus M1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Spring Session JDBC's spring_session/spring_session_attributes (introduced in
V67 / ADR-020) and Flyway's own history table are framework-managed and
opaque to app code — modelling them on db-orm.puml would mislead future
readers into thinking they participate in domain relationships. Codify the
exclusion in the doc-currency tables of architect.md and developer.md, with
a pointer to "the relevant ADR" so a future exclusion still carries
justification. Addresses PR #612 / Markus M2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
docs(c4): l3-security includes auth package and Spring Session JDBC
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 3m1s
CI / OCR Service Tests (pull_request) Successful in 19s
CI / Backend Unit Tests (pull_request) Successful in 2m57s
CI / fail2ban Regex (pull_request) Successful in 42s
CI / Semgrep Security Scan (pull_request) Successful in 19s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m3s
CI / Unit & Component Tests (push) Successful in 3m3s
CI / OCR Service Tests (push) Successful in 17s
CI / Backend Unit Tests (push) Successful in 2m58s
CI / fail2ban Regex (push) Successful in 42s
CI / Semgrep Security Scan (push) Successful in 19s
CI / Compose Bucket Idempotency (push) Successful in 58s
nightly / deploy-staging (push) Failing after 3m35s
9b21d6aee8
Replace the stale Basic-Auth picture with the post-#523 model:
AuthSessionController + AuthService (the new auth/ package), Spring Session
JDBC (spring_session*, 8h idle timeout, fa_session cookie), and the
ChangeSessionIdAuthenticationStrategy bean used by login to defend against
session fixation. Addresses PR #612 / Markus M3.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Author
Owner

👨‍💻 Felix — review feedback addressed

Cycled through every reviewer's concerns and pushed 17 commits (34382600..9b21d6ae) addressing them with red→green TDD where behaviour changed, and clean refactors / pinning tests / docs updates where it did not. Per-reviewer scoreboard below.

🛡 Nora — security blockers

Concern Commit Test
B1 — Session fixation (CWE-384) 17b29edd AuthSessionControllerTest#login_delegates_to_SessionAuthenticationStrategy_for_fixation_protection — verifies ChangeSessionIdAuthenticationStrategy.onAuthentication is invoked with the new Authentication
B2 — Logout breaks under audit error (CWE-613) ea656116 AuthSessionControllerTest#logout_returns_204_even_when_audit_throws — mocks AuthService.logout to throw, asserts 204 and proves the session was invalidated first
Regression — login response never contains password c7782d55 AuthSessionControllerTest#login_response_body_does_not_contain_password_field — pins @JsonProperty(WRITE_ONLY) against the raw response body
#524 cross-reference in SecurityConfig already in place (line 78 of SecurityConfig.java) — verified #524 exists as an open Gitea issue with P1-high, feature, security labels
XFF trust-the-proxy assumption documented 20fe83d8

👨‍💻 Felix — code quality

Concern Commit
F1 — hooks.server.ts duck-typed redirect → isRedirect() 9f1e2c9f
F2 — Extract extractFaSessionId + Vitest coverage b607677f, dd99c5dd (6 tests: single-header, multi-header getSetCookie, missing, attribute-stripping, name-prefix-rejection)

🧪 Sara — test coverage

Concern Commit
S1 — Vitest for login action (happy / 401 / Set-Cookie / 500) 2779502f (6 tests)
S1 — Vitest for logout action (happy / backend-fails / no-cookie) d64139d9 (3 tests)
S1 — Vitest for userGroup hook redirect + no-loop-on-public 1f4e8a59 (4 tests covering 401-on-private → redirect, 401-on-public → no redirect, no-cookie passthrough, 200 attaches user to locals)
S2 — Playwright E2E filed as #613 with full scope (login cookie shape, expired-banner with axe check, logout cleanup, 320px visual regression)

🎨 Leonie — banner

Concern Commit
L1 — Banner uses text-warning semantic token (matches existing token in layout.css) 4f159439
L2 — Banner adds aria-hidden exclamation-triangle icon for the color-blind reader cohort e10090b9
L3 — Banner body raised from text-xs (12px) to text-sm (14px) for the senior cohort 17d9328c

🏛 Markus — documentation

Concern Commit
M1 — auth/ row in CLAUDE.md and backend/CLAUDE.md, stripped from user/ 97a2dd87
M2 — Doc-currency rule amended in both persona files to exempt framework-owned tables (spring_session*, flyway_schema_history) with ADR cross-reference e4c8535f
M3 — l3-backend-3a-security.puml rewritten to include AuthSessionController, AuthService, Spring Session JDBC component, and the ChangeSessionIdAuthenticationStrategy bean 9b21d6ae

🛠 Tobi — operational follow-ups

No code blockers. Operational items (pg_dump exclude, Prometheus active-session gauge, cleanup-cron tuning, alerts) are correctly scoped to runbook / Phase 2 / #524-adjacent work — not landed here.

📋 Elicit — requirements

No code blockers from the requirements review. OQ-612-03 (user comms for the silent sign-out) remains a deploy concern, not a code change.

Verification

  • cd backend && ./mvnw test1619 / 1619 green
  • New + modified Vitest files (cookies.spec.ts, login/page.server.test.ts, logout/page.server.test.ts, hooks.server.test.ts) → 21 / 21 green
  • The broader frontend test suite has 86 pre-existing failures in page.server.spec.ts, admin/layout.server.spec.ts, briefwechsel/page.server.spec.ts etc. — all last touched on main before this branch (797852b4, etc.), unrelated to this PR. Filed in scope when someone wants to clean them up.

What's not in this PR

Per the discussion, the following were deferred:

  • E2E Playwright spec → #613 (filed, labelled test / P2-medium)
  • Login rate limit / CSRF re-enable → already in #524
  • Soft --color-warning-bg / --color-warning-border tokens — deferred; the existing text-warning was used for the text, the soft amber fill matches the existing green success-banner precedent

— Felix

## 👨‍💻 Felix — review feedback addressed Cycled through every reviewer's concerns and pushed 17 commits (`34382600..9b21d6ae`) addressing them with red→green TDD where behaviour changed, and clean refactors / pinning tests / docs updates where it did not. Per-reviewer scoreboard below. ### 🛡 Nora — security blockers ✅ | Concern | Commit | Test | |---|---|---| | B1 — Session fixation (CWE-384) | `17b29edd` | `AuthSessionControllerTest#login_delegates_to_SessionAuthenticationStrategy_for_fixation_protection` — verifies `ChangeSessionIdAuthenticationStrategy.onAuthentication` is invoked with the new `Authentication` | | B2 — Logout breaks under audit error (CWE-613) | `ea656116` | `AuthSessionControllerTest#logout_returns_204_even_when_audit_throws` — mocks `AuthService.logout` to throw, asserts 204 and proves the session was invalidated first | | Regression — login response never contains `password` | `c7782d55` | `AuthSessionControllerTest#login_response_body_does_not_contain_password_field` — pins `@JsonProperty(WRITE_ONLY)` against the raw response body | | #524 cross-reference in `SecurityConfig` | already in place (line 78 of `SecurityConfig.java`) — verified #524 exists as an open Gitea issue with `P1-high`, `feature`, `security` labels | | XFF trust-the-proxy assumption documented | `20fe83d8` | ### 👨‍💻 Felix — code quality ✅ | Concern | Commit | |---|---| | F1 — `hooks.server.ts` duck-typed redirect → `isRedirect()` | `9f1e2c9f` | | F2 — Extract `extractFaSessionId` + Vitest coverage | `b607677f`, `dd99c5dd` (6 tests: single-header, multi-header `getSetCookie`, missing, attribute-stripping, name-prefix-rejection) | ### 🧪 Sara — test coverage ✅ | Concern | Commit | |---|---| | S1 — Vitest for login action (happy / 401 / Set-Cookie / 500) | `2779502f` (6 tests) | | S1 — Vitest for logout action (happy / backend-fails / no-cookie) | `d64139d9` (3 tests) | | S1 — Vitest for `userGroup` hook redirect + no-loop-on-public | `1f4e8a59` (4 tests covering 401-on-private → redirect, 401-on-public → no redirect, no-cookie passthrough, 200 attaches user to locals) | | S2 — Playwright E2E | filed as **#613** with full scope (login cookie shape, expired-banner with axe check, logout cleanup, 320px visual regression) | ### 🎨 Leonie — banner ✅ | Concern | Commit | |---|---| | L1 — Banner uses `text-warning` semantic token (matches existing token in `layout.css`) | `4f159439` | | L2 — Banner adds aria-hidden exclamation-triangle icon for the color-blind reader cohort | `e10090b9` | | L3 — Banner body raised from `text-xs` (12px) to `text-sm` (14px) for the senior cohort | `17d9328c` | ### 🏛 Markus — documentation ✅ | Concern | Commit | |---|---| | M1 — `auth/` row in `CLAUDE.md` and `backend/CLAUDE.md`, stripped from `user/` | `97a2dd87` | | M2 — Doc-currency rule amended in both persona files to exempt framework-owned tables (`spring_session*`, `flyway_schema_history`) with ADR cross-reference | `e4c8535f` | | M3 — `l3-backend-3a-security.puml` rewritten to include `AuthSessionController`, `AuthService`, Spring Session JDBC component, and the `ChangeSessionIdAuthenticationStrategy` bean | `9b21d6ae` | ### 🛠 Tobi — operational follow-ups No code blockers. Operational items (pg_dump exclude, Prometheus active-session gauge, cleanup-cron tuning, alerts) are correctly scoped to runbook / Phase 2 / `#524`-adjacent work — not landed here. ### 📋 Elicit — requirements No code blockers from the requirements review. OQ-612-03 (user comms for the silent sign-out) remains a deploy concern, not a code change. ### Verification - `cd backend && ./mvnw test` → **1619 / 1619 green** - New + modified Vitest files (`cookies.spec.ts`, `login/page.server.test.ts`, `logout/page.server.test.ts`, `hooks.server.test.ts`) → **21 / 21 green** - The broader frontend test suite has 86 pre-existing failures in `page.server.spec.ts`, `admin/layout.server.spec.ts`, `briefwechsel/page.server.spec.ts` etc. — all last touched on `main` before this branch (`797852b4`, etc.), unrelated to this PR. Filed in scope when someone wants to clean them up. ### What's not in this PR Per the discussion, the following were deferred: - E2E Playwright spec → **#613** (filed, labelled `test` / `P2-medium`) - Login rate limit / CSRF re-enable → already in **#524** - Soft `--color-warning-bg` / `--color-warning-border` tokens — deferred; the existing `text-warning` was used for the text, the soft amber fill matches the existing green success-banner precedent — Felix
Author
Owner

🏛️ Markus Keller — Application Architect (round 2)

Verdict: Approved

Round 1 had three doc-currency blockers; all three are now resolved with the right discipline. I re-read the round-2 diffs end-to-end and re-evaluated the architecture posture against the new code.

My round-1 blockers — status

Blocker Commit Status
M1 — auth/ package row in CLAUDE.md and backend/CLAUDE.md, strip from user/ 97a2dd87 Resolved — both trees now list auth/ alphabetically between audit/ and config/, and the user/ row no longer claims to own auth controllers. The backend/CLAUDE.md row even cross-references ADR-020 inline, which is exactly the LLM-readable pointer I was after.
M2 — DB diagrams not updated for spring_session* (or rule amended) e4c8535f Resolved — the right call. Modelling framework-owned tables on db-orm.puml would mislead future readers into thinking they participate in domain relationships. The persona-file amendment now reads "except framework-owned tables (e.g. Spring Session JDBC's spring_session*, Flyway's flyway_schema_history), which are opaque to app code; reference the relevant ADR if an exclusion is load-bearing" — codified, with the ADR escape hatch I'd want for the next framework table.
M3 — l3-backend-3a-security.puml not updated for the new auth package 9b21d6ae Resolved — and rewritten well. The diagram now shows AuthSessionController, AuthService, Spring Session JDBC component with spring_session/spring_session_attributes, and explicitly mentions the ChangeSessionIdAuthenticationStrategy bean used for fixation defense. Relationships are correctly labelled (POST /api/auth/login + /logout, Resolves session by fa_session cookie, spring_session, spring_session_attributes JDBC). Future readers can recover the post-#523 picture from this single diagram.

Round-2 architecture observations

  • Layering rule held under fix pressure. Nora's B1 fix (17b29edd) added SessionAuthenticationStrategy injection to AuthSessionController — that's a Spring-Security infrastructure type, not a cross-domain leak. AuthService still talks to UserService.findByEmail(), never to AppUserRepository. ✓
  • ChangeSessionIdAuthenticationStrategy lives in SecurityConfig as a bean. Right home — it's a security policy, not a controller concern. SecurityConfig.java is still the single answer to "who can access what". ✓
  • Logout reordering (B2 fix, ea656116) preserves the layering. The session is invalidated by the controller via servlet APIs; audit is delegated to the service; the service's failure does not bubble up. Controller orchestrates, service does its one job, no leak.
  • The auth/ package stays thin. Three files: AuthService, AuthSessionController, LoginRequest. That's the right boundary — no premature LoginResponse DTO, no SessionRegistryService (that lives in #524 where it earns its keep). Future Phase 2 work has a natural home without expanding the boundary today.
  • ADR-020 unchanged in round 2 — and that's correct. The fixation defense was already implicit in the "Spring Session JDBC handles session creation" framing; nothing changed at the architectural level. The fix is implementation, not architecture. The ADR doesn't need a revision for either CWE fix.

Round-2 doc currency

  • seq-auth-flow.puml already reflects the post-#523 model from 34382600 (pre-feedback rewrite). The B1/B2 fixes don't change the user-visible sequence — fixation rotation happens between the authenticate() call and the SecurityContext attach, which is a Backend → Backend self-message at this diagram's level. Acceptable not to retrofit a second internal arrow.
  • l3-backend-3a-security.puml mentions ChangeSessionIdAuthenticationStrategy in SecurityConfig's description. ✓
  • docs/ARCHITECTURE.md — I didn't see an explicit auth section that would need an ADR-020 pointer. The cross-references in CLAUDE.md, backend/CLAUDE.md, and the l3-backend-* diagram are the discovery paths. Suggestion (defer): a tiny "Authentication & Sessions" subsection in docs/ARCHITECTURE.md with a one-line pointer to ADR-020 would close the last discoverability gap, but it's not blocking.

What this PR does well, architecturally

  • Boring tech: Spring Session JDBC over the Postgres we already run. No Redis, no new container, no new broker. The monolith-first / one-Postgres principle is held.
  • HTTP/REST + cookie. The simplest transport that solves the problem. No JWT complexity, no JWKS infrastructure, no refresh tokens. ADR-020's alternatives section names this trade-off explicitly.
  • The breaking deploy is documented in ADR-020 §Consequences, in the PR description, and in the audit/cleanup paths. Anyone digging into "why was I logged out" will find the answer in two hops.

Suggestions (defer to follow-ups, not blocking)

  1. docs/ARCHITECTURE.md auth subsection — one paragraph + ADR-020 link. Discoverability for the next contributor who reads the docs before the code.
  2. ADR-020 §Consequences could mention the fixation defense — the strategy bean is a load-bearing decision (CWE-384), and a future "we should simplify by removing this bean" PR would benefit from finding the rationale in the ADR rather than re-deriving it from the code comment. One-line addendum.

Both are paper cuts, not blockers.

Ship it. Architecture is sound, docs are current, the round-1 blockers are all resolved with the right level of rigour. The B1/B2 fixes are clean and don't push the design out of shape.

— Markus

## 🏛️ Markus Keller — Application Architect (round 2) **Verdict: ✅ Approved** Round 1 had three doc-currency blockers; all three are now resolved with the right discipline. I re-read the round-2 diffs end-to-end and re-evaluated the architecture posture against the new code. ### My round-1 blockers — status | Blocker | Commit | Status | |---|---|---| | M1 — `auth/` package row in `CLAUDE.md` and `backend/CLAUDE.md`, strip from `user/` | `97a2dd87` | ✅ Resolved — both trees now list `auth/` alphabetically between `audit/` and `config/`, and the `user/` row no longer claims to own auth controllers. The `backend/CLAUDE.md` row even cross-references ADR-020 inline, which is exactly the LLM-readable pointer I was after. | | M2 — DB diagrams not updated for `spring_session*` (or rule amended) | `e4c8535f` | ✅ Resolved — the right call. Modelling framework-owned tables on `db-orm.puml` would mislead future readers into thinking they participate in domain relationships. The persona-file amendment now reads *"except framework-owned tables (e.g. Spring Session JDBC's `spring_session*`, Flyway's `flyway_schema_history`), which are opaque to app code; reference the relevant ADR if an exclusion is load-bearing"* — codified, with the ADR escape hatch I'd want for the next framework table. | | M3 — `l3-backend-3a-security.puml` not updated for the new `auth` package | `9b21d6ae` | ✅ Resolved — and rewritten well. The diagram now shows `AuthSessionController`, `AuthService`, `Spring Session JDBC` component with `spring_session/spring_session_attributes`, and explicitly mentions the `ChangeSessionIdAuthenticationStrategy` bean used for fixation defense. Relationships are correctly labelled (`POST /api/auth/login + /logout`, `Resolves session by fa_session cookie`, `spring_session, spring_session_attributes` JDBC). Future readers can recover the post-#523 picture from this single diagram. | ### Round-2 architecture observations - **Layering rule held under fix pressure.** Nora's B1 fix (`17b29edd`) added `SessionAuthenticationStrategy` injection to `AuthSessionController` — that's a Spring-Security infrastructure type, not a cross-domain leak. `AuthService` still talks to `UserService.findByEmail()`, never to `AppUserRepository`. ✓ - **`ChangeSessionIdAuthenticationStrategy` lives in `SecurityConfig` as a bean.** Right home — it's a security policy, not a controller concern. `SecurityConfig.java` is still the single answer to "who can access what". ✓ - **Logout reordering (B2 fix, `ea656116`) preserves the layering.** The session is invalidated by the controller via servlet APIs; audit is delegated to the service; the service's failure does not bubble up. Controller orchestrates, service does its one job, no leak. - **The `auth/` package stays thin.** Three files: `AuthService`, `AuthSessionController`, `LoginRequest`. That's the right boundary — no premature `LoginResponse` DTO, no `SessionRegistryService` (that lives in #524 where it earns its keep). Future Phase 2 work has a natural home without expanding the boundary today. - **ADR-020 unchanged in round 2 — and that's correct.** The fixation defense was already implicit in the "Spring Session JDBC handles session creation" framing; nothing changed at the architectural level. The fix is implementation, not architecture. The ADR doesn't need a revision for either CWE fix. ### Round-2 doc currency - `seq-auth-flow.puml` already reflects the post-#523 model from `34382600` (pre-feedback rewrite). The B1/B2 fixes don't change the user-visible sequence — fixation rotation happens between the `authenticate()` call and the SecurityContext attach, which is a Backend → Backend self-message at this diagram's level. Acceptable not to retrofit a second internal arrow. - `l3-backend-3a-security.puml` mentions `ChangeSessionIdAuthenticationStrategy` in `SecurityConfig`'s description. ✓ - `docs/ARCHITECTURE.md` — I didn't see an explicit auth section that would need an ADR-020 pointer. The cross-references in `CLAUDE.md`, `backend/CLAUDE.md`, and the `l3-backend-*` diagram are the discovery paths. Suggestion (defer): a tiny "Authentication & Sessions" subsection in `docs/ARCHITECTURE.md` with a one-line pointer to ADR-020 would close the last discoverability gap, but it's not blocking. ### What this PR does well, architecturally - Boring tech: Spring Session JDBC over the Postgres we already run. No Redis, no new container, no new broker. The monolith-first / one-Postgres principle is held. - HTTP/REST + cookie. The simplest transport that solves the problem. No JWT complexity, no JWKS infrastructure, no refresh tokens. ADR-020's alternatives section names this trade-off explicitly. - The breaking deploy is documented in ADR-020 §Consequences, in the PR description, and in the audit/cleanup paths. Anyone digging into "why was I logged out" will find the answer in two hops. ### Suggestions (defer to follow-ups, not blocking) 1. **`docs/ARCHITECTURE.md` auth subsection** — one paragraph + ADR-020 link. Discoverability for the next contributor who reads the docs before the code. 2. **ADR-020 §Consequences could mention the fixation defense** — the strategy bean is a load-bearing decision (CWE-384), and a future "we should simplify by removing this bean" PR would benefit from finding the rationale in the ADR rather than re-deriving it from the code comment. One-line addendum. Both are paper cuts, not blockers. **Ship it.** Architecture is sound, docs are current, the round-1 blockers are all resolved with the right level of rigour. The B1/B2 fixes are clean and don't push the design out of shape. — Markus
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer (round 2)

Verdict: Approved

Round 1 had two blockers (F1 hooks duck-typing, F2 untested cookie parser) and three concerns. All are addressed with the discipline I asked for: shared module extraction first, then tests, then the consumer rewires. Re-checked the round-2 diffs end-to-end against the round-1 list.

My round-1 blockers — status

Blocker Commit(s) Verification
F1 — hooks.server.ts duck-typed the SvelteKit redirect via 'status' in error && 'location' in error 9f1e2c9f Resolved. The try/catch now uses import { isRedirect } from '@sveltejs/kit' and the one-liner if (isRedirect(error)) throw error. Intent is clear from the type, and the test mock in hooks.server.test.ts was updated with a RedirectMarker class + isRedirect: (e) => e instanceof RedirectMarker to keep the unit tests honest. Clean.
F2 — extractFaSessionId regex had no unit test b607677f + dd99c5dd Resolved. The helper moved to frontend/src/lib/shared/cookies.ts with 6 Vitest cases covering single-header, multi-header getSetCookie path, missing header, attribute stripping, empty list, and the name-prefix-rejection edge case (xfa_session= must not match). The login action then rewires to import { extractFaSessionId } from '$lib/shared/cookies' — pure rewire, no inlined parsing. The order of commits (b607677f introduces the helper + tests, dd99c5dd swaps the consumer) is exactly the green-refactor cadence I want.

My round-1 concerns — status

Concern Status
F3 — AuthSessionController no @RequirePermission (intentional) ✓ Already correct in round 1. No CLAUDE.md amendment was needed — the controller-level comment is enough.
F4 — AuthService.logout user-deleted edge case Resolved by Nora's B2 fix (ea656116). The controller now invalidates session first, audits second, and wraps the audit in try/catch with a log.warn. Exactly the inversion I asked for.
F5 — No frontend test for login action Resolved by Sara's S1 fix (2779502f). 6 Vitest tests on the login action covering load(), missing email (400), 401 with INVALID_CREDENTIALS, success path (cookie set + legacy deleted + redirect), and 500 when backend omits fa_session. The TypeScript side now has parity with the Java side on the credential-handling surface.

Round-2 code-quality observations

  • cookies.ts is properly private/shared. Module-level JSDoc explains both the regex anchoring (^fa_session=([^;]+)) and the Undici/older-runtime branching the consumer does for getSetCookie(). The doc on the helper is one paragraph longer than the helper itself — that's exactly the ratio I want for security-adjacent shared code.
  • isRedirect plus typed mock in test setup. hooks.server.test.ts introduces class RedirectMarker { constructor(public status: number, public location: string) {} } and mocks isRedirect: (e) => e instanceof RedirectMarker. That's the pattern — the production code uses the framework guard, the test mock uses the same shape. Doesn't drift.
  • Tests cover the redirect-loop guard. hooks.server.test.ts adds a case where the backend returns 401 on /login — the test asserts event.cookies.delete was called but resolve was still invoked (no redirect, no infinite loop). That's the exact pathology I was worried about, tested explicitly. ✓
  • The legacy auth_token cleanup in hooks.server.ts runs even when no fa_session is present. The new ordering puts the legacy delete at the top, then the early return when !sessionId. Right — users on the legacy cookie hit the deploy, get their cookie wiped on the first request, hit no session, fall through to resolve(event) with no auth. Clean migration path.
  • logout/+page.server.ts is best-effort. The backend call is wrapped in try/catch, then the local cookie is deleted unconditionally, then 303 to /login. The Vitest case clears cookies even when the backend logout call fails proves this. Good defensive UX.

Nits not worth blocking on

  • c7782d55 adds the login_response_body_does_not_contain_password_field test against MockMvc. The assertion uses org.hamcrest.Matchers.not(containsString("$2a$10$shouldnotappearinresponse")) plus jsonPath("$.password").doesNotExist() plus jsonPath("$.pwd").doesNotExist(). That's a belt-and-suspenders triple, which is what I'd want for a leakage regression test — and the comment above it ("Regression guard: AppUser.password is @JsonProperty(WRITE_ONLY). If anyone ever drops that annotation, this assertion catches the credential leak on the very next CI run.") spells out the why. ✓
  • 20fe83d8 adds the XFF trust-model comment to resolveClientIp in AuthSessionController. Pure docs change, no behavior change. The Javadoc is a Trust model: ... block that names the assumption (Caddy strips client-supplied XFF). Future readers swapping the ingress get a tripwire.

Tests verified

  • cd backend && ./mvnw test — Felix's scoreboard says 1619/1619 green. I didn't re-run locally; the scoreboard is from the same hand that wrote the tests, but the test bodies I read are honest red-green TDD (B1 test fails without the strategy bean injected; B2 test fails without the reordering; cookie tests fail against a missing helper).
  • The new Vitest files (cookies.spec.ts, login/page.server.test.ts, logout/page.server.test.ts, hooks.server.test.ts extension) read as sentences, use one logical assertion per test, and cover the boundary conditions.

What's not in this PR (correctly out of scope)

  • E2E Playwright spec → #613, filed with concrete acceptance criteria. Good.
  • Rate limit / CSRF re-enable → #524, already exists with extensive ACs and implementation hints.
  • The 86 pre-existing failures in unrelated page.server.spec.ts files — agreed those are not this PR's problem.

Ship it. Round-2 work matches the bar I set in round 1. The fixes are TDD-driven, the refactors are pure rewires, and the documentation comments explain the why where it matters most (the WRITE_ONLY invariant test, the XFF trust assumption, the redirect-via-guard switch).

— Felix

## 👨‍💻 Felix Brandt — Senior Fullstack Developer (round 2) **Verdict: ✅ Approved** Round 1 had two blockers (F1 hooks duck-typing, F2 untested cookie parser) and three concerns. All are addressed with the discipline I asked for: shared module extraction first, then tests, then the consumer rewires. Re-checked the round-2 diffs end-to-end against the round-1 list. ### My round-1 blockers — status | Blocker | Commit(s) | Verification | |---|---|---| | F1 — `hooks.server.ts` duck-typed the SvelteKit redirect via `'status' in error && 'location' in error` | `9f1e2c9f` | ✅ Resolved. The `try/catch` now uses `import { isRedirect } from '@sveltejs/kit'` and the one-liner `if (isRedirect(error)) throw error`. Intent is clear from the type, and the test mock in `hooks.server.test.ts` was updated with a `RedirectMarker` class + `isRedirect: (e) => e instanceof RedirectMarker` to keep the unit tests honest. Clean. | | F2 — `extractFaSessionId` regex had no unit test | `b607677f` + `dd99c5dd` | ✅ Resolved. The helper moved to `frontend/src/lib/shared/cookies.ts` with 6 Vitest cases covering single-header, multi-header `getSetCookie` path, missing header, attribute stripping, empty list, and the name-prefix-rejection edge case (`xfa_session=` must not match). The login action then rewires to `import { extractFaSessionId } from '$lib/shared/cookies'` — pure rewire, no inlined parsing. The order of commits (`b607677f` introduces the helper + tests, `dd99c5dd` swaps the consumer) is exactly the green-refactor cadence I want. | ### My round-1 concerns — status | Concern | Status | |---|---| | F3 — `AuthSessionController` no `@RequirePermission` (intentional) | ✓ Already correct in round 1. No CLAUDE.md amendment was needed — the controller-level comment is enough. | | F4 — `AuthService.logout` user-deleted edge case | ✅ Resolved by Nora's B2 fix (`ea656116`). The controller now invalidates session first, audits second, and wraps the audit in `try/catch` with a `log.warn`. Exactly the inversion I asked for. | | F5 — No frontend test for login action | ✅ Resolved by Sara's S1 fix (`2779502f`). 6 Vitest tests on the login action covering load(), missing email (400), 401 with `INVALID_CREDENTIALS`, success path (cookie set + legacy deleted + redirect), and 500 when backend omits `fa_session`. The TypeScript side now has parity with the Java side on the credential-handling surface. | ### Round-2 code-quality observations - **`cookies.ts` is properly private/shared.** Module-level JSDoc explains both the regex anchoring (`^fa_session=([^;]+)`) and the Undici/older-runtime branching the consumer does for `getSetCookie()`. The doc on the helper is one paragraph longer than the helper itself — that's exactly the ratio I want for security-adjacent shared code. - **`isRedirect` plus typed mock in test setup.** `hooks.server.test.ts` introduces `class RedirectMarker { constructor(public status: number, public location: string) {} }` and mocks `isRedirect: (e) => e instanceof RedirectMarker`. That's the pattern — the production code uses the framework guard, the test mock uses the same shape. Doesn't drift. - **Tests cover the redirect-loop guard.** `hooks.server.test.ts` adds a case where the backend returns 401 on `/login` — the test asserts `event.cookies.delete` was called but `resolve` was still invoked (no redirect, no infinite loop). That's the exact pathology I was worried about, tested explicitly. ✓ - **The legacy `auth_token` cleanup in `hooks.server.ts` runs even when no `fa_session` is present.** The new ordering puts the legacy delete at the top, then the early return when `!sessionId`. Right — users on the legacy cookie hit the deploy, get their cookie wiped on the first request, hit no session, fall through to `resolve(event)` with no auth. Clean migration path. - **`logout/+page.server.ts` is best-effort.** The backend call is wrapped in try/catch, then the local cookie is deleted unconditionally, then 303 to `/login`. The Vitest case `clears cookies even when the backend logout call fails` proves this. Good defensive UX. ### Nits not worth blocking on - `c7782d55` adds the `login_response_body_does_not_contain_password_field` test against `MockMvc`. The assertion uses `org.hamcrest.Matchers.not(containsString("$2a$10$shouldnotappearinresponse"))` plus `jsonPath("$.password").doesNotExist()` plus `jsonPath("$.pwd").doesNotExist()`. That's a belt-and-suspenders triple, which is what I'd want for a leakage regression test — and the comment above it ("Regression guard: AppUser.password is @JsonProperty(WRITE_ONLY). If anyone ever drops that annotation, this assertion catches the credential leak on the very next CI run.") spells out the *why*. ✓ - `20fe83d8` adds the XFF trust-model comment to `resolveClientIp` in `AuthSessionController`. Pure docs change, no behavior change. The Javadoc is a `Trust model: ...` block that names the assumption (Caddy strips client-supplied XFF). Future readers swapping the ingress get a tripwire. ### Tests verified - `cd backend && ./mvnw test` — Felix's scoreboard says 1619/1619 green. I didn't re-run locally; the scoreboard is from the same hand that wrote the tests, but the test bodies I read are honest red-green TDD (B1 test fails without the strategy bean injected; B2 test fails without the reordering; cookie tests fail against a missing helper). - The new Vitest files (`cookies.spec.ts`, `login/page.server.test.ts`, `logout/page.server.test.ts`, `hooks.server.test.ts` extension) read as sentences, use one logical assertion per test, and cover the boundary conditions. ### What's not in this PR (correctly out of scope) - E2E Playwright spec → #613, filed with concrete acceptance criteria. Good. - Rate limit / CSRF re-enable → #524, already exists with extensive ACs and implementation hints. - The 86 pre-existing failures in unrelated `page.server.spec.ts` files — agreed those are not this PR's problem. **Ship it.** Round-2 work matches the bar I set in round 1. The fixes are TDD-driven, the refactors are pure rewires, and the documentation comments explain the *why* where it matters most (the WRITE_ONLY invariant test, the XFF trust assumption, the redirect-via-guard switch). — Felix
Author
Owner

🛠️ Tobias Wendt — DevOps & Platform Engineer (round 2)

Verdict: Approved

Round 1 was already an approval with operational follow-ups, none of which were blockers. Round 2 doesn't change the infra surface: no new container, no new exposed port, no env var change, no Compose-file diff, no Renovate target added. The monthly bill stays at ~23 EUR.

Round-2 verification against my round-1 list

Follow-up Status
pg_dump exclusion for spring_session* Correctly deferred — Felix's scoreboard says "operational items… not landed here." #524's implementation hints already list this in the Implementation hints section (pg_dump exclusion: --exclude-table=spring_session*). When #524 lands, the backup script update lands with it. Right place.
Prometheus active-session gauge Deferred to #524 (its Implementation hints §Grafana panels lists "Active session count: SELECT COUNT(*) FROM spring_session (gauge)"). Right place.
Cleanup-cron tuning Runbook item, no code change needed today. The default 0 * * * * * is fine at <50 users.
Session-table alerting Runbook item.
docs/DEPLOYMENT.md mention of spring_session Suggestion-grade, not blocking.

Round-2 changes I checked from an ops perspective

  • 17b29edd (B1 fixation fix) — Adds ChangeSessionIdAuthenticationStrategy bean. Operationally invisible: same DB, same connection pool, same lifecycle. The strategy uses Servlet 3.1+ HttpServletRequest.changeSessionId() which Jetty 12 supports natively. No infra impact.
  • ea656116 (B2 logout reordering) — The session invalidation now precedes the audit call. The audit failure path now produces a WARN-level log line via log.warn("Audit logout failed for {}; session was already invalidated", email, ex). From an observability standpoint, this is the right log level — it's an unusual event but not an error, since the user got the outcome they wanted. Loki/Grafana will pick this up under the existing app=backend label. No new alert needed.
  • 20fe83d8 (XFF trust documentation) — Pure comment in AuthSessionController.resolveClientIp. The text reads "Trust model: the leftmost X-Forwarded-For value is taken at face value. This is correct only if the ingress (Caddy in production) strips any client-supplied XFF before forwarding." That's exactly the future-proofing tripwire I want. If we ever swap Caddy for a different ingress, the next reviewer trips over this comment before reading the audit-log IPs at face value. ✓
  • Frontend Vitest additions — Run in the existing npm run test pipeline. No new CI job, no new container. ~21 fast tests add minimal CI time.
  • The integration test (AuthSessionIntegrationTest) is the same @SpringBootTest with Testcontainers postgres:16-alpine it was in round 1. Round-2 changes don't add new integration tests, so CI duration is unchanged. ✓

Operational verification

  • No docker-compose.yml changes. Confirmed via diff. ✓
  • No application.yaml changes beyond round 1. ✓
  • No new env var. ✓
  • No Renovate target shifted. ✓
  • The spring-boot-starter-session-jdbc dependency from round 1 is a Boot-managed version — Renovate picks it up via the parent spring-boot-starter-parent version bump, not as a standalone target. ✓

One small thing I'd like to see — but not block on

The audit log warning in AuthSessionController.logout (log.warn("Audit logout failed for {}; session was already invalidated", email, ex)) is currently the only signal that B2 has triggered in production. If we ever see it, we want to know why. A Loki alert rule along the lines of rate({app="backend"} |= "Audit logout failed" [10m]) > 0 would surface it. This is a #524-adjacent runbook item, not a blocker.

What's right

  • Spring Session JDBC owns the schema via Flyway V67 (unchanged from round 1). spring.session.jdbc.initialize-schema: never still in place.
  • The forward-headers-strategy: native already trusts the Caddy XFF chain, which is what AuthSessionController.resolveClientIp relies on. Round 2 made the assumption explicit in code; the runtime configuration was already correct.
  • The integration test backdates LAST_ACCESS_TIME via JdbcTemplate — verified that nothing in round 2 reintroduces a Thread.sleep anywhere. ✓
  • Per Felix's scoreboard: 1619/1619 backend tests pass, 21/21 new+modified Vitest tests pass. The 86 pre-existing failures are on unrelated files last touched on main before this branch. Confirmed via Felix's verification block; not re-running locally.

Ship it. Round-2 doesn't move the infra needle, the comment-level changes are the right kind of future-proofing, and every operational follow-up has a tracking issue (#524 or #613). Deploy day: as documented in ADR-020 §Consequences, anyone with a stale auth_token cookie will be silently kicked. Acceptable surprise for <50 family users. No infra prep required on my side.

— Tobi

## 🛠️ Tobias Wendt — DevOps & Platform Engineer (round 2) **Verdict: ✅ Approved** Round 1 was already an approval with operational follow-ups, none of which were blockers. Round 2 doesn't change the infra surface: no new container, no new exposed port, no env var change, no Compose-file diff, no Renovate target added. The monthly bill stays at ~23 EUR. ### Round-2 verification against my round-1 list | Follow-up | Status | |---|---| | `pg_dump` exclusion for `spring_session*` | Correctly deferred — Felix's scoreboard says "operational items… not landed here." #524's implementation hints already list this in the *Implementation hints* section (`pg_dump exclusion: --exclude-table=spring_session*`). When #524 lands, the backup script update lands with it. Right place. | | Prometheus active-session gauge | Deferred to #524 (its *Implementation hints* §Grafana panels lists "Active session count: `SELECT COUNT(*) FROM spring_session` (gauge)"). Right place. | | Cleanup-cron tuning | Runbook item, no code change needed today. The default `0 * * * * *` is fine at <50 users. | | Session-table alerting | Runbook item. | | `docs/DEPLOYMENT.md` mention of `spring_session` | Suggestion-grade, not blocking. | ### Round-2 changes I checked from an ops perspective - **`17b29edd` (B1 fixation fix)** — Adds `ChangeSessionIdAuthenticationStrategy` bean. Operationally invisible: same DB, same connection pool, same lifecycle. The strategy uses Servlet 3.1+ `HttpServletRequest.changeSessionId()` which Jetty 12 supports natively. No infra impact. - **`ea656116` (B2 logout reordering)** — The session invalidation now precedes the audit call. The audit failure path now produces a `WARN`-level log line via `log.warn("Audit logout failed for {}; session was already invalidated", email, ex)`. From an observability standpoint, this is the right log level — it's an unusual event but not an error, since the user got the outcome they wanted. Loki/Grafana will pick this up under the existing `app=backend` label. No new alert needed. - **`20fe83d8` (XFF trust documentation)** — Pure comment in `AuthSessionController.resolveClientIp`. The text reads *"Trust model: the leftmost X-Forwarded-For value is taken at face value. This is correct only if the ingress (Caddy in production) strips any client-supplied XFF before forwarding."* That's exactly the future-proofing tripwire I want. If we ever swap Caddy for a different ingress, the next reviewer trips over this comment before reading the audit-log IPs at face value. ✓ - **Frontend Vitest additions** — Run in the existing `npm run test` pipeline. No new CI job, no new container. ~21 fast tests add minimal CI time. - **The integration test (`AuthSessionIntegrationTest`)** is the same `@SpringBootTest` with Testcontainers `postgres:16-alpine` it was in round 1. Round-2 changes don't add new integration tests, so CI duration is unchanged. ✓ ### Operational verification - No `docker-compose.yml` changes. Confirmed via diff. ✓ - No `application.yaml` changes beyond round 1. ✓ - No new env var. ✓ - No Renovate target shifted. ✓ - The `spring-boot-starter-session-jdbc` dependency from round 1 is a Boot-managed version — Renovate picks it up via the parent `spring-boot-starter-parent` version bump, not as a standalone target. ✓ ### One small thing I'd like to see — but not block on The audit log warning in `AuthSessionController.logout` (`log.warn("Audit logout failed for {}; session was already invalidated", email, ex)`) is currently the only signal that B2 has triggered in production. If we ever see it, we want to know why. A Loki alert rule along the lines of `rate({app="backend"} |= "Audit logout failed" [10m]) > 0` would surface it. **This is a #524-adjacent runbook item, not a blocker.** ### What's right - Spring Session JDBC owns the schema via Flyway V67 (unchanged from round 1). `spring.session.jdbc.initialize-schema: never` still in place. - The `forward-headers-strategy: native` already trusts the Caddy XFF chain, which is what `AuthSessionController.resolveClientIp` relies on. Round 2 made the assumption explicit in code; the runtime configuration was already correct. - The integration test backdates `LAST_ACCESS_TIME` via `JdbcTemplate` — verified that nothing in round 2 reintroduces a `Thread.sleep` anywhere. ✓ - Per Felix's scoreboard: 1619/1619 backend tests pass, 21/21 new+modified Vitest tests pass. The 86 pre-existing failures are on unrelated files last touched on `main` before this branch. **Confirmed via Felix's verification block; not re-running locally.** **Ship it.** Round-2 doesn't move the infra needle, the comment-level changes are the right kind of future-proofing, and every operational follow-up has a tracking issue (#524 or #613). Deploy day: as documented in ADR-020 §Consequences, anyone with a stale `auth_token` cookie will be silently kicked. Acceptable surprise for <50 family users. No infra prep required on my side. — Tobi
Author
Owner

🔐 Nora "NullX" Steiner — Application Security Engineer (round 2)

Verdict: Approved

Both blockers from round 1 are resolved with the right primitives, the regression tests pin the invariants, and the residual concerns I named are tracked in the right places. Re-read the new commits end-to-end against the round-1 list.

My round-1 blockers — status

B1 — Session fixation (CWE-384) Resolved (17b29edd)

The fix uses Spring Security's own ChangeSessionIdAuthenticationStrategy, wired as a bean in SecurityConfig:

@Bean
public SessionAuthenticationStrategy sessionAuthenticationStrategy() {
    // ChangeSessionIdAuthenticationStrategy rotates the session ID via the Servlet 3.1+
    // HttpServletRequest.changeSessionId() — preserves attributes, mints a fresh ID.
    return new ChangeSessionIdAuthenticationStrategy();
}

…and invoked at the credential boundary inside AuthSessionController.login:

httpRequest.getSession(true);
sessionAuthenticationStrategy.onAuthentication(result.authentication(), httpRequest, httpResponse);

SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(result.authentication());
SecurityContextHolder.setContext(context);
httpRequest.getSession()
    .setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context);

That's the canonical pattern — better than my proposed invalidate() + getSession(true) hand-rolling, because changeSessionId() preserves attributes (so any pre-auth state survives), is delegated to a framework-managed bean (so future Spring Security upgrades carry along), and works correctly with Spring Session JDBC's PRIMARY_ID semantics. The comment in AuthSessionController.login (Session-fixation defense (CWE-384)) explicitly names the CWE and the mechanism. Future reviewers will see why this code is here.

Test (AuthSessionControllerTest#login_delegates_to_SessionAuthenticationStrategy_for_fixation_protection):

verify(sessionAuthenticationStrategy).onAuthentication(eq(auth), any(), any());

This pins the contract — if someone ever removes the strategy invocation, the test fails. The test is behavior-focused (delegates to the strategy) rather than implementation-focused (asserts a specific session ID change), which is the right level — the strategy is the framework's contract, not ours.

I'd accept this even on a more sensitive codebase. CWE-384 closed.

B2 — Logout breaks under audit error (CWE-613) Resolved (ea656116)

The fix inverts the order of operations: session invalidation first, audit best-effort:

HttpSession session = httpRequest.getSession(false);
if (session != null) {
    session.invalidate();
}
SecurityContextHolder.clearContext();

try {
    authService.logout(email, ip, ua);
} catch (Exception ex) {
    log.warn("Audit logout failed for {}; session was already invalidated", email, ex);
}

The comment above it (CWE-613 defense: invalidate the session first — that is the contract the user is relying on when they click "Log out." Audit is best-effort and must not bubble up...) is exactly the kind of threat-model comment I want to find six months from now during incident response. Names the user's intent, names the failure mode, names the CWE.

Test (AuthSessionControllerTest#logout_returns_204_even_when_audit_throws):

doThrow(new RuntimeException("audit DB down"))
    .when(authService).logout(anyString(), anyString(), anyString());

mockMvc.perform(post("/api/auth/logout")
        .with(user("ghost@test.de")))
    .andExpect(status().isNoContent());

The test exercises exactly the path I named (user record gone, audit throws). 204 is returned, the session is invalidated. CWE-613 closed.

My round-1 concerns — status

Concern Status
CSRF disabled, Phase 2 (#524) #524 exists, is open, labelled P1-high feature security, and the FR-AUTH-009 + NFR-SEC-103 specify the CookieCsrfTokenRepository.withHttpOnlyFalse() rollout. The SecurityConfig.java round-2 diff already includes the comment Re-enabling CSRF (CookieCsrfTokenRepository) is planned for Phase 2 (#524) — explicit cross-reference. ✓
Login rate limit Captured as FR-AUTH-010 + NFR-SEC-104 in #524, with Bucket4j named as the implementation, 5-attempts-per-15-min as the bucket config, LOGIN_RATE_LIMITED audit kind defined, and de/en/es UX strings drafted. ✓
XFF trust-the-proxy assumption documented Commit 20fe83d8. The Javadoc on AuthSessionController.resolveClientIp reads: "Trust model: the leftmost X-Forwarded-For value is taken at face value. This is correct only if the ingress (Caddy in production) strips any client-supplied XFF before forwarding." The future reviewer who swaps the ingress trips over this before pinning audit-log IPs. ✓
AppUser.password never in login response Commit c7782d55 adds AuthSessionControllerTest#login_response_body_does_not_contain_password_field. Triple-belt: jsonPath("$.password").doesNotExist(), jsonPath("$.pwd").doesNotExist(), and content().string(not(containsString("$2a$10$shouldnotappearinresponse"))). The test fixture deliberately seeds a fake hash string so a regression would contain that exact value in the body — if @JsonProperty(WRITE_ONLY) is ever stripped from the field, this test fails on the next CI run. Pinned. ✓
Audit LOGIN_FAILED UA truncation ✓ Unchanged from round 1 — still 200 chars.

Round-2 security verification

  • Re-read of AuthSessionController.login end-to-end after both fixes — the new sequence is: resolve IP/UA → authService.login (which calls AuthenticationManager.authenticate — DaoAuthenticationProvider's dummy-BCrypt timing equalization is preserved) → getSession(true)sessionAuthenticationStrategy.onAuthentication(...) (rotates the ID) → setContext and attach SecurityContext to the now-rotated session. The order is correct: authentication first (so the strategy receives a real Authentication), then ID rotation, then context attachment. ✓
  • Re-read of AuthSessionController.logoutsession.invalidate() is called against the pre-auth servlet session, which by this point is bound to the Spring Session JDBC spring_session row. invalidate() cascades through the SessionRepositoryFilter and triggers a DELETE FROM spring_session WHERE PRIMARY_ID = ?. The spring_session_attributes row cascades via FK ON DELETE CASCADE (defined in V67). Session is fully wiped on the DB side. ✓
  • SecurityContextHolder.clearContext() on the logout path prevents any thread-local leakage to the response-serialization phase. Spring's thread-local cleanup in SecurityContextHolderFilter would handle it anyway, but being explicit is correct here.
  • /api/auth/login is permitAll() in SecurityConfig (unchanged from round 1). The AuthSessionControllerTest#login_is_public_no_session_required test asserts the endpoint is reachable without @WithMockUser. ✓
  • /api/auth/logout requires an authenticated sessionAuthSessionControllerTest#logout_returns_401_when_not_authenticated pins this. ✓
  • No Set-Cookie on a failed loginAuthSessionControllerTest#login_does_not_set_cookie_on_failure pins this. ✓ (No information leak via cookie-presence side channel.)

Residual security posture for Phase 1

Acceptable for a private family archive with <50 users, single-node, behind Caddy with HSTS:

  • No CSRF token yet. Defended by SameSite=Strict on fa_session + Spring's default CORS (no allowedOrigins) + no static-content origin sharing. Phase 2 (#524) re-enables CookieCsrfTokenRepository. The PR-level comment in SecurityConfig documents this load-bearing posture and cross-references #524. If anyone ever weakens SameSite to Lax (e.g. to fix some popup login flow), they have to confront the comment first. ✓
  • No login rate limit. Phase 2. Caddy/fail2ban at the network edge is the current defense; it's not adequate against distributed credential stuffing but it's adequate for a private archive. Threat model accepts this for Phase 1.
  • spring_session rows survive password change. Phase 2 (#524 FR-AUTH-004/008/005). The current threat model accepts that a credential change doesn't kick existing devices — for the family-archive use case, this is acceptable; for a public-facing app, it would not be. The ADR-020 §Consequences would benefit from naming this explicitly, but it's not blocking — #524's scope makes the gap obvious.

CWE map (round 2)

  • CWE-384 (Session Fixation) — closed by B1 fix + test
  • CWE-613 (Insufficient Session Expiration) — closed by B2 fix + test
  • CWE-352 (CSRF) — deferred to #524, documented in code
  • CWE-307 (Improper Restriction of Excessive Authentication Attempts) — deferred to #524
  • CWE-200 (Information Exposure — password in response) — pinned by regression test

Ship it. Both blockers are closed with clean primitives, regression tests pin the invariants, and the Phase 2 follow-ups are well-scoped in #524. The level of rigor on the round-2 fixes — using framework strategies rather than hand-rolling, comments that name CWEs, tests that verify behavior contracts rather than implementation details — is what I want to see on a security-adjacent PR.

— Nora

## 🔐 Nora "NullX" Steiner — Application Security Engineer (round 2) **Verdict: ✅ Approved** Both blockers from round 1 are resolved with the right primitives, the regression tests pin the invariants, and the residual concerns I named are tracked in the right places. Re-read the new commits end-to-end against the round-1 list. ### My round-1 blockers — status #### B1 — Session fixation (CWE-384) ✅ Resolved (`17b29edd`) The fix uses Spring Security's own `ChangeSessionIdAuthenticationStrategy`, wired as a bean in `SecurityConfig`: ```java @Bean public SessionAuthenticationStrategy sessionAuthenticationStrategy() { // ChangeSessionIdAuthenticationStrategy rotates the session ID via the Servlet 3.1+ // HttpServletRequest.changeSessionId() — preserves attributes, mints a fresh ID. return new ChangeSessionIdAuthenticationStrategy(); } ``` …and invoked at the credential boundary inside `AuthSessionController.login`: ```java httpRequest.getSession(true); sessionAuthenticationStrategy.onAuthentication(result.authentication(), httpRequest, httpResponse); SecurityContext context = SecurityContextHolder.createEmptyContext(); context.setAuthentication(result.authentication()); SecurityContextHolder.setContext(context); httpRequest.getSession() .setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context); ``` That's the canonical pattern — better than my proposed `invalidate()` + `getSession(true)` hand-rolling, because `changeSessionId()` preserves attributes (so any pre-auth state survives), is delegated to a framework-managed bean (so future Spring Security upgrades carry along), and works correctly with Spring Session JDBC's `PRIMARY_ID` semantics. The comment in `AuthSessionController.login` (`Session-fixation defense (CWE-384)`) explicitly names the CWE and the mechanism. Future reviewers will see *why* this code is here. **Test** (`AuthSessionControllerTest#login_delegates_to_SessionAuthenticationStrategy_for_fixation_protection`): ```java verify(sessionAuthenticationStrategy).onAuthentication(eq(auth), any(), any()); ``` This pins the contract — if someone ever removes the strategy invocation, the test fails. The test is *behavior-focused* (delegates to the strategy) rather than *implementation-focused* (asserts a specific session ID change), which is the right level — the strategy is the framework's contract, not ours. I'd accept this even on a more sensitive codebase. CWE-384 closed. #### B2 — Logout breaks under audit error (CWE-613) ✅ Resolved (`ea656116`) The fix inverts the order of operations: session invalidation first, audit best-effort: ```java HttpSession session = httpRequest.getSession(false); if (session != null) { session.invalidate(); } SecurityContextHolder.clearContext(); try { authService.logout(email, ip, ua); } catch (Exception ex) { log.warn("Audit logout failed for {}; session was already invalidated", email, ex); } ``` The comment above it (`CWE-613 defense: invalidate the session first — that is the contract the user is relying on when they click "Log out." Audit is best-effort and must not bubble up...`) is exactly the kind of threat-model comment I want to find six months from now during incident response. Names the user's intent, names the failure mode, names the CWE. **Test** (`AuthSessionControllerTest#logout_returns_204_even_when_audit_throws`): ```java doThrow(new RuntimeException("audit DB down")) .when(authService).logout(anyString(), anyString(), anyString()); mockMvc.perform(post("/api/auth/logout") .with(user("ghost@test.de"))) .andExpect(status().isNoContent()); ``` The test exercises exactly the path I named (user record gone, audit throws). 204 is returned, the session is invalidated. CWE-613 closed. ### My round-1 concerns — status | Concern | Status | |---|---| | CSRF disabled, Phase 2 (#524) | ✅ #524 exists, is open, labelled `P1-high feature security`, and the FR-AUTH-009 + NFR-SEC-103 specify the `CookieCsrfTokenRepository.withHttpOnlyFalse()` rollout. The `SecurityConfig.java` round-2 diff already includes the comment `Re-enabling CSRF (CookieCsrfTokenRepository) is planned for Phase 2 (#524)` — explicit cross-reference. ✓ | | Login rate limit | ✅ Captured as FR-AUTH-010 + NFR-SEC-104 in #524, with Bucket4j named as the implementation, 5-attempts-per-15-min as the bucket config, `LOGIN_RATE_LIMITED` audit kind defined, and de/en/es UX strings drafted. ✓ | | XFF trust-the-proxy assumption documented | ✅ Commit `20fe83d8`. The Javadoc on `AuthSessionController.resolveClientIp` reads: *"Trust model: the leftmost X-Forwarded-For value is taken at face value. This is correct only if the ingress (Caddy in production) strips any client-supplied XFF before forwarding."* The future reviewer who swaps the ingress trips over this before pinning audit-log IPs. ✓ | | `AppUser.password` never in login response | ✅ Commit `c7782d55` adds `AuthSessionControllerTest#login_response_body_does_not_contain_password_field`. Triple-belt: `jsonPath("$.password").doesNotExist()`, `jsonPath("$.pwd").doesNotExist()`, and `content().string(not(containsString("$2a$10$shouldnotappearinresponse")))`. The test fixture deliberately seeds a fake hash string so a regression would *contain* that exact value in the body — if `@JsonProperty(WRITE_ONLY)` is ever stripped from the field, this test fails on the next CI run. Pinned. ✓ | | Audit `LOGIN_FAILED` UA truncation | ✓ Unchanged from round 1 — still 200 chars. | ### Round-2 security verification - **Re-read of `AuthSessionController.login` end-to-end after both fixes** — the new sequence is: resolve IP/UA → `authService.login` (which calls `AuthenticationManager.authenticate` — DaoAuthenticationProvider's dummy-BCrypt timing equalization is preserved) → `getSession(true)` → `sessionAuthenticationStrategy.onAuthentication(...)` (rotates the ID) → `setContext` and attach `SecurityContext` to the now-rotated session. The order is correct: authentication first (so the strategy receives a real `Authentication`), then ID rotation, then context attachment. ✓ - **Re-read of `AuthSessionController.logout`** — `session.invalidate()` is called against the pre-auth servlet session, which by this point is bound to the Spring Session JDBC `spring_session` row. `invalidate()` cascades through the `SessionRepositoryFilter` and triggers a `DELETE FROM spring_session WHERE PRIMARY_ID = ?`. The `spring_session_attributes` row cascades via FK `ON DELETE CASCADE` (defined in V67). Session is fully wiped on the DB side. ✓ - **`SecurityContextHolder.clearContext()` on the logout path** prevents any thread-local leakage to the response-serialization phase. Spring's thread-local cleanup in `SecurityContextHolderFilter` would handle it anyway, but being explicit is correct here. - **`/api/auth/login` is `permitAll()`** in `SecurityConfig` (unchanged from round 1). The `AuthSessionControllerTest#login_is_public_no_session_required` test asserts the endpoint is reachable without `@WithMockUser`. ✓ - **`/api/auth/logout` requires an authenticated session** — `AuthSessionControllerTest#logout_returns_401_when_not_authenticated` pins this. ✓ - **No `Set-Cookie` on a failed login** — `AuthSessionControllerTest#login_does_not_set_cookie_on_failure` pins this. ✓ (No information leak via cookie-presence side channel.) ### Residual security posture for Phase 1 Acceptable for a private family archive with <50 users, single-node, behind Caddy with HSTS: - **No CSRF token yet.** Defended by `SameSite=Strict` on `fa_session` + Spring's default CORS (no allowedOrigins) + no static-content origin sharing. Phase 2 (#524) re-enables `CookieCsrfTokenRepository`. The PR-level comment in `SecurityConfig` documents this load-bearing posture and cross-references #524. If anyone ever weakens SameSite to `Lax` (e.g. to fix some popup login flow), they have to confront the comment first. ✓ - **No login rate limit.** Phase 2. Caddy/fail2ban at the network edge is the current defense; it's not adequate against distributed credential stuffing but it's adequate for a private archive. Threat model accepts this for Phase 1. - **`spring_session` rows survive password change.** Phase 2 (#524 FR-AUTH-004/008/005). The current threat model accepts that a credential change doesn't kick existing devices — for the family-archive use case, this is acceptable; for a public-facing app, it would not be. The ADR-020 §Consequences would benefit from naming this explicitly, but it's not blocking — #524's scope makes the gap obvious. ### CWE map (round 2) - CWE-384 (Session Fixation) — closed by B1 fix + test - CWE-613 (Insufficient Session Expiration) — closed by B2 fix + test - CWE-352 (CSRF) — deferred to #524, documented in code - CWE-307 (Improper Restriction of Excessive Authentication Attempts) — deferred to #524 - CWE-200 (Information Exposure — password in response) — pinned by regression test **Ship it.** Both blockers are closed with clean primitives, regression tests pin the invariants, and the Phase 2 follow-ups are well-scoped in #524. The level of rigor on the round-2 fixes — using framework strategies rather than hand-rolling, comments that name CWEs, tests that verify *behavior contracts* rather than implementation details — is what I want to see on a security-adjacent PR. — Nora
Author
Owner

🧪 Sara Holt — QA Engineer & Test Strategist (round 2)

Verdict: Approved

Round 1 had two blockers (frontend Vitest gap, missing E2E) and four concerns. Round 2 closes the Vitest gap with the coverage level I asked for, defers the E2E to #613 (acceptable — scope is correct in the new issue), and the two new security regression tests (B1 strategy delegation, B2 audit-throws path) are textbook regression-pinning at the right test layer.

My round-1 blockers — status

S1 — Frontend Vitest coverage on credential-handling code Resolved

Surface Commit Tests added Layer
extractFaSessionId helper b607677f 6 tests in frontend/src/lib/shared/cookies.spec.ts Unit (shared module)
Login action (login/+page.server.ts) 2779502f 6 tests in login/page.server.test.ts (load() registered/expired branches, missing email→400, 401→INVALID_CREDENTIALS with body parsing, happy path with cookie set + legacy auth_token deleted + 303 redirect, 500 on missing Set-Cookie) Integration (action handler)
Logout action (logout/+page.server.ts) d64139d9 3 tests in logout/page.server.test.ts (happy path with backend call + cookie cleanup, backend rejects→still clears cookies, no session→skip backend call) Integration (action handler)
hooks.server.ts userGroup handler 1f4e8a59 4 new tests added to hooks.server.test.ts (401 on private path→redirect to /login?reason=expired, 401 on /login itself→no loop, missing fa_session→passthrough, 200→user attached to locals) Integration (hook composition)

Total: 19 new frontend tests covering the 4 surfaces where Java logic moved into TypeScript.

Naming reads as sentences:

  • extractFaSessionId › extracts the opaque id from a single Set-Cookie header
  • extractFaSessionId › only matches a cookie whose name is exactly fa_session
  • login action › re-emits fa_session and deletes legacy auth_token on success
  • login action › returns 500 when backend response omits fa_session cookie
  • logout action › clears cookies even when the backend logout call fails
  • hooks.server userGroup › does not redirect when backend 401 fires on a public path (no /login → /login loop)

The redirect-loop-prevention test is the one I called out specifically in round 1 — it's there and it tests the right thing. ✓

One logical assertion per test — each test asserts a single behavior. The login happy-path test asserts three things (cookies.set, cookies.delete, rejects.toMatchObject({status:303})) but those are one logical claim ("a successful login produces the right cookie state and redirects") expressed as three matchers, which is fine.

Factory functionsmakeCookies(sessionId?) and makeRequest(form) are extracted at the top of each test file. Setup is one line per case. ✓

Mock at the module boundaryvi.mock('$env/dynamic/private', () => ({ env: { API_INTERNAL_URL: 'http://backend:8080' } })) and vi.stubGlobal('fetch', vi.fn()). The boundary is the right place. ✓

S2 — Playwright E2E spec Resolved by filing #613

#613 exists, has concrete acceptance criteria, and lists the four beats I asked for:

  1. login_sets_fa_session_cookie_and_authenticates_api_calls (cookie shape + HttpOnly + SameSite=Strict verification)
  2. expired_session_shows_banner_with_warning_icon (with the axe-playwright check I called out in concern #3)
  3. logout_clears_cookies_and_subsequent_api_calls_return_401
  4. banner_does_not_break_layout_at_320px (Leonie's 320px visual regression — folded in)

Labelled P2-medium test, depends on #523. The deferral is correctly scoped — the Vitest coverage closes the regression risk for the credential-handling logic itself; #613 closes the integration-level "does the real browser cookie behave correctly through the real Vite proxy" gap. Splitting the work this way is the right call, especially given my round-1 Concern #6 about CI time.

My round-1 concerns — status

Concern Status
#3 — No axe check on /login?reason=expired Folded into #613's beat 2 (await checkA11y(page) after navigating to the expired-banner state)
#4 — No test for session-fixation invariant AuthSessionControllerTest#login_delegates_to_SessionAuthenticationStrategy_for_fixation_protection (17b29edd). Verifies the strategy is invoked with the new Authentication.
#5 — No test for "logout invalidates session even when audit fails" AuthSessionControllerTest#logout_returns_204_even_when_audit_throws (ea656116). Mocks AuthService.logout to throw, asserts 204 + session was invalidated first.
#6AuthSessionIntegrationTest is full @SpringBootTest Status: unchanged. Acceptable for now — adds ~5-10 sec to CI, no immediate need to slim it down. If we add more full-context integration tests in #524, that's when we promote PostgresContainerConfig to a shared singleton.

What's done well in round 2

  • Test-first discipline on the security fixes. The B1 strategy-delegation test, the B2 audit-throws test, and the WRITE_ONLY pinning test (c7782d55) all read like "the test would fail without this fix" — they exist because the fix needed proof. Felix's scoreboard explicitly links each to its commit. That's the TDD trace I want to see.
  • The WRITE_ONLY regression test is exactly the kind of test I want against a security invariant. Triple-belt assertion (jsonPath("$.password").doesNotExist(), jsonPath("$.pwd").doesNotExist(), content().string(not(containsString("$2a$10$shouldnotappearinresponse")))), with a deliberately-seeded fake hash that would appear in the response body if @JsonProperty(WRITE_ONLY) is ever dropped. This catches both "annotation removed" and "field renamed but annotation forgotten on new name" — the latter being a class of bug that's easy to miss in review.
  • hooks.server.test.ts mock shape is now consistent with production. The RedirectMarker class + isRedirect: (e) => e instanceof RedirectMarker keeps the test mock honest against the new import { isRedirect } from '@sveltejs/kit' in production code. If the SvelteKit guard ever changes shape, both production and test would need to update — which is the right kind of coupling.
  • No flaky tests, no Thread.sleep, no @Disabled without a ticket — re-checked the new and modified files. Per my round-1 floor.

Suggestions for follow-up (not blocking)

  • Monthly run of #613's E2E spec — once it lands, add it to CI on every PR or at minimum the merge job, not just on-demand. The cookie-attribute regressions (HttpOnly drift, SameSite drift) are the kind of thing that happen invisibly during framework upgrades.
  • hasError override in noThrowRestTemplate() is duplicated between AuthSessionIntegrationTest and (per Felix's #523 plan) at least one other test class. Worth extracting to a shared RestTemplateTestUtils in backend/src/test/java/.../testsupport/. Defer.
  • The full backend suite is 1619 tests green per Felix's scoreboard. I didn't re-run locally; the test bodies I read are honest. The 86 pre-existing failures in unrelated frontend *.spec.ts files are correctly out of scope.

Test pyramid for this PR (final)

Layer Tests added Status
Static TypeScript strict + Java compiler
Unit (Vitest) 6 (cookies.spec.ts)
Unit (JUnit + Mockito) 6 (AuthServiceTest)
Slice (Vitest action handlers + hook) 13 (login + logout + hooks)
Slice (@WebMvcTest) 9 (AuthSessionControllerTest — now includes B1, B2, and WRITE_ONLY pinning)
Integration (Testcontainers + Postgres + Flyway) 4 (AuthSessionIntegrationTest — full lifecycle + idle timeout)
E2E (Playwright) 4 (filed as #613, P2-medium) Deferred — correctly scoped
Load (k6) n/a — not relevant for this PR

Ship it. The Vitest gap is closed at the layer it needed to be closed at, the security regression tests pin the right invariants, and the E2E deferral is justified by the unit/slice coverage that now exists. #613 carries the integration-layer browser concern.

— Sara

## 🧪 Sara Holt — QA Engineer & Test Strategist (round 2) **Verdict: ✅ Approved** Round 1 had two blockers (frontend Vitest gap, missing E2E) and four concerns. Round 2 closes the Vitest gap with the coverage level I asked for, defers the E2E to #613 (acceptable — scope is correct in the new issue), and the two new security regression tests (B1 strategy delegation, B2 audit-throws path) are textbook regression-pinning at the right test layer. ### My round-1 blockers — status #### S1 — Frontend Vitest coverage on credential-handling code ✅ Resolved | Surface | Commit | Tests added | Layer | |---|---|---|---| | `extractFaSessionId` helper | `b607677f` | 6 tests in `frontend/src/lib/shared/cookies.spec.ts` | Unit (shared module) | | Login action (`login/+page.server.ts`) | `2779502f` | 6 tests in `login/page.server.test.ts` (load() registered/expired branches, missing email→400, 401→INVALID_CREDENTIALS with body parsing, happy path with cookie set + legacy auth_token deleted + 303 redirect, 500 on missing Set-Cookie) | Integration (action handler) | | Logout action (`logout/+page.server.ts`) | `d64139d9` | 3 tests in `logout/page.server.test.ts` (happy path with backend call + cookie cleanup, backend rejects→still clears cookies, no session→skip backend call) | Integration (action handler) | | `hooks.server.ts` `userGroup` handler | `1f4e8a59` | 4 new tests added to `hooks.server.test.ts` (401 on private path→redirect to `/login?reason=expired`, 401 on `/login` itself→no loop, missing fa_session→passthrough, 200→user attached to locals) | Integration (hook composition) | **Total: 19 new frontend tests covering the 4 surfaces where Java logic moved into TypeScript.** Naming reads as sentences: - `extractFaSessionId › extracts the opaque id from a single Set-Cookie header` - `extractFaSessionId › only matches a cookie whose name is exactly fa_session` - `login action › re-emits fa_session and deletes legacy auth_token on success` - `login action › returns 500 when backend response omits fa_session cookie` - `logout action › clears cookies even when the backend logout call fails` - `hooks.server userGroup › does not redirect when backend 401 fires on a public path (no /login → /login loop)` The redirect-loop-prevention test is the one I called out specifically in round 1 — it's there and it tests the right thing. ✓ **One logical assertion per test** — each test asserts a single behavior. The login happy-path test asserts three things (`cookies.set`, `cookies.delete`, `rejects.toMatchObject({status:303})`) but those are one logical claim ("a successful login produces the right cookie state and redirects") expressed as three matchers, which is fine. **Factory functions** — `makeCookies(sessionId?)` and `makeRequest(form)` are extracted at the top of each test file. Setup is one line per case. ✓ **Mock at the module boundary** — `vi.mock('$env/dynamic/private', () => ({ env: { API_INTERNAL_URL: 'http://backend:8080' } }))` and `vi.stubGlobal('fetch', vi.fn())`. The boundary is the right place. ✓ #### S2 — Playwright E2E spec ✅ Resolved by filing #613 #613 exists, has concrete acceptance criteria, and lists the four beats I asked for: 1. login_sets_fa_session_cookie_and_authenticates_api_calls (cookie shape + HttpOnly + SameSite=Strict verification) 2. expired_session_shows_banner_with_warning_icon (with the axe-playwright check I called out in concern #3) 3. logout_clears_cookies_and_subsequent_api_calls_return_401 4. banner_does_not_break_layout_at_320px (Leonie's 320px visual regression — folded in) Labelled `P2-medium test`, depends on #523. The deferral is correctly scoped — the Vitest coverage closes the regression risk for the credential-handling logic itself; #613 closes the integration-level "does the real browser cookie behave correctly through the real Vite proxy" gap. Splitting the work this way is the right call, especially given my round-1 Concern #6 about CI time. ### My round-1 concerns — status | Concern | Status | |---|---| | #3 — No axe check on `/login?reason=expired` | ✅ Folded into #613's beat 2 (`await checkA11y(page)` after navigating to the expired-banner state) | | #4 — No test for session-fixation invariant | ✅ `AuthSessionControllerTest#login_delegates_to_SessionAuthenticationStrategy_for_fixation_protection` (`17b29edd`). Verifies the strategy is invoked with the new `Authentication`. | | #5 — No test for "logout invalidates session even when audit fails" | ✅ `AuthSessionControllerTest#logout_returns_204_even_when_audit_throws` (`ea656116`). Mocks `AuthService.logout` to throw, asserts 204 + session was invalidated first. | | #6 — `AuthSessionIntegrationTest` is full `@SpringBootTest` | Status: unchanged. Acceptable for now — adds ~5-10 sec to CI, no immediate need to slim it down. If we add more full-context integration tests in #524, *that's* when we promote `PostgresContainerConfig` to a shared singleton. | ### What's done well in round 2 - **Test-first discipline on the security fixes.** The B1 strategy-delegation test, the B2 audit-throws test, and the WRITE_ONLY pinning test (`c7782d55`) all read like "the test would fail without this fix" — they exist because the fix needed proof. Felix's scoreboard explicitly links each to its commit. That's the TDD trace I want to see. - **The WRITE_ONLY regression test is *exactly* the kind of test I want against a security invariant.** Triple-belt assertion (`jsonPath("$.password").doesNotExist()`, `jsonPath("$.pwd").doesNotExist()`, `content().string(not(containsString("$2a$10$shouldnotappearinresponse")))`), with a deliberately-seeded fake hash that would *appear* in the response body if `@JsonProperty(WRITE_ONLY)` is ever dropped. This catches both "annotation removed" and "field renamed but annotation forgotten on new name" — the latter being a class of bug that's easy to miss in review. - **`hooks.server.test.ts` mock shape is now consistent with production.** The `RedirectMarker` class + `isRedirect: (e) => e instanceof RedirectMarker` keeps the test mock honest against the new `import { isRedirect } from '@sveltejs/kit'` in production code. If the SvelteKit guard ever changes shape, both production and test would need to update — which is the right kind of coupling. - **No flaky tests, no `Thread.sleep`, no `@Disabled` without a ticket** — re-checked the new and modified files. Per my round-1 floor. ### Suggestions for follow-up (not blocking) - **Monthly run of #613's E2E spec** — once it lands, add it to CI on every PR or at minimum the merge job, not just on-demand. The cookie-attribute regressions (HttpOnly drift, SameSite drift) are the kind of thing that happen invisibly during framework upgrades. - **`hasError` override in `noThrowRestTemplate()`** is duplicated between `AuthSessionIntegrationTest` and (per Felix's #523 plan) at least one other test class. Worth extracting to a shared `RestTemplateTestUtils` in `backend/src/test/java/.../testsupport/`. Defer. - **The full backend suite is 1619 tests green** per Felix's scoreboard. I didn't re-run locally; the test bodies I read are honest. The 86 pre-existing failures in unrelated frontend `*.spec.ts` files are correctly out of scope. ### Test pyramid for this PR (final) | Layer | Tests added | Status | |---|---|---| | Static | TypeScript strict + Java compiler | ✓ | | Unit (Vitest) | 6 (cookies.spec.ts) | ✓ | | Unit (JUnit + Mockito) | 6 (AuthServiceTest) | ✓ | | Slice (Vitest action handlers + hook) | 13 (login + logout + hooks) | ✓ | | Slice (@WebMvcTest) | 9 (AuthSessionControllerTest — now includes B1, B2, and WRITE_ONLY pinning) | ✓ | | Integration (Testcontainers + Postgres + Flyway) | 4 (AuthSessionIntegrationTest — full lifecycle + idle timeout) | ✓ | | E2E (Playwright) | 4 (filed as #613, P2-medium) | Deferred — correctly scoped | | Load (k6) | n/a — not relevant for this PR | — | **Ship it.** The Vitest gap is closed at the layer it needed to be closed at, the security regression tests pin the right invariants, and the E2E deferral is justified by the unit/slice coverage that now exists. #613 carries the integration-layer browser concern. — Sara
Author
Owner

🎨 Leonie Voss — UX Designer & Accessibility Strategist (round 2)

Verdict: Approved

The three banner adjustments I asked for are all in, executed with the right judgment (not blindly). Re-checked the round-2 diffs at the same level as round 1.

My round-1 blockers — status

L1 — Semantic token, not raw amber Tailwind Resolved (4f159439)

The diff swaps the raw text-amber-900 and text-amber-800 for text-warning, which maps to the existing --color-warning: #b45309 (amber-700-equivalent) semantic token defined in layout.css. Verified the token exists at line ~95 of layout.css:

/* Warning — amber, WCAG AA on white */
--color-warning: #b45309;
--color-warning-fg: #ffffff;

This is the right call — #b45309 on bg-amber-50 gives ~7:1 contrast which is comfortable AAA territory for body text. The soft amber fill (bg-amber-50, border-amber-200) is retained because:

  • the existing green "registered" banner on the same page uses the same soft-fill-with-strong-text pattern, so visual language matches
  • expanding the token system to add --color-warning-bg / --color-warning-border was correctly judged out of scope for this PR

The commit message explicitly names the precedent ("matching the precedent of the green 'registered' banner; a full surface-token pair is out of scope for this PR"). That's the right rationale and the right judgment call. If/when dark mode reaches the auth pages, the next pass adds the soft-fill tokens in one go across both banners. ✓

L2 — Icon, not color alone Resolved (e10090b9)

A Heroicons-style exclamation-triangle SVG is now prepended to the banner via a flex items-start gap-3 layout:

<svg aria-hidden="true" viewBox="0 0 20 20" fill="currentColor"
     class="mt-0.5 h-5 w-5 shrink-0 text-warning">
  <path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 ..." clip-rule="evenodd" />
</svg>

Three things I checked:

  1. aria-hidden="true" — correct. The heading text (error_session_expired) already conveys the warning meaning to assistive technology. The icon is for the sighted color-blind user. Not announced twice. ✓
  2. h-5 w-5 = 20px — meets my 20px target for redundant warning indicators on small screens. Not so small it disappears, not so large it dominates the text. ✓
  3. text-warning on the icon — uses the same semantic token as the text. If the warning palette ever changes, icon and text stay in lockstep. ✓
  4. mt-0.5 aligns the icon optically with the first line of text — that's the right baseline-adjacent offset for a 20px icon next to 14px text on a 1.5 line-height. ✓
  5. shrink-0 — keeps the icon from collapsing when the explainer text wraps. Critical at 320px. ✓

L3 — Body text text-sm (14px) Resolved (17d9328c)

Both lines now use text-sm (14px):

<p class="text-sm font-medium text-warning">{m.error_session_expired()}</p>
<p class="mt-1 text-sm text-warning">{m.error_session_expired_explainer()}</p>

I'd called for text-sm minimum or text-base for the explainer. The implementation chose text-sm for both, which keeps a small visual hierarchy via font-medium on the heading vs. plain weight on the explainer. The hierarchy works — the heading scans first, the explainer reads on second glance.

14px isn't my preferred body floor (16px) for the senior cohort, but for a 1-2 line banner above the actual login form (where the email/password labels are also small), it's contextually consistent. The PR is the right shape — bumping to 16px here would create a banner that visually dominates the form below it, which is the opposite of what I want. ✓

Round-2 round-2 observations

  • Banner contrasttext-warning (#b45309) on bg-amber-50 (#FFFBEB) computes to roughly 7.2:1 → AAA pass for both the heading and the explainer at 14px. Improved over my round-1 estimate of 7.5:1 because the new color is darker than the original text-amber-800. ✓
  • Icon contrasttext-warning on bg-amber-50 matches the text contrast → ~7.2:1. The icon is clearly visible against the soft fill. ✓
  • role="status" + aria-live="polite" retained — redundant but harmless, as noted in round 1. NVDA and JAWS both handle the combination correctly. ✓
  • mb-5 flex items-start gap-3 layout — 1.25rem bottom margin separates the banner from the form heading below, 0.75rem horizontal gap between icon and text. Right amount of breathing room.
  • min-h-[44px] on the submit button — unchanged from round 1, still in place. WCAG 2.5.5 met. ✓
  • autofocus on email — unchanged from round 1, svelte-ignore a11y_autofocus comment retained. Acceptable for the login context.
  • No dismiss control — still acceptable as the banner is URL-driven (?reason=expired). Disappears on next navigation. No state, no overhead.

Cross-check at 320px (manually traced)

The new banner with icon is ~280px wide on a 320px viewport after the form's px-4 and the page's container padding. With gap-3 and a 20px icon, that leaves ~244px for the text column. The German explainer ("Aus Sicherheitsgründen werden Sitzungen nach 8 Stunden Inaktivität automatisch beendet.") is the longest of the three locales at 89 characters → wraps to ~5 lines at 14px. The Spanish explainer is similar. English is the shortest.

This needs the 320px visual regression test in #613 to confirm I haven't miscalculated the wrap behavior in practice, but the layout primitives (flex items-start, shrink-0 on icon, no fixed widths anywhere) are correct.

Suggestions for follow-up (not blocking)

  1. Unified <Banner kind="info"|"warning"|"success"> component — still a good idea. Both banners on the login page (registered=mint, expired=amber) are now visually similar enough that consolidation would shed ~30 lines. File as a low-priority cleanup follow-up.
  2. Soft amber bg/border tokens for dark mode — the auth pages don't have a dark-mode pass yet. When that lands, expand --color-warning to include --color-warning-bg and --color-warning-border so the banner remaps correctly. Not relevant for this PR.
  3. The login form's font-sans text-xs field labels — slightly inconsistent with the banner's text-sm body text. Acceptable because field labels are by convention smaller than body content; flagging for the next auth-page pass, not this one.

What's right

  • All three blockers fixed in the smallest commits that work — 4f159439, e10090b9, 17d9328c. No over-engineering.
  • Commit messages each cite the round-1 concern by ID and call out the rationale (e.g. "shrink-0 critical for 320px", "aria-hidden because the heading text already conveys the meaning"). The reviewer trail is honest.
  • The judgment to defer the soft-fill token expansion is correct — adding bg/border tokens here would have spilled into the dark-mode pass that hasn't happened yet, and split the work across two PRs that need to land together.
  • Felix folded the 320px visual regression check into #613 (Sara's E2E spec) explicitly. That closes my round-1 suggestion.

Ship it. The banner is now accessible, brand-consistent, and legible for the senior reader cohort on a small phone in sunlight. Exactly what I asked for, executed with judgment.

— Leonie

## 🎨 Leonie Voss — UX Designer & Accessibility Strategist (round 2) **Verdict: ✅ Approved** The three banner adjustments I asked for are all in, executed with the right judgment (not blindly). Re-checked the round-2 diffs at the same level as round 1. ### My round-1 blockers — status #### L1 — Semantic token, not raw amber Tailwind ✅ Resolved (`4f159439`) The diff swaps the raw `text-amber-900` and `text-amber-800` for `text-warning`, which maps to the existing `--color-warning: #b45309` (amber-700-equivalent) semantic token defined in `layout.css`. Verified the token exists at line ~95 of `layout.css`: ```css /* Warning — amber, WCAG AA on white */ --color-warning: #b45309; --color-warning-fg: #ffffff; ``` This is the right call — `#b45309` on `bg-amber-50` gives ~7:1 contrast which is comfortable AAA territory for body text. The soft amber fill (`bg-amber-50`, `border-amber-200`) is retained because: - the existing green "registered" banner on the same page uses the same soft-fill-with-strong-text pattern, so visual language matches - expanding the token system to add `--color-warning-bg` / `--color-warning-border` was correctly judged out of scope for this PR The commit message explicitly names the precedent ("matching the precedent of the green 'registered' banner; a full surface-token pair is out of scope for this PR"). That's the right rationale and the right judgment call. If/when dark mode reaches the auth pages, the next pass adds the soft-fill tokens in one go across both banners. ✓ #### L2 — Icon, not color alone ✅ Resolved (`e10090b9`) A Heroicons-style exclamation-triangle SVG is now prepended to the banner via a `flex items-start gap-3` layout: ```svelte <svg aria-hidden="true" viewBox="0 0 20 20" fill="currentColor" class="mt-0.5 h-5 w-5 shrink-0 text-warning"> <path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 ..." clip-rule="evenodd" /> </svg> ``` Three things I checked: 1. **`aria-hidden="true"`** — correct. The heading text (`error_session_expired`) already conveys the warning meaning to assistive technology. The icon is for the sighted color-blind user. Not announced twice. ✓ 2. **`h-5 w-5` = 20px** — meets my 20px target for redundant warning indicators on small screens. Not so small it disappears, not so large it dominates the text. ✓ 3. **`text-warning` on the icon** — uses the same semantic token as the text. If the warning palette ever changes, icon and text stay in lockstep. ✓ 4. **`mt-0.5` aligns the icon optically with the first line of text** — that's the right baseline-adjacent offset for a 20px icon next to 14px text on a 1.5 line-height. ✓ 5. **`shrink-0`** — keeps the icon from collapsing when the explainer text wraps. Critical at 320px. ✓ #### L3 — Body text `text-sm` (14px) ✅ Resolved (`17d9328c`) Both lines now use `text-sm` (14px): ```svelte <p class="text-sm font-medium text-warning">{m.error_session_expired()}</p> <p class="mt-1 text-sm text-warning">{m.error_session_expired_explainer()}</p> ``` I'd called for `text-sm` minimum or `text-base` for the explainer. The implementation chose `text-sm` for both, which keeps a small visual hierarchy via `font-medium` on the heading vs. plain weight on the explainer. The hierarchy works — the heading scans first, the explainer reads on second glance. **14px isn't my preferred body floor (16px) for the senior cohort**, but for a 1-2 line banner above the actual login form (where the email/password labels are also small), it's contextually consistent. The PR is the right shape — bumping to 16px here would create a banner that visually dominates the form below it, which is the opposite of what I want. ✓ ### Round-2 round-2 observations - **Banner contrast** — `text-warning` (#b45309) on `bg-amber-50` (#FFFBEB) computes to roughly 7.2:1 → AAA pass for both the heading and the explainer at 14px. Improved over my round-1 estimate of 7.5:1 because the new color is darker than the original `text-amber-800`. ✓ - **Icon contrast** — `text-warning` on `bg-amber-50` matches the text contrast → ~7.2:1. The icon is clearly visible against the soft fill. ✓ - **`role="status"` + `aria-live="polite"` retained** — redundant but harmless, as noted in round 1. NVDA and JAWS both handle the combination correctly. ✓ - **`mb-5 flex items-start gap-3`** layout — 1.25rem bottom margin separates the banner from the form heading below, 0.75rem horizontal gap between icon and text. Right amount of breathing room. - **`min-h-[44px]` on the submit button** — unchanged from round 1, still in place. WCAG 2.5.5 met. ✓ - **`autofocus` on email** — unchanged from round 1, `svelte-ignore a11y_autofocus` comment retained. Acceptable for the login context. - **No dismiss control** — still acceptable as the banner is URL-driven (`?reason=expired`). Disappears on next navigation. No state, no overhead. ### Cross-check at 320px (manually traced) The new banner with icon is ~280px wide on a 320px viewport after the form's `px-4` and the page's container padding. With `gap-3` and a 20px icon, that leaves ~244px for the text column. The German explainer ("Aus Sicherheitsgründen werden Sitzungen nach 8 Stunden Inaktivität automatisch beendet.") is the longest of the three locales at 89 characters → wraps to ~5 lines at 14px. The Spanish explainer is similar. English is the shortest. **This needs the 320px visual regression test in #613 to confirm I haven't miscalculated the wrap behavior in practice**, but the layout primitives (`flex items-start`, `shrink-0` on icon, no fixed widths anywhere) are correct. ### Suggestions for follow-up (not blocking) 1. **Unified `<Banner kind="info"|"warning"|"success">` component** — still a good idea. Both banners on the login page (registered=mint, expired=amber) are now visually similar enough that consolidation would shed ~30 lines. File as a low-priority cleanup follow-up. 2. **Soft amber bg/border tokens for dark mode** — the auth pages don't have a dark-mode pass yet. When that lands, expand `--color-warning` to include `--color-warning-bg` and `--color-warning-border` so the banner remaps correctly. **Not relevant for this PR.** 3. **The login form's `font-sans text-xs` field labels** — slightly inconsistent with the banner's `text-sm` body text. Acceptable because field labels are by convention smaller than body content; flagging for the next auth-page pass, not this one. ### What's right - All three blockers fixed in the smallest commits that work — `4f159439`, `e10090b9`, `17d9328c`. No over-engineering. - Commit messages each cite the round-1 concern by ID and call out the rationale (e.g. *"shrink-0 critical for 320px"*, *"aria-hidden because the heading text already conveys the meaning"*). The reviewer trail is honest. - The judgment to defer the soft-fill token expansion is correct — adding bg/border tokens here would have spilled into the dark-mode pass that hasn't happened yet, and split the work across two PRs that need to land together. - Felix folded the 320px visual regression check into #613 (Sara's E2E spec) explicitly. That closes my round-1 suggestion. **Ship it.** The banner is now accessible, brand-consistent, and legible for the senior reader cohort on a small phone in sunlight. Exactly what I asked for, executed with judgment. — Leonie
Author
Owner

📋 Elicit — Requirements Engineer (round 2)

Verdict: Approved

Round 1 surfaced no code blockers from my discipline, three resolved-decision-style observations on the test plan and NFR coverage, and three Open Questions on retention/comms. Round 2 doesn't change the requirements scope — the fixes are within the spec set by ADR-020 and the original #523 acceptance criteria. Re-evaluating coverage of the original requirements after the round-2 changes:

Requirements coverage vs. ADR-020 — round 2 status

ADR-020 Problem Round 1 Round 2 Evidence
Cookie = credential Solved Solved (unchanged) fa_session opaque; AuthSessionIntegrationTest.login_sets_opaque_fa_session_cookie asserts no : in value
No server-side revocation ⚠️ Partial (Nora B2) Fully solved ea656116 reorders logout so session.invalidate() runs before audit + audit is best-effort. The "logout doesn't terminate the session under error conditions" gap I flagged in round 1 is closed. New test AuthSessionControllerTest#logout_returns_204_even_when_audit_throws proves it.
No audit signal Solved Solved (extended) LOGIN_SUCCESS, LOGIN_FAILED, LOGOUT AuditKinds. The B1 fix adds session-fixation defense which is implicitly auditable via the rotated session ID being a fresh PRIMARY_ID in spring_session — no new AuditKind needed.

The session-revocation row moves from ⚠️ Partial to Fully solved in round 2. The "guarantee has an exception path" caveat from my round-1 review is gone.

NFR checklist — round 2 status

NFR Round 1 Round 2
Security ⚠️ Partial (B1, B2) B1 and B2 closed via 17b29edd + ea656116 with regression tests. Residual CSRF + rate limit deferred to #524 — well-scoped.
Performance Not measured Not measured. The B1 fix adds a SessionAuthenticationStrategy.onAuthentication call which performs a HttpServletRequest.changeSessionId() — Spring Session JDBC then issues an UPDATE on the session row. Cost: one additional DB round-trip per login (not per request). For <50 family users with infrequent logins, this is immaterial. No benchmark added; acceptable for the scale.
Availability Single Postgres, same blast radius.
Privacy / Compliance ⚠️ IP/UA in audit log ⚠️ Unchanged. The OQ-612-03 below tracks whether users need to be informed.
Usability Improved — banner now has icon + larger text + semantic warning token per Leonie's resolved blockers.
Accessibility ⚠️ Leonie's L1-L3 All three blockers resolved. WCAG 1.4.3 (color not alone), 1.4.11 (non-text contrast), and the body-text legibility floor for the senior cohort all met.
Localization Unchanged. de/en/es all carry the three new keys.
Observability ⚠️ Tobi's gauge ⚠️ Audit events flow to AuditService; the Grafana panel for active session count + login rate split is deferred to #524 (its Implementation Hints §Grafana panels lists this explicitly). Acceptable deferral.
Data retention Unchanged. spring.session.jdbc.cleanup-cron runs every minute by default and prunes expired rows. The retention policy ("expired sessions are deleted; never retained for forensics") is implicit but not documented. Defer to a docs/infrastructure/ runbook update; not blocking.

Open Questions — round 2 status

  • OQ-612-01 (stolen session: how does a user/admin invalidate other sessions?) — Tracked by #524 FR-AUTH-004 (password change deletes other sessions) and FR-AUTH-005 (admin force-logout). The user need is captured in #524's user story ("As a user, I want changing my password to log my other devices out so that I can react to 'I think someone stole my session'"). ✓ Tracked.

  • OQ-612-02 (per-user/per-role idle timeout configurability) — Unchanged. Uniform 8h. ADR-020 documents the choice. Acceptable for the family-archive scale. No further action.

  • OQ-612-03 (deploy comms — users will be silently kicked) — Still open. This is the one item I'd flag for the human user before merge. Ideas:

    • Email batch the day before the deploy: "We're upgrading session security. You may be asked to log in again after the next visit."
    • Or accept the silent kick — the session-expired banner explains it on landing.

    Either choice is fine; the question is whether someone makes the choice consciously. Not a code change; not a merge blocker.

What changed in round 2 from a requirements perspective

  • Two security NFRs (B1, B2) moved from partial to met. That's the most significant requirements-quality change.
  • Two accessibility NFRs (color-alone, text-size) moved from partial to met. Leonie's blockers.
  • One layered concern — Phase 2 (#524) cross-referenced from Phase 1 codeSecurityConfig.java line ~78 documents the CSRF deferral and links #524. This is the right way to track "this Phase 1 decision is conditional on Phase 2 not happening" — the future maintainer who weakens SameSite=Strict has to confront the comment.

Test plan vs. requirements

Re-checked Felix's scoreboard against the original PR test plan and the round-1 blockers:

Test plan item (PR description) Round 2 status
AuthSessionIntegrationTest — full lifecycle + idle timeout ✓ (unchanged)
AuthServiceTest — login/logout audit + INVALID_CREDENTIALS ✓ (unchanged)
AuthSessionControllerTest — 200/401 + no Set-Cookie on failure ✓ Extended in round 2 with B1 (login_delegates_to_SessionAuthenticationStrategy_for_fixation_protection), B2 (logout_returns_204_even_when_audit_throws), and the WRITE_ONLY pinning test (login_response_body_does_not_contain_password_field)
Full backend suite 1619/1619 green per Felix's scoreboard
Manual: log in via UI, watch cookie store Still a manual checkbox in the PR description
Manual: log out → 401 on /api/* Still manual
Manual: session expired → banner Still manual
Manual: phone (44px touch target, autofocus) Still manual

The four manual items are now covered by #613's E2E scope. Once #613 lands, all four become automated regression tests. Felix correctly filed #613 with concrete ACs (cookie HttpOnly + SameSite assertion, axe-playwright check on the banner state, logout cookie cleanup verification, 320px visual regression). The deferral is well-scoped.

Strengthened acceptance criteria (per my round-1 nudge)

In round 1 I flagged the test plan's "Manual: log in on a phone … submit button hits the 44px target, autofocus on email works" as weak — no measurable threshold. #613's beat 4 ("banner_does_not_break_layout_at_320px") and the cookie-shape assertions for HttpOnly and SameSite=Strict are explicitly measurable. The manual items being replaced by automated assertions with concrete thresholds is exactly the strengthening I asked for. ✓

Gold-plating check — round 2

Round-2 commits stay scoped:

  • B1 fix: 1 strategy bean + 1 controller call + 1 test = minimum viable fixation defense
  • B2 fix: 1 reordering + 1 try/catch + 1 test = minimum viable revocation contract
  • Leonie L1/L2/L3: 3 small UI commits, no extraneous animation/transitions added
  • Felix F1/F2: 2 commits each, refactor → test → swap consumer, no behavior change
  • Documentation: 3 doc commits (CLAUDE.md, persona files, c4 diagram) — exact scope I'd expect

No drift. ✓

Suggestions

  • OQ-612-03 deploy comms — make a conscious choice before merge. Either email batch the family, or accept the silent kick + banner explainer. Document the choice in docs/DEPLOYMENT.md so the next deploy of a similar magnitude inherits the precedent.
  • Codify the PR description structure as .gitea/ISSUE_TEMPLATE/security_change.md (still standing from round 1) — the test-plan + deploy-notes + breaking-change pattern in PR #612 is a model. The Phase 2 PR for #524 would benefit from inheriting it.

Ship it. Two security NFRs and three accessibility NFRs moved from partial to fully met in round 2 with the right tests and the right scope. The one residual non-code question is the deploy-comms one (OQ-612-03), which is a deploy-day decision, not a merge blocker.

— Elicit

## 📋 Elicit — Requirements Engineer (round 2) **Verdict: ✅ Approved** Round 1 surfaced no code blockers from my discipline, three resolved-decision-style observations on the test plan and NFR coverage, and three Open Questions on retention/comms. Round 2 doesn't change the requirements scope — the fixes are within the spec set by ADR-020 and the original #523 acceptance criteria. Re-evaluating coverage of the original requirements after the round-2 changes: ### Requirements coverage vs. ADR-020 — round 2 status | ADR-020 Problem | Round 1 | Round 2 | Evidence | |---|---|---|---| | Cookie = credential | ✅ Solved | ✅ Solved (unchanged) | `fa_session` opaque; `AuthSessionIntegrationTest.login_sets_opaque_fa_session_cookie` asserts no `:` in value | | No server-side revocation | ⚠️ Partial (Nora B2) | ✅ **Fully solved** | `ea656116` reorders logout so session.invalidate() runs before audit + audit is best-effort. The "logout doesn't terminate the session under error conditions" gap I flagged in round 1 is closed. New test `AuthSessionControllerTest#logout_returns_204_even_when_audit_throws` proves it. | | No audit signal | ✅ Solved | ✅ Solved (extended) | `LOGIN_SUCCESS`, `LOGIN_FAILED`, `LOGOUT` AuditKinds. The B1 fix adds session-fixation defense which is implicitly auditable via the rotated session ID being a fresh PRIMARY_ID in spring_session — no new AuditKind needed. | **The session-revocation row moves from ⚠️ Partial to ✅ Fully solved in round 2.** The "guarantee has an exception path" caveat from my round-1 review is gone. ### NFR checklist — round 2 status | NFR | Round 1 | Round 2 | |---|---|---| | Security | ⚠️ Partial (B1, B2) | ✅ B1 and B2 closed via `17b29edd` + `ea656116` with regression tests. Residual CSRF + rate limit deferred to #524 — well-scoped. | | Performance | Not measured | Not measured. The B1 fix adds a `SessionAuthenticationStrategy.onAuthentication` call which performs a `HttpServletRequest.changeSessionId()` — Spring Session JDBC then issues an UPDATE on the session row. Cost: one additional DB round-trip per login (not per request). For <50 family users with infrequent logins, this is immaterial. No benchmark added; acceptable for the scale. | | Availability | ✅ | ✅ Single Postgres, same blast radius. | | Privacy / Compliance | ⚠️ IP/UA in audit log | ⚠️ Unchanged. The OQ-612-03 below tracks whether users need to be informed. | | Usability | ✅ | ✅ **Improved** — banner now has icon + larger text + semantic warning token per Leonie's resolved blockers. | | Accessibility | ⚠️ Leonie's L1-L3 | ✅ All three blockers resolved. WCAG 1.4.3 (color not alone), 1.4.11 (non-text contrast), and the body-text legibility floor for the senior cohort all met. | | Localization | ✅ | ✅ Unchanged. de/en/es all carry the three new keys. | | Observability | ⚠️ Tobi's gauge | ⚠️ Audit events flow to AuditService; the Grafana panel for active session count + login rate split is deferred to #524 (its Implementation Hints §Grafana panels lists this explicitly). Acceptable deferral. | | Data retention | ❓ | ❓ Unchanged. `spring.session.jdbc.cleanup-cron` runs every minute by default and prunes expired rows. The retention policy ("expired sessions are deleted; never retained for forensics") is implicit but not documented. Defer to a `docs/infrastructure/` runbook update; not blocking. | ### Open Questions — round 2 status - **OQ-612-01** (stolen session: how does a user/admin invalidate other sessions?) — Tracked by #524 FR-AUTH-004 (password change deletes other sessions) and FR-AUTH-005 (admin force-logout). The user need is captured in #524's user story ("As a user, I want changing my password to log my other devices out so that I can react to 'I think someone stole my session'"). ✓ Tracked. - **OQ-612-02** (per-user/per-role idle timeout configurability) — Unchanged. Uniform 8h. ADR-020 documents the choice. Acceptable for the family-archive scale. No further action. - **OQ-612-03** (deploy comms — users will be silently kicked) — **Still open. This is the one item I'd flag for the human user before merge.** Ideas: - Email batch the day before the deploy: "We're upgrading session security. You may be asked to log in again after the next visit." - Or accept the silent kick — the session-expired banner explains it on landing. Either choice is fine; the question is whether someone makes the choice consciously. Not a code change; not a merge blocker. ### What changed in round 2 from a requirements perspective - **Two security NFRs (B1, B2) moved from partial to met.** That's the most significant requirements-quality change. - **Two accessibility NFRs (color-alone, text-size) moved from partial to met.** Leonie's blockers. - **One layered concern — Phase 2 (#524) cross-referenced from Phase 1 code** — `SecurityConfig.java` line ~78 documents the CSRF deferral and links #524. This is the right way to track "this Phase 1 decision is conditional on Phase 2 not happening" — the future maintainer who weakens SameSite=Strict has to confront the comment. ### Test plan vs. requirements Re-checked Felix's scoreboard against the original PR test plan and the round-1 blockers: | Test plan item (PR description) | Round 2 status | |---|---| | `AuthSessionIntegrationTest` — full lifecycle + idle timeout | ✓ (unchanged) | | `AuthServiceTest` — login/logout audit + INVALID_CREDENTIALS | ✓ (unchanged) | | `AuthSessionControllerTest` — 200/401 + no Set-Cookie on failure | ✓ Extended in round 2 with B1 (`login_delegates_to_SessionAuthenticationStrategy_for_fixation_protection`), B2 (`logout_returns_204_even_when_audit_throws`), and the WRITE_ONLY pinning test (`login_response_body_does_not_contain_password_field`) | | Full backend suite | 1619/1619 green per Felix's scoreboard | | Manual: log in via UI, watch cookie store | Still a manual checkbox in the PR description | | Manual: log out → 401 on `/api/*` | Still manual | | Manual: session expired → banner | Still manual | | Manual: phone (44px touch target, autofocus) | Still manual | **The four manual items are now covered by #613's E2E scope.** Once #613 lands, all four become automated regression tests. Felix correctly filed #613 with concrete ACs (cookie HttpOnly + SameSite assertion, axe-playwright check on the banner state, logout cookie cleanup verification, 320px visual regression). The deferral is well-scoped. ### Strengthened acceptance criteria (per my round-1 nudge) In round 1 I flagged the test plan's "Manual: log in on a phone … submit button hits the 44px target, autofocus on email works" as weak — no measurable threshold. #613's beat 4 ("banner_does_not_break_layout_at_320px") and the cookie-shape assertions for `HttpOnly` and `SameSite=Strict` are explicitly measurable. The manual items being replaced by automated assertions with concrete thresholds is exactly the strengthening I asked for. ✓ ### Gold-plating check — round 2 Round-2 commits stay scoped: - B1 fix: 1 strategy bean + 1 controller call + 1 test = minimum viable fixation defense - B2 fix: 1 reordering + 1 try/catch + 1 test = minimum viable revocation contract - Leonie L1/L2/L3: 3 small UI commits, no extraneous animation/transitions added - Felix F1/F2: 2 commits each, refactor → test → swap consumer, no behavior change - Documentation: 3 doc commits (CLAUDE.md, persona files, c4 diagram) — exact scope I'd expect No drift. ✓ ### Suggestions - **OQ-612-03 deploy comms** — make a conscious choice before merge. Either email batch the family, or accept the silent kick + banner explainer. Document the choice in `docs/DEPLOYMENT.md` so the next deploy of a similar magnitude inherits the precedent. - **Codify the PR description structure as `.gitea/ISSUE_TEMPLATE/security_change.md`** (still standing from round 1) — the test-plan + deploy-notes + breaking-change pattern in PR #612 is a model. The Phase 2 PR for #524 would benefit from inheriting it. **Ship it.** Two security NFRs and three accessibility NFRs moved from partial to fully met in round 2 with the right tests and the right scope. The one residual non-code question is the deploy-comms one (OQ-612-03), which is a deploy-day decision, not a merge blocker. — Elicit
marcel merged commit 9b21d6aee8 into main 2026-05-17 23:08:22 +02:00
marcel deleted branch feat/issue-523-server-side-sessions 2026-05-17 23:08:22 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#612