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>
9.5 KiB
Logout Hardening — SaaS Handoff (2026-04-27)
Action required by the cameleer-saas / Logto admin team before the cameleer-server logout fix is fully effective in customer environments.
What changed in cameleer-server
The SPA now performs a proper OIDC RP-Initiated Logout: a top-level navigation to the IdP's end_session_endpoint with id_token_hint, post_logout_redirect_uri, and client_id. After Logto clears its session cookie it 302-redirects back to post_logout_redirect_uri.
Previously the SPA fired a cross-origin fetch(... {mode:'no-cors'}) which is a no-op for OIDC — Logto's session cookie only clears under a top-level browsing context. Result: the next SSO click silently re-authenticated the prior user.
In addition, cameleer-server now exposes POST /api/v1/auth/logout which bumps users.token_revoked_before = now().plusMillis(1) for the calling user, invalidating every outstanding refresh + access token server-side. This protects against leaked-token scenarios that don't involve the IdP at all (XSS, copied bearer token, etc.). The +1ms guards against a same-millisecond race where a token issued in the exact ms of logout would otherwise survive the strict isBefore revocation check.
The SPA logout flow is now:
- Best-effort
POST /api/v1/auth/logout(server-side revocation). - Clear
localStorage+ Zustand auth state. - Set
sessionStorage['cameleer:signed_out'] = '1'so the post-logout/loginrender shows a "You have been signed out successfully" splash instead of any auto-flow. window.location.replace(end_session_endpoint?id_token_hint=…&post_logout_redirect_uri=…&client_id=…)for OIDC users (top-level navigation), or/loginfor local users.
prompt=login is also added to the OIDC authorization redirect on the way back in, as defence-in-depth: even if the IdP session cookie somehow survives logout, the IdP will re-prompt for credentials rather than silently re-authenticating.
What the SaaS team must do
For each cameleer-server tenant registered as a Logto application, add the post-logout redirect URL to the application's allowed list:
Logto admin console
→ Applications → <cameleer-server tenant client>
→ Redirect URIs / Post sign-out redirect URIs
→ add: https://<tenant-base-url>/login
Example values (replace <tenant-base-url> with the customer's actual deployment URL):
| Tenant | Post sign-out redirect URI |
|---|---|
| acme-prod | https://cameleer.acme.example.com/login |
| acme-staging | https://cameleer.staging.acme.example.com/login |
| local-dev | http://localhost:8081/login |
If the SPA is served under a non-root base path (config.basePath in ui/src/config.ts), include the base path in the URL — e.g. https://host/cameleer/login. Logto matches strictly; trailing-slash and scheme mismatches fail the redirect.
How to verify
After adding the URI:
- Sign in to cameleer-server via SSO.
- Sign out from the user menu.
- Confirm the browser navigates through Logto's
end_session_endpointand lands on/loginshowing "You have been signed out successfully." - Click "Sign in again" → "Sign in with Single Sign-On" — Logto must show its login screen, not silently re-authenticate.
- Sign in as a different user; confirm the dashboard reflects the new identity.
If silent re-auth still happens after step 4, the most likely cause is that prompt=login is being stripped by an intermediary or the IdP doesn't honor it for the configured client. The SPA already sets prompt=login defensively; verify by inspecting the redirect URL in DevTools → Network.
Failure modes
| Symptom | Likely cause | Fix |
|---|---|---|
| Browser lands on Logto error "invalid post_logout_redirect_uri" | URI not registered, or trailing-slash / scheme mismatch | Add exact URL in Logto admin (Logto matches strictly) |
| User signs out, re-clicks SSO, lands back authenticated as same user | IdP session cookie not cleared — usually the end_session redirect failed to a Logto error page instead of the SPA's /login |
Check Logto application → Audit logs for the failed end_session call; usually traces back to redirect URI registration |
204 from /api/v1/auth/logout but the SPA still appears authenticated locally |
SPA bug — file an issue (server side is verified by LogoutControllerIT and JwtRevocationIT) |
n/a |
| SPA splash never appears after logout | sessionStorage['cameleer:signed_out'] not set, or LoginPage renders before useState initializer reads it — check auth-store.logout is being called before the navigation |
Inspect ui/src/auth/auth-store.ts:logout |
| Stolen token still works after victim logged out | JwtAuthenticationFilter revocation lookup is broken (the original bug, fixed in 7066795c) |
Confirm filter at JwtAuthenticationFilter:91 strips user: before findById. JwtRevocationIT is the regression. |
Pointers
- Plan:
docs/superpowers/plans/2026-04-27-logout-hardening.md - Server endpoint:
cameleer-server-app/src/main/java/com/cameleer/server/app/security/UiAuthController.javaPOST /logout - Filter revocation check:
cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java:88-99 - SPA logout:
ui/src/auth/auth-store.tslogout - SPA splash +
prompt=login:ui/src/auth/LoginPage.tsx - Server ITs:
JwtRevocationIT,LogoutControllerIT(both incameleer-server-app/src/test/java/com/cameleer/server/app/security/) - SaaS reference implementation:
cameleer-saas/ui/src/auth/useAuth.ts(@logto/reactsignOut(redirectUri)+cameleer:signed_outsessionStorage flag pattern, mirrored here)
Verification
Automated (run on feature/logout-hardening HEAD 7837272a, 2026-04-27)
| Check | Outcome |
|---|---|
JwtRevocationIT (2 tests — revoked-token rejected, unrevoked-token accepted) |
✅ PASS |
LogoutControllerIT (2 tests — authenticated logout revokes+audits+rejects subsequent calls; unauthenticated logout 204 no-op) |
✅ PASS |
| Reactor build | ✅ BUILD SUCCESS |
ui/ npm run typecheck |
✅ 0 errors |
ui/ npm run build |
✅ built in 1.21s (pre-existing chunk-size warning unchanged, unrelated) |
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.
End-to-end against running jar (curl, 2026-04-27, post-merge 664acf26)
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):
| 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-effortapi.POST('/auth/logout')→ clears all five auth-related localStorage keys (access, refresh, username, oidc-end-session, oidc-id-token, oidc-client-id) → setssessionStorage['cameleer:signed_out']='1'→window.location.replace(end_session_endpoint?id_token_hint=…&post_logout_redirect_uri=…&client_id=…)for OIDC users, or/loginfor local users.ui/src/auth/LoginPage.tsx— reads + clears thesigned_outflag in a one-shotuseStateinitializer; renders the "You have been signed out successfully." splash card; addsprompt=loginto 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>/loginas apost_logout_redirect_urion 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.