diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 8bffc24d..d837b2e3 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -159,7 +159,13 @@ export function createApiFetch(deps: ApiClientDeps): FetchFn { const refreshed = await refresh(); if (!refreshed) { - onSessionExpired(); + // Suppress the session-expired divert while a logout POST + // is in flight — see `logoutInProgress` above. Without this + // gate the ambient 401 race cancels the pending logout and + // swallows its `post_logout_url` response body. + if (!logoutInProgress) { + onSessionExpired(); + } throw new Error('Session expired'); } const retryResponse = await rawFetch(input, init); @@ -183,6 +189,24 @@ export function setSessionExpiredHandler(fn: () => void): void { sessionExpiredHandler = fn; } +// Logout-in-progress gate. Set to true by the logout endpoint wrapper +// (endpoints/auth.ts) for the duration of the POST /api/auth/logout +// call; reset in its `finally`. While set, `sessionExpiredHandler` +// is suppressed — an ambient 401 during the logout window is expected +// (the backend clears cookies and revokes the session as part of the +// logout response, so any in-flight fetch racing the logout will 401), +// and firing the handler would navigate to `/login?source=session_expired` +// mid-flight, cancelling the logout POST before we get its response +// body. Since the response body carries `post_logout_url` (the IdP's +// end_session_endpoint URL for OIDC-linked sessions), losing it means +// the browser never redirects to the IdP and the SSO session persists. +// See AppShell.svelte::onLogout for the caller-side counterpart. +let logoutInProgress = false; + +export function setLogoutInProgress(value: boolean): void { + logoutInProgress = value; +} + // Same shape as `sessionExpiredHandler` — mutable so the app can install // the real behaviour post-mount, and a fallback for the (rare) case // where no handler is wired yet (bootstrap, tests). The fallback does diff --git a/frontend/src/lib/api/endpoints/auth.ts b/frontend/src/lib/api/endpoints/auth.ts index 5ac741d3..6c0ac025 100644 --- a/frontend/src/lib/api/endpoints/auth.ts +++ b/frontend/src/lib/api/endpoints/auth.ts @@ -3,7 +3,7 @@ * primitives here intentionally bypass it (see client.ts) so a 401 surfaces as * a genuine failure to the caller. */ -import { ApiError, apiFetch } from '$lib/api/client'; +import { ApiError, apiFetch, setLogoutInProgress } from '$lib/api/client'; import { getCsrfHeaders } from '$lib/api/csrf'; import type { AuthResponse, User } from '$lib/api/types'; @@ -425,17 +425,32 @@ export async function unlinkOidc(): Promise { } export async function logout(): Promise { - const res = await apiFetch('/api/auth/logout', { - method: 'POST', - credentials: 'same-origin', - headers: { ...JSON_HEADERS, ...getCsrfHeaders() }, - body: '{}' - }); - if (!res.ok) return {}; + // Gate the session-expired handler for the duration of this call. + // The backend revokes the session + clears cookies as part of the + // logout response, so any in-flight fetch racing us will 401. Without + // the gate, that ambient 401 would trigger a navigation to + // `/login?source=session_expired`, cancel the pending logout POST, + // and swallow the `post_logout_url` response body — leaving the SSO + // session live on the IdP because we never navigate to its + // end_session_endpoint. See client.ts `logoutInProgress` for details. + setLogoutInProgress(true); try { - const body = (await res.json()) as { post_logout_url?: unknown }; - return typeof body?.post_logout_url === 'string' ? { postLogoutUrl: body.post_logout_url } : {}; - } catch { - return {}; + const res = await apiFetch('/api/auth/logout', { + method: 'POST', + credentials: 'same-origin', + headers: { ...JSON_HEADERS, ...getCsrfHeaders() }, + body: '{}' + }); + if (!res.ok) return {}; + try { + const body = (await res.json()) as { post_logout_url?: unknown }; + return typeof body?.post_logout_url === 'string' + ? { postLogoutUrl: body.post_logout_url } + : {}; + } catch { + return {}; + } + } finally { + setLogoutInProgress(false); } }