docs(handoff): logout-hardening — server-side end-to-end verified

Drove the full revocation flow against a running cameleer-server-app jar
(temp postgres+clickhouse, env-var admin):

  GET  /auth/me  with fresh token             -> 200
  POST /auth/logout                            -> 204
  GET  /auth/me  with same revoked token       -> 401
  POST /auth/logout (unauthenticated)          -> 204
  users.token_revoked_before                   -> non-null
  audit_log (action=logout, category=AUTH)    -> 1 row, SUCCESS

Proves the full chain end-to-end: controller revokes, audit lands, and
the JwtAuthenticationFilter prefix-strip fix actually enforces revocation
against the bare users.user_id (the original bug).

Browser-driven SPA smoke is still owed — Playwright MCP allowlist in
this env blocks 8081, so the SPA flow was verified by code-inspection
during Tasks 4+5. OIDC-user smoke against Logto remains owed pending
post_logout_redirect_uri registration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-27 12:16:43 +02:00
parent 664acf2614
commit 47c303afa0

View File

@@ -86,11 +86,33 @@ If silent re-auth still happens after step 4, the most likely cause is that `pro
The pre-existing revocation-bug regression (token still works after logout) is now covered by `JwtRevocationIT.revokedTokenIsRejectedOnAuthenticatedRequest` and the end-to-end logout flow by `LogoutControllerIT.logoutRevokesTokensAuditsAndRejectsSubsequentCalls`. Both depend on the `JwtAuthenticationFilter` prefix-strip fix in commit `7066795c`.
### Manual — required from the SaaS team
### End-to-end against running jar (curl, 2026-04-27, post-merge `664acf26`)
- [ ] Register `https://<tenant-base-url>/login` as a `post_logout_redirect_uri` on the Logto application for each cameleer-server tenant (per the table above).
- [ ] Local-user smoke (in a browser): sign in → sign out → confirm 204 from `/api/v1/auth/logout` in DevTools Network tab → confirm "Signed out successfully" splash → "Sign in again" → confirm local form re-renders cleanly.
- [ ] OIDC-user smoke (in a browser, against Logto): sign in via SSO as user A → sign out → confirm top-level navigation through Logto's `end_session_endpoint` → land on splash → "Sign in again" → "Sign in with SSO" → confirm Logto **shows its login screen** (not silent re-auth) → sign in as user B → confirm dashboard reflects B (not A).
- [ ] Stolen-token smoke: copy `cameleer-access-token` from localStorage → sign out → confirm `curl -H "Authorization: Bearer <token>" .../api/v1/auth/me` returns 401.
Driven against the real `cameleer-server-app-1.0-SNAPSHOT.jar` running on `:8081` with temp Postgres + ClickHouse and env-var admin (`CAMELEER_SERVER_SECURITY_UIUSER=admin`):
The automated coverage proves the server-side revocation works. The manual checks prove the IdP-side session is also cleared and the UX flow is correct end-to-end.
| Step | Call | Expected | Actual |
|---|---|---|---|
| 1 | `GET /auth/me` with fresh token | 200 | ✅ 200 |
| 2 | `POST /auth/logout` (authenticated) | 204 | ✅ 204 |
| 3 | `GET /auth/me` with same (now revoked) token | 401 | ✅ 401 |
| 4 | `POST /auth/logout` without any token | 204 (best-effort no-op) | ✅ 204 |
| 5 | `users.token_revoked_before` for `admin` | non-null timestamp | ✅ `2026-04-27 10:15:47.259973+00` |
| 6 | `audit_log` row | `username=admin, action=logout, category=AUTH, result=SUCCESS` | ✅ |
This proves the full server-side chain is wired correctly: the controller revokes, the audit row lands, the `JwtAuthenticationFilter` prefix-strip fix from `7066795c` correctly enforces the revocation against the bare `users.user_id`, and the unauthenticated path is the no-op the SPA's logout depends on.
### SPA flow (verified by code inspection — Playwright MCP allowlist blocked browser drive)
The Playwright MCP server in this environment has a fixed `--allowed-origins` list that doesn't include `http://localhost:8081`, so a browser-driven smoke wasn't possible without restarting Claude Code. Instead, the SPA logout path was reviewed line-by-line during Tasks 4 + 5:
- `ui/src/auth/auth-store.ts:logout` — best-effort `api.POST('/auth/logout')` → clears all five auth-related localStorage keys (access, refresh, username, oidc-end-session, oidc-id-token, oidc-client-id) → sets `sessionStorage['cameleer:signed_out']='1'``window.location.replace(end_session_endpoint?id_token_hint=…&post_logout_redirect_uri=…&client_id=…)` for OIDC users, or `/login` for local users.
- `ui/src/auth/LoginPage.tsx` — reads + clears the `signed_out` flag in a one-shot `useState` initializer; renders the "You have been signed out successfully." splash card; adds `prompt=login` to the OIDC authorization URL.
- Type-check + production build green.
A future run with Playwright access (or the user's own browser) should re-verify the visual flow before declaring this 100% closed.
### Manual — still owed
- [ ] Register `https://<tenant-base-url>/login` as a `post_logout_redirect_uri` on the Logto application for each cameleer-server tenant (per the table above). **Blocking** for OIDC users; without this, end_session redirects to a Logto error page.
- [ ] Browser smoke for local-user logout (visual confirmation of the splash and "Sign in again" form re-render). Server-side behavior is already proven by the curl run above.
- [ ] OIDC-user smoke against Logto: sign in as user A → sign out → confirm top-level navigation through Logto's `end_session_endpoint` → splash renders → "Sign in again" → "Sign in with SSO" → confirm Logto **shows its login screen** (not silent re-auth) → sign in as user B → confirm dashboard reflects B (not A). This is the original repro scenario.