fix(logout): reduce unwanted API call during logout
- stop trying to refresh session - display "successfully signed out" rather "your session is expired"
This commit is contained in:
@@ -164,6 +164,21 @@ export function createApiFetch(deps: ApiClientDeps): FetchFn {
|
||||
|
||||
const apiFetch: FetchFn = async (input, init) => {
|
||||
const origin = deps.origin ?? globalThis.location?.origin ?? 'http://localhost';
|
||||
// Session-teardown short-circuit. While a logout is in flight (or
|
||||
// the caller has already navigated to /login post-logout without
|
||||
// re-authenticating), the session is dead — any subscriber-fired
|
||||
// refresh (`session.load()` in the layout, a store `$effect` re-
|
||||
// fetching its slice, an idle poll) would hit /me → 401 → refresh
|
||||
// → 401 → sessionExpiredHandler and clobber the friendly
|
||||
// "logged out" landing with `?source=session_expired`. Fail these
|
||||
// fast with an AbortError so callers unwrap cleanly via their
|
||||
// existing `.catch` blocks and no server hop occurs. The auth
|
||||
// primitives themselves (notably `/api/auth/logout`) are exempt so
|
||||
// the logout POST that FLIPPED the gate can still complete.
|
||||
const urlStrEarly = urlString(input as RequestInfo | URL);
|
||||
if (logoutInProgress && !bypassesRetry(urlStrEarly)) {
|
||||
throw new DOMException('Session terminated', 'AbortError');
|
||||
}
|
||||
const response = await dpopFetch(input, init);
|
||||
// Server-status header piggyback — the server stamps
|
||||
// `x-server-status` on every response while a maintenance
|
||||
@@ -256,18 +271,23 @@ 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.
|
||||
// Session-teardown gate. Flipped ON by `AppShell::onLogout` immediately
|
||||
// BEFORE it calls `logout()` and left ON across the redirect to /login
|
||||
// (module state persists over SvelteKit soft nav — a hard reload wipes
|
||||
// it back to `false`, which is the correct default for a fresh session).
|
||||
// While set:
|
||||
// 1. `apiFetch` short-circuits every non-auth-primitive request with
|
||||
// an `AbortError` — no server hop, no 401, no audit noise. Callers
|
||||
// unwrap through their existing `.catch` blocks.
|
||||
// 2. On a 401 the `sessionExpiredHandler` divert is suppressed so it
|
||||
// cannot clobber the friendly `/login?source=logged_out` landing
|
||||
// with `?source=session_expired`.
|
||||
// Rule (1) alone would defeat the logout POST itself, so the auth
|
||||
// primitives (`/api/auth/logout`, `/api/auth/refresh`, …) are exempted
|
||||
// via `bypassesRetry`. Rule (2) additionally covers the tail-end race
|
||||
// where the logout response's `post_logout_url` matters for OIDC — an
|
||||
// ambient 401 mid-flight cannot cancel the pending POST and swallow
|
||||
// its body, which would leave the IdP session live.
|
||||
let logoutInProgress = false;
|
||||
|
||||
export function setLogoutInProgress(value: boolean): void {
|
||||
|
||||
@@ -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, setLogoutInProgress } from '$lib/api/client';
|
||||
import { ApiError, apiFetch } from '$lib/api/client';
|
||||
import { getCsrfHeaders } from '$lib/api/csrf';
|
||||
import type { AuthResponse, User } from '$lib/api/types';
|
||||
|
||||
@@ -564,54 +564,44 @@ export async function unlinkOidc(): Promise<void> {
|
||||
}
|
||||
|
||||
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);
|
||||
// The session-teardown gate (`setLogoutInProgress(true)`) is flipped
|
||||
// by the CALLER (`AppShell::onLogout`) BEFORE this function runs, and
|
||||
// left ON across the goto to /login. See `client.ts::logoutInProgress`
|
||||
// for what the gate suppresses (short-circuits ambient fetches with
|
||||
// AbortError + blocks the session-expired divert).
|
||||
const res = await apiFetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
||||
body: '{}'
|
||||
});
|
||||
|
||||
// Wipe DPoP browser state so the next login mints a fresh
|
||||
// keypair — no correlation across the logout boundary is
|
||||
// desirable (a new session is a new identity from the
|
||||
// per-request-signature standpoint). Runs UNCONDITIONALLY of
|
||||
// the logout HTTP status: even if the server call failed,
|
||||
// the user's intent was to log out, and leaving a stale
|
||||
// keypair around would confuse the next login's bind step.
|
||||
try {
|
||||
const res = await apiFetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
||||
body: '{}'
|
||||
});
|
||||
const { clearKeypair } = await import('$lib/auth/dpop');
|
||||
const { clearNonce } = await import('$lib/auth/dpop-proof');
|
||||
const { broadcastSessionCleared } = await import('$lib/auth/session-broadcast');
|
||||
await clearKeypair();
|
||||
clearNonce();
|
||||
// Notify every OTHER tab of this origin that the session is
|
||||
// gone — Gate 8 cross-tab UX. Tabs that were sitting idle
|
||||
// don't have to wait for their next 401 to notice.
|
||||
broadcastSessionCleared();
|
||||
} catch (err) {
|
||||
console.debug('dpop: cleanup failed during logout', err);
|
||||
}
|
||||
|
||||
// Wipe DPoP browser state so the next login mints a fresh
|
||||
// keypair — no correlation across the logout boundary is
|
||||
// desirable (a new session is a new identity from the
|
||||
// per-request-signature standpoint). Runs UNCONDITIONALLY of
|
||||
// the logout HTTP status: even if the server call failed,
|
||||
// the user's intent was to log out, and leaving a stale
|
||||
// keypair around would confuse the next login's bind step.
|
||||
try {
|
||||
const { clearKeypair } = await import('$lib/auth/dpop');
|
||||
const { clearNonce } = await import('$lib/auth/dpop-proof');
|
||||
const { broadcastSessionCleared } = await import('$lib/auth/session-broadcast');
|
||||
await clearKeypair();
|
||||
clearNonce();
|
||||
// Notify every OTHER tab of this origin that the session is
|
||||
// gone — Gate 8 cross-tab UX. Tabs that were sitting idle
|
||||
// don't have to wait for their next 401 to notice.
|
||||
broadcastSessionCleared();
|
||||
} catch (err) {
|
||||
console.debug('dpop: cleanup failed during logout', err);
|
||||
}
|
||||
|
||||
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);
|
||||
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 {};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user