Files
emaui/libs/auth/src/lib/utils/refresh-token.ts
mihretue baea9f2b57 chore(dev): serve the portal on 3000 for the Fayda redirect
The Fayda redirect URI registered for local testing is
http://localhost:3000/callback, matched exactly by the provider, so the portal
has to be the thing listening there. Its dev and preview servers move from 4200
to 3000 and the API moves to 3001.

Every hardcoded fallback to http://localhost:3000/api follows — six copies of
the same default across libs/api, libs/auth, the portal and the backoffice —
otherwise a developer without a .env would have had the app calling itself.

e2e is unaffected: it binds its own ports (3011/4302/4303) explicitly.
2026-08-25 19:04:27 +00:00

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:3001/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;
}