import { authStorage } from "./auth-storage"; const BASE_API_URL = (import.meta as { env?: Record }).env?.[ "VITE_BASE_API_URL" ] ?? "http://localhost:3000/api"; interface RefreshResponse { token: string; refreshToken: string; } /** * Marks the one failure that actually ends a session: the server rejecting the * refresh token. Read structurally by the API layer's 401 handler — a shared * error class would mean `libs/api` importing `libs/auth`, which already * imports `libs/api`. */ const sessionExpired = (message: string) => Object.assign(new Error(message), { sessionExpired: true }); let inFlight: Promise | null = null; let signedOut = false; /** * Set on logout, cleared on login. A refresh that was already in flight when * the user (or the idle timer) signed out must not write its response back * into storage — that would silently re-authenticate an unattended desk. */ export function setSignedOut(v: boolean) { signedOut = v; } /** * Concurrent 401s must share one refresh. A page fires several requests at * once; without this each one POSTs the same refresh token, the server rotates * on the first and rejects the rest, and the losers tear down the session the * winner just renewed. */ export function refreshAccessToken(): Promise { inFlight ??= acquireAndRefresh().finally(() => { inFlight = null; }); return inFlight; } /** * Cross-tab guard on top of the in-tab one: cookies are shared per origin, so * two tabs expiring together would both POST the same rotating refresh token * and the loser would tear down the session the winner just renewed. A Web * Lock makes the second tab wait; if the first tab already refreshed while it * waited, the fresh token is sitting in storage and no request is needed. */ async function acquireAndRefresh(): Promise { if (typeof navigator === "undefined" || !navigator.locks) { // Old Safari / test env — in-tab de-dup still applies. return runRefresh(); } const tokenBefore = authStorage.getToken(); return navigator.locks.request("ema-token-refresh", async () => { const current = authStorage.getToken(); if (current && current !== tokenBefore) return current; return runRefresh(); }); } async function runRefresh(): Promise { if (signedOut) throw new Error("Signed out"); const refreshToken = authStorage.getRefreshToken(); if (!refreshToken) throw sessionExpired("No refresh token available"); // The IAM service exposes this as `refresh-token`; posting to `/auth/refresh` // 404s, which the caller would turn into a silent logout. const response = await fetch(`${BASE_API_URL}/auth/refresh-token`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refreshToken }), }); if ( response.status === 400 || response.status === 401 || response.status === 403 ) { throw sessionExpired("Refresh token rejected"); } // Anything else is the API having a bad minute — a 502, a proxy timeout. The // session is still valid, so leave it alone and let the screen report it. if (!response.ok) { throw new Error(`Token refresh failed (${response.status})`); } const data: RefreshResponse = await response.json(); // Deliberately NOT sessionExpired: the user already signed out, so there is // no session left to end — just refuse to resurrect it. if (signedOut) throw new Error("Signed out during refresh"); authStorage.setToken(data.token); if (data.refreshToken) authStorage.setRefreshToken(data.refreshToken); return data.token; }