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