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:
parent
30ac050af9
commit
c653fe42a6
2 changed files with 20 additions and 9 deletions
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue