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
-9
View File
@@ -34,7 +34,6 @@ from lib.auth import (
check_login_rate, check_login_rate,
check_webauthn_rate, check_webauthn_rate,
clear_active_refresh_token, clear_active_refresh_token,
decode_token,
generate_tokens, generate_tokens,
get_access_ttl, get_access_ttl,
validate_token, validate_token,
@@ -131,14 +130,6 @@ def auth_logout(request: Any, body: Any) -> dict[str, Any]:
if jti: if jti:
blacklist_token(jti) blacklist_token(jti)
refresh_token = body.get("refresh_token")
if refresh_token:
payload = decode_token(refresh_token)
if payload:
refresh_jti = payload.get("jti")
if refresh_jti:
blacklist_token(refresh_jti, token_type="refresh")
username = body.get("username") username = body.get("username")
if username: if username:
blacklist_active_refresh_token(username) blacklist_active_refresh_token(username)
+2
View File
@@ -263,8 +263,10 @@ def delete_user(username: str) -> bool:
if user is None: if user is None:
raise ValueError(f"User {username!r} not found") raise ValueError(f"User {username!r} not found")
blacklist_active_refresh_token(username)
db = get_db() db = get_db()
db.run(Q_DELETE_USER, (username,)) db.run(Q_DELETE_USER, (username,))
_cleanup_blacklist()
return True return True
+13 -1
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. * 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: ... } * Sends: POST /api/auth/refresh { refresh_token: ... }
* On success: updates ``window.__auth_token__`` and ``sessionStorage['vw:refresh']``. * On success: updates ``window.__auth_token__`` and ``sessionStorage['vw:refresh']``.
@@ -76,10 +81,13 @@ function getStoredAuth() {
* @returns {Promise<boolean>} ``true`` if refresh succeeded * @returns {Promise<boolean>} ``true`` if refresh succeeded
*/ */
async function tryRefreshToken() { async function tryRefreshToken() {
if (_refreshPromise) return _refreshPromise;
_refreshPromise = (async () => {
try {
const stored = getStoredAuth(); const stored = getStoredAuth();
if (!stored.refresh) return false; if (!stored.refresh) return false;
try {
const res = await fetch('/api/auth/refresh', { const res = await fetch('/api/auth/refresh', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
@@ -111,6 +119,9 @@ async function tryRefreshToken() {
clearAuthTokens(); clearAuthTokens();
return false; 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 }; export { setAuthToken, clearAuthTokens, getAuthToken, tryRefreshToken, redirectLogin };
/** ─── Toast notifications ────────────────────────────────── */ /** ─── Toast notifications ────────────────────────────────── */
/** Toast notification queue. Exported for ToastContainer component. */ /** Toast notification queue. Exported for ToastContainer component. */
+9 -47
View File
@@ -7,7 +7,7 @@
*/ */
import { refreshByTopic } from './model.js?v=9'; 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 _wsConn = null;
let _wsReconnectMs = 0; let _wsReconnectMs = 0;
@@ -16,49 +16,6 @@ let _wsFailCount = 0;
/** Direct onMessage handlers — { topics, handler, unsubscribed }[] */ /** Direct onMessage handlers — { topics, handler, unsubscribed }[] */
const _directHandlers = []; 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__` * Build the WebSocket URL. Supports an override via `window.__WS_URL__`
* (useful for proxy setups). Falls back to port 9091 when the current * (useful for proxy setups). Falls back to port 9091 when the current
@@ -94,17 +51,22 @@ function _wsConnect() {
_wsFailCount++; _wsFailCount++;
if (_wsFailCount >= 3) { if (_wsFailCount >= 3) {
// Attempt token refresh after repeated failures. No redirect // Attempt token refresh after repeated failures. The reconnect
// on failure — the reconnect loop continues. // is handled inside the IIFE to avoid double-scheduling when
// refresh succeeds.
(async () => { (async () => {
const ok = await _tryRefreshToken(); const ok = await tryRefreshToken();
if (ok) { if (ok) {
_wsFailCount = 0; _wsFailCount = 0;
_wsReconnectMs = 0; _wsReconnectMs = 0;
_wsConn = null; _wsConn = null;
setTimeout(_wsConnect, 100); setTimeout(_wsConnect, 100);
} else {
_wsReconnectMs = Math.min(_wsReconnectMs * 2 + 1000, 15000);
setTimeout(_wsConnect, _wsReconnectMs);
} }
})(); })();
return;
} }
_wsReconnectMs = Math.min(_wsReconnectMs * 2 + 1000, 15000); _wsReconnectMs = Math.min(_wsReconnectMs * 2 + 1000, 15000);