Auto-refresh Azure access tokens on 401

The SPA's MSAL access token has a 1h lifetime. When the tab idles
past it, the first request after returns a cached-but-expired token,
the backend (correctly) 401s with "Signature has expired", and the
user has to hard-refresh. acquireTokenSilent doesn't always
pre-empt this because its expiry check can pass on the cached entry
that's then expired by the time the backend validates it.

Make the client recover: getToken now accepts { forceRefresh }, and
the api client retries any 401 once with a forced-refresh token. If
the retry also 401s we propagate (means MSAL itself can't refresh —
genuinely signed out — and the user is routed back to the gate on
the next action).

No backend change: the JWT expiry check is correct. Bypass mode is
unaffected (token is "" either way; the retry is a no-op for it).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
DJP 2026-05-15 18:20:46 -04:00
parent 30ac050af9
commit c653fe42a6
2 changed files with 20 additions and 9 deletions

View file

@ -47,21 +47,30 @@ export type Run = {
live_error: string | null;
};
type GetTokenFn = () => Promise<string>;
type GetTokenFn = (opts?: { forceRefresh?: boolean }) => Promise<string>;
async function req<T>(
path: string,
getToken: GetTokenFn,
init: RequestInit = {}
): Promise<T> {
const token = await getToken();
const headers: Record<string, string> = {
...((init.headers as Record<string, string>) || {}),
const doFetch = async (force: boolean) => {
const token = await getToken(force ? { forceRefresh: true } : undefined);
const headers: Record<string, string> = {
...((init.headers as Record<string, string>) || {}),
};
if (token) headers.Authorization = `Bearer ${token}`;
if (init.body && !headers["Content-Type"]) headers["Content-Type"] = "application/json";
return fetch(`${API_BASE}${path}`, { ...init, headers });
};
if (token) headers.Authorization = `Bearer ${token}`;
if (init.body && !headers["Content-Type"]) headers["Content-Type"] = "application/json";
const res = await fetch(`${API_BASE}${path}`, { ...init, headers });
// On 401, retry once with a freshly-refreshed token. Covers the case where
// MSAL returned a cached access token that was just past expiry by the time
// the backend validated it. If the retry also 401s we propagate the error.
let res = await doFetch(false);
if (res.status === 401) {
res = await doFetch(true);
}
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`${res.status} ${res.statusText}${text}`);

View file

@ -60,7 +60,8 @@ type AuthCtx = {
ready: boolean;
signIn: () => Promise<void>;
signOut: () => Promise<void>;
getToken: () => Promise<string>;
/** Pass { forceRefresh: true } after a 401 to bypass MSAL's cache. */
getToken: (opts?: { forceRefresh?: boolean }) => Promise<string>;
bypass: boolean;
};
@ -118,12 +119,13 @@ function RealAuthProvider({ children }: { children: ReactNode }) {
if (account) await instance.logoutPopup({ account });
}, [instance, account]);
const getToken = useCallback(async () => {
const getToken = useCallback(async (opts?: { forceRefresh?: boolean }) => {
if (!account) throw new Error("Not signed in");
try {
const r = await instance.acquireTokenSilent({
account,
scopes: REQUEST_SCOPES,
forceRefresh: opts?.forceRefresh === true,
});
return r.accessToken;
} catch (e) {