Serialize authenticated requests to fix persistent login redirect loop
The last fix only deduplicated concurrent refresh calls, but the underlying problem is broader: the backend's session nonce is single-use and rotates on every authenticated request, so ANY two concurrent authenticated calls race for it, not just refresh calls. The dashboard fires ~5 authenticated requests in parallel (summary, cashflow, merchants, accounts, transactions) plus the app shell's own profile fetch, all reading the same nonce cookie before any response updates it. Only the first to reach the server can win; losers get a nonce-mismatch 401, and if that race happens to catch the refresh call itself against another still-rotating request, refresh legitimately fails and forces a hard logout. That's what produced the "works for a while, then dumped back to login" pattern. Fix: queue every apiFetch call so only one nonce-consuming request is ever in flight per tab, refresh-and-retry included. This fully removes the race within a tab (a second tab open to the same account is a separate, much rarer case and not what was happening here). Also redirect to login if a post-refresh retry still 401s, since under serialization that means the session is genuinely invalid rather than a timing collision.
This commit is contained in:
parent
136f41020f
commit
d6b25dd78d
86
lib/api.ts
86
lib/api.ts
@ -34,47 +34,59 @@ export function clearAuth(): void {
|
|||||||
localStorage.removeItem(USER_KEY);
|
localStorage.removeItem(USER_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared in-flight refresh promise so concurrent 401s (e.g. several widgets
|
// The backend binds every authenticated request to a single-use, rotating
|
||||||
// loading at once) all await the same refresh call instead of each firing
|
// session nonce: each request must present the current nonce and immediately
|
||||||
// their own — refresh tokens are single-use, so racing calls would otherwise
|
// invalidates it for the next one. That makes concurrent authenticated
|
||||||
// invalidate each other and force a false session-expired logout.
|
// requests fundamentally unsafe — if two calls read the same nonce cookie
|
||||||
let refreshInFlight: Promise<boolean> | null = null;
|
// 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<unknown> = Promise.resolve();
|
||||||
|
|
||||||
|
function enqueue<T>(task: () => Promise<T>): Promise<T> {
|
||||||
|
const result = requestQueue.then(task, task);
|
||||||
|
requestQueue = result.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
async function tryRefresh(): Promise<boolean> {
|
async function tryRefresh(): Promise<boolean> {
|
||||||
if (refreshInFlight) return refreshInFlight;
|
try {
|
||||||
|
const res = await fetch("/api/auth/refresh", {
|
||||||
refreshInFlight = (async () => {
|
method: "POST",
|
||||||
try {
|
headers: { "Content-Type": "application/json" },
|
||||||
const res = await fetch("/api/auth/refresh", {
|
});
|
||||||
method: "POST",
|
if (!res.ok) {
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
clearAuth();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const payload = (await res.json()) as ApiResponse<unknown>;
|
|
||||||
if (payload.error) {
|
|
||||||
clearAuth();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
clearAuth();
|
clearAuth();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
})();
|
const payload = (await res.json()) as ApiResponse<unknown>;
|
||||||
|
if (payload.error) {
|
||||||
try {
|
clearAuth();
|
||||||
return await refreshInFlight;
|
return false;
|
||||||
} finally {
|
}
|
||||||
refreshInFlight = null;
|
return true;
|
||||||
|
} catch {
|
||||||
|
clearAuth();
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiFetch<T = unknown>(
|
async function performFetch<T>(
|
||||||
path: string,
|
path: string,
|
||||||
options: RequestInit = {}
|
options: RequestInit
|
||||||
): Promise<ApiResponse<T>> {
|
): Promise<ApiResponse<T>> {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
...(options.headers as Record<string, string>),
|
...(options.headers as Record<string, string>),
|
||||||
@ -94,7 +106,8 @@ export async function apiFetch<T = unknown>(
|
|||||||
const refreshed = await tryRefresh();
|
const refreshed = await tryRefresh();
|
||||||
if (refreshed) {
|
if (refreshed) {
|
||||||
res = await fetch(path, { ...options, headers });
|
res = await fetch(path, { ...options, headers });
|
||||||
} else {
|
}
|
||||||
|
if (!refreshed || res.status === 401) {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
window.location.href = "/login";
|
window.location.href = "/login";
|
||||||
}
|
}
|
||||||
@ -108,3 +121,10 @@ export async function apiFetch<T = unknown>(
|
|||||||
|
|
||||||
return res.json() as Promise<ApiResponse<T>>;
|
return res.json() as Promise<ApiResponse<T>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function apiFetch<T = unknown>(
|
||||||
|
path: string,
|
||||||
|
options: RequestInit = {}
|
||||||
|
): Promise<ApiResponse<T>> {
|
||||||
|
return enqueue(() => performFetch<T>(path, options));
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user