diff --git a/lib/api.ts b/lib/api.ts index 28334df..ad46c8b 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -34,47 +34,59 @@ export function clearAuth(): void { localStorage.removeItem(USER_KEY); } -// Shared in-flight refresh promise so concurrent 401s (e.g. several widgets -// loading at once) all await the same refresh call instead of each firing -// their own — refresh tokens are single-use, so racing calls would otherwise -// invalidate each other and force a false session-expired logout. -let refreshInFlight: Promise | null = null; +// The backend binds every authenticated request to a single-use, rotating +// session nonce: each request must present the current nonce and immediately +// invalidates it for the next one. That makes concurrent authenticated +// requests fundamentally unsafe — if two calls read the same nonce cookie +// before either response comes back to update it (e.g. a dashboard loading +// several widgets in parallel with Promise.all), only the first one to reach +// the server can win; every other one, including a refresh call caught in +// the same race, gets rejected and can cascade into a forced logout. +// +// The only fully correct fix on this side is to never let more than one +// nonce-consuming request be in flight at once: every apiFetch call is +// queued and runs strictly after the previous one (refresh-and-retry +// included) has completely finished, so the nonce cookie is always settled +// before the next request reads it. This only serializes requests within +// this browser tab — a second tab open to the same account still shares the +// same nonce cookie and could race against this one; that's a separate, +// rarer case and not what was happening here. +let requestQueue: Promise = Promise.resolve(); + +function enqueue(task: () => Promise): Promise { + const result = requestQueue.then(task, task); + requestQueue = result.then( + () => undefined, + () => undefined + ); + return result; +} async function tryRefresh(): Promise { - if (refreshInFlight) return refreshInFlight; - - refreshInFlight = (async () => { - try { - const res = await fetch("/api/auth/refresh", { - method: "POST", - headers: { "Content-Type": "application/json" }, - }); - if (!res.ok) { - clearAuth(); - return false; - } - const payload = (await res.json()) as ApiResponse; - if (payload.error) { - clearAuth(); - return false; - } - return true; - } catch { + try { + const res = await fetch("/api/auth/refresh", { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + if (!res.ok) { clearAuth(); return false; } - })(); - - try { - return await refreshInFlight; - } finally { - refreshInFlight = null; + const payload = (await res.json()) as ApiResponse; + if (payload.error) { + clearAuth(); + return false; + } + return true; + } catch { + clearAuth(); + return false; } } -export async function apiFetch( +async function performFetch( path: string, - options: RequestInit = {} + options: RequestInit ): Promise> { const headers: Record = { ...(options.headers as Record), @@ -94,7 +106,8 @@ export async function apiFetch( const refreshed = await tryRefresh(); if (refreshed) { res = await fetch(path, { ...options, headers }); - } else { + } + if (!refreshed || res.status === 401) { if (typeof window !== "undefined") { window.location.href = "/login"; } @@ -108,3 +121,10 @@ export async function apiFetch( return res.json() as Promise>; } + +export function apiFetch( + path: string, + options: RequestInit = {} +): Promise> { + return enqueue(() => performFetch(path, options)); +}