# 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`.

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