diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 64e742e5..82fe7e9b 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -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 { diff --git a/frontend/src/lib/api/endpoints/auth.ts b/frontend/src/lib/api/endpoints/auth.ts index 241bfec7..1e8fff43 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, 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 { } export async function logout(): Promise { - // 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 {}; } } diff --git a/frontend/src/lib/components/AppShell.svelte b/frontend/src/lib/components/AppShell.svelte index c0fcca6f..6200c1ec 100644 --- a/frontend/src/lib/components/AppShell.svelte +++ b/frontend/src/lib/components/AppShell.svelte @@ -4,6 +4,7 @@ import { resolve } from '$app/paths'; import { page } from '$app/state'; import { logout } from '$lib/api/endpoints/auth'; + import { setLogoutInProgress } from '$lib/api/client'; import { searchResources } from '$lib/api/endpoints/search'; import { fileInlineUrl, deleteFile } from '$lib/api/endpoints/files'; import { deleteFolder } from '$lib/api/endpoints/folders'; @@ -494,6 +495,14 @@ } async function onLogout() { + // Flip the session-teardown gate BEFORE the logout POST so every + // ambient/subscriber-fired fetch that fires between here and the + // /login mount short-circuits with AbortError instead of hitting + // the server (see `client.ts::logoutInProgress`). Left ON across + // the goto — module state persists over soft nav, so a stale + // reactive re-fetch during the transition still no-ops. A hard + // reload later (or the IdP round-trip below) wipes it naturally. + setLogoutInProgress(true); let postLogoutUrl: string | undefined; try { ({ postLogoutUrl } = await logout()); @@ -504,18 +513,18 @@ // Full-page navigation to the IdP end-session endpoint. Do NOT // touch local session state first: `session.reset()` fires the // layout $effect guard which races us with a competing - // `goto('/login?redirect=...')`, and any ambient in-flight - // fetch that 401s trips the sessionExpiredHandler with yet - // another navigation to `/login?source=session_expired`. Two - // or three concurrent navigations cancel each other and the - // browser stalls on the current page. The IdP round-trip lands - // us back on `/login` where the SPA reboots fresh from scratch — + // `goto('/login?redirect=...')`. The IdP round-trip lands us + // back on `/login` where the SPA reboots fresh from scratch — // no local cleanup needed here. window.location.replace(postLogoutUrl); return; } session.reset(); - await goto(resolve('/login')); + // `?source=logged_out` distinguishes the friendly explicit-logout + // landing from `?source=session_expired` (auto-divert on 401 → + // refresh 401). The login page reads the flag, shows the success + // notice, and skips its existing-session probe. + await goto(resolve('/login?source=logged_out')); } diff --git a/frontend/src/lib/stores/session.svelte.ts b/frontend/src/lib/stores/session.svelte.ts index 465b28eb..2e724f70 100644 --- a/frontend/src/lib/stores/session.svelte.ts +++ b/frontend/src/lib/stores/session.svelte.ts @@ -119,6 +119,17 @@ class SessionStore { this.user = null; this.homeFolderId = null; this.homeFolderName = null; + // Mark the store as `loaded` so any subsequent `session.load()` — + // notably the login page's existing-session probe and the root + // layout's post-nav mount — short-circuits to `null` instead of + // re-probing `/api/auth/me`. After an explicit logout we know for + // a fact the session is gone; a probe would 401, the interceptor + // would retry via /refresh (also 401), and `sessionExpiredHandler` + // would divert to `/login?source=session_expired` — clobbering the + // nice "logged out" landing. On a hard nav (natural expiry path) + // module state is fresh and this flag is `false` again, so the + // probe still runs there. + this.loaded = true; } } diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index e84ae0dd..52feb865 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -98,6 +98,16 @@ // immediately after so revisits / manual logouts don't re-show // the stale message. let sessionExpiredNotice = $state(false); + // One-shot "logged out" success banner, distinct from the + // session-expired one above. Triggered by AppShell::onLogout via + // `?source=logged_out`. Consumed on mount (URL stripped) so the + // notice never re-appears on reload. + let loggedOutNotice = $state(false); + // Also gates the existing-session probe below — after an explicit + // logout we know the session is dead; probing would 401 → refresh + // → 401 and clobber this landing with `?source=session_expired` + // via the interceptor. + let skipExistingSessionProbe = $state(false); // One-shot notice populated from ?login_error= on mount. // Set by the OIDC callback's AutoLinkRefused redirect when the // IdP-returned email matches an existing local account but the @@ -345,8 +355,13 @@ // Strip it from the URL so the banner never re-appears on // reloads / manual logout redirects. Uses history.replaceState // (no navigation, no scroll jump). - if (page.url.searchParams.get('source') === 'session_expired') { - sessionExpiredNotice = true; + const sourceParam = page.url.searchParams.get('source'); + if (sourceParam === 'session_expired' || sourceParam === 'logged_out') { + if (sourceParam === 'session_expired') sessionExpiredNotice = true; + else loggedOutNotice = true; + // Either flag means we KNOW there's no live session — skip + // the existing-session probe further down. + skipExistingSessionProbe = true; const stripped = new URL(page.url); stripped.searchParams.delete('source'); window.history.replaceState( @@ -394,15 +409,21 @@ } // 2) Existing-session probe: if already authenticated, skip the form. - try { - const me = await fetchMe(); - if (me) { - session.setUser(me); - await goto(resolve(redirectTarget), { replaceState: true }); - return; + // Skipped when we KNOW the session is gone (explicit logout or + // interceptor-detected expiry) — probing would 401, the + // interceptor would retry via /refresh (also 401), and the + // sessionExpiredHandler would clobber this landing. + if (!skipExistingSessionProbe) { + try { + const me = await fetchMe(); + if (me) { + session.setUser(me); + await goto(resolve(redirectTarget), { replaceState: true }); + return; + } + } catch { + /* probe failed — show the login page */ } - } catch { - /* probe failed — show the login page */ } // 3) Bootstrap probe: a fresh install (no admin) must be set up first. @@ -527,6 +548,24 @@ {/if} + {#if loggedOutNotice} +
+ {t('auth.logged_out', 'Successfully signed out.')} + +
+ {/if} + {#if postRegisterNotice && mode === 'login'}