fix: deduplicate token refresh, serialize concurrent attempts, clean up logout path
This commit is contained in:
+43
-31
@@ -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. */
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { refreshByTopic } from './model.js?v=9';
|
||||
import { clearAuthTokens } from './api.js?v=12';
|
||||
import { tryRefreshToken } from './api.js?v=12';
|
||||
|
||||
let _wsConn = null;
|
||||
let _wsReconnectMs = 0;
|
||||
@@ -16,49 +16,6 @@ let _wsFailCount = 0;
|
||||
/** Direct onMessage handlers — { topics, handler, unsubscribed }[] */
|
||||
const _directHandlers = [];
|
||||
|
||||
/**
|
||||
* Refresh the access token. On failure, clears all tokens to prevent
|
||||
* an infinite reconnection loop with a stale token.
|
||||
*
|
||||
* @returns {Promise<boolean>} true if token was refreshed
|
||||
*/
|
||||
async function _tryRefreshToken() {
|
||||
const refresh = sessionStorage.getItem('vw:refresh');
|
||||
if (!refresh) return false;
|
||||
try {
|
||||
const res = await fetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the WebSocket URL. Supports an override via `window.__WS_URL__`
|
||||
* (useful for proxy setups). Falls back to port 9091 when the current
|
||||
@@ -94,17 +51,22 @@ function _wsConnect() {
|
||||
_wsFailCount++;
|
||||
|
||||
if (_wsFailCount >= 3) {
|
||||
// Attempt token refresh after repeated failures. No redirect
|
||||
// on failure — the reconnect loop continues.
|
||||
// Attempt token refresh after repeated failures. The reconnect
|
||||
// is handled inside the IIFE to avoid double-scheduling when
|
||||
// refresh succeeds.
|
||||
(async () => {
|
||||
const ok = await _tryRefreshToken();
|
||||
const ok = await tryRefreshToken();
|
||||
if (ok) {
|
||||
_wsFailCount = 0;
|
||||
_wsReconnectMs = 0;
|
||||
_wsConn = null;
|
||||
setTimeout(_wsConnect, 100);
|
||||
} else {
|
||||
_wsReconnectMs = Math.min(_wsReconnectMs * 2 + 1000, 15000);
|
||||
setTimeout(_wsConnect, _wsReconnectMs);
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
_wsReconnectMs = Math.min(_wsReconnectMs * 2 + 1000, 15000);
|
||||
|
||||
Reference in New Issue
Block a user