Stage M8
This commit is contained in:
@@ -34,9 +34,71 @@ export function getServerApi() {
|
||||
* and then break the moment customer sign-in lands in M8, which is precisely
|
||||
* the kind of latent inconsistency worth removing now.
|
||||
*/
|
||||
/**
|
||||
* In-memory access token.
|
||||
*
|
||||
* Module scope rather than React state, for the same reasons as the admin's:
|
||||
* the API client reads it from inside a `fetch` callback and it must survive
|
||||
* re-renders. It is never written to localStorage or a readable cookie —
|
||||
* those outlive the tab and are readable by any script, which is exactly what
|
||||
* an XSS payload goes looking for. The httpOnly refresh cookie is what
|
||||
* survives a reload.
|
||||
*/
|
||||
let accessToken: string | null = null;
|
||||
let onSessionLost: (() => void) | null = null;
|
||||
|
||||
/**
|
||||
* The in-flight refresh, if any.
|
||||
*
|
||||
* Without it, every request that 401s at the same moment starts its own
|
||||
* rotation — and because rotation invalidates the previous token, the second
|
||||
* one is treated as *token reuse* and revokes the whole family. A shopper with
|
||||
* two tabs open would be signed out for it. One shared promise, one rotation.
|
||||
*/
|
||||
let refreshInFlight: Promise<boolean> | null = null;
|
||||
|
||||
export const customerTokenStore = {
|
||||
get: (): string | null => accessToken,
|
||||
set: (token: string | null): void => {
|
||||
accessToken = token;
|
||||
},
|
||||
onLost: (handler: (() => void) | null): void => {
|
||||
onSessionLost = handler;
|
||||
},
|
||||
};
|
||||
|
||||
export const browserApi = createApiClient({
|
||||
baseUrl: '',
|
||||
getAccessToken: () => null, // wired to the customer auth store in M8
|
||||
getAccessToken: () => customerTokenStore.get(),
|
||||
|
||||
/**
|
||||
* Transparent re-auth: on a 401, rotate once and retry.
|
||||
*
|
||||
* Storefront-specific consequence: a shopper whose access token expires
|
||||
* mid-checkout must not be bounced to a login form. If the rotation itself
|
||||
* fails they were genuinely signed out, and the provider reacts.
|
||||
*/
|
||||
onUnauthorized: () => {
|
||||
refreshInFlight ??= (async () => {
|
||||
try {
|
||||
const refreshed = await browserApi.auth.refresh();
|
||||
customerTokenStore.set(refreshed.accessToken);
|
||||
return true;
|
||||
} catch {
|
||||
customerTokenStore.set(null);
|
||||
onSessionLost?.();
|
||||
return false;
|
||||
} finally {
|
||||
// Cleared in a microtask so every caller awaiting this rotation sees
|
||||
// the same result before a new one can start.
|
||||
queueMicrotask(() => {
|
||||
refreshInFlight = null;
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
return refreshInFlight;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user