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.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-27 11:57:04 +02:00
parent da3895c31d
commit 82e2593332
2 changed files with 31 additions and 7 deletions

View File

@@ -77,6 +77,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({

View File

@@ -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);
} }
}, },
})); }));