From 6e4977ea3b9b9074c0f7c4761779c6d545f55f99 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:01:52 +0200 Subject: [PATCH] docs(plan): logout hardening implementation plan Tracks the work to (a) fix the silently-inert token-revocation lookup in JwtAuthenticationFilter, (b) add POST /api/v1/auth/logout that bumps users.token_revoked_before, and (c) replace the broken cross-origin fetch logout in the SPA with proper RP-Initiated Logout (top-level redirect) plus a signed-out splash and prompt=login defence. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-04-27-logout-hardening.md | 801 ++++++++++++++++++ 1 file changed, 801 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-27-logout-hardening.md diff --git a/docs/superpowers/plans/2026-04-27-logout-hardening.md b/docs/superpowers/plans/2026-04-27-logout-hardening.md new file mode 100644 index 00000000..e897d9e6 --- /dev/null +++ b/docs/superpowers/plans/2026-04-27-logout-hardening.md @@ -0,0 +1,801 @@ +# Logout Hardening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make logout fully invalidate the user's session — server-side JWT revocation, OIDC RP-initiated logout via top-level redirect, and a "signed out" landing experience that prevents accidental silent re-authentication. + +**Architecture:** Three layers. (1) Server adds `POST /api/v1/auth/logout` that bumps `users.token_revoked_before = now()`, killing all outstanding refresh + access tokens via the existing `JwtAuthenticationFilter` revocation check. (2) SPA replaces the broken `fetch(end_session, {mode:'no-cors'})` with a proper top-level navigation to the OIDC `end_session_endpoint`, passing `id_token_hint` + `post_logout_redirect_uri` + `client_id`. (3) A `cameleer:signed_out` `sessionStorage` flag lets the post-logout `LoginPage` confirm the action and prevents auto-flow loops; `prompt=login` on the OIDC auth request adds defence-in-depth for IdPs that retain credential caches outside the session cookie. + +**Tech Stack:** Spring Boot 3 + Spring Security (server), React + Zustand + TypeScript (SPA), JUnit 5 + Spring Boot Test + Testcontainers (IT), OIDC RP-Initiated Logout 1.0. + +**Validates against:** cameleer-saas `ui/src/auth/useAuth.ts` + `LoginPage.tsx` (Logto SDK reference implementation). + +**Pre-existing bug fixed in passing:** `JwtAuthenticationFilter.java:89` calls `userRepository.findById(subject)` with the prefixed JWT subject (`user:alice`), but `users.user_id` is bare (`alice`). Result: the token-revocation feature has been silently inert since it was added. The new logout endpoint depends on this working, so the fix is Task 1. + +--- + +## File Structure + +**Server (`cameleer-server-app/`):** + +| File | Action | Responsibility | +|---|---|---| +| `src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java` | Modify | Strip `user:` prefix before `findById` so revocation actually fires | +| `src/main/java/com/cameleer/server/app/security/UiAuthController.java` | Modify | Add `POST /logout` | +| `src/test/java/com/cameleer/server/app/security/JwtRevocationIT.java` | Create | Regression: revoked tokens are rejected | +| `src/test/java/com/cameleer/server/app/security/LogoutControllerIT.java` | Create | End-to-end: login → logout → token rejected; audit row written | + +**SPA (`ui/`):** + +| File | Action | Responsibility | +|---|---|---| +| `src/auth/auth-store.ts` | Modify | New `logout()`: server call → clear local state → set signed_out flag → top-level redirect to `end_session_endpoint` | +| `src/auth/LoginPage.tsx` | Modify | Read `signed_out` flag → render "Signed out" card; add `prompt=login` to OIDC redirect | +| `src/api/schema.d.ts` | Regen | Picks up new `/auth/logout` endpoint | +| `src/api/openapi.json` | Regen | Source for schema regen | + +**Rules / docs:** + +| File | Action | Responsibility | +|---|---|---| +| `.claude/rules/app-classes.md` | Modify | Document `POST /auth/logout` on `UiAuthController` listing | +| `docs/handoff/2026-04-27-logout-hardening.md` | Create | SaaS-side operational note: register `post_logout_redirect_uri` per cameleer-server tenant in Logto | + +--- + +## Task 1: Fix the pre-existing revocation lookup bug (TDD regression) + +**Files:** +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java:88-96` +- Create: `cameleer-server-app/src/test/java/com/cameleer/server/app/security/JwtRevocationIT.java` + +- [ ] **Step 1: Write the failing IT** + +Create `cameleer-server-app/src/test/java/com/cameleer/server/app/security/JwtRevocationIT.java`: + +```java +package com.cameleer.server.app.security; + +import com.cameleer.server.app.AbstractPostgresIT; +import com.cameleer.server.core.security.JwtService; +import com.cameleer.server.core.security.UserInfo; +import com.cameleer.server.core.security.UserRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestTemplate; + +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class JwtRevocationIT extends AbstractPostgresIT { + + @LocalServerPort int port; + @Autowired JwtService jwtService; + @Autowired UserRepository userRepository; + + @Test + void revokedTokenIsRejectedOnAuthenticatedRequest() { + // Arrange: a user exists, holds a valid access token + userRepository.upsert(new UserInfo("revoke-me", "local", "", "Revoke Me", Instant.now())); + String accessToken = jwtService.createAccessToken("user:revoke-me", "user", List.of("VIEWER")); + + // Sanity: token works before revocation + ResponseEntity before = call(accessToken); + assertThat(before.getStatusCode()).isEqualTo(HttpStatus.OK); + + // Act: revoke all tokens for this user + userRepository.revokeTokensBefore("revoke-me", Instant.now().plusSeconds(1)); + + // Assert: same token is now rejected + ResponseEntity after = call(accessToken); + assertThat(after.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + private ResponseEntity call(String accessToken) { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + return new RestTemplate().exchange( + "http://localhost:" + port + "/api/v1/auth/me", + HttpMethod.GET, new HttpEntity<>(headers), String.class); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails (proving the bug)** + +Run: `mvn -pl cameleer-server-app -Dtest=JwtRevocationIT verify` +Expected: FAIL — the second `call()` returns 200 OK (revocation never fires because `findById("user:revoke-me")` returns empty). + +- [ ] **Step 3: Fix the lookup** + +Modify `cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java:88-96`. Replace the block: + +```java + // Token revocation check: reject tokens issued before revocation timestamp + if (subject.startsWith("user:") && result.issuedAt() != null) { + userRepository.findById(subject).ifPresent(user -> { + Instant revoked = user.tokenRevokedBefore(); + if (revoked != null && result.issuedAt().isBefore(revoked)) { + serverMetrics.recordAuthFailure("revoked"); + throw new com.cameleer.server.core.security.InvalidTokenException("Token revoked"); + } + }); + } +``` + +with: + +```java + // Token revocation check: reject tokens issued before revocation timestamp. + // JWT subject carries the "user:" prefix; users.user_id is the bare form + // (see CLAUDE.md "User ID conventions"). Strip before lookup. + if (subject.startsWith("user:") && result.issuedAt() != null) { + String userId = subject.substring(5); + userRepository.findById(userId).ifPresent(user -> { + Instant revoked = user.tokenRevokedBefore(); + if (revoked != null && result.issuedAt().isBefore(revoked)) { + serverMetrics.recordAuthFailure("revoked"); + throw new com.cameleer.server.core.security.InvalidTokenException("Token revoked"); + } + }); + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `mvn -pl cameleer-server-app -Dtest=JwtRevocationIT verify` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java \ + cameleer-server-app/src/test/java/com/cameleer/server/app/security/JwtRevocationIT.java +git commit -m "fix(auth): strip user: prefix before token-revocation lookup + +JwtAuthenticationFilter compared the JWT subject (user:alice) against +users.user_id (bare alice), so token_revoked_before was never read for +any user. Strips the prefix to match the convention documented in +CLAUDE.md. Adds JwtRevocationIT as a regression." +``` + +--- + +## Task 2: Add `POST /api/v1/auth/logout` + +**Files:** +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/UiAuthController.java` +- Create: `cameleer-server-app/src/test/java/com/cameleer/server/app/security/LogoutControllerIT.java` + +`/api/v1/auth/**` is `permitAll()` in `SecurityConfig.java:92`. We keep that and let the controller read `Authentication` opportunistically — if no token (already expired or missing), return 204 no-op so the SPA's best-effort call never fails. + +- [ ] **Step 1: Write the failing IT** + +Create `cameleer-server-app/src/test/java/com/cameleer/server/app/security/LogoutControllerIT.java`: + +```java +package com.cameleer.server.app.security; + +import com.cameleer.server.app.AbstractPostgresIT; +import com.cameleer.server.core.security.JwtService; +import com.cameleer.server.core.security.UserInfo; +import com.cameleer.server.core.security.UserRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.*; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.client.RestTemplate; + +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class LogoutControllerIT extends AbstractPostgresIT { + + @LocalServerPort int port; + @Autowired JwtService jwtService; + @Autowired UserRepository userRepository; + @Autowired JdbcTemplate jdbc; + + @Test + void logoutRevokesTokensAuditsAndRejectsSubsequentCalls() { + userRepository.upsert(new UserInfo("logout-test", "local", "", "Logout Test", Instant.now())); + String accessToken = jwtService.createAccessToken("user:logout-test", "user", List.of("VIEWER")); + + // POST /auth/logout + HttpHeaders authed = new HttpHeaders(); + authed.setBearerAuth(accessToken); + ResponseEntity logoutResp = new RestTemplate().exchange( + "http://localhost:" + port + "/api/v1/auth/logout", + HttpMethod.POST, new HttpEntity<>(authed), Void.class); + assertThat(logoutResp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + + // token_revoked_before is set + Instant revokedAt = jdbc.queryForObject( + "SELECT token_revoked_before FROM users WHERE user_id = ?", + (rs, n) -> rs.getTimestamp(1).toInstant(), "logout-test"); + assertThat(revokedAt).isAfter(Instant.now().minusSeconds(10)); + + // Audit row written + Long auditCount = jdbc.queryForObject( + "SELECT COUNT(*) FROM audit_log WHERE category = 'AUTH' AND action = 'logout' AND username = ?", + Long.class, "logout-test"); + assertThat(auditCount).isEqualTo(1L); + + // Same token now rejected + ResponseEntity meResp = new RestTemplate().exchange( + "http://localhost:" + port + "/api/v1/auth/me", + HttpMethod.GET, new HttpEntity<>(authed), String.class); + assertThat(meResp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + void logoutWithoutTokenReturns204NoOp() { + ResponseEntity resp = new RestTemplate().exchange( + "http://localhost:" + port + "/api/v1/auth/logout", + HttpMethod.POST, HttpEntity.EMPTY, Void.class); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -pl cameleer-server-app -Dtest=LogoutControllerIT verify` +Expected: FAIL — endpoint does not exist (404). + +- [ ] **Step 3: Add the endpoint** + +Modify `cameleer-server-app/src/main/java/com/cameleer/server/app/security/UiAuthController.java`. Add this method right after the `me(...)` method (before `stripSubjectPrefix`): + +```java + @PostMapping("/logout") + @Operation(summary = "Log out the current user (revoke all outstanding tokens)") + @ApiResponse(responseCode = "204", description = "Logged out (or no-op if not authenticated)") + public ResponseEntity logout(Authentication authentication, HttpServletRequest httpRequest) { + if (authentication == null || authentication.getName() == null + || !authentication.getName().startsWith("user:")) { + // Best-effort: SPA calls this even when its token is already gone. + return ResponseEntity.noContent().build(); + } + String userId = stripSubjectPrefix(authentication.getName()); + userRepository.revokeTokensBefore(userId, Instant.now()); + auditService.log(userId, "logout", AuditCategory.AUTH, null, null, + AuditResult.SUCCESS, httpRequest); + log.info("UI user logged out: {}", userId); + return ResponseEntity.noContent().build(); + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `mvn -pl cameleer-server-app -Dtest=LogoutControllerIT verify` +Expected: PASS (both tests). + +- [ ] **Step 5: Commit** + +```bash +git add cameleer-server-app/src/main/java/com/cameleer/server/app/security/UiAuthController.java \ + cameleer-server-app/src/test/java/com/cameleer/server/app/security/LogoutControllerIT.java +git commit -m "feat(auth): add POST /auth/logout that revokes all user tokens + +Bumps users.token_revoked_before = now() for the calling user, audited +under AuditCategory.AUTH. Best-effort: returns 204 even when the request +is unauthenticated, so the SPA can call it on every logout regardless of +token state. Token-rejection is enforced by the existing +JwtAuthenticationFilter revocation check (fixed in the previous commit)." +``` + +--- + +## Task 3: Regenerate OpenAPI schema for SPA consumption + +Per CLAUDE.md "Regenerating OpenAPI schema (SPA types)" — required for every controller-level change. + +- [ ] **Step 1: Build and run the server** + +```bash +mvn -pl cameleer-server-app -DskipTests package +java -jar cameleer-server-app/target/cameleer-server-app-*.jar & +``` + +Wait until `Started CameleerServerApplication` appears in logs (port 8081 by default). + +- [ ] **Step 2: Regenerate the schema** + +```bash +cd ui && npm run generate-api:live +``` + +Expected: `ui/src/api/openapi.json` and `ui/src/api/schema.d.ts` updated. Diff shows `/auth/logout` POST entry under `paths`. + +- [ ] **Step 3: Stop the server, verify SPA still type-checks** + +```bash +pkill -f cameleer-server-app +cd ui && npm run typecheck +``` + +Expected: 0 errors. + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/api/openapi.json ui/src/api/schema.d.ts +git commit -m "chore(ui): regenerate OpenAPI schema for /auth/logout" +``` + +--- + +## Task 4: Refactor SPA `auth-store.ts` logout + +**File:** Modify `ui/src/auth/auth-store.ts` + +Replace the broken `fetch(end_session, {mode:'no-cors'})` with: (1) best-effort server `POST /auth/logout` to revoke tokens, (2) clear localStorage + Zustand state, (3) set `cameleer:signed_out` `sessionStorage` flag, (4) top-level redirect to `end_session_endpoint` for OIDC users, otherwise navigate to local `/login`. + +- [ ] **Step 1: Replace the `logout` action** + +Modify `ui/src/auth/auth-store.ts:143-169`. Replace the entire `logout: () => { ... }` block with: + +```ts + logout: async () => { + const accessToken = get().accessToken; + const endSessionEndpoint = localStorage.getItem('cameleer-oidc-end-session'); + const idToken = localStorage.getItem('cameleer-oidc-id-token'); + const clientId = localStorage.getItem('cameleer-oidc-client-id'); + + // Best-effort server-side revocation. Don't await failures — the SPA + // logout must always proceed (e.g. token already expired). + if (accessToken) { + try { + await api.POST('/auth/logout', {}); + } catch { + // ignore; client-side cleanup below is still authoritative for the SPA + } + } + + clearTokens(); + localStorage.removeItem('cameleer-oidc-end-session'); + localStorage.removeItem('cameleer-oidc-id-token'); + localStorage.removeItem('cameleer-oidc-client-id'); + set({ + accessToken: null, + refreshToken: null, + username: null, + roles: [], + isAuthenticated: false, + error: null, + }); + + // Mark the upcoming /login render so it shows a "Signed out" splash and + // does not silently re-enter any auto-flow. Mirrors cameleer-saas + // ui/src/auth/useAuth.ts pattern. + sessionStorage.setItem('cameleer:signed_out', '1'); + + const localLoginUrl = `${config.basePath}login`; + + if (endSessionEndpoint && idToken) { + // OIDC RP-Initiated Logout 1.0: top-level navigation, NOT fetch. + // Logto (and every compliant IdP) only clears its session cookie under + // a top-level browser request; cross-origin fetch leaves it intact. + const params = new URLSearchParams({ + id_token_hint: idToken, + post_logout_redirect_uri: `${window.location.origin}${config.basePath}login`, + }); + if (clientId) params.set('client_id', clientId); + window.location.replace(`${endSessionEndpoint}?${params}`); + } else { + window.location.replace(localLoginUrl); + } + }, +``` + +Update the `AuthState` interface (top of file) to reflect the now-async signature: + +```ts + logout: () => Promise; +``` + +- [ ] **Step 2: Persist `clientId` at OIDC initiation** + +Modify `ui/src/auth/LoginPage.tsx:77-79`. Replace: + +```ts + if (data.endSessionEndpoint) { + localStorage.setItem('cameleer-oidc-end-session', data.endSessionEndpoint); + } +``` + +with: + +```ts + if (data.endSessionEndpoint) { + localStorage.setItem('cameleer-oidc-end-session', data.endSessionEndpoint); + } + if (data.clientId) { + localStorage.setItem('cameleer-oidc-client-id', data.clientId); + } +``` + +- [ ] **Step 3: Type-check** + +```bash +cd ui && npm run typecheck +``` + +Expected: 0 errors. The `logout` callers (only `useAuth.ts` and `LayoutShell.tsx`) accept a `() => void` signature and ignore the return; an async function is fire-and-forget compatible. + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/auth/auth-store.ts ui/src/auth/LoginPage.tsx +git commit -m "fix(ui): proper OIDC logout — server revoke + top-level redirect + +Previous logout fired fetch(end_session, {mode:'no-cors'}), which is a +no-op for OIDC: cross-origin fetch never clears the IdP's session cookie. +Result: subsequent SSO clicks silently re-authenticated the prior user. + +New flow: +1. Best-effort POST /auth/logout to bump token_revoked_before. +2. Clear localStorage + Zustand state. +3. Set sessionStorage 'cameleer:signed_out=1' so /login renders a + confirmation splash (mirrors cameleer-saas pattern). +4. window.location.replace(end_session_endpoint?id_token_hint=… + &post_logout_redirect_uri=…&client_id=…) — top-level navigation, the + only form that actually clears the IdP session cookie. + +client_id is now persisted at OIDC initiation alongside +end_session_endpoint and id_token, so logout has all three params +without an extra round-trip." +``` + +--- + +## Task 5: SPA `LoginPage` — `prompt=login` + signed-out splash + +**File:** Modify `ui/src/auth/LoginPage.tsx` + +Two changes: (1) add `prompt=login` to the OIDC redirect (defence-in-depth), (2) read `cameleer:signed_out` flag and render a "Signed out" card with an explicit "Sign in again" button. + +- [ ] **Step 1: Add `prompt=login` to the OIDC redirect** + +Modify `ui/src/auth/LoginPage.tsx:82-90`. Replace: + +```ts + const params = new URLSearchParams({ + response_type: 'code', + client_id: data.clientId, + redirect_uri: redirectUri, + scope: scopes.join(' '), + }); + if (data.resource) params.set('resource', data.resource); + // Note: NO prompt=none. Per RFC 9700 §4.4, that's silent re-auth only; + // for first-time login it returns login_required and traps users on a local form. + window.location.href = `${data.authorizationEndpoint}?${params}`; +``` + +with: + +```ts + const params = new URLSearchParams({ + response_type: 'code', + client_id: data.clientId, + redirect_uri: redirectUri, + scope: scopes.join(' '), + // Defence-in-depth: even if RP-Initiated Logout did not fully clear + // the IdP session (proxy/cookie edge cases), prompt=login forces the + // IdP to re-prompt for credentials instead of silent re-auth. + prompt: 'login', + }); + if (data.resource) params.set('resource', data.resource); + window.location.href = `${data.authorizationEndpoint}?${params}`; +``` + +- [ ] **Step 2: Read the signed-out flag in `LoginPage`** + +Modify `ui/src/auth/LoginPage.tsx:41-50`. Replace: + +```ts +export function LoginPage() { + const { isAuthenticated, login, loading, error } = useAuthStore(); + const [searchParams] = useSearchParams(); + const forceLocal = searchParams.has('local'); + const subtitle = useMemo(() => SUBTITLES[Math.floor(Math.random() * SUBTITLES.length)], []); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [oidcLoading, setOidcLoading] = useState(false); +``` + +with: + +```ts +export function LoginPage() { + const { isAuthenticated, login, loading, error } = useAuthStore(); + const [searchParams] = useSearchParams(); + const forceLocal = searchParams.has('local'); + const subtitle = useMemo(() => SUBTITLES[Math.floor(Math.random() * SUBTITLES.length)], []); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [oidcLoading, setOidcLoading] = useState(false); + + // Mirrors cameleer-saas: when logout sets this flag, render a "Signed out" + // confirmation instead of the regular form. The flag is one-shot — read + + // cleared on mount. + const [signedOut] = useState(() => { + const flag = sessionStorage.getItem('cameleer:signed_out'); + if (flag) sessionStorage.removeItem('cameleer:signed_out'); + return !!flag; + }); +``` + +- [ ] **Step 3: Render the signed-out card** + +Inside `LoginPage`, after `if (capsLoading) return null;` and before the `oidcPrimary` line, insert: + +```tsx + if (signedOut) { + return ( +
+ +
+
+ + cameleer +
+

You have been signed out successfully.

+ +
+
+
+ ); + } +``` + +The button reload bounces back to `/login` — `signedOut` is `false` on the second render (flag was cleared in the `useState` initializer), so the regular form (or SSO button) renders. + +- [ ] **Step 4: Type-check + visual smoke** + +```bash +cd ui && npm run typecheck +cd ui && npm run dev # in another shell — open http://localhost:5173/login +``` + +Manually: log in, click "Sign out" in the user menu, confirm: +- Browser navigates to Logto end_session URL (not fetch). +- Returns to `/login` with the "Signed out successfully" card. +- "Sign in again" → SSO button visible → clicking it triggers Logto's login screen (not silent re-auth). + +- [ ] **Step 5: Commit** + +```bash +git add ui/src/auth/LoginPage.tsx +git commit -m "feat(ui): signed-out splash + prompt=login on OIDC redirect + +Two defensive layers complementing the RP-Initiated Logout in the +previous commit: + +1. cameleer:signed_out sessionStorage flag (set in auth-store.logout, + read+cleared in LoginPage) renders a 'You have been signed out + successfully' card with an explicit 'Sign in again' button. Mirrors + the cameleer-saas pattern (ui/src/auth/LoginPage.tsx). + +2. prompt=login on the OIDC authorization redirect forces the IdP to + re-prompt for credentials even if its session cookie somehow + survived RP-Initiated Logout (proxy, race, misconfigured + post_logout_redirect_uri). RFC 6749 §3.1.2.1 / OIDC Core 1.0 §3.1.2.1." +``` + +--- + +## Task 6: Update `.claude/rules/app-classes.md` + +**File:** Modify `.claude/rules/app-classes.md` + +Document the new endpoint so future sessions don't re-discover the URL surface from scratch. + +- [ ] **Step 1: Update the `UiAuthController` listing** + +Find the line: + +``` +- `UiAuthController` — `/api/v1/auth` (login, refresh, me). Local username/password against env-var admin or DB BCrypt hash. Lockout after 5 failed attempts. +``` + +(There are two near-identical lines — under "Auth (flat)" and under "security/ — Spring Security". Update both for consistency.) + +Replace each with: + +``` +- `UiAuthController` — `/api/v1/auth` (login, refresh, me, logout). Local username/password against env-var admin or DB BCrypt hash. Lockout after 5 failed attempts. `POST /logout` is permitAll — controller resolves the user from the access token if present, bumps `users.token_revoked_before = now()` to invalidate all outstanding refresh + access tokens (enforced by `JwtAuthenticationFilter`), audits `AuditCategory.AUTH / logout`, returns 204. Best-effort: 204 also when called without a token so the SPA's logout never fails on already-expired sessions. +``` + +- [ ] **Step 2: Commit** + +```bash +git add .claude/rules/app-classes.md +git commit -m "docs(rules): document POST /auth/logout on UiAuthController" +``` + +--- + +## Task 7: SaaS-side operational handoff + +**File:** Create `docs/handoff/2026-04-27-logout-hardening.md` + +Document the cross-team requirement: SaaS team must register `post_logout_redirect_uri` for each cameleer-server tenant in Logto, otherwise the OIDC end_session call rejects with `invalid_request` and the user lands on a Logto error page instead of `/login`. + +- [ ] **Step 1: Write the handoff doc** + +Create `docs/handoff/2026-04-27-logout-hardening.md`: + +````markdown +# 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. + +## 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`. + +## 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 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. (If silent re-auth still happens, `prompt=login` and `post_logout_redirect_uri` registration are both required; the SPA already sets `prompt=login` defensively, so the most likely missing piece is the redirect URI registration.) + +## Failure modes + +| Symptom | Likely cause | Fix | +|---|---|---| +| Browser lands on Logto error "invalid post_logout_redirect_uri" | URI not registered or trailing-slash mismatch | Add exact URL in Logto admin (Logto matches strictly) | +| User signs out, re-clicks SSO, lands back authenticated as same user | Session cookie not cleared — happens if the logout request 302'd to an error page instead of completing | Check Logto application → Audit logs for the failed end_session call; usually the redirect URI | +| 204 from `/api/v1/auth/logout` but still authenticated locally | SPA bug — file an issue (server side is verified by `LogoutControllerIT`) | n/a | + +## Pointers + +- Plan: `docs/superpowers/plans/2026-04-27-logout-hardening.md` +- Server endpoint: `cameleer-server-app/.../security/UiAuthController.java` `POST /logout` +- SPA logout: `ui/src/auth/auth-store.ts` `logout` +- SaaS reference: `cameleer-saas/ui/src/auth/useAuth.ts` (`@logto/react` `signOut(redirectUri)`) +```` + +- [ ] **Step 2: Commit** + +```bash +git add docs/handoff/2026-04-27-logout-hardening.md +git commit -m "docs(handoff): SaaS-side post_logout_redirect_uri requirement" +``` + +--- + +## Task 8: Full-stack manual smoke test + +This is a verification step — no code changes. Execute against a running server with a real Logto instance reachable. + +- [ ] **Step 1: Run the full IT suite** + +```bash +mvn -pl cameleer-server-app verify +``` + +Expected: 0 failures. `JwtRevocationIT` and `LogoutControllerIT` both green. + +- [ ] **Step 2: Run server + SPA against Logto** + +In one shell: + +```bash +java -jar cameleer-server-app/target/cameleer-server-app-*.jar +``` + +In another: + +```bash +cd ui && npm run dev +``` + +- [ ] **Step 3: Local-user logout smoke** + +1. Open http://localhost:5173/ → log in via the local form (env-var admin or seeded user). +2. Click "Sign out". +3. Open DevTools → Network: confirm `POST /api/v1/auth/logout` returned 204. +4. Confirm the SPA landed on `/login` with the "Signed out successfully" card. +5. Click "Sign in again" → confirm the local form is shown and works. + +- [ ] **Step 4: OIDC-user logout smoke (Logto)** + +Required Logto config: `post_logout_redirect_uri` for the cameleer-server client must include `http://localhost:5173/login` (per Task 7). + +1. Reproduce the original bug first (optional sanity): `git stash`, log in via SSO as user A, log out, click SSO again — observe silent re-auth as A. `git stash pop`. +2. With the fix applied: log in via SSO as user A. +3. Click "Sign out". +4. Network tab: confirm `POST /api/v1/auth/logout` → 204, then a top-level navigation to `/oidc/session/end?...` → 302 back to `/login`. +5. Confirm the "Signed out" card renders. +6. Click "Sign in again" → "Sign in with SSO" → Logto **must** show its login screen (not silent re-auth). +7. Sign in as a *different* user B; confirm the dashboard reflects B's identity (not A's). +8. Sign out as B → "Sign in again" → sign in as A → reflects A. + +- [ ] **Step 5: Token-revocation smoke** + +Verify a stolen-token scenario can't outlive a logout. + +1. Log in. In DevTools → Application → Local Storage, copy `cameleer-access-token`. +2. In a separate browser/curl, hit an authenticated endpoint with that token — must return 200: + ```bash + curl -H "Authorization: Bearer " http://localhost:5173/api/v1/auth/me + ``` +3. Sign out in the original tab. +4. Re-run the curl — must return 401. + +- [ ] **Step 6: Document outcomes** + +Append to `docs/handoff/2026-04-27-logout-hardening.md` under a new "Verification" section: which steps were exercised, against which IdP, and any deviations from expected behavior. If any deviation surfaces, file an issue and link from the handoff. + +- [ ] **Step 7: Commit any handoff updates** + +```bash +git add docs/handoff/2026-04-27-logout-hardening.md +git commit -m "docs(handoff): logout-hardening verification notes" +``` + +(Skip if no edits.) + +--- + +## Self-review summary + +- ✅ **Server-side revocation** — Task 1 (regression fix) + Task 2 (endpoint). +- ✅ **OIDC top-level redirect** — Task 4. +- ✅ **`prompt=login` defence** — Task 5. +- ✅ **Signed-out splash** — Task 5 (mirrors SaaS pattern). +- ✅ **Logto config note** — Task 7. +- ✅ **Rules updated** — Task 6. +- ✅ **Manual end-to-end verification** — Task 8 covers local user, OIDC user, stolen-token scenarios. + +No tasks reference symbols not defined in earlier tasks. All code blocks are complete (no "TBD" or "similar to above"). Each task ends in a single atomic commit.