950c8c0f38
provide the DPoP nonce via cookie on login, this reduce the amount of API call and prevent having any first call returning in 401
375 lines
15 KiB
TypeScript
375 lines
15 KiB
TypeScript
/**
|
|
* Typed API client with transparent 401 → token-refresh → retry.
|
|
*
|
|
* Ported from static/js/core/fetchWrapper.js. Unlike that wrapper, this does
|
|
* NOT monkeypatch `window.fetch`; every endpoint module calls `apiFetch`
|
|
* explicitly. The behavioural invariants are preserved exactly:
|
|
*
|
|
* - A captured raw `fetch` is used for the real network calls so the refresh
|
|
* request and the retry never re-enter the interceptor (no recursion).
|
|
* - Concurrent 401s collapse into a single in-flight `/api/auth/refresh`.
|
|
* - Cross-origin responses are passed through untouched.
|
|
* - Auth primitives (login/logout/refresh/register/setup/oidc/device) and
|
|
* public-share endpoints (/api/s/) bypass the refresh-and-retry path:
|
|
* a 401 there is genuine ("bad credentials" / "password required"), not an
|
|
* expired access token.
|
|
* - When refresh fails, the session-expired handler fires (clear + redirect)
|
|
* and the call rejects.
|
|
*/
|
|
|
|
import { getCsrfHeaders } from './csrf';
|
|
import { updateFromHeader } from '$lib/stores/serverStatus.svelte';
|
|
import {
|
|
buildDpopProof,
|
|
isDpopNonceChallenge,
|
|
updateNonceFromResponse
|
|
} from '$lib/auth/dpop-proof';
|
|
|
|
/**
|
|
* Name of the response header the server stamps while a
|
|
* maintenance event is live. Case-insensitive on the wire — the
|
|
* Fetch API's `Headers.get` matches irrespective of case, so this
|
|
* constant matches whatever axum emits.
|
|
*/
|
|
const SERVER_STATUS_HEADER = 'x-server-status';
|
|
|
|
const REFRESH_ENDPOINT = '/api/auth/refresh';
|
|
|
|
/** Auth primitives — a 401 here is genuine, never an expired access token.
|
|
* Also used by the session-teardown gate to exempt endpoints that must
|
|
* still fire during / immediately after a logout (the logout POST itself,
|
|
* and every login path a user might retry on the /login landing). */
|
|
const AUTH_PRIMITIVES = [
|
|
'/api/auth/login',
|
|
'/api/auth/logout',
|
|
'/api/auth/refresh',
|
|
'/api/auth/register',
|
|
'/api/auth/setup',
|
|
'/api/auth/oidc/',
|
|
'/api/auth/device/',
|
|
'/api/auth/opaque/',
|
|
'/api/auth/magic-link/',
|
|
'/api/auth/status',
|
|
'/api/auth/dpop/'
|
|
];
|
|
|
|
export type FetchFn = typeof fetch;
|
|
|
|
export interface ApiClientDeps {
|
|
/** Underlying fetch used for the real network call (bypasses the interceptor). */
|
|
rawFetch: FetchFn;
|
|
/** Invoked once when a refresh definitively fails (clear session + redirect). */
|
|
onSessionExpired: () => void;
|
|
/**
|
|
* Invoked when the server returns `403 { error_type: "PasswordChangeRequired" }`.
|
|
* Typically routes the SPA to `/profile?forcePasswordChange=1` — the same
|
|
* destination the root layout's nav-guard uses for a fresh navigation. Default
|
|
* is a no-op; the app wires the real handler at startup.
|
|
*/
|
|
onPasswordChangeRequired?: () => void;
|
|
/** Test seam for `window.location.origin`. */
|
|
origin?: string;
|
|
}
|
|
|
|
function urlString(input: RequestInfo | URL): string {
|
|
if (typeof input === 'string') return input;
|
|
if (input instanceof URL) return input.href;
|
|
return input.url ?? '';
|
|
}
|
|
|
|
function isCrossOrigin(urlStr: string, origin: string): boolean {
|
|
try {
|
|
return new URL(urlStr, origin).origin !== origin;
|
|
} catch {
|
|
// Unparseable URL — treat as cross-origin so we pass it through untouched.
|
|
return true;
|
|
}
|
|
}
|
|
|
|
function bypassesRetry(urlStr: string): boolean {
|
|
return AUTH_PRIMITIVES.some((p) => urlStr.includes(p)) || urlStr.includes('/api/s/');
|
|
}
|
|
|
|
/**
|
|
* Build an isolated apiFetch with its own refresh-dedup state. Used directly in
|
|
* tests; the app uses the default singleton below.
|
|
*/
|
|
export function createApiFetch(deps: ApiClientDeps): FetchFn {
|
|
const { rawFetch, onSessionExpired } = deps;
|
|
// Default no-op keeps existing test callers that don't wire this
|
|
// dep from crashing on a 403 PasswordChangeRequired — they'd just
|
|
// see the raw 403 flow through, which is what they already assert.
|
|
const onPasswordChangeRequired = deps.onPasswordChangeRequired ?? (() => {});
|
|
let refreshInFlight: Promise<boolean> | null = null;
|
|
|
|
async function refresh(): Promise<boolean> {
|
|
if (refreshInFlight) return refreshInFlight;
|
|
refreshInFlight = (async () => {
|
|
try {
|
|
const r = await dpopFetch(REFRESH_ENDPOINT, {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
|
|
body: '{}'
|
|
});
|
|
return r.ok;
|
|
} catch {
|
|
return false;
|
|
} finally {
|
|
refreshInFlight = null;
|
|
}
|
|
})();
|
|
return refreshInFlight;
|
|
}
|
|
|
|
/**
|
|
* Wrap the raw fetch with DPoP proof injection + nonce challenge/retry.
|
|
*
|
|
* 1. Build proof for the request's method + canonical URL (no query).
|
|
* 2. Attach as `DPoP` header. Fail-open if the keypair is unavailable
|
|
* (browser without SubtleCrypto / IndexedDB) — we simply skip the
|
|
* header and let the request go through unbound; server-side
|
|
* middleware exempts unbound sessions.
|
|
* 3. After response, harvest a fresh `DPoP-Nonce` if the server sent
|
|
* one, so the NEXT request has the current nonce.
|
|
* 4. If the response is a nonce challenge (`401 use_dpop_nonce`),
|
|
* REBUILD the proof with the just-received nonce and retry ONCE.
|
|
* A second challenge on the retry is a bug — surface it as a real
|
|
* 401 rather than looping.
|
|
*
|
|
* Cross-origin requests skip DPoP entirely (privacy — don't leak the
|
|
* user's public key to third parties). Request bodies are consumed at
|
|
* most once during retry: `init.body` is passed by reference, and the
|
|
* only mutating step is `Headers`; a caller-supplied `ReadableStream`
|
|
* body would need `duplex: 'half'`, which they'd already have to opt
|
|
* into for cross-origin CORS anyway.
|
|
*/
|
|
async function dpopFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
|
const origin = deps.origin ?? globalThis.location?.origin ?? 'http://localhost';
|
|
const urlStr = urlString(input as RequestInfo | URL);
|
|
if (isCrossOrigin(urlStr, origin)) return rawFetch(input, init);
|
|
|
|
const method = init?.method ?? 'GET';
|
|
const withProof = async (): Promise<Response> => {
|
|
const proof = await buildDpopProof(method, urlStr);
|
|
const initWithProof: RequestInit = proof
|
|
? { ...init, headers: mergeHeader(init?.headers, 'DPoP', proof) }
|
|
: (init ?? {});
|
|
const res = await rawFetch(input, initWithProof);
|
|
updateNonceFromResponse(res);
|
|
return res;
|
|
};
|
|
|
|
const first = await withProof();
|
|
if (!isDpopNonceChallenge(first)) return first;
|
|
// `updateNonceFromResponse` already stored the fresh nonce
|
|
// carried on this 401; the next `buildDpopProof` will pick it
|
|
// up. If the RETRY also produces `use_dpop_nonce`, surface it
|
|
// — infinite retry would mask a server-side nonce bug.
|
|
return withProof();
|
|
}
|
|
|
|
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
|
|
// event is in progress (see middleware::server_status). Read
|
|
// it and update the reactive store; the AppShell banner
|
|
// subscribes and shows/hides itself. Absent header = nothing
|
|
// happening; the update fn resets the store to default in
|
|
// that case so a lingering banner disappears.
|
|
//
|
|
// Runs on EVERY response including a 401 (below) so a session
|
|
// refresh doesn't accidentally clear a live banner.
|
|
updateFromHeader(response.headers.get(SERVER_STATUS_HEADER));
|
|
|
|
// Backend `require_no_password_change_pending_layer` returns 403
|
|
// `PasswordChangeRequired` on every non-allowlisted endpoint
|
|
// while the caller's `force_password_change_at_next_login` flag
|
|
// is set (admin picked a temporary password). Intercepting here
|
|
// short-circuits any stale-tab request that outran the SPA's
|
|
// nav-guard — the user is bounced to `/profile` in mandatory
|
|
// mode, matching what the guard would do on a fresh navigation.
|
|
//
|
|
// Clones the body so downstream callers can still consume the
|
|
// response after we've peeked at the error_type. Skipped for
|
|
// non-JSON responses (WebDAV, etc.) — the check silently
|
|
// falls through and returns the original 403 to the caller,
|
|
// which will surface its own error the usual way.
|
|
if (response.status === 403) {
|
|
const clone = response.clone();
|
|
try {
|
|
const body = (await clone.json()) as { error_type?: unknown };
|
|
if (body?.error_type === 'PasswordChangeRequired') {
|
|
onPasswordChangeRequired();
|
|
}
|
|
} catch {
|
|
/* not JSON or parse failed — pass through as normal 403 */
|
|
}
|
|
return response;
|
|
}
|
|
|
|
if (response.status !== 401) return response;
|
|
|
|
const urlStr = urlString(input as RequestInfo | URL);
|
|
if (isCrossOrigin(urlStr, origin)) return response;
|
|
if (bypassesRetry(urlStr)) return response;
|
|
|
|
const refreshed = await refresh();
|
|
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();
|
|
}
|
|
throw new Error('Session expired');
|
|
}
|
|
// Retry through dpopFetch (not rawFetch directly) so the
|
|
// post-refresh request also carries a valid DPoP proof —
|
|
// otherwise a session bound to a keypair would 401 again on
|
|
// the retry with `dpop_missing`.
|
|
const retryResponse = await dpopFetch(input, init);
|
|
updateFromHeader(retryResponse.headers.get(SERVER_STATUS_HEADER));
|
|
return retryResponse;
|
|
};
|
|
|
|
return apiFetch;
|
|
}
|
|
|
|
/**
|
|
* Merge a single header into an existing `HeadersInit` (`Headers`, plain
|
|
* object, or array-of-pairs), returning a fresh `Headers` so the caller's
|
|
* init isn't mutated. Preserves case-insensitivity via the `Headers` API.
|
|
*/
|
|
function mergeHeader(base: HeadersInit | undefined, name: string, value: string): Headers {
|
|
const merged = new Headers(base ?? {});
|
|
merged.set(name, value);
|
|
return merged;
|
|
}
|
|
|
|
// ── Default singleton ──────────────────────────────────────────────────────
|
|
|
|
let sessionExpiredHandler: () => void = () => {
|
|
if (typeof window !== 'undefined') {
|
|
window.location.href = '/login?source=session_expired';
|
|
}
|
|
};
|
|
|
|
/** Wire the real session-expired behaviour (clear store + redirect) at startup. */
|
|
export function setSessionExpiredHandler(fn: () => void): void {
|
|
sessionExpiredHandler = fn;
|
|
}
|
|
|
|
// 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 {
|
|
logoutInProgress = value;
|
|
}
|
|
|
|
/** Read-only view of the gate — used by cross-tab handlers to distinguish
|
|
* OUR logout (already handled by AppShell.onLogout with source=logged_out)
|
|
* from ANOTHER tab's logout (which needs a bare redirect). */
|
|
export function isLogoutInProgress(): boolean {
|
|
return logoutInProgress;
|
|
}
|
|
|
|
// 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
|
|
// a hard `window.location` navigation so a stale tab that outran the
|
|
// SPA's nav-guard still lands the user on the mandatory form.
|
|
let passwordChangeRequiredHandler: () => void = () => {
|
|
if (typeof window !== 'undefined') {
|
|
const here = encodeURIComponent(window.location.pathname + window.location.search);
|
|
window.location.href = `/profile?forcePasswordChange=1&next=${here}`;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Wire the SPA's mandatory-mode handler. Called once from the root
|
|
* layout: uses `goto()` for a soft nav so `next=` preserves the
|
|
* intended destination without triggering a full page reload.
|
|
*/
|
|
export function setPasswordChangeRequiredHandler(fn: () => void): void {
|
|
passwordChangeRequiredHandler = fn;
|
|
}
|
|
|
|
const rawFetch: FetchFn =
|
|
typeof globalThis.fetch === 'function' ? globalThis.fetch.bind(globalThis) : (undefined as never);
|
|
|
|
/** App-wide fetch — route every API call through this. */
|
|
export const apiFetch: FetchFn = createApiFetch({
|
|
rawFetch,
|
|
onSessionExpired: () => sessionExpiredHandler(),
|
|
onPasswordChangeRequired: () => passwordChangeRequiredHandler()
|
|
});
|
|
|
|
/** Convenience: fetch JSON, throwing on non-2xx. */
|
|
export async function apiJson<T>(input: RequestInfo | URL, init?: RequestInit): Promise<T> {
|
|
const res = await apiFetch(input, init);
|
|
if (!res.ok) {
|
|
throw new ApiError(res.status, res.statusText, input);
|
|
}
|
|
return (await res.json()) as T;
|
|
}
|
|
|
|
export class ApiError extends Error {
|
|
/**
|
|
* `error_type` field from the backend's `ErrorResponse` body, when
|
|
* present. Callers switch on this to render specific UX for
|
|
* distinguished failures (e.g. `EmailNotVerified` → "resend
|
|
* verification link" prompt). Falls back to `undefined` when the
|
|
* response body isn't parseable or the endpoint doesn't emit one.
|
|
*/
|
|
readonly errorType?: string;
|
|
|
|
constructor(
|
|
readonly status: number,
|
|
readonly statusText: string,
|
|
readonly resource: RequestInfo | URL,
|
|
errorType?: string,
|
|
serverMessage?: string
|
|
) {
|
|
super(
|
|
serverMessage ?? `API ${status} ${statusText} for ${urlString(resource as RequestInfo | URL)}`
|
|
);
|
|
this.name = 'ApiError';
|
|
this.errorType = errorType;
|
|
}
|
|
}
|