fix(oidc): fix race on logout
This commit is contained in:
@@ -159,7 +159,13 @@ export function createApiFetch(deps: ApiClientDeps): FetchFn {
|
|||||||
|
|
||||||
const refreshed = await refresh();
|
const refreshed = await refresh();
|
||||||
if (!refreshed) {
|
if (!refreshed) {
|
||||||
|
// 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();
|
onSessionExpired();
|
||||||
|
}
|
||||||
throw new Error('Session expired');
|
throw new Error('Session expired');
|
||||||
}
|
}
|
||||||
const retryResponse = await rawFetch(input, init);
|
const retryResponse = await rawFetch(input, init);
|
||||||
@@ -183,6 +189,24 @@ export function setSessionExpiredHandler(fn: () => void): void {
|
|||||||
sessionExpiredHandler = fn;
|
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
|
// Same shape as `sessionExpiredHandler` — mutable so the app can install
|
||||||
// the real behaviour post-mount, and a fallback for the (rare) case
|
// the real behaviour post-mount, and a fallback for the (rare) case
|
||||||
// where no handler is wired yet (bootstrap, tests). The fallback does
|
// where no handler is wired yet (bootstrap, tests). The fallback does
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* primitives here intentionally bypass it (see client.ts) so a 401 surfaces as
|
* primitives here intentionally bypass it (see client.ts) so a 401 surfaces as
|
||||||
* a genuine failure to the caller.
|
* 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 { getCsrfHeaders } from '$lib/api/csrf';
|
||||||
import type { AuthResponse, User } from '$lib/api/types';
|
import type { AuthResponse, User } from '$lib/api/types';
|
||||||
|
|
||||||
@@ -425,6 +425,16 @@ export async function unlinkOidc(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function logout(): Promise<LogoutResult> {
|
export async function logout(): Promise<LogoutResult> {
|
||||||
|
// 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 res = await apiFetch('/api/auth/logout', {
|
const res = await apiFetch('/api/auth/logout', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
@@ -434,8 +444,13 @@ export async function logout(): Promise<LogoutResult> {
|
|||||||
if (!res.ok) return {};
|
if (!res.ok) return {};
|
||||||
try {
|
try {
|
||||||
const body = (await res.json()) as { post_logout_url?: unknown };
|
const body = (await res.json()) as { post_logout_url?: unknown };
|
||||||
return typeof body?.post_logout_url === 'string' ? { postLogoutUrl: body.post_logout_url } : {};
|
return typeof body?.post_logout_url === 'string'
|
||||||
|
? { postLogoutUrl: body.post_logout_url }
|
||||||
|
: {};
|
||||||
} catch {
|
} catch {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
setLogoutInProgress(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user