From 82e25933328003961b19a58bda256bf481dfd8fe Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:57:04 +0200 Subject: [PATCH] =?UTF-8?q?fix(ui):=20proper=20OIDC=20logout=20=E2=80=94?= =?UTF-8?q?=20server=20revoke=20+=20top-level=20redirect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ui/src/auth/LoginPage.tsx | 3 +++ ui/src/auth/auth-store.ts | 35 ++++++++++++++++++++++++++++------- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/ui/src/auth/LoginPage.tsx b/ui/src/auth/LoginPage.tsx index 515b3de8..17df6779 100644 --- a/ui/src/auth/LoginPage.tsx +++ b/ui/src/auth/LoginPage.tsx @@ -77,6 +77,9 @@ export function LoginPage() { if (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 scopes = ['openid', 'email', 'profile', ...PLATFORM_SCOPES, ...(data.additionalScopes || [])]; const params = new URLSearchParams({ diff --git a/ui/src/auth/auth-store.ts b/ui/src/auth/auth-store.ts index 4b3e9e3c..47571bb9 100644 --- a/ui/src/auth/auth-store.ts +++ b/ui/src/auth/auth-store.ts @@ -13,7 +13,7 @@ interface AuthState { login: (username: string, password: string) => Promise; loginWithOidcCode: (code: string, redirectUri: string) => Promise; refresh: () => Promise; - logout: () => void; + logout: () => Promise; } function parseRolesFromJwt(token: string): string[] { @@ -140,12 +140,26 @@ export const useAuthStore = create((set, get) => ({ } }, - logout: () => { + 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 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(); localStorage.removeItem('cameleer-oidc-end-session'); localStorage.removeItem('cameleer-oidc-id-token'); + localStorage.removeItem('cameleer-oidc-client-id'); set({ accessToken: null, refreshToken: null, @@ -154,17 +168,24 @@ export const useAuthStore = create((set, get) => ({ isAuthenticated: false, 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) { + // 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({ id_token_hint: idToken, post_logout_redirect_uri: `${window.location.origin}${config.basePath}login`, }); - fetch(`${endSessionEndpoint}?${params}`, { mode: 'no-cors' }).finally(() => { - window.location.href = loginUrl; - }); + if (clientId) params.set('client_id', clientId); + window.location.replace(`${endSessionEndpoint}?${params}`); } else { - window.location.href = loginUrl; + window.location.replace(localLoginUrl); } }, }));