Files
cameleer-server/docs/handoff/2026-04-27-logout-hardening.md
hsiegeln 463c6348b3 docs(handoff): logout-hardening verification notes
Records the automated outcomes (4/4 ITs pass, typecheck + build green)
and lists the three manual smoke tests still required from the SaaS
team — local-user, OIDC-user against Logto, stolen-token. The OIDC test
depends on Logto-side post_logout_redirect_uri registration; the others
can be exercised against any cameleer-server deployment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 12:04:02 +02:00

7.6 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:

  1. Best-effort POST /api/v1/auth/logout (server-side revocation).
  2. Clear localStorage + Zustand auth state.
  3. Set sessionStorage['cameleer:signed_out'] = '1' so the post-logout /login render shows a "You have been signed out successfully" splash instead of any auto-flow.
  4. window.location.replace(end_session_endpoint?id_token_hint=…&post_logout_redirect_uri=…&client_id=…) for OIDC users (top-level navigation), or /login for 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:

  1. Sign in to cameleer-server via SSO.
  2. Sign out from the user menu.
  3. Confirm the browser navigates through Logto's end_session_endpoint and lands on /login showing "You have been signed out successfully."
  4. Click "Sign in again" → "Sign in with Single Sign-On" — Logto must show its login screen, not silently re-authenticate.
  5. 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.java POST /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.ts logout
  • SPA splash + prompt=login: ui/src/auth/LoginPage.tsx
  • Server ITs: JwtRevocationIT, LogoutControllerIT (both in cameleer-server-app/src/test/java/com/cameleer/server/app/security/)
  • SaaS reference implementation: cameleer-saas/ui/src/auth/useAuth.ts (@logto/react signOut(redirectUri) + cameleer:signed_out sessionStorage 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.

Manual — required from the SaaS team

  • 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.

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.