diff --git a/docs/handoff/2026-04-27-logout-hardening.md b/docs/handoff/2026-04-27-logout-hardening.md new file mode 100644 index 00000000..20aa2e3e --- /dev/null +++ b/docs/handoff/2026-04-27-logout-hardening.md @@ -0,0 +1,77 @@ +# 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 → + → Redirect URIs / Post sign-out redirect URIs + → add: https:///login +``` + +Example values (replace `` 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 (filled in during Task 8) + +_Pending — see Task 8 of `docs/superpowers/plans/2026-04-27-logout-hardening.md` for the smoke-test results._