mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
# Conflicts: # apps/portal/src/app/features/exams/pages/ExamsPage/columns.tsx # apps/portal/src/app/features/exams/pages/ExamsPage/index.tsx # libs/api/src/lib/features/licensing/licensing.helpers.ts # libs/auth/src/lib/components/AuthBootstrap.tsx
103 lines
3.6 KiB
TypeScript
103 lines
3.6 KiB
TypeScript
import { authStorage } from "./auth-storage";
|
|
|
|
const BASE_API_URL =
|
|
(import.meta as { env?: Record<string, string> }).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<string> | 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<string> {
|
|
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<string> {
|
|
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<string> {
|
|
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;
|
|
}
|