tech-debt(auth): replace cookie-promotion glue with a proper session-based auth model #522

Closed
opened 2026-05-11 18:20:55 +02:00 by marcel · 10 comments
Owner

Context

PR #521 (closing #520) added an AuthTokenCookieFilter to the backend that promotes an auth_token cookie to a Basic-auth Authorization header so browser-direct /api/* calls authenticate in production (Caddy doesn't translate the cookie the way the dev Vite proxy does).

The fix works. It unblocks the deploy. But the underlying auth model is not architecturally sound. This issue captures the tech-debt and frames the requirements for a proper replacement.

Why the current model is debt

Concern Current state Risk
Credential lifetime in the cookie The cookie carries the literal Basic <base64(email:password)> string with 24 h TTL An XSS bypass of HttpOnly (browser bug, devtools, malicious extension) leaks the actual password, not a session token. Password rotation does not invalidate the cookie.
Server-side revocation None. Logout removes the cookie on the client; the backend doesn't track or invalidate anything. An attacker who steals a cookie has it until expiry. The operator has no "kick this user / kick all sessions" lever.
CSRF defense surface After #520 the only CSRF defense for state-changing endpoints is SameSite=strict on the cookie (CSRF protection is csrf.disable() in SecurityConfig). A single config flip (cookie → SameSite=lax, CORS allowedOrigins expanded, third-party SSO added) silently opens CSRF on every write endpoint.
Coupling between layers The auth model depends on an implicit URL-encode/decode contract spanning login/+page.server.ts, hooks.server.ts, vite.config.ts, AuthTokenCookieFilter, and the Caddy /api/* routing decision. High risk that a future change to any one layer silently breaks the chain.
Test signal The login action does its own out-of-band /api/users/me round-trip to validate credentials. There is no first-class login endpoint. Adds latency, splits the auth flow across two paths, and makes audit logging of "user logged in" awkward.

User need

As an operator of the Familienarchiv platform
I want the application's auth model to use server-side sessions with revocable tokens
so that I can rotate or invalidate credentials without trusting client clock or cookie expiry, recover from a credential leak in minutes, and reason about CSRF defense in standard terms.

As a registered user (transcriber / reader)
I want my login session to be invalidated when I log out or change my password
so that I'm not unknowingly authenticated for up to 24 h after I intended to "leave."

Functional requirements

FR-AUTH-001 — There SHALL be a first-class POST /api/auth/login endpoint that accepts email+password, validates against app_users, and on success establishes a session.

FR-AUTH-002 — Session establishment SHALL set an opaque session identifier (not a credential) in an HttpOnly, SameSite=strict, Secure (on HTTPS) cookie. The session record SHALL be stored server-side.

FR-AUTH-003 — There SHALL be a POST /api/auth/logout endpoint that invalidates the server-side session record. After calling it, requests carrying the same cookie SHALL be treated as unauthenticated (401), even before the cookie's Max-Age elapses.

FR-AUTH-004 — A password change (existing flow in PasswordResetService + future "change own password" if added) SHALL invalidate all other active sessions for the affected user. Optionally, the current session may be preserved or rotated.

FR-AUTH-005 — An admin SHALL be able to forcibly invalidate all sessions for a target user (operator break-glass for "this account is compromised"). UI surface deferred — backend capability + audit log entry are in scope here.

FR-AUTH-006AuthTokenCookieFilter and the Basic auth machinery downstream of it SHALL be removed once the new model is live. The SvelteKit login action SHALL stop building and storing a Basic ... cookie value.

FR-AUTH-007 — The deployment story SHALL remain the same: SvelteKit form-action login, no client-side JWT handling in browser JS, no extra round-trips per request.

Non-functional requirements

NFR-SEC-101 — No user password (plaintext or base64-encoded) SHALL be present in any cookie sent to the browser at any point after login completes.

NFR-SEC-102 — A stolen session cookie SHALL become inert within ≤ 60 seconds of an operator-initiated invalidation (logout, password change, force-logout).

NFR-SEC-103 — Cross-Site Request Forgery protection on state-changing endpoints (POST, PUT, PATCH, DELETE) SHALL be enforced by two independent mechanisms: a strict-SameSite cookie AND a server-issued CSRF token bound to the session. Either alone is insufficient.

NFR-PERF-101 — Authenticated request overhead SHALL NOT exceed 5 ms (p95) added by session lookup compared to the current in-memory Basic-decode path.

NFR-OBSV-101 — Every login attempt (success and failure), logout, and admin-forced-logout SHALL produce a structured audit log entry (AuditService) including: timestamp, user ID (or supplied email on failure), source IP, user-agent fingerprint, outcome.

NFR-COMPAT-101 — Existing dev tooling SHALL continue to work without code changes: vite.config.ts proxy, the dev e2e test profile, and the e2e profile's deterministic admin seed.

Acceptance criteria

Given a fresh app_users row and no existing session
When the user submits valid credentials to POST /api/auth/login
Then the response sets an HttpOnly + SameSite=strict cookie containing
  an opaque session ID (no credential bytes)
And  a row exists in the session store keyed by that ID, linked to the user

Given an active session
When the user calls POST /api/auth/logout
Then the session store row is removed
And  any subsequent request with the same cookie returns 401

Given an admin and a target user with N active sessions
When the admin calls the force-logout capability for that user
Then all N session store rows for that user are removed
And  an AuditService entry of type "admin_force_logout" is recorded

Given a state-changing endpoint (POST /api/persons, PUT /api/documents/{id}, etc.)
When a request arrives with a valid session cookie but no CSRF token
Then the response is 403 with code CSRF_TOKEN_MISSING

Given an authenticated user changing their own password
When the password change succeeds
Then all sessions for that user other than the current one are invalidated

Given the new auth model is in production
When the AuthTokenCookieFilter class is searched for
Then no source file references it
And  the auth_token cookie is no longer set by the login action
And  Caddy's /api/* routing requires no header injection or cookie translation

Constraints & open questions

The existing stack already includes Spring Session JDBC (spring-session-jdbc per CLAUDE.md → "Tech Stack") and app_users lives in Postgres. The infrastructure to back FR-AUTH-001..005 is in-house — no new managed services required. Whether to use Spring Session, JWT (access + refresh), or a custom session table is an implementation decision for the developer.

OQ-AUTH-001 — Should logout invalidate ALL of the user's sessions (single-session model) or just the current one (multi-device model)? Impacts FR-AUTH-003 ACs.

OQ-AUTH-002 — Is there a session-idle-timeout requirement separate from the 24 h absolute lifetime? (Background: the current implementation has only Max-Age, no idle timer.) Impacts NFR-SEC-102.

OQ-AUTH-003 — Does the "admin force-logout" capability need a UI surface in this issue, or is the backend capability + audit log sufficient (UI deferred)?

OQ-AUTH-004 — Browser remembers-me checkbox: do we want it now (longer absolute session lifetime when checked) or defer?

Priority & sizing

  • MoSCoW: Should for v1.1.0. Not a blocker for v1.0.0 first deploy — #521 is the unblocker. But strongly recommended within the first release cycle after first prod deploy because every day in production with the current model is a day where a stolen cookie equals stolen credentials.
  • T-shirt size: L (estimated 1–2 days dedicated, given the existing Spring Session JDBC dependency and the bounded scope: backend filter swap + login action rewrite + Caddy unchanged + tests).
  • Linked NFRs: NFR-SEC-101, NFR-SEC-102, NFR-SEC-103, NFR-PERF-101, NFR-OBSV-101, NFR-COMPAT-101.

Out of scope (won't this issue)

  • SSO / federated identity (Google, GitHub).
  • 2FA / MFA.
  • Step-up authentication for admin operations.
  • Public-facing API tokens (machine-to-machine).
  • Account lockout / progressive backoff after failed attempts (already partially handled by fail2ban at the Caddy layer per #499).

Discovered

During the production-deploy bootstrap of #497. PR #521 added a cookie → header translator that papers over a missing session design. This issue captures the design debt and frames the proper replacement as testable requirements.

— Elicit (req engineering)

## Context PR #521 (closing #520) added an `AuthTokenCookieFilter` to the backend that promotes an `auth_token` cookie to a Basic-auth `Authorization` header so browser-direct `/api/*` calls authenticate in production (Caddy doesn't translate the cookie the way the dev Vite proxy does). The fix works. It unblocks the deploy. **But the underlying auth model is not architecturally sound.** This issue captures the tech-debt and frames the requirements for a proper replacement. ## Why the current model is debt | Concern | Current state | Risk | |---|---|---| | Credential lifetime in the cookie | The cookie carries the literal `Basic <base64(email:password)>` string with 24 h TTL | An XSS bypass of HttpOnly (browser bug, devtools, malicious extension) leaks the actual password, not a session token. Password rotation does not invalidate the cookie. | | Server-side revocation | None. Logout removes the cookie on the client; the backend doesn't track or invalidate anything. | An attacker who steals a cookie has it until expiry. The operator has no "kick this user / kick all sessions" lever. | | CSRF defense surface | After #520 the only CSRF defense for state-changing endpoints is `SameSite=strict` on the cookie (CSRF protection is `csrf.disable()` in `SecurityConfig`). | A single config flip (cookie → `SameSite=lax`, CORS `allowedOrigins` expanded, third-party SSO added) silently opens CSRF on every write endpoint. | | Coupling between layers | The auth model depends on an implicit URL-encode/decode contract spanning `login/+page.server.ts`, `hooks.server.ts`, `vite.config.ts`, `AuthTokenCookieFilter`, and the Caddy `/api/*` routing decision. | High risk that a future change to any one layer silently breaks the chain. | | Test signal | The login action does its own out-of-band `/api/users/me` round-trip to validate credentials. There is no first-class login endpoint. | Adds latency, splits the auth flow across two paths, and makes audit logging of "user logged in" awkward. | ## User need > **As an** operator of the Familienarchiv platform > **I want** the application's auth model to use server-side sessions with revocable tokens > **so that** I can rotate or invalidate credentials without trusting client clock or cookie expiry, recover from a credential leak in minutes, and reason about CSRF defense in standard terms. > **As a** registered user (transcriber / reader) > **I want** my login session to be invalidated when I log out or change my password > **so that** I'm not unknowingly authenticated for up to 24 h after I intended to "leave." ## Functional requirements **FR-AUTH-001** — There SHALL be a first-class `POST /api/auth/login` endpoint that accepts email+password, validates against `app_users`, and on success establishes a session. **FR-AUTH-002** — Session establishment SHALL set an opaque session identifier (not a credential) in an `HttpOnly`, `SameSite=strict`, `Secure` (on HTTPS) cookie. The session record SHALL be stored server-side. **FR-AUTH-003** — There SHALL be a `POST /api/auth/logout` endpoint that invalidates the server-side session record. After calling it, requests carrying the same cookie SHALL be treated as unauthenticated (401), even before the cookie's `Max-Age` elapses. **FR-AUTH-004** — A password change (existing flow in `PasswordResetService` + future "change own password" if added) SHALL invalidate all other active sessions for the affected user. Optionally, the current session may be preserved or rotated. **FR-AUTH-005** — An admin SHALL be able to forcibly invalidate all sessions for a target user (operator break-glass for "this account is compromised"). UI surface deferred — backend capability + audit log entry are in scope here. **FR-AUTH-006** — `AuthTokenCookieFilter` and the `Basic` auth machinery downstream of it SHALL be removed once the new model is live. The SvelteKit login action SHALL stop building and storing a `Basic ...` cookie value. **FR-AUTH-007** — The deployment story SHALL remain the same: SvelteKit form-action login, no client-side JWT handling in browser JS, no extra round-trips per request. ## Non-functional requirements **NFR-SEC-101** — No user password (plaintext or base64-encoded) SHALL be present in any cookie sent to the browser at any point after login completes. **NFR-SEC-102** — A stolen session cookie SHALL become inert within ≤ 60 seconds of an operator-initiated invalidation (logout, password change, force-logout). **NFR-SEC-103** — Cross-Site Request Forgery protection on state-changing endpoints (`POST`, `PUT`, `PATCH`, `DELETE`) SHALL be enforced by **two independent mechanisms**: a strict-SameSite cookie AND a server-issued CSRF token bound to the session. Either alone is insufficient. **NFR-PERF-101** — Authenticated request overhead SHALL NOT exceed 5 ms (p95) added by session lookup compared to the current in-memory Basic-decode path. **NFR-OBSV-101** — Every login attempt (success and failure), logout, and admin-forced-logout SHALL produce a structured audit log entry (`AuditService`) including: timestamp, user ID (or supplied email on failure), source IP, user-agent fingerprint, outcome. **NFR-COMPAT-101** — Existing dev tooling SHALL continue to work without code changes: `vite.config.ts` proxy, the dev e2e test profile, and the `e2e` profile's deterministic admin seed. ## Acceptance criteria ``` Given a fresh app_users row and no existing session When the user submits valid credentials to POST /api/auth/login Then the response sets an HttpOnly + SameSite=strict cookie containing an opaque session ID (no credential bytes) And a row exists in the session store keyed by that ID, linked to the user Given an active session When the user calls POST /api/auth/logout Then the session store row is removed And any subsequent request with the same cookie returns 401 Given an admin and a target user with N active sessions When the admin calls the force-logout capability for that user Then all N session store rows for that user are removed And an AuditService entry of type "admin_force_logout" is recorded Given a state-changing endpoint (POST /api/persons, PUT /api/documents/{id}, etc.) When a request arrives with a valid session cookie but no CSRF token Then the response is 403 with code CSRF_TOKEN_MISSING Given an authenticated user changing their own password When the password change succeeds Then all sessions for that user other than the current one are invalidated Given the new auth model is in production When the AuthTokenCookieFilter class is searched for Then no source file references it And the auth_token cookie is no longer set by the login action And Caddy's /api/* routing requires no header injection or cookie translation ``` ## Constraints & open questions The existing stack already includes **Spring Session JDBC** (`spring-session-jdbc` per `CLAUDE.md` → "Tech Stack") and `app_users` lives in Postgres. The infrastructure to back FR-AUTH-001..005 is in-house — no new managed services required. Whether to use Spring Session, JWT (access + refresh), or a custom session table is an implementation decision for the developer. **OQ-AUTH-001** — Should logout invalidate ALL of the user's sessions (single-session model) or just the current one (multi-device model)? *Impacts FR-AUTH-003 ACs.* **OQ-AUTH-002** — Is there a session-idle-timeout requirement separate from the 24 h absolute lifetime? (Background: the current implementation has only `Max-Age`, no idle timer.) *Impacts NFR-SEC-102.* **OQ-AUTH-003** — Does the "admin force-logout" capability need a UI surface in this issue, or is the backend capability + audit log sufficient (UI deferred)? **OQ-AUTH-004** — Browser remembers-me checkbox: do we want it now (longer absolute session lifetime when checked) or defer? ## Priority & sizing - MoSCoW: **Should** for `v1.1.0`. **Not** a blocker for `v1.0.0` first deploy — #521 is the unblocker. But strongly recommended within the first release cycle after first prod deploy because every day in production with the current model is a day where a stolen cookie equals stolen credentials. - T-shirt size: **L** (estimated 1–2 days dedicated, given the existing Spring Session JDBC dependency and the bounded scope: backend filter swap + login action rewrite + Caddy unchanged + tests). - Linked NFRs: NFR-SEC-101, NFR-SEC-102, NFR-SEC-103, NFR-PERF-101, NFR-OBSV-101, NFR-COMPAT-101. ## Out of scope (won't this issue) - SSO / federated identity (Google, GitHub). - 2FA / MFA. - Step-up authentication for admin operations. - Public-facing API tokens (machine-to-machine). - Account lockout / progressive backoff after failed attempts (already partially handled by fail2ban at the Caddy layer per #499). ## Discovered During the production-deploy bootstrap of #497. PR #521 added a cookie → header translator that papers over a missing session design. This issue captures the design debt and frames the proper replacement as testable requirements. — Elicit (req engineering)
Author
Owner

🏛 Markus Keller — Senior Application Architect

Observations

  • The "already in stack" claim is wrong. backend/pom.xml does not declare spring-session-jdbc — and V2__drop_spring_session_tables.sql explicitly drops the spring_session / spring_session_attributes tables with the comment: "Spring Session JDBC was included as a dependency but never used." CLAUDE.md is stale here, and the issue inherited that staleness. This is not a one-line decision anymore: re-adding the dependency, the migration, and the configuration is part of the work.
  • SecurityConfig.java:54 carries a clear load-bearing comment about CSRF being disabled only because SameSite+CORS holds the fort. That comment is good architecture documentation, and the new design replaces it cleanly — exactly the kind of debt removal an ADR should record.
  • The existing AuthController (/api/auth/forgot-password, /reset-password, /register) is the obvious home for /api/auth/login + /api/auth/logout. No need to invent a new package.
  • The AuditService already supports rich audit kinds via the AuditKind enum — LOGIN_SUCCESS, LOGIN_FAILED, LOGOUT, ADMIN_FORCE_LOGOUT are additive, low-risk additions.

Recommendations

  • Pick Spring Session JDBC, not a custom session table. Boring tech, runs on existing Postgres, has built-in SessionRepository.deleteById() and findByPrincipalName() (via FindByIndexNameSessionRepository) — both needed for FR-AUTH-003/004/005. A custom table reinvents indexing, cleanup, serialization, and concurrent-access semantics for no benefit.
  • Re-enable Spring Security's built-in CSRF (synchronizer token, CookieCsrfTokenRepository.withHttpOnlyFalse()). Synchronizer pattern + SameSite=strict cookie is the textbook "two independent mechanisms" that NFR-SEC-103 calls for. Don't write a custom CSRF filter.
  • Write the ADR before the code. Title: "Stateful authentication via Spring Session JDBC; CSRF re-enabled via synchronizer token; AuthTokenCookieFilter removed." Context = the current model and why it's debt; Decision = Spring Session JDBC + Spring Security CSRF; Alternatives = custom table (rejected: reinvention) and JWT (rejected: no revocation primitive); Consequences = session DB writes on every login, but bounded by login rate.
  • Doc updates this PR must touch: docs/architecture/c4/seq-auth-flow.puml (rewrite — cookie promotion is gone); new ADR in docs/adr/; CLAUDE.md security section (update permissions if ADMIN_SESSION or similar lands); docs/ARCHITECTURE.md if the permission table grows. Plus update the stale "Spring Session JDBC" line in CLAUDE.md regardless. It misled this issue.
  • Layering: the session-store access stays inside the user domain (or a new auth package). Don't let SessionRegistryService leak into other services — admin force-logout calls go through UserService or a dedicated AuthService, not directly into the session repo.

Open Decisions

  • Module boundary for the new auth code: extend user package, or extract a new auth package? Cost of extending user: the package already holds AuthController, InviteController, PasswordResetService — adding LoginController, SessionService keeps it cohesive but pushes the package size up. Cost of extracting auth: cleaner separation, but introduces a cross-domain call (AuthService → UserService) and one more module to maintain. Recommend extending user for now; extract when the package exceeds ~25 files.

— Markus

## 🏛 Markus Keller — Senior Application Architect ### Observations - **The "already in stack" claim is wrong.** `backend/pom.xml` does not declare `spring-session-jdbc` — and `V2__drop_spring_session_tables.sql` explicitly drops the `spring_session` / `spring_session_attributes` tables with the comment: *"Spring Session JDBC was included as a dependency but never used."* `CLAUDE.md` is stale here, and the issue inherited that staleness. This is not a one-line decision anymore: re-adding the dependency, the migration, and the configuration is part of the work. - `SecurityConfig.java:54` carries a clear load-bearing comment about CSRF being disabled only because SameSite+CORS holds the fort. That comment is good architecture documentation, and the new design replaces it cleanly — exactly the kind of debt removal an ADR should record. - The existing `AuthController` (`/api/auth/forgot-password`, `/reset-password`, `/register`) is the obvious home for `/api/auth/login` + `/api/auth/logout`. No need to invent a new package. - The `AuditService` already supports rich audit kinds via the `AuditKind` enum — `LOGIN_SUCCESS`, `LOGIN_FAILED`, `LOGOUT`, `ADMIN_FORCE_LOGOUT` are additive, low-risk additions. ### Recommendations - **Pick Spring Session JDBC, not a custom session table.** Boring tech, runs on existing Postgres, has built-in `SessionRepository.deleteById()` and `findByPrincipalName()` (via `FindByIndexNameSessionRepository`) — both needed for FR-AUTH-003/004/005. A custom table reinvents indexing, cleanup, serialization, and concurrent-access semantics for no benefit. - **Re-enable Spring Security's built-in CSRF (synchronizer token, `CookieCsrfTokenRepository.withHttpOnlyFalse()`).** Synchronizer pattern + SameSite=strict cookie is the textbook "two independent mechanisms" that NFR-SEC-103 calls for. Don't write a custom CSRF filter. - **Write the ADR before the code.** Title: *"Stateful authentication via Spring Session JDBC; CSRF re-enabled via synchronizer token; AuthTokenCookieFilter removed."* Context = the current model and why it's debt; Decision = Spring Session JDBC + Spring Security CSRF; Alternatives = custom table (rejected: reinvention) and JWT (rejected: no revocation primitive); Consequences = session DB writes on every login, but bounded by login rate. - **Doc updates this PR must touch:** `docs/architecture/c4/seq-auth-flow.puml` (rewrite — cookie promotion is gone); new ADR in `docs/adr/`; `CLAUDE.md` security section (update permissions if `ADMIN_SESSION` or similar lands); `docs/ARCHITECTURE.md` if the permission table grows. **Plus update the stale "Spring Session JDBC" line in `CLAUDE.md` regardless.** It misled this issue. - **Layering:** the session-store access stays inside the `user` domain (or a new `auth` package). Don't let `SessionRegistryService` leak into other services — admin force-logout calls go through `UserService` or a dedicated `AuthService`, not directly into the session repo. ### Open Decisions - **Module boundary for the new auth code: extend `user` package, or extract a new `auth` package?** Cost of extending `user`: the package already holds `AuthController`, `InviteController`, `PasswordResetService` — adding `LoginController`, `SessionService` keeps it cohesive but pushes the package size up. Cost of extracting `auth`: cleaner separation, but introduces a cross-domain call (`AuthService → UserService`) and one more module to maintain. Recommend extending `user` for now; extract when the package exceeds ~25 files. — Markus
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Observations

  • AuthController.java already has /api/auth/{forgot-password,reset-password,register} — adding /api/auth/login + /api/auth/logout slots in naturally.
  • frontend/src/routes/login/+page.server.ts builds Basic auth client-side and round-trips to /api/users/me to validate. That round-trip disappears once the backend has a real login endpoint that returns the user payload in the response body.
  • frontend/src/hooks.server.ts:49-67 (userGroup handle) re-fetches /api/users/me on every SSR request to populate event.locals.user. With server-side sessions that store the user's principal, this can be replaced by reading the principal directly from the session — eliminating one HTTP round-trip per page render.
  • frontend/src/hooks.server.ts:70-113 (handleFetch) and the Vite proxy header-injection logic in vite.config.ts both exist solely to inject Authorization: Basic from the cookie. Both disappear when the cookie carries an opaque session ID that the backend recognizes natively.
  • frontend/src/routes/logout/+page.server.ts currently just calls cookies.delete('auth_token'). It must POST to /api/auth/logout first, then delete the cookie — otherwise the server-side session record lingers.

Recommendations

  • TDD order (red/green for each step):
    1. AuthControllerTest: POST /api/auth/login with valid credentials returns 200 + user JSON + sets SESSION cookie. Red first.
    2. Same test class: invalid credentials returns 401, ErrorCode INVALID_CREDENTIALS (add it).
    3. POST /api/auth/logout with active session returns 204 and removes the session record. Red first.
    4. POST /api/auth/logout without session returns 401.
    5. Integration test (Testcontainers + real spring_session table): full login → authed request → logout → 401 on next request.
    6. PasswordResetServiceTest: after resetPassword(), all spring_session rows for the user are deleted. Red first — this behavior doesn't exist today.
    7. Frontend: LoginPageServerTest (existing pattern in +page.server.test.ts files) — login action calls POST /api/auth/login, no longer hits /api/users/me.
  • DTO shape for /api/auth/login: request { email, password }, response is the existing AppUser shape (same as /api/users/me). Reuse the type — drives the same TypeScript binding via OpenAPI regen.
  • Don't bundle the AuthController refactor with anything else in this PR. Atomic commit per step.
  • Don't keep AuthTokenCookieFilter as a fallback. FR-AUTH-006 explicitly says "removed once new model is live." Half-removed filters are how this kind of debt comes back. Delete the class and its test in the same commit that lands the new login endpoint.
  • Mark ErrorCode.INVALID_CREDENTIALS and CSRF_TOKEN_MISSING in the same PR — frontend errors.ts + messages/{de,en,es}.json updates. The issue's AC 403 with code CSRF_TOKEN_MISSING requires this exact code to exist.
  • @RequirePermission does NOT apply to /api/auth/login or /api/auth/logout. Login is pre-authentication; logout authenticates via session, not a permission. Add to the permitAll() matchers in SecurityConfig.
  • Remove formLogin(...) from SecurityConfig — once /api/auth/login exists, the implicit Spring Security form-login filter is dead code with a confusing surface (it still accepts POST to /login).

Open Decisions

  • Should /api/auth/login response include the CSRF token in a JSON field, or set it via a separate XSRF-TOKEN cookie? Cookie approach (Spring's default CookieCsrfTokenRepository.withHttpOnlyFalse()) is one less thing for the frontend to manage and works with use:enhance forms. JSON field is more explicit but requires the SvelteKit login action to forward the token into a cookie itself. Recommend the cookie approach unless Nora flags a leakage concern — let her decide.

— Felix

## 👨‍💻 Felix Brandt — Senior Fullstack Developer ### Observations - `AuthController.java` already has `/api/auth/{forgot-password,reset-password,register}` — adding `/api/auth/login` + `/api/auth/logout` slots in naturally. - `frontend/src/routes/login/+page.server.ts` builds Basic auth client-side and round-trips to `/api/users/me` to validate. That round-trip disappears once the backend has a real login endpoint that returns the user payload in the response body. - `frontend/src/hooks.server.ts:49-67` (`userGroup` handle) re-fetches `/api/users/me` on every SSR request to populate `event.locals.user`. With server-side sessions that store the user's principal, this can be replaced by reading the principal directly from the session — eliminating one HTTP round-trip per page render. - `frontend/src/hooks.server.ts:70-113` (`handleFetch`) and the Vite proxy header-injection logic in `vite.config.ts` both exist solely to inject `Authorization: Basic` from the cookie. **Both disappear** when the cookie carries an opaque session ID that the backend recognizes natively. - `frontend/src/routes/logout/+page.server.ts` currently just calls `cookies.delete('auth_token')`. It must POST to `/api/auth/logout` first, then delete the cookie — otherwise the server-side session record lingers. ### Recommendations - **TDD order (red/green for each step):** 1. `AuthControllerTest`: `POST /api/auth/login` with valid credentials returns 200 + user JSON + sets `SESSION` cookie. Red first. 2. Same test class: invalid credentials returns 401, ErrorCode `INVALID_CREDENTIALS` (add it). 3. `POST /api/auth/logout` with active session returns 204 and removes the session record. Red first. 4. `POST /api/auth/logout` without session returns 401. 5. Integration test (Testcontainers + real `spring_session` table): full login → authed request → logout → 401 on next request. 6. `PasswordResetServiceTest`: after `resetPassword()`, all `spring_session` rows for the user are deleted. Red first — this behavior doesn't exist today. 7. Frontend: `LoginPageServerTest` (existing pattern in `+page.server.test.ts` files) — login action calls `POST /api/auth/login`, no longer hits `/api/users/me`. - **DTO shape for `/api/auth/login`:** request `{ email, password }`, response is the existing `AppUser` shape (same as `/api/users/me`). Reuse the type — drives the same TypeScript binding via OpenAPI regen. - **Don't bundle the AuthController refactor with anything else in this PR.** Atomic commit per step. - **Don't keep `AuthTokenCookieFilter` as a fallback.** FR-AUTH-006 explicitly says "removed once new model is live." Half-removed filters are how this kind of debt comes back. Delete the class and its test in the same commit that lands the new login endpoint. - **Mark `ErrorCode.INVALID_CREDENTIALS` and `CSRF_TOKEN_MISSING`** in the same PR — frontend `errors.ts` + `messages/{de,en,es}.json` updates. The issue's AC `403 with code CSRF_TOKEN_MISSING` requires this exact code to exist. - **`@RequirePermission` does NOT apply to `/api/auth/login` or `/api/auth/logout`.** Login is pre-authentication; logout authenticates via session, not a permission. Add to the `permitAll()` matchers in `SecurityConfig`. - **Remove `formLogin(...)` from `SecurityConfig`** — once `/api/auth/login` exists, the implicit Spring Security form-login filter is dead code with a confusing surface (it still accepts POST to `/login`). ### Open Decisions - **Should `/api/auth/login` response include the CSRF token in a JSON field, or set it via a separate `XSRF-TOKEN` cookie?** Cookie approach (Spring's default `CookieCsrfTokenRepository.withHttpOnlyFalse()`) is one less thing for the frontend to manage and works with `use:enhance` forms. JSON field is more explicit but requires the SvelteKit login action to forward the token into a cookie itself. Recommend the cookie approach unless Nora flags a leakage concern — let her decide. — Felix
Author
Owner

🛡 Nora Steiner — Application Security Engineer

Observations

  • The current cookie is a credential, not a token. login/+page.server.ts:46 stores Basic ${btoa(email:password)} in auth_token. Anyone reading the cookie (XSS bypassing HttpOnly via a browser bug, devtools on a shared machine, a compromised browser extension) gets the user's password directly. The 24h Max-Age makes this worse: rotating the password does not invalidate the cookie because the cookie is the password. This is a P1 issue, not tech debt — and the issue is correct to flag it.
  • The secure: isHttps runtime branch in login/+page.server.ts:50 is a footgun. If anything ever serves this app over HTTP in production (misconfigured reverse proxy, internal load balancer terminating TLS upstream of the SvelteKit node), the cookie ships in cleartext. Defaults should be secure: true always; dev gets HTTPS via mkcert/Caddy local.
  • CSRF is genuinely undefended today. SecurityConfig:54 disables CSRF. The load-bearing comment is honest but the defense is single-layer: SameSite=strict alone. The fail-mode is silent: a cookie config change (e.g., someone "fixing" Safari cross-domain quirks by flipping to lax) opens every POST/PUT/PATCH/DELETE to CSRF with no observable signal.
  • Password reset doesn't invalidate anything today. PasswordResetService.resetPassword() updates the password hash and the token's used flag — that's it. The user's old auth_token cookie (with the OLD password's base64) still authenticates for up to 24h because the backend never sees the password; it sees the Basic header and does a fresh bcrypt comparison each request. Wait — verify this: if the old cookie contains base64 of email:OLD_password, and the bcrypt compare runs against the new hash, the cookie should fail. So technically it does invalidate. But the user has no way to force logout, and a compromised cookie carrying the new password (if it leaked post-reset) is still good. The lack of server-side session state means there's no kill switch.
  • No backend logout exists. logout/+page.server.ts just cookies.delete(). A stolen cookie from before logout still authenticates against the backend. The operator has no way to revoke.
  • spring_session tables were dropped in V2 — issue's "infrastructure is in-house" claim is stale. This affects the L sizing.

Recommendations

  • Make Secure flag unconditional in production. Drop the isHttps branch. Use application-dev.yaml profile to override only in dev — never a runtime check on url.protocol.
  • Re-enable Spring Security's CSRF via CookieCsrfTokenRepository.withHttpOnlyFalse(). This is the standard synchronizer-token pattern: server sets XSRF-TOKEN (readable by JS), client must echo it in X-XSRF-TOKEN header on writes. Spring Security wires it automatically — write the test that proves POST /api/persons without the header returns 403, then enable.
  • ErrorCode.CSRF_TOKEN_MISSING is mandated by the AC — add to ErrorCode.java, errors.ts, and all three i18n files. Also add INVALID_CREDENTIALS and SESSION_EXPIRED.
  • Active-session invalidation MUST happen in PasswordResetService.resetPassword() in this issue. Issue currently only covers FR-AUTH-004 (change-own-password). Add an explicit FR (or amend FR-AUTH-004 to cover both): after resetPassword() and after any password change, delete all spring_session rows for that user. Failing test first.
  • Add ErrorCode.SESSION_EXPIRED as distinct from UNAUTHORIZED. The frontend should show "Du wurdest abgemeldet, bitte erneut anmelden" — see Leonie's note.
  • Audit logging in scope (NFR-OBSV-101). Add AuditKind.LOGIN_SUCCESS, LOGIN_FAILED, LOGOUT, ADMIN_FORCE_LOGOUT. Payload must include source IP and user-agent (truncated to 200 chars to bound storage growth). Never log the password attemptLOGIN_FAILED payload is {email, ip, ua} only.
  • Rate-limit /api/auth/login at the application layer with Bucket4j. fail2ban via Caddy logs is too coarse (banning whole IPs, slow to react). Bucket4j gives per-(IP, email) buckets. Default: 5 attempts per 15 min, response is 429 with ErrorCode.TOO_MANY_LOGIN_ATTEMPTS. This is an additive NFR I'd add to the issue:
    • NFR-SEC-104 — Login attempts SHALL be rate-limited to ≤5 failures per (IP, email) per 15-minute window. Exceeding the limit returns 429.
  • Test matrix — minimum: delete_returns403_when_csrf_token_missing, login_returns401_when_password_wrong, login_returns429_when_rate_limited, password_reset_invalidates_all_user_sessions, admin_force_logout_invalidates_all_target_sessions_and_audits. Each starts red.
  • Threat model the migration window: when the new code lands, every user with an existing auth_token cookie must be forced to re-login. Don't ship a compat layer that accepts both — that's exactly the kind of "transitional" code that becomes permanent.

Open Decisions

  • OQ-AUTH-001 — single vs multi-device logout. Threat-model answer:
    • Logout button: current session only. Standard UX, low security cost.
    • Password change / reset: ALL sessions. Otherwise a compromised cookie survives the reset. This is mandatory, not a preference.
    • Admin force-logout: ALL sessions for target user. Operator break-glass.
      Recommendation: code three explicit paths. Don't conflate them.
  • OQ-AUTH-002 — idle timeout. Recommend yes: 8h idle, 24h absolute. Without an idle timer, a session that's been sitting on a logged-out shared workstation is good for the rest of its 24h window even if the user hasn't touched it. Cost is one extra column in spring_session (already there: LAST_ACCESS_TIME), so zero schema cost.
  • OQ-AUTH-004 — remember-me. Defer. Once implemented, "remember me" extends the absolute lifetime to 30d or similar — the threat model changes (now a stolen laptop = 30 days of access). Get the baseline shipped first, then evaluate.

— Nora ("NullX")

## 🛡 Nora Steiner — Application Security Engineer ### Observations - **The current cookie is a credential, not a token.** `login/+page.server.ts:46` stores `Basic ${btoa(email:password)}` in `auth_token`. Anyone reading the cookie (XSS bypassing HttpOnly via a browser bug, devtools on a shared machine, a compromised browser extension) gets the user's password directly. The 24h `Max-Age` makes this worse: rotating the password does not invalidate the cookie because the cookie *is* the password. **This is a P1 issue, not tech debt — and the issue is correct to flag it.** - **The `secure: isHttps` runtime branch in `login/+page.server.ts:50` is a footgun.** If anything ever serves this app over HTTP in production (misconfigured reverse proxy, internal load balancer terminating TLS upstream of the SvelteKit node), the cookie ships in cleartext. Defaults should be `secure: true` always; dev gets HTTPS via mkcert/Caddy local. - **CSRF is genuinely undefended today.** `SecurityConfig:54` disables CSRF. The load-bearing comment is honest but the defense is single-layer: SameSite=strict alone. The fail-mode is silent: a cookie config change (e.g., someone "fixing" Safari cross-domain quirks by flipping to `lax`) opens every POST/PUT/PATCH/DELETE to CSRF with no observable signal. - **Password reset doesn't invalidate anything today.** `PasswordResetService.resetPassword()` updates the password hash and the token's used flag — that's it. The user's old `auth_token` cookie (with the OLD password's base64) still authenticates for up to 24h because the backend never sees the password; it sees the Basic header and does a fresh bcrypt comparison each request. **Wait — verify this:** if the old cookie contains base64 of `email:OLD_password`, and the bcrypt compare runs against the new hash, the cookie should fail. So technically it does invalidate. But the user has no way to *force* logout, and a compromised cookie carrying the new password (if it leaked post-reset) is still good. The lack of server-side session state means there's no kill switch. - **No backend logout exists.** `logout/+page.server.ts` just `cookies.delete()`. A stolen cookie from before logout still authenticates against the backend. The operator has no way to revoke. - **`spring_session` tables were dropped in V2** — issue's "infrastructure is in-house" claim is stale. This affects the L sizing. ### Recommendations - **Make `Secure` flag unconditional in production.** Drop the `isHttps` branch. Use `application-dev.yaml` profile to override only in dev — never a runtime check on `url.protocol`. - **Re-enable Spring Security's CSRF via `CookieCsrfTokenRepository.withHttpOnlyFalse()`.** This is the standard synchronizer-token pattern: server sets `XSRF-TOKEN` (readable by JS), client must echo it in `X-XSRF-TOKEN` header on writes. Spring Security wires it automatically — write the test that proves `POST /api/persons` without the header returns 403, then enable. - **`ErrorCode.CSRF_TOKEN_MISSING` is mandated by the AC** — add to `ErrorCode.java`, `errors.ts`, and all three i18n files. Also add `INVALID_CREDENTIALS` and `SESSION_EXPIRED`. - **Active-session invalidation MUST happen in `PasswordResetService.resetPassword()` in this issue.** Issue currently only covers FR-AUTH-004 (change-own-password). Add an explicit FR (or amend FR-AUTH-004 to cover both): after `resetPassword()` and after any password change, delete all `spring_session` rows for that user. Failing test first. - **Add `ErrorCode.SESSION_EXPIRED`** as distinct from `UNAUTHORIZED`. The frontend should show "Du wurdest abgemeldet, bitte erneut anmelden" — see Leonie's note. - **Audit logging in scope (NFR-OBSV-101).** Add `AuditKind.LOGIN_SUCCESS`, `LOGIN_FAILED`, `LOGOUT`, `ADMIN_FORCE_LOGOUT`. Payload must include source IP and user-agent (truncated to 200 chars to bound storage growth). **Never log the password attempt** — `LOGIN_FAILED` payload is `{email, ip, ua}` only. - **Rate-limit `/api/auth/login` at the application layer with Bucket4j.** fail2ban via Caddy logs is too coarse (banning whole IPs, slow to react). Bucket4j gives per-(IP, email) buckets. Default: 5 attempts per 15 min, response is 429 with `ErrorCode.TOO_MANY_LOGIN_ATTEMPTS`. This is an additive NFR I'd add to the issue: - **NFR-SEC-104** — Login attempts SHALL be rate-limited to ≤5 failures per (IP, email) per 15-minute window. Exceeding the limit returns 429. - **Test matrix — minimum:** `delete_returns403_when_csrf_token_missing`, `login_returns401_when_password_wrong`, `login_returns429_when_rate_limited`, `password_reset_invalidates_all_user_sessions`, `admin_force_logout_invalidates_all_target_sessions_and_audits`. Each starts red. - **Threat model the migration window:** when the new code lands, every user with an existing `auth_token` cookie must be forced to re-login. Don't ship a compat layer that accepts both — that's exactly the kind of "transitional" code that becomes permanent. ### Open Decisions - **OQ-AUTH-001 — single vs multi-device logout.** **Threat-model answer:** - *Logout button:* current session only. Standard UX, low security cost. - *Password change / reset:* ALL sessions. Otherwise a compromised cookie survives the reset. This is mandatory, not a preference. - *Admin force-logout:* ALL sessions for target user. Operator break-glass. Recommendation: code three explicit paths. Don't conflate them. - **OQ-AUTH-002 — idle timeout.** Recommend yes: 8h idle, 24h absolute. Without an idle timer, a session that's been sitting on a logged-out shared workstation is good for the rest of its 24h window even if the user hasn't touched it. Cost is one extra column in `spring_session` (already there: `LAST_ACCESS_TIME`), so zero schema cost. - **OQ-AUTH-004 — remember-me.** Defer. Once implemented, "remember me" extends the absolute lifetime to 30d or similar — the threat model changes (now a stolen laptop = 30 days of access). Get the baseline shipped first, then evaluate. — Nora ("NullX")
Author
Owner

🧪 Sara Holt — Senior QA Engineer

Observations

  • The acceptance criteria are in Given-When-Then form already — that's directly usable as the integration-test scaffolding. Good issue writing.
  • Backend coverage gate is 88% branch (from pom.xml JaCoCo <minimum>0.88</minimum>). Auth touches many code paths; this work will move the coverage numbers and the PR must hold the line.
  • PasswordResetTestHelper.java already exists for e2e to drive deterministic password resets. The new login model needs an analogous test seam — either reuse the pattern (an AuthE2EController already exists) or extend it.
  • The NFRs NFR-PERF-101 ("≤5ms p95 added by session lookup") and NFR-SEC-102 ("≤60s for stolen cookie to become inert") are measurable but the issue doesn't say where the measurement is captured. These need to be wired into the test suite, not assumed.
  • E2E currently uses Playwright. Multi-device/multi-tab scenarios are non-trivial — need to think about how a single Playwright browser mimics "two devices."

Recommendations

  • Test pyramid for this work:
Layer Tests
Unit (Mockito) AuthService.login, AuthService.logout, session invalidation logic, audit-log writes
Slice (@WebMvcTest) AuthController happy/error paths for both endpoints, CSRF rejection on a sample write controller
Integration (Testcontainers + real Postgres + Spring Session JDBC tables) full lifecycle: login → authed read → logout → 401; password reset → all sessions gone; admin force-logout → all sessions gone, audit row present
E2E (Playwright) login form → home page; logout button → login redirect; session-expired UX (mock expiry by deleting the session row mid-test); multi-context test: open browser.newContext() twice for the same user, log out from one, verify the other still works (logout-current-only)
Load (k6 smoke, optional) /api/auth/login at sustained 50 RPS; measure p95 of the next authenticated GET — this is NFR-PERF-101 evidence
  • NFR-SEC-102 (≤60s for stolen cookie to become inert) is implicitly an "immediate" requirement, not a "within 60s eventual consistency" one — Spring Session JDBC deletes are synchronous. Write the integration test that asserts: at T+0 DELETE FROM spring_session WHERE id = ?, at T+0+ε next request returns 401. There's no 60s window in this design — the AC should probably read "≤1 request" rather than "≤60 seconds." Worth tightening with Elicit.
  • The "force-logout" capability needs explicit permission boundary tests. It's an ADMIN_USER (or new ADMIN_PERMISSION?) operation. Tests:
    • force_logout_returns403_when_caller_has_only_WRITE_ALL
    • force_logout_returns401_when_unauthenticated
    • force_logout_returns200_and_audits_when_admin
  • Add CSRF regression tests to every existing write endpoint, not just one. A @ParameterizedTest driven by a list of write endpoints catches the case where someone adds a new write controller and forgets the CSRF token requirement. Pattern:
    @ParameterizedTest
    @MethodSource("writeEndpoints")
    void write_endpoint_rejects_request_without_csrf_token(String method, String path) { ... }
    
  • Don't mock SessionRepository in integration tests. Use Testcontainers + the real Flyway-managed spring_session table. H2 will not behave the same way; mocked repos hide the actual session-store semantics that NFR-SEC-102 depends on.
  • Quality gate for this PR: no drop in branch coverage; new code carries its own ≥80% branch coverage; zero flaky tests added (rerun the new integration tests 10× locally before pushing).
  • E2E timing target: the existing E2E suite is bounded — new auth tests should add no more than 60s to total runtime.

Open Decisions

  • Should AuthE2EController (test-profile-only) gain a force-create-session helper so e2e tests can seed an authenticated session without going through the real login form? Pro: speeds up every E2E test that needs a logged-in user. Con: another e2e-only surface to maintain and double-check is gated. Recommend yes — PasswordResetTestHelper set the precedent.

— Sara

## 🧪 Sara Holt — Senior QA Engineer ### Observations - The acceptance criteria are in Given-When-Then form already — that's directly usable as the integration-test scaffolding. Good issue writing. - Backend coverage gate is **88% branch** (from `pom.xml` JaCoCo `<minimum>0.88</minimum>`). Auth touches many code paths; this work *will* move the coverage numbers and the PR must hold the line. - `PasswordResetTestHelper.java` already exists for e2e to drive deterministic password resets. The new login model needs an analogous test seam — either reuse the pattern (an `AuthE2EController` already exists) or extend it. - The NFRs `NFR-PERF-101` ("≤5ms p95 added by session lookup") and `NFR-SEC-102` ("≤60s for stolen cookie to become inert") are measurable but the issue doesn't say where the measurement is captured. These need to be wired into the test suite, not assumed. - E2E currently uses Playwright. Multi-device/multi-tab scenarios are non-trivial — need to think about how a single Playwright `browser` mimics "two devices." ### Recommendations - **Test pyramid for this work:** | Layer | Tests | |---|---| | Unit (Mockito) | `AuthService.login`, `AuthService.logout`, session invalidation logic, audit-log writes | | Slice (`@WebMvcTest`) | `AuthController` happy/error paths for both endpoints, CSRF rejection on a sample write controller | | Integration (Testcontainers + real Postgres + Spring Session JDBC tables) | full lifecycle: login → authed read → logout → 401; password reset → all sessions gone; admin force-logout → all sessions gone, audit row present | | E2E (Playwright) | login form → home page; logout button → login redirect; session-expired UX (mock expiry by deleting the session row mid-test); multi-context test: open `browser.newContext()` twice for the same user, log out from one, verify the other still works (logout-current-only) | | Load (k6 smoke, optional) | `/api/auth/login` at sustained 50 RPS; measure p95 of the next authenticated GET — this is NFR-PERF-101 evidence | - **`NFR-SEC-102` (≤60s for stolen cookie to become inert)** is implicitly an "immediate" requirement, not a "within 60s eventual consistency" one — Spring Session JDBC deletes are synchronous. Write the integration test that asserts: at T+0 `DELETE FROM spring_session WHERE id = ?`, at T+0+ε next request returns 401. There's no 60s window in this design — the AC should probably read "≤1 request" rather than "≤60 seconds." Worth tightening with Elicit. - **The "force-logout" capability needs explicit permission boundary tests.** It's an `ADMIN_USER` (or new `ADMIN_PERMISSION`?) operation. Tests: - `force_logout_returns403_when_caller_has_only_WRITE_ALL` - `force_logout_returns401_when_unauthenticated` - `force_logout_returns200_and_audits_when_admin` - **Add CSRF regression tests to *every* existing write endpoint, not just one.** A `@ParameterizedTest` driven by a list of write endpoints catches the case where someone adds a new write controller and forgets the CSRF token requirement. Pattern: ```java @ParameterizedTest @MethodSource("writeEndpoints") void write_endpoint_rejects_request_without_csrf_token(String method, String path) { ... } ``` - **Don't mock `SessionRepository` in integration tests.** Use Testcontainers + the real Flyway-managed `spring_session` table. H2 will not behave the same way; mocked repos hide the actual session-store semantics that NFR-SEC-102 depends on. - **Quality gate for this PR:** no drop in branch coverage; new code carries its own ≥80% branch coverage; zero flaky tests added (rerun the new integration tests 10× locally before pushing). - **E2E timing target:** the existing E2E suite is bounded — new auth tests should add no more than 60s to total runtime. ### Open Decisions - **Should `AuthE2EController` (test-profile-only) gain a `force-create-session` helper** so e2e tests can seed an authenticated session without going through the real login form? Pro: speeds up every E2E test that needs a logged-in user. Con: another `e2e`-only surface to maintain and double-check is gated. Recommend yes — `PasswordResetTestHelper` set the precedent. — Sara
Author
Owner

⚙ Tobias Wendt — DevOps & Platform Engineer

Observations

  • 🚨 The issue's stack claim is stale. I searched backend/pom.xml — there is no spring-session-jdbc dependency. V2__drop_spring_session_tables.sql explicitly drops the tables with the note "Spring Session JDBC was included as a dependency but never used. Authentication is stateless HTTP Basic Auth; sessions are never written." Re-introducing it means: (1) add the Maven dependency, (2) write V33__recreate_spring_session_tables.sql (Spring Session ships the canonical DDL — copy it), (3) configure spring.session.store-type=jdbc in application.yaml. Not a one-liner, but boring and well-documented.
  • The deployment story is genuinely simpler after this change: Caddy stays unchanged, the AuthTokenCookieFilter is gone, the Vite proxy header-injection in vite.config.ts is gone. Three layers removed.
  • /actuator/* is already blocked in Caddy (confirmed by recent commit 9686e304 — actuator block wrapped in handle for precedence). Force-logout admin endpoints stay reachable since they live under /api/*, not /actuator/*.
  • The spring_session table will hold session IDs that are credential-equivalents. Backup blast radius matters. A leaked nightly pg_dump becomes a session goldmine.

Recommendations

  • Cutover is breaking — plan a maintenance window. Every active user gets logged out on the deploy. Don't try to honor old auth_token cookies with a transitional filter: that's the exact "transitional code becomes permanent" anti-pattern. Schedule:
    1. Pre-deploy: post operator banner ("Wartung 22:00–22:15, danach bitte erneut anmelden").
    2. Deploy backend + frontend together.
    3. All in-flight sessions become 401 — users re-login.
    4. Estimated downtime visible to users: zero (the app works, just shows the login screen).
  • Add spring_session exclusion to backup retention policy. Two options:
    • Exclude the table from pg_dump entirely (--exclude-table=spring_session*). Pro: leaked backup ≠ session theft. Con: restoring loses all sessions, which is acceptable.
    • Keep it in the dump but ensure backup files are encrypted at rest (Hetzner Object Storage server-side encryption is on by default). Pro: simpler. Con: still leaks if Hetzner credentials leak.
      Recommend option 1. Document in docs/infrastructure/.
  • Observability — new Grafana panels needed:
    • Active session count (gauge): SELECT COUNT(*) FROM spring_session
    • Login rate (success + failure split): from audit log rows LOGIN_SUCCESS / LOGIN_FAILED per minute
    • Logout rate (manual + idle + admin force): from audit log rows
    • Add a Loki alert: rate({app="backend"} |= "LOGIN_FAILED" [5m]) > 10 — likely brute force in progress
  • Caddy log format must capture /api/auth/login for fail2ban. Currently Caddy logs /api/* by URI but the auth-specific filter may or may not be in the jail config. Audit the existing fail2ban jail before relying on it as a defense layer (Nora has noted Bucket4j as a better fit anyway).
  • Pin the spring-session-jdbc version explicitly in pom.xml. Don't rely on Spring Boot's BOM transitively — security-relevant dependencies should be pinned and bumped by Renovate.
  • Schema migration ordering: V33__add_spring_session_tables.sql runs before any code referencing it. Stamp the migration with a comment explaining why we're recreating what V2 dropped — future readers will be confused otherwise.
  • CI: the new integration tests need Spring Session tables in Testcontainers. Flyway runs in the test classpath, so the migration handles this automatically. Verify the existing PostgresContainerConfig test setup runs all migrations (it should — confirm with Sara).

Open Decisions

  • Force-logout: API-only this issue, or also a CLI? A bash helper script (scripts/force-logout-user.sh <email>) hitting the admin API is useful for true break-glass scenarios when the admin UI is down. Cost: one shell script, ~20 lines. Benefit: operator can revoke sessions even if the SvelteKit frontend is broken. Recommend yes — keep it tiny and document in docs/infrastructure/.

— Tobi

## ⚙ Tobias Wendt — DevOps & Platform Engineer ### Observations - **🚨 The issue's stack claim is stale.** I searched `backend/pom.xml` — there is no `spring-session-jdbc` dependency. `V2__drop_spring_session_tables.sql` explicitly drops the tables with the note *"Spring Session JDBC was included as a dependency but never used. Authentication is stateless HTTP Basic Auth; sessions are never written."* Re-introducing it means: (1) add the Maven dependency, (2) write `V33__recreate_spring_session_tables.sql` (Spring Session ships the canonical DDL — copy it), (3) configure `spring.session.store-type=jdbc` in `application.yaml`. Not a one-liner, but boring and well-documented. - The deployment story is genuinely *simpler* after this change: Caddy stays unchanged, the `AuthTokenCookieFilter` is gone, the Vite proxy header-injection in `vite.config.ts` is gone. Three layers removed. - `/actuator/*` is already blocked in Caddy (confirmed by recent commit `9686e304` — actuator block wrapped in `handle` for precedence). Force-logout admin endpoints stay reachable since they live under `/api/*`, not `/actuator/*`. - The `spring_session` table will hold session IDs that are credential-equivalents. **Backup blast radius matters.** A leaked nightly pg_dump becomes a session goldmine. ### Recommendations - **Cutover is breaking — plan a maintenance window.** Every active user gets logged out on the deploy. Don't try to honor old `auth_token` cookies with a transitional filter: that's the exact "transitional code becomes permanent" anti-pattern. Schedule: 1. Pre-deploy: post operator banner ("Wartung 22:00–22:15, danach bitte erneut anmelden"). 2. Deploy backend + frontend together. 3. All in-flight sessions become 401 — users re-login. 4. Estimated downtime visible to users: zero (the app works, just shows the login screen). - **Add `spring_session` exclusion to backup retention policy.** Two options: - Exclude the table from `pg_dump` entirely (`--exclude-table=spring_session*`). Pro: leaked backup ≠ session theft. Con: restoring loses all sessions, which is acceptable. - Keep it in the dump but ensure backup files are encrypted at rest (Hetzner Object Storage server-side encryption is on by default). Pro: simpler. Con: still leaks if Hetzner credentials leak. Recommend option 1. Document in `docs/infrastructure/`. - **Observability — new Grafana panels needed:** - Active session count (gauge): `SELECT COUNT(*) FROM spring_session` - Login rate (success + failure split): from audit log rows `LOGIN_SUCCESS` / `LOGIN_FAILED` per minute - Logout rate (manual + idle + admin force): from audit log rows - Add a Loki alert: `rate({app="backend"} |= "LOGIN_FAILED" [5m]) > 10` — likely brute force in progress - **Caddy log format must capture `/api/auth/login` for fail2ban.** Currently Caddy logs `/api/*` by URI but the auth-specific filter may or may not be in the jail config. Audit the existing fail2ban jail before relying on it as a defense layer (Nora has noted Bucket4j as a better fit anyway). - **Pin the `spring-session-jdbc` version explicitly in pom.xml.** Don't rely on Spring Boot's BOM transitively — security-relevant dependencies should be pinned and bumped by Renovate. - **Schema migration ordering:** `V33__add_spring_session_tables.sql` runs before any code referencing it. Stamp the migration with a comment explaining why we're recreating what V2 dropped — future readers will be confused otherwise. - **CI: the new integration tests need Spring Session tables in Testcontainers.** Flyway runs in the test classpath, so the migration handles this automatically. Verify the existing `PostgresContainerConfig` test setup runs all migrations (it should — confirm with Sara). ### Open Decisions - **Force-logout: API-only this issue, or also a CLI?** A bash helper script (`scripts/force-logout-user.sh <email>`) hitting the admin API is useful for true break-glass scenarios when the admin UI is down. Cost: one shell script, ~20 lines. Benefit: operator can revoke sessions even if the SvelteKit frontend is broken. Recommend yes — keep it tiny and document in `docs/infrastructure/`. — Tobi
Author
Owner

🎨 Leonie Voss — UI/UX Design & Accessibility

Observations

  • The issue is backend-heavy and that's correct — but several user-visible moments hang off this work:
    1. Login form — already works (/login/+page.svelte), error handling will need to map the new INVALID_CREDENTIALS and TOO_MANY_LOGIN_ATTEMPTS codes.
    2. Logout — currently silent: click → redirect to /login. With server-side logout, this becomes a real action that might fail. Needs a confirmation/feedback state.
    3. Session expiry — today, when the 24h cookie expires, the user hits any page and gets redirected to /login with no explanation. Particularly disorienting for the 60+ audience.
    4. CSRF token failures — for users on tabs left open for hours, the CSRF token may rotate. Form submission failures need a clear recovery path.
  • The issue mentions admin force-logout (FR-AUTH-005) with "UI surface deferred." That's fine for v1.1.0 but the backend audit-log row will be visible in the existing activity feed (/aktivitaeten/) — make sure the rendering doesn't leak a confusing entry like audit.adminforcelogout with no German label.

Recommendations

  • Add an i18n key session_expired to messages/{de,en,es}.json. When the SvelteKit handleAuth hook (hooks.server.ts:32-38) redirects an unauthenticated user, append ?reason=expired to the login URL, and render an aria-live="polite" info banner on the login page:
    {#if $page.url.searchParams.get('reason') === 'expired'}
      <div role="status" aria-live="polite" class="rounded-sm border border-line bg-canvas p-4 mb-4">
        <p class="text-ink-2">{m.session_expired()}</p>
      </div>
    {/if}
    
    • de: "Du wurdest abgemeldet. Bitte melde Dich erneut an."
    • en: "You were signed out. Please sign in again."
    • es: "Has cerrado sesión. Por favor, inicia sesión de nuevo."
  • Logout flow: post to /api/auth/logout from the existing logout action, then delete cookie, then redirect. The user sees the same redirect-to-/login. No new UI required — keep it simple. But: if the backend logout call fails, still delete the local cookie and redirect (defense in depth — the user wanted to log out, don't trap them on an error page).
  • Login button touch target: min-h-[44px] minimum (WCAG 2.2 SC 2.5.8). Verify on /login/+page.svelte — if it's currently py-2 px-4 only, that may be under 44px depending on font size. Fix in this PR.
  • Login form error message: map INVALID_CREDENTIALS to a generic "E-Mail oder Passwort sind nicht korrekt." — never differentiate "user not found" from "wrong password" (this is also a security concern — Nora agrees).
  • Map TOO_MANY_LOGIN_ATTEMPTS to: "Zu viele Anmeldeversuche. Bitte warte 15 Minuten und versuche es erneut." Don't expose the exact bucket window if Nora's Bucket4j config changes.
  • Password change confirmation toast: if/when this PR also wires "password change invalidates other sessions," the success message should explicitly say "Alle anderen Geräte wurden abgemeldet." That's a security signal masquerading as a UX message — users feel safer when they see it.
  • CSRF token failure UX: when a CSRF_TOKEN_MISSING 403 comes back from a form submission, the SvelteKit error boundary should show: "Diese Seite wurde zu lange offen gelassen. Bitte neu laden und erneut versuchen." with a "Seite neu laden" button. Don't show a generic 403 — that's frightening for non-technical users.
  • Mobile/elderly: don't introduce idle-timeout pop-ups. If Nora's recommendation of 8h idle + 24h absolute lands, do NOT show a "Your session will expire in 60 seconds" countdown modal — those are stress-inducing for the 60+ audience and add no real security value. Just expire silently and route to the session-expired banner on the next request.

Open Decisions

None from the UX angle — all recommendations above are concrete. The admin force-logout UI (OQ-AUTH-003) is a backend-only ticket; UI design for it can wait for v1.2.0.

— Leonie

## 🎨 Leonie Voss — UI/UX Design & Accessibility ### Observations - The issue is backend-heavy and that's correct — but several user-visible moments hang off this work: 1. **Login form** — already works (`/login/+page.svelte`), error handling will need to map the new `INVALID_CREDENTIALS` and `TOO_MANY_LOGIN_ATTEMPTS` codes. 2. **Logout** — currently silent: click → redirect to `/login`. With server-side logout, this becomes a real action that *might fail*. Needs a confirmation/feedback state. 3. **Session expiry** — today, when the 24h cookie expires, the user hits any page and gets redirected to `/login` with no explanation. Particularly disorienting for the 60+ audience. 4. **CSRF token failures** — for users on tabs left open for hours, the CSRF token may rotate. Form submission failures need a clear recovery path. - The issue mentions admin force-logout (FR-AUTH-005) with "UI surface deferred." That's fine for v1.1.0 but the backend audit-log row will be visible in the existing activity feed (`/aktivitaeten/`) — make sure the rendering doesn't leak a confusing entry like `audit.adminforcelogout` with no German label. ### Recommendations - **Add an i18n key `session_expired` to `messages/{de,en,es}.json`.** When the SvelteKit `handleAuth` hook (`hooks.server.ts:32-38`) redirects an unauthenticated user, append `?reason=expired` to the login URL, and render an `aria-live="polite"` info banner on the login page: ```svelte {#if $page.url.searchParams.get('reason') === 'expired'} <div role="status" aria-live="polite" class="rounded-sm border border-line bg-canvas p-4 mb-4"> <p class="text-ink-2">{m.session_expired()}</p> </div> {/if} ``` - de: "Du wurdest abgemeldet. Bitte melde Dich erneut an." - en: "You were signed out. Please sign in again." - es: "Has cerrado sesión. Por favor, inicia sesión de nuevo." - **Logout flow:** post to `/api/auth/logout` from the existing logout action, then delete cookie, then redirect. The user sees the same redirect-to-`/login`. **No new UI required** — keep it simple. But: if the backend logout call fails, still delete the local cookie and redirect (defense in depth — the user *wanted* to log out, don't trap them on an error page). - **Login button touch target:** `min-h-[44px]` minimum (WCAG 2.2 SC 2.5.8). Verify on `/login/+page.svelte` — if it's currently `py-2 px-4` only, that may be under 44px depending on font size. **Fix in this PR.** - **Login form error message:** map `INVALID_CREDENTIALS` to a generic "E-Mail oder Passwort sind nicht korrekt." — never differentiate "user not found" from "wrong password" (this is also a security concern — Nora agrees). - **Map `TOO_MANY_LOGIN_ATTEMPTS`** to: "Zu viele Anmeldeversuche. Bitte warte 15 Minuten und versuche es erneut." Don't expose the exact bucket window if Nora's Bucket4j config changes. - **Password change confirmation toast:** if/when this PR also wires "password change invalidates other sessions," the success message should explicitly say "Alle anderen Geräte wurden abgemeldet." That's a security signal masquerading as a UX message — users feel safer when they see it. - **CSRF token failure UX:** when a `CSRF_TOKEN_MISSING` 403 comes back from a form submission, the SvelteKit error boundary should show: "Diese Seite wurde zu lange offen gelassen. Bitte neu laden und erneut versuchen." with a "Seite neu laden" button. Don't show a generic 403 — that's frightening for non-technical users. - **Mobile/elderly: don't introduce idle-timeout pop-ups.** If Nora's recommendation of 8h idle + 24h absolute lands, do NOT show a "Your session will expire in 60 seconds" countdown modal — those are stress-inducing for the 60+ audience and add no real security value. Just expire silently and route to the session-expired banner on the next request. ### Open Decisions *None from the UX angle — all recommendations above are concrete. The admin force-logout UI (OQ-AUTH-003) is a backend-only ticket; UI design for it can wait for v1.2.0.* — Leonie
Author
Owner

📋 Elicit — Requirements Engineering (self-review)

I wrote this issue. Re-reading it after Markus, Tobi, and Nora's findings, several things need correcting before implementation can start.

Observations

  • Factual error in the "Constraints & open questions" section. I wrote "The existing stack already includes Spring Session JDBC (per CLAUDE.md)." CLAUDE.md says so but the actual code disagrees: V2__drop_spring_session_tables.sql removed it as unused, and backend/pom.xml has no spring-session-jdbc dependency. This inflates the assumed simplicity of the work and affects sizing.
  • Missing FR: I covered "password change invalidates other sessions" (FR-AUTH-004) but the forgot-password flow (which is technically a password reset, not a change) is not explicitly covered. Nora is right that this is a gap.
  • NFR-SEC-102 wording is imprecise. "≤60s for invalidation to take effect" suggests eventual consistency. With Spring Session JDBC, deletes are synchronous — invalidation is ≤1 request, not ≤60s. Sara flagged this.
  • No rate-limit NFR. I deferred to fail2ban via the existing #499 link, but that's a coarse network-layer defense. Nora's NFR-SEC-104 (Bucket4j application-layer rate limit) is a missing requirement.
  • The sizing label "L (1–2 days)" assumed Spring Session JDBC was already wired. With the dependency add, migration, observability, Bucket4j, audit-kind additions, doc updates, and the breaking-cutover plan, this is closer to XL (3–4 days).

Recommendations (issue amendments)

  • Update the "Constraints" paragraph:

    The existing stack already includes Spring Session JDBC (spring-session-jdbc per CLAUDE.md → "Tech Stack")...

    The previous attempt to use Spring Session JDBC was dropped in V2__drop_spring_session_tables.sql because the app ran stateless. Re-introducing it requires: (1) adding spring-session-jdbc to backend/pom.xml, (2) a new Flyway migration recreating the canonical Spring Session tables, (3) spring.session.store-type=jdbc in application.yaml. The CLAUDE.md "Tech Stack" line referencing Spring Session JDBC is currently stale and must be updated alongside.

  • Add FR-AUTH-008:

    FR-AUTH-008 — A successful password reset via the forgot-password flow (POST /api/auth/reset-password) SHALL invalidate all active sessions for the affected user.

  • Tighten NFR-SEC-102 wording:

    A stolen session cookie SHALL become inert within ≤ 60 seconds of an operator-initiated invalidation...
    A stolen session cookie SHALL become inert on the next request after operator-initiated invalidation (logout, password change, password reset, or admin force-logout). No grace period is permitted.

  • Add NFR-SEC-104:

    NFR-SEC-104POST /api/auth/login SHALL enforce a per-(IP, email) rate limit at the application layer. After 5 failed attempts within a 15-minute window, further attempts SHALL return HTTP 429 with ErrorCode.TOO_MANY_LOGIN_ATTEMPTS. This requirement is independent of any reverse-proxy-layer protection (e.g., fail2ban).

  • Add an AC for forced-disable scenario (Sara's point):

    Given a user with an active session
    When their AppUser record is disabled by an admin
    Then the next request bearing their session cookie returns 401
    And an audit log entry of type ADMIN_FORCE_LOGOUT is recorded

  • Resize: L → XL.
  • Resolve OQ-AUTH-001 now:
    • Logout = current session only
    • Password change/reset = all sessions
    • Admin force-logout = all sessions for target
    • These three are not the same path. Code them explicitly and remove OQ-AUTH-001 from the open list.
  • Resolve OQ-AUTH-002 now: 8h idle, 24h absolute. Spring Session JDBC supports both via MaxInactiveIntervalInSeconds + Spring Session's filter rotation. Costs zero schema or config complexity. Remove OQ-AUTH-002.
  • Resolve OQ-AUTH-003 now: backend capability + audit-log entry in scope; UI deferred to a new issue (which I'll draft alongside the v1.1.0 milestone). Remove OQ-AUTH-003.
  • OQ-AUTH-004 (remember-me) — defer. Out of scope this issue. Don't try to land it here.

Open Decisions

  • Should this issue be split into Phase 1 (replace cookie+Basic with session ID, CSRF still disabled) and Phase 2 (CSRF re-enable + force-logout + password-change invalidation)? Pro: smaller PRs, smaller blast radius per cutover. Con: leaves the "no CSRF on writes" debt open for an extra release; doubles the doc/audit work. Recommendation: single issue, single PR — the cutover already breaks all in-flight sessions, so paying for two cutovers buys little. But if XL sizing makes this feel too risky for a single weekend, splitting is defensible.

— Elicit

## 📋 Elicit — Requirements Engineering (self-review) I wrote this issue. Re-reading it after Markus, Tobi, and Nora's findings, several things need correcting before implementation can start. ### Observations - **Factual error in the "Constraints & open questions" section.** I wrote *"The existing stack already includes Spring Session JDBC (per CLAUDE.md)."* `CLAUDE.md` says so but the actual code disagrees: `V2__drop_spring_session_tables.sql` removed it as unused, and `backend/pom.xml` has no `spring-session-jdbc` dependency. This inflates the assumed simplicity of the work and affects sizing. - **Missing FR:** I covered "password change invalidates other sessions" (FR-AUTH-004) but the *forgot-password* flow (which is technically a password reset, not a change) is not explicitly covered. Nora is right that this is a gap. - **NFR-SEC-102 wording is imprecise.** "≤60s for invalidation to take effect" suggests eventual consistency. With Spring Session JDBC, deletes are synchronous — invalidation is ≤1 request, not ≤60s. Sara flagged this. - **No rate-limit NFR.** I deferred to fail2ban via the existing #499 link, but that's a coarse network-layer defense. Nora's NFR-SEC-104 (Bucket4j application-layer rate limit) is a missing requirement. - **The sizing label "L (1–2 days)"** assumed Spring Session JDBC was already wired. With the dependency add, migration, observability, Bucket4j, audit-kind additions, doc updates, and the breaking-cutover plan, this is closer to **XL (3–4 days)**. ### Recommendations (issue amendments) - **Update the "Constraints" paragraph:** > ~~The existing stack already includes Spring Session JDBC (`spring-session-jdbc` per `CLAUDE.md` → "Tech Stack")...~~ > > **The previous attempt to use Spring Session JDBC was dropped in `V2__drop_spring_session_tables.sql` because the app ran stateless. Re-introducing it requires: (1) adding `spring-session-jdbc` to `backend/pom.xml`, (2) a new Flyway migration recreating the canonical Spring Session tables, (3) `spring.session.store-type=jdbc` in `application.yaml`. The `CLAUDE.md` "Tech Stack" line referencing Spring Session JDBC is currently stale and must be updated alongside.** - **Add FR-AUTH-008:** > **FR-AUTH-008** — A successful password reset via the forgot-password flow (`POST /api/auth/reset-password`) SHALL invalidate all active sessions for the affected user. - **Tighten NFR-SEC-102 wording:** > ~~A stolen session cookie SHALL become inert within ≤ 60 seconds of an operator-initiated invalidation...~~ > **A stolen session cookie SHALL become inert on the next request after operator-initiated invalidation (logout, password change, password reset, or admin force-logout). No grace period is permitted.** - **Add NFR-SEC-104:** > **NFR-SEC-104** — `POST /api/auth/login` SHALL enforce a per-(IP, email) rate limit at the application layer. After 5 failed attempts within a 15-minute window, further attempts SHALL return HTTP 429 with `ErrorCode.TOO_MANY_LOGIN_ATTEMPTS`. This requirement is independent of any reverse-proxy-layer protection (e.g., fail2ban). - **Add an AC for forced-disable scenario** (Sara's point): > Given a user with an active session > When their `AppUser` record is disabled by an admin > Then the next request bearing their session cookie returns 401 > And an audit log entry of type `ADMIN_FORCE_LOGOUT` is recorded - **Resize: L → XL.** - **Resolve OQ-AUTH-001 now:** - Logout = current session only - Password change/reset = all sessions - Admin force-logout = all sessions for target - These three are not the same path. Code them explicitly and remove OQ-AUTH-001 from the open list. - **Resolve OQ-AUTH-002 now:** 8h idle, 24h absolute. Spring Session JDBC supports both via `MaxInactiveIntervalInSeconds` + Spring Session's filter rotation. Costs zero schema or config complexity. Remove OQ-AUTH-002. - **Resolve OQ-AUTH-003 now:** backend capability + audit-log entry in scope; UI deferred to a new issue (which I'll draft alongside the v1.1.0 milestone). Remove OQ-AUTH-003. - **OQ-AUTH-004 (remember-me) — defer.** Out of scope this issue. Don't try to land it here. ### Open Decisions - **Should this issue be split into Phase 1 (replace cookie+Basic with session ID, CSRF still disabled) and Phase 2 (CSRF re-enable + force-logout + password-change invalidation)?** Pro: smaller PRs, smaller blast radius per cutover. Con: leaves the "no CSRF on writes" debt open for an extra release; doubles the doc/audit work. Recommendation: **single issue, single PR** — the cutover already breaks all in-flight sessions, so paying for two cutovers buys little. But if XL sizing makes this feel too risky for a single weekend, splitting is defensible. — Elicit
Author
Owner

🗳️ Decision Queue — Action Required

4 decisions need your input before implementation starts. The other open questions raised in personas above have concrete recommendations and don't need a separate decision.

Architecture

  • Module boundary for new auth code: extend user package, or extract a new auth package? Extending user keeps existing controllers (AuthController, InviteController, PasswordResetService) together but grows package size. Extracting auth is cleaner separation but adds a cross-domain call (AuthService → UserService) and a new module to maintain. (Raised by: Markus)

Implementation

  • CSRF token transport: XSRF-TOKEN cookie (Spring default) or JSON field in /api/auth/login response? Cookie approach is one less moving part and works with use:enhance. JSON field is more explicit but requires the SvelteKit login action to set a separate cookie itself. (Raised by: Felix — recommended cookie approach, deferred to Nora for security review)

Scope & Sizing

  • Split the issue into Phase 1 (session-ID + audit + observability) and Phase 2 (CSRF + force-logout + password-change invalidation), or keep as one XL PR? One PR ships one cutover and avoids double doc/migration cost. Splitting reduces per-PR risk but stretches the "no CSRF" debt across an extra release. (Raised by: Elicit)

Operations

  • Force-logout: API endpoint only, or also a small CLI helper script for break-glass scenarios? A ~20-line bash script hitting the admin API is useful when the SvelteKit frontend is down. Cost is trivial; benefit is operator confidence during incidents. (Raised by: Tobi — recommended yes)

Cross-cutting observations all personas converged on (no decision needed — already reflected as recommendations above):

  • 🚨 Spring Session JDBC is NOT in the stackV2__drop_spring_session_tables.sql removed it; CLAUDE.md is stale. Flagged independently by Markus, Tobi, Nora, and Elicit. The issue's sizing and "in-house infrastructure" framing need updating.
  • 🚨 Password reset must invalidate all sessions for the user — currently missing from issue; flagged by Nora, Sara, Elicit. Add as FR-AUTH-008.
  • 🚨 Rate limiting is a missing NFR — flagged by Nora and Elicit. Add NFR-SEC-104 (Bucket4j, 5 attempts / 15 min per IP+email).
  • Cutover is breaking — Tobi and Nora both insist on a hard cutover; do not ship a transitional filter. Schedule a maintenance window.
  • Several ErrorCode values must be added: INVALID_CREDENTIALS, CSRF_TOKEN_MISSING, SESSION_EXPIRED, TOO_MANY_LOGIN_ATTEMPTS. With matching i18n in de/en/es and frontend errors.ts mapping (Felix, Nora, Leonie).
## 🗳️ Decision Queue — Action Required _4 decisions need your input before implementation starts. The other open questions raised in personas above have concrete recommendations and don't need a separate decision._ ### Architecture - **Module boundary for new auth code: extend `user` package, or extract a new `auth` package?** Extending `user` keeps existing controllers (`AuthController`, `InviteController`, `PasswordResetService`) together but grows package size. Extracting `auth` is cleaner separation but adds a cross-domain call (`AuthService → UserService`) and a new module to maintain. _(Raised by: Markus)_ ### Implementation - **CSRF token transport: `XSRF-TOKEN` cookie (Spring default) or JSON field in `/api/auth/login` response?** Cookie approach is one less moving part and works with `use:enhance`. JSON field is more explicit but requires the SvelteKit login action to set a separate cookie itself. _(Raised by: Felix — recommended cookie approach, deferred to Nora for security review)_ ### Scope & Sizing - **Split the issue into Phase 1 (session-ID + audit + observability) and Phase 2 (CSRF + force-logout + password-change invalidation), or keep as one XL PR?** One PR ships one cutover and avoids double doc/migration cost. Splitting reduces per-PR risk but stretches the "no CSRF" debt across an extra release. _(Raised by: Elicit)_ ### Operations - **Force-logout: API endpoint only, or also a small CLI helper script for break-glass scenarios?** A ~20-line bash script hitting the admin API is useful when the SvelteKit frontend is down. Cost is trivial; benefit is operator confidence during incidents. _(Raised by: Tobi — recommended yes)_ --- **Cross-cutting observations all personas converged on** (no decision needed — already reflected as recommendations above): - 🚨 **Spring Session JDBC is NOT in the stack** — `V2__drop_spring_session_tables.sql` removed it; `CLAUDE.md` is stale. Flagged independently by Markus, Tobi, Nora, and Elicit. The issue's sizing and "in-house infrastructure" framing need updating. - 🚨 **Password reset must invalidate all sessions for the user** — currently missing from issue; flagged by Nora, Sara, Elicit. Add as FR-AUTH-008. - 🚨 **Rate limiting is a missing NFR** — flagged by Nora and Elicit. Add NFR-SEC-104 (Bucket4j, 5 attempts / 15 min per IP+email). - ⚠ **Cutover is breaking** — Tobi and Nora both insist on a hard cutover; do not ship a transitional filter. Schedule a maintenance window. - ⚠ **Several `ErrorCode` values must be added**: `INVALID_CREDENTIALS`, `CSRF_TOKEN_MISSING`, `SESSION_EXPIRED`, `TOO_MANY_LOGIN_ATTEMPTS`. With matching i18n in de/en/es and frontend `errors.ts` mapping (Felix, Nora, Leonie).
Author
Owner

Module: new auth
XSRF-Token cookie
We split this ticket

Module: new auth XSRF-Token cookie We split this ticket
Author
Owner

📋 Elicit — splitting and closing

This issue is being closed in favour of two scoped replacements, both filed against the new v1.1.0 milestone:

  • #523feat(auth): server-side session model replacing Basic-auth cookie promotion
    Phase 1 / cutover. Replaces the cookie-as-credential model with opaque Spring-Session-JDBC-backed sessions. One breaking deploy: every in-flight session is invalidated.
    Covers: FR-AUTH-001/002/003/006/007, NFR-SEC-101/102, NFR-PERF-101, NFR-OBSV-101 (partial — login + logout audit kinds), NFR-COMPAT-101.

  • #524feat(auth): defense-in-depth — CSRF, session revocation, login rate limit
    Phase 2 / additive. Sits on top of #523. Re-enables Spring Security CSRF (XSRF-TOKEN cookie), invalidates sessions on password change/reset/disable/admin-force-logout, and rate-limits /api/auth/login via Bucket4j.
    Covers: FR-AUTH-004/005/008/009/010, NFR-SEC-103/104, NFR-OBSV-101 (completion) + NFR-OBSV-102, extends NFR-COMPAT-101.
    Blocked by #523.

Why split

  • #523 is a single hard cutover; #524 is purely additive. Folding them would force the same risky deploy decisions to be made simultaneously and double the review surface.
  • Each phase is independently testable, shippable, and revertable.
  • No requirement is split across both — clean traceability.

Decisions resolved in this discussion (carried forward)

OQ Decision Where it lives
Module boundary New auth package #523
CSRF token transport XSRF-TOKEN cookie (Spring default) #524
Logout scope Current session only #523
Password change scope All other sessions (current preserved) #524
Password reset / force-logout scope All sessions for user #524
Idle timeout 8h idle, 24h absolute #523
Cutover strategy Hard cutover, no transitional filter #523
Force-logout UI Deferred to v1.2.0 (separate future issue)
Force-logout CLI helper Yes (small bash script) #524
Remember-me Out of scope both

Corrections to the original issue body (folded into the replacements)

  • 🚨 Spring Session JDBC is NOT currently in the stackV2__drop_spring_session_tables.sql removed it; CLAUDE.md is stale. Re-introducing the dependency + a new Flyway migration is part of #523's scope (resized from L to L+ accordingly).
  • 🚨 NFR-SEC-102 wording tightened — invalidation is synchronous (≤1 request), not eventual (≤60s).
  • 🚨 Password reset session invalidation (was missing from this issue) — added as FR-AUTH-008 in #524.
  • 🚨 Rate-limit NFR (was deferred to fail2ban) — added as FR-AUTH-010 / NFR-SEC-104 in #524 (Bucket4j, application-layer, per-(IP, email)).

Closing as superseded by #523 + #524.

— Elicit

## 📋 Elicit — splitting and closing This issue is being closed in favour of two scoped replacements, both filed against the new **v1.1.0** milestone: - **#523** — `feat(auth): server-side session model replacing Basic-auth cookie promotion` *Phase 1 / cutover.* Replaces the cookie-as-credential model with opaque Spring-Session-JDBC-backed sessions. One breaking deploy: every in-flight session is invalidated. Covers: FR-AUTH-001/002/003/006/007, NFR-SEC-101/102, NFR-PERF-101, NFR-OBSV-101 (partial — login + logout audit kinds), NFR-COMPAT-101. - **#524** — `feat(auth): defense-in-depth — CSRF, session revocation, login rate limit` *Phase 2 / additive.* Sits on top of #523. Re-enables Spring Security CSRF (XSRF-TOKEN cookie), invalidates sessions on password change/reset/disable/admin-force-logout, and rate-limits `/api/auth/login` via Bucket4j. Covers: FR-AUTH-004/005/008/009/010, NFR-SEC-103/104, NFR-OBSV-101 (completion) + NFR-OBSV-102, extends NFR-COMPAT-101. Blocked by #523. ### Why split - #523 is a single hard cutover; #524 is purely additive. Folding them would force the same risky deploy decisions to be made simultaneously and double the review surface. - Each phase is independently testable, shippable, and revertable. - No requirement is split across both — clean traceability. ### Decisions resolved in this discussion (carried forward) | OQ | Decision | Where it lives | |---|---|---| | Module boundary | New `auth` package | #523 | | CSRF token transport | XSRF-TOKEN cookie (Spring default) | #524 | | Logout scope | Current session only | #523 | | Password change scope | All other sessions (current preserved) | #524 | | Password reset / force-logout scope | All sessions for user | #524 | | Idle timeout | 8h idle, 24h absolute | #523 | | Cutover strategy | Hard cutover, no transitional filter | #523 | | Force-logout UI | Deferred to v1.2.0 | (separate future issue) | | Force-logout CLI helper | Yes (small bash script) | #524 | | Remember-me | Out of scope | both | ### Corrections to the original issue body (folded into the replacements) - 🚨 **Spring Session JDBC is NOT currently in the stack** — `V2__drop_spring_session_tables.sql` removed it; `CLAUDE.md` is stale. Re-introducing the dependency + a new Flyway migration is part of #523's scope (resized from L to L+ accordingly). - 🚨 **NFR-SEC-102 wording tightened** — invalidation is synchronous (≤1 request), not eventual (≤60s). - 🚨 **Password reset session invalidation** (was missing from this issue) — added as FR-AUTH-008 in #524. - 🚨 **Rate-limit NFR** (was deferred to fail2ban) — added as FR-AUTH-010 / NFR-SEC-104 in #524 (Bucket4j, application-layer, per-(IP, email)). Closing as superseded by #523 + #524. — Elicit
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#522