fix: deduplicate token refresh, serialize concurrent attempts, clean up logout path

This commit is contained in:
2026-07-27 19:15:27 +00:00
parent ca110c321d
commit cc5679a1cd
4 changed files with 54 additions and 87 deletions
+43 -31
View File
@@ -66,8 +66,13 @@ function getStoredAuth() {
};
}
/** Serialize concurrent refresh attempts — only one refresh in-flight at a time. */
let _refreshPromise = null;
/**
* Attempt to refresh the access token using the stored refresh token.
* Concurrent calls wait on the in-flight refresh; subsequent calls reuse
* whatever the outcome was.
*
* Sends: POST /api/auth/refresh { refresh_token: ... }
* On success: updates ``window.__auth_token__`` and ``sessionStorage['vw:refresh']``.
@@ -76,41 +81,47 @@ function getStoredAuth() {
* @returns {Promise<boolean>} ``true`` if refresh succeeded
*/
async function tryRefreshToken() {
const stored = getStoredAuth();
if (!stored.refresh) return false;
if (_refreshPromise) return _refreshPromise;
try {
const res = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ refresh_token: stored.refresh }),
credentials: 'same-origin',
});
if (res.status !== 200) {
_refreshPromise = (async () => {
try {
const stored = getStoredAuth();
if (!stored.refresh) return false;
const res = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ refresh_token: stored.refresh }),
credentials: 'same-origin',
});
if (res.status !== 200) {
clearAuthTokens();
return false;
}
const json = await res.json();
if (!json.ok || !json.data?.tokens) {
clearAuthTokens();
return false;
}
const tokens = json.data.tokens;
window.__auth_token__ = tokens.access_token;
sessionStorage.setItem('vw:refresh', tokens.refresh_token);
sessionStorage.setItem('vw:access_ttl', String((json.data.access_ttl || 300) * 1000));
if (tokens.session_id) {
sessionStorage.setItem('vw:session_id', tokens.session_id);
}
if (json.data.user) {
sessionStorage.setItem('vw:user', JSON.stringify(json.data.user));
localStorage.setItem('vw:user', JSON.stringify(json.data.user));
}
return true;
} catch {
clearAuthTokens();
return false;
}
const json = await res.json();
if (!json.ok || !json.data?.tokens) {
clearAuthTokens();
return false;
}
const tokens = json.data.tokens;
window.__auth_token__ = tokens.access_token;
sessionStorage.setItem('vw:refresh', tokens.refresh_token);
sessionStorage.setItem('vw:access_ttl', String((json.data.access_ttl || 300) * 1000));
if (tokens.session_id) {
sessionStorage.setItem('vw:session_id', tokens.session_id);
}
if (json.data.user) {
sessionStorage.setItem('vw:user', JSON.stringify(json.data.user));
localStorage.setItem('vw:user', JSON.stringify(json.data.user));
}
return true;
} catch {
clearAuthTokens();
return false;
}
})();
_refreshPromise = _refreshPromise.finally(() => { _refreshPromise = null; });
return _refreshPromise;
}
/**
@@ -189,6 +200,7 @@ export async function apiFetch(url, options = {}) {
*/
export { setAuthToken, clearAuthTokens, getAuthToken, tryRefreshToken, redirectLogin };
/** ─── Toast notifications ────────────────────────────────── */
/** Toast notification queue. Exported for ToastContainer component. */