Files
cameleer-server/docs/superpowers/plans/2026-04-27-logout-hardening.md
hsiegeln 6e4977ea3b 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) <noreply@anthropic.com>
2026-04-27 09:01:52 +02:00

33 KiB

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:

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<String> 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<String> after = call(accessToken);
        assertThat(after.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
    }

    private ResponseEntity<String> 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:

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

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

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<Void> 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<String> 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<Void> 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):

    @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<Void> 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
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
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
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
pkill -f cameleer-server-app
cd ui && npm run typecheck

Expected: 0 errors.

  • Step 4: Commit
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:

  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:

  logout: () => Promise<void>;
  • Step 2: Persist clientId at OIDC initiation

Modify ui/src/auth/LoginPage.tsx:77-79. Replace:

      if (data.endSessionEndpoint) {
        localStorage.setItem('cameleer-oidc-end-session', data.endSessionEndpoint);
      }

with:

      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
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
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 LoginPageprompt=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:

      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:

      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:

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:

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:

  if (signedOut) {
    return (
      <div className={styles.page}>
        <Card className={styles.card}>
          <div className={styles.loginForm}>
            <div className={styles.logo}>
              <img src={brandLogo} alt="" className={styles.logoImg} />
              cameleer
            </div>
            <p className={styles.subtitle}>You have been signed out successfully.</p>
            <Button
              variant="primary"
              onClick={() => { window.location.replace(`${config.basePath}login`); }}
              className={styles.submitButton}
            >
              Sign in again
            </Button>
          </div>
        </Card>
      </div>
    );
  }

The button reload bounces back to /loginsignedOut 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
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

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

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

## 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
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
mvn -pl cameleer-server-app verify

Expected: 0 failures. JwtRevocationIT and LogoutControllerIT both green.

  • Step 2: Run server + SPA against Logto

In one shell:

java -jar cameleer-server-app/target/cameleer-server-app-*.jar

In another:

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 <logto>/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:
    curl -H "Authorization: Bearer <token>" 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
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.