Merge feature/logout-hardening: server-side revocation + RP-Initiated Logout
Fixes a silent token-revocation bug (JwtAuthenticationFilter was looking up users by prefixed JWT subject instead of the bare user_id), adds POST /api/v1/auth/logout that bumps token_revoked_before, and replaces the broken cross-origin fetch logout in the SPA with a proper top-level RP-Initiated Logout redirect (id_token_hint + post_logout_redirect_uri + client_id). Adds a signed-out splash and prompt=login defence. Operational follow-up: SaaS team must register <base-url>/login as a post_logout_redirect_uri on each Logto tenant client. See docs/handoff/2026-04-27-logout-hardening.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -114,7 +114,7 @@ Env-scoped read-path controllers (`AlertController`, `AlertRuleController`, `Ale
|
|||||||
|
|
||||||
### Auth (flat)
|
### Auth (flat)
|
||||||
|
|
||||||
- `UiAuthController` — `/api/v1/auth` (login, refresh, me). Local username/password against env-var admin or DB BCrypt hash. Lockout after 5 failed attempts.
|
- `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().plusMillis(1)` 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. The +1ms guards against same-millisecond races (JWT `iat` is ms-quantised, filter check is strict `isBefore`).
|
||||||
- `OidcAuthController` — `/api/v1/auth/oidc` (config, callback). Code → token exchange. Roles via custom JWT claim, claim mapping rules, or default roles.
|
- `OidcAuthController` — `/api/v1/auth/oidc` (config, callback). Code → token exchange. Roles via custom JWT claim, claim mapping rules, or default roles.
|
||||||
- `AuthCapabilitiesController` — `GET /api/v1/auth/capabilities` (unauthenticated). Reports `{oidc:{enabled, providerName, primary}, localAccounts:{enabled, adminRecoveryOnly}}` so the SPA renders the login page deterministically. `oidc.primary == oidc.enabled`; `localAccounts.adminRecoveryOnly == oidc.primary`. `providerName` is best-effort label via `OidcProviderNameDeriver` (Logto / Keycloak / Auth0 / Okta / Single Sign-On). The SPA hides the local form behind `?local` when `adminRecoveryOnly` is true.
|
- `AuthCapabilitiesController` — `GET /api/v1/auth/capabilities` (unauthenticated). Reports `{oidc:{enabled, providerName, primary}, localAccounts:{enabled, adminRecoveryOnly}}` so the SPA renders the login page deterministically. `oidc.primary == oidc.enabled`; `localAccounts.adminRecoveryOnly == oidc.primary`. `providerName` is best-effort label via `OidcProviderNameDeriver` (Logto / Keycloak / Auth0 / Okta / Single Sign-On). The SPA hides the local form behind `?local` when `adminRecoveryOnly` is true.
|
||||||
|
|
||||||
@@ -168,7 +168,7 @@ Env-scoped read-path controllers (`AlertController`, `AlertRuleController`, `Ale
|
|||||||
- `SecurityConfig` — WebSecurityFilterChain, JWT filter, CORS, OIDC conditional. `/api/v1/admin/outbound-connections/**` GETs permit OPERATOR in addition to ADMIN (defense-in-depth at controller level); mutations remain ADMIN-only. Alerting matchers: GET `/environments/*/alerts/**` VIEWER+; POST/PUT/DELETE rules and silences OPERATOR+; ack/read/bulk-read VIEWER+; POST `/alerts/notifications/*/retry` OPERATOR+.
|
- `SecurityConfig` — WebSecurityFilterChain, JWT filter, CORS, OIDC conditional. `/api/v1/admin/outbound-connections/**` GETs permit OPERATOR in addition to ADMIN (defense-in-depth at controller level); mutations remain ADMIN-only. Alerting matchers: GET `/environments/*/alerts/**` VIEWER+; POST/PUT/DELETE rules and silences OPERATOR+; ack/read/bulk-read VIEWER+; POST `/alerts/notifications/*/retry` OPERATOR+.
|
||||||
- `JwtAuthenticationFilter` — OncePerRequestFilter, validates Bearer tokens
|
- `JwtAuthenticationFilter` — OncePerRequestFilter, validates Bearer tokens
|
||||||
- `JwtServiceImpl` — HMAC-SHA256 JWT (Nimbus JOSE)
|
- `JwtServiceImpl` — HMAC-SHA256 JWT (Nimbus JOSE)
|
||||||
- `UiAuthController` — `/api/v1/auth` (login, refresh, me). Upserts `users.user_id = request.username()` (bare); signs JWTs with `subject = "user:" + userId`. `refresh`/`me` strip the `"user:"` prefix from incoming subjects via `stripSubjectPrefix()` before any DB/RBAC lookup.
|
- `UiAuthController` — `/api/v1/auth` (login, refresh, me, logout). Upserts `users.user_id = request.username()` (bare); signs JWTs with `subject = "user:" + userId`. `refresh`/`me`/`logout` strip the `"user:"` prefix from incoming subjects via `stripSubjectPrefix()` before any DB/RBAC lookup. `logout` revokes outstanding tokens by writing `users.token_revoked_before` and audits under `AuditCategory.AUTH / logout`.
|
||||||
- `OidcAuthController` — `/api/v1/auth/oidc` (login-uri, token-exchange, logout). Upserts `users.user_id = "oidc:" + oidcUser.subject()` (no `user:` prefix); signs JWTs with `subject = "user:oidc:" + oidcUser.subject()`. `applyClaimMappings` + `getSystemRoleNames` calls all use the bare `oidc:<sub>` form.
|
- `OidcAuthController` — `/api/v1/auth/oidc` (login-uri, token-exchange, logout). Upserts `users.user_id = "oidc:" + oidcUser.subject()` (no `user:` prefix); signs JWTs with `subject = "user:oidc:" + oidcUser.subject()`. `applyClaimMappings` + `getSystemRoleNames` calls all use the bare `oidc:<sub>` form.
|
||||||
- `OidcTokenExchanger` — code -> tokens, role extraction from access_token then id_token
|
- `OidcTokenExchanger` — code -> tokens, role extraction from access_token then id_token
|
||||||
- `OidcProviderHelper` — OIDC discovery, JWK source cache
|
- `OidcProviderHelper` — OIDC discovery, JWK source cache
|
||||||
|
|||||||
@@ -84,9 +84,12 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
JwtValidationResult result = jwtService.validateAccessToken(token);
|
JwtValidationResult result = jwtService.validateAccessToken(token);
|
||||||
String subject = result.subject();
|
String subject = result.subject();
|
||||||
|
|
||||||
// Token revocation check: reject tokens issued before revocation timestamp
|
// 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) {
|
if (subject.startsWith("user:") && result.issuedAt() != null) {
|
||||||
userRepository.findById(subject).ifPresent(user -> {
|
String userId = subject.substring(5);
|
||||||
|
userRepository.findById(userId).ifPresent(user -> {
|
||||||
Instant revoked = user.tokenRevokedBefore();
|
Instant revoked = user.tokenRevokedBefore();
|
||||||
if (revoked != null && result.issuedAt().isBefore(revoked)) {
|
if (revoked != null && result.issuedAt().isBefore(revoked)) {
|
||||||
serverMetrics.recordAuthFailure("revoked");
|
serverMetrics.recordAuthFailure("revoked");
|
||||||
|
|||||||
@@ -183,6 +183,26 @@ public class UiAuthController {
|
|||||||
return ResponseEntity.ok(detail);
|
return ResponseEntity.ok(detail);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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:")) {
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
String userId = stripSubjectPrefix(authentication.getName());
|
||||||
|
// +1ms guards against same-millisecond races: JWT iat is quantised to
|
||||||
|
// milliseconds (Date.from(now) in JwtServiceImpl), and the filter check
|
||||||
|
// is strict isBefore. Without the bump, a token issued in the same
|
||||||
|
// millisecond as logout would survive revocation.
|
||||||
|
userRepository.revokeTokensBefore(userId, Instant.now().plusMillis(1));
|
||||||
|
auditService.log(userId, "logout", AuditCategory.AUTH, null, null,
|
||||||
|
AuditResult.SUCCESS, httpRequest);
|
||||||
|
log.info("UI user logged out: {}", userId);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map a JWT subject ({@code "user:<name>"} or {@code "user:oidc:<sub>"}) to the DB key:
|
* Map a JWT subject ({@code "user:<name>"} or {@code "user:oidc:<sub>"}) to the DB key:
|
||||||
* just the bare username. FKs on {@code alert_rules.created_by},
|
* just the bare username. FKs on {@code alert_rules.created_by},
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
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.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.web.client.TestRestTemplate;
|
||||||
|
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 java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration test verifying that {@code users.token_revoked_before} is honoured
|
||||||
|
* by {@link JwtAuthenticationFilter}. Regression for the prefix-mismatch bug
|
||||||
|
* where the filter looked up the JWT subject ({@code user:alice}) against
|
||||||
|
* {@code users.user_id} (bare {@code alice}), so revocation never fired.
|
||||||
|
*/
|
||||||
|
class JwtRevocationIT extends AbstractPostgresIT {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private TestRestTemplate restTemplate;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JwtService jwtService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserRepository userRepository;
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void cleanup() {
|
||||||
|
userRepository.delete("revoke-me");
|
||||||
|
userRepository.delete("never-revoked");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void revokedTokenIsRejectedOnAuthenticatedRequest() {
|
||||||
|
userRepository.upsert(new UserInfo(
|
||||||
|
"revoke-me", "local", "", "Revoke Me", Instant.now()));
|
||||||
|
String accessToken = jwtService.createAccessToken(
|
||||||
|
"user:revoke-me", "user", List.of("VIEWER"));
|
||||||
|
|
||||||
|
ResponseEntity<String> before = call(accessToken);
|
||||||
|
assertThat(before.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
|
||||||
|
userRepository.revokeTokensBefore("revoke-me", Instant.now().plusSeconds(1));
|
||||||
|
|
||||||
|
ResponseEntity<String> after = call(accessToken);
|
||||||
|
assertThat(after.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unrevokedUserTokenIsAccepted() {
|
||||||
|
userRepository.upsert(new UserInfo(
|
||||||
|
"never-revoked", "local", "", "Never Revoked", Instant.now()));
|
||||||
|
String accessToken = jwtService.createAccessToken(
|
||||||
|
"user:never-revoked", "user", List.of("VIEWER"));
|
||||||
|
|
||||||
|
ResponseEntity<String> resp = call(accessToken);
|
||||||
|
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<String> call(String accessToken) {
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setBearerAuth(accessToken);
|
||||||
|
return restTemplate.exchange(
|
||||||
|
"/api/v1/auth/me",
|
||||||
|
HttpMethod.GET,
|
||||||
|
new HttpEntity<>(headers),
|
||||||
|
String.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
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.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.web.client.TestRestTemplate;
|
||||||
|
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.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class LogoutControllerIT extends AbstractPostgresIT {
|
||||||
|
|
||||||
|
@Autowired TestRestTemplate restTemplate;
|
||||||
|
@Autowired JwtService jwtService;
|
||||||
|
@Autowired UserRepository userRepository;
|
||||||
|
@Autowired JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void cleanup() {
|
||||||
|
userRepository.delete("logout-test");
|
||||||
|
jdbc.update("DELETE FROM audit_log WHERE username = ?", "logout-test");
|
||||||
|
}
|
||||||
|
|
||||||
|
@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"));
|
||||||
|
|
||||||
|
HttpHeaders authed = new HttpHeaders();
|
||||||
|
authed.setBearerAuth(accessToken);
|
||||||
|
|
||||||
|
ResponseEntity<Void> logoutResp = restTemplate.exchange(
|
||||||
|
"/api/v1/auth/logout", HttpMethod.POST, new HttpEntity<>(authed), Void.class);
|
||||||
|
assertThat(logoutResp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||||
|
|
||||||
|
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));
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
ResponseEntity<String> meResp = restTemplate.exchange(
|
||||||
|
"/api/v1/auth/me", HttpMethod.GET, new HttpEntity<>(authed), String.class);
|
||||||
|
assertThat(meResp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void logoutWithoutTokenReturns204NoOp() {
|
||||||
|
ResponseEntity<Void> resp = restTemplate.exchange(
|
||||||
|
"/api/v1/auth/logout", HttpMethod.POST, HttpEntity.EMPTY, Void.class);
|
||||||
|
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||||
|
}
|
||||||
|
}
|
||||||
96
docs/handoff/2026-04-27-logout-hardening.md
Normal file
96
docs/handoff/2026-04-27-logout-hardening.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# 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.
|
||||||
801
docs/superpowers/plans/2026-04-27-logout-hardening.md
Normal file
801
docs/superpowers/plans/2026-04-27-logout-hardening.md
Normal file
@@ -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<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:
|
||||||
|
|
||||||
|
```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<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`):
|
||||||
|
|
||||||
|
```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<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**
|
||||||
|
|
||||||
|
```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<void>;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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 (
|
||||||
|
<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 `/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 → <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**
|
||||||
|
|
||||||
|
```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 `<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:
|
||||||
|
```bash
|
||||||
|
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**
|
||||||
|
|
||||||
|
```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.
|
||||||
File diff suppressed because one or more lines are too long
39
ui/src/api/schema.d.ts
vendored
39
ui/src/api/schema.d.ts
vendored
@@ -753,6 +753,23 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/auth/logout": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Log out the current user (revoke all outstanding tokens) */
|
||||||
|
post: operations["logout"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/auth/login": {
|
"/auth/login": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2743,8 +2760,8 @@ export interface components {
|
|||||||
AttributeFilter: {
|
AttributeFilter: {
|
||||||
key?: string;
|
key?: string;
|
||||||
value?: string;
|
value?: string;
|
||||||
keyOnly?: boolean;
|
|
||||||
wildcard?: boolean;
|
wildcard?: boolean;
|
||||||
|
keyOnly?: boolean;
|
||||||
};
|
};
|
||||||
SearchRequest: {
|
SearchRequest: {
|
||||||
status?: string;
|
status?: string;
|
||||||
@@ -3652,7 +3669,7 @@ export interface components {
|
|||||||
/** @description Whether OIDC is configured AND enabled */
|
/** @description Whether OIDC is configured AND enabled */
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
/** @description Best-effort display label, e.g. "Logto", "Keycloak", "Single Sign-On" */
|
/** @description Best-effort display label, e.g. "Logto", "Keycloak", "Single Sign-On" */
|
||||||
providerName: string;
|
providerName?: string;
|
||||||
/** @description When true, OIDC is the canonical entry point and the SPA hides the local form unless ?local is set */
|
/** @description When true, OIDC is the canonical entry point and the SPA hides the local form unless ?local is set */
|
||||||
primary?: boolean;
|
primary?: boolean;
|
||||||
};
|
};
|
||||||
@@ -5864,6 +5881,24 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
logout: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Logged out (or no-op if not authenticated) */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
login: {
|
login: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -47,11 +47,43 @@ export function LoginPage() {
|
|||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [oidcLoading, setOidcLoading] = useState(false);
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
const { data: caps, isError: capsFailed, isLoading: capsLoading } = useAuthCapabilities();
|
const { data: caps, isError: capsFailed, isLoading: capsLoading } = useAuthCapabilities();
|
||||||
|
|
||||||
if (isAuthenticated) return <Navigate to="/" replace />;
|
if (isAuthenticated) return <Navigate to="/" replace />;
|
||||||
if (capsLoading) return null;
|
if (capsLoading) return null;
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const oidcPrimary = caps?.oidc?.primary === true;
|
const oidcPrimary = caps?.oidc?.primary === true;
|
||||||
const adminRecoveryOnly = caps?.localAccounts?.adminRecoveryOnly === true;
|
const adminRecoveryOnly = caps?.localAccounts?.adminRecoveryOnly === true;
|
||||||
const providerName = caps?.oidc?.providerName || 'Single Sign-On';
|
const providerName = caps?.oidc?.providerName || 'Single Sign-On';
|
||||||
@@ -77,6 +109,9 @@ export function LoginPage() {
|
|||||||
if (data.endSessionEndpoint) {
|
if (data.endSessionEndpoint) {
|
||||||
localStorage.setItem('cameleer-oidc-end-session', data.endSessionEndpoint);
|
localStorage.setItem('cameleer-oidc-end-session', data.endSessionEndpoint);
|
||||||
}
|
}
|
||||||
|
if (data.clientId) {
|
||||||
|
localStorage.setItem('cameleer-oidc-client-id', data.clientId);
|
||||||
|
}
|
||||||
const redirectUri = `${window.location.origin}${config.basePath}oidc/callback`;
|
const redirectUri = `${window.location.origin}${config.basePath}oidc/callback`;
|
||||||
const scopes = ['openid', 'email', 'profile', ...PLATFORM_SCOPES, ...(data.additionalScopes || [])];
|
const scopes = ['openid', 'email', 'profile', ...PLATFORM_SCOPES, ...(data.additionalScopes || [])];
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
@@ -84,10 +119,13 @@ export function LoginPage() {
|
|||||||
client_id: data.clientId,
|
client_id: data.clientId,
|
||||||
redirect_uri: redirectUri,
|
redirect_uri: redirectUri,
|
||||||
scope: scopes.join(' '),
|
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.
|
||||||
|
// OIDC Core 1.0 §3.1.2.1.
|
||||||
|
prompt: 'login',
|
||||||
});
|
});
|
||||||
if (data.resource) params.set('resource', data.resource);
|
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}`;
|
window.location.href = `${data.authorizationEndpoint}?${params}`;
|
||||||
} catch {
|
} catch {
|
||||||
useAuthStore.setState({ error: 'OIDC configuration unavailable. Try the local form via /login?local.' });
|
useAuthStore.setState({ error: 'OIDC configuration unavailable. Try the local form via /login?local.' });
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ interface AuthState {
|
|||||||
login: (username: string, password: string) => Promise<void>;
|
login: (username: string, password: string) => Promise<void>;
|
||||||
loginWithOidcCode: (code: string, redirectUri: string) => Promise<void>;
|
loginWithOidcCode: (code: string, redirectUri: string) => Promise<void>;
|
||||||
refresh: () => Promise<boolean>;
|
refresh: () => Promise<boolean>;
|
||||||
logout: () => void;
|
logout: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseRolesFromJwt(token: string): string[] {
|
function parseRolesFromJwt(token: string): string[] {
|
||||||
@@ -140,12 +140,26 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
logout: () => {
|
logout: async () => {
|
||||||
|
const accessToken = get().accessToken;
|
||||||
const endSessionEndpoint = localStorage.getItem('cameleer-oidc-end-session');
|
const endSessionEndpoint = localStorage.getItem('cameleer-oidc-end-session');
|
||||||
const idToken = localStorage.getItem('cameleer-oidc-id-token');
|
const idToken = localStorage.getItem('cameleer-oidc-id-token');
|
||||||
|
const clientId = localStorage.getItem('cameleer-oidc-client-id');
|
||||||
|
|
||||||
|
// Best-effort server-side revocation. Don't fail logout if it errors —
|
||||||
|
// the SPA-side cleanup below is authoritative for the SPA.
|
||||||
|
if (accessToken) {
|
||||||
|
try {
|
||||||
|
await api.POST('/auth/logout', {});
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
clearTokens();
|
clearTokens();
|
||||||
localStorage.removeItem('cameleer-oidc-end-session');
|
localStorage.removeItem('cameleer-oidc-end-session');
|
||||||
localStorage.removeItem('cameleer-oidc-id-token');
|
localStorage.removeItem('cameleer-oidc-id-token');
|
||||||
|
localStorage.removeItem('cameleer-oidc-client-id');
|
||||||
set({
|
set({
|
||||||
accessToken: null,
|
accessToken: null,
|
||||||
refreshToken: null,
|
refreshToken: null,
|
||||||
@@ -154,17 +168,24 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
const loginUrl = `${config.basePath}login`;
|
|
||||||
|
// Tell the upcoming /login render that this is a post-logout landing,
|
||||||
|
// not a fresh visit. Mirrors cameleer-saas ui/src/auth/useAuth.ts.
|
||||||
|
sessionStorage.setItem('cameleer:signed_out', '1');
|
||||||
|
|
||||||
|
const localLoginUrl = `${config.basePath}login`;
|
||||||
|
|
||||||
if (endSessionEndpoint && idToken) {
|
if (endSessionEndpoint && idToken) {
|
||||||
|
// OIDC RP-Initiated Logout 1.0: top-level navigation, NOT fetch.
|
||||||
|
// Cross-origin fetch never clears the IdP's session cookie.
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
id_token_hint: idToken,
|
id_token_hint: idToken,
|
||||||
post_logout_redirect_uri: `${window.location.origin}${config.basePath}login`,
|
post_logout_redirect_uri: `${window.location.origin}${config.basePath}login`,
|
||||||
});
|
});
|
||||||
fetch(`${endSessionEndpoint}?${params}`, { mode: 'no-cors' }).finally(() => {
|
if (clientId) params.set('client_id', clientId);
|
||||||
window.location.href = loginUrl;
|
window.location.replace(`${endSessionEndpoint}?${params}`);
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
window.location.href = loginUrl;
|
window.location.replace(localLoginUrl);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user