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.
131 lines
3.8 KiB
TypeScript
131 lines
3.8 KiB
TypeScript
// Client-side fetch helper with automatic HttpOnly cookie refresh.
|
|
|
|
export interface ApiResponse<T = unknown> {
|
|
data: T;
|
|
meta: { timestamp: string; version: "v1" };
|
|
error: null | { message: string; code?: string };
|
|
}
|
|
|
|
const USER_KEY = "ledgerone_user";
|
|
|
|
export function getStoredToken(): string {
|
|
return "";
|
|
}
|
|
|
|
export function getStoredUser<T = unknown>(): T | null {
|
|
if (typeof window === "undefined") return null;
|
|
try {
|
|
const raw = localStorage.getItem(USER_KEY);
|
|
return raw ? (JSON.parse(raw) as T) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function storeAuthTokens(data: {
|
|
accessToken?: string;
|
|
refreshToken?: string;
|
|
user: unknown;
|
|
}): void {
|
|
localStorage.setItem(USER_KEY, JSON.stringify(data.user));
|
|
}
|
|
|
|
export function clearAuth(): void {
|
|
localStorage.removeItem(USER_KEY);
|
|
}
|
|
|
|
// 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<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> {
|
|
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<unknown>;
|
|
if (payload.error) {
|
|
clearAuth();
|
|
return false;
|
|
}
|
|
return true;
|
|
} catch {
|
|
clearAuth();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function performFetch<T>(
|
|
path: string,
|
|
options: RequestInit
|
|
): Promise<ApiResponse<T>> {
|
|
const headers: Record<string, string> = {
|
|
...(options.headers as Record<string, string>),
|
|
};
|
|
if (
|
|
options.body &&
|
|
typeof options.body === "string" &&
|
|
!headers["Content-Type"]
|
|
) {
|
|
headers["Content-Type"] = "application/json";
|
|
}
|
|
|
|
let res = await fetch(path, { ...options, headers });
|
|
|
|
// Auto-refresh on 401
|
|
if (res.status === 401) {
|
|
const refreshed = await tryRefresh();
|
|
if (refreshed) {
|
|
res = await fetch(path, { ...options, headers });
|
|
}
|
|
if (!refreshed || res.status === 401) {
|
|
if (typeof window !== "undefined") {
|
|
window.location.href = "/login";
|
|
}
|
|
return {
|
|
data: null as T,
|
|
meta: { timestamp: new Date().toISOString(), version: "v1" },
|
|
error: { message: "Session expired. Please sign in again." },
|
|
};
|
|
}
|
|
}
|
|
|
|
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));
|
|
}
|