From cc5679a1cd2344a6cd87fb44d02816b1385bf08f Mon Sep 17 00:00:00 2001 From: Mike Teehan Date: Mon, 27 Jul 2026 19:15:27 +0000 Subject: [PATCH] fix: deduplicate token refresh, serialize concurrent attempts, clean up logout path --- daemon/handlers/auth.py | 9 ---- lib/auth_users.py | 2 + webui/static/hoover/api.js | 74 +++++++++++++++++++------------- webui/static/hoover/websocket.js | 56 ++++-------------------- 4 files changed, 54 insertions(+), 87 deletions(-) diff --git a/daemon/handlers/auth.py b/daemon/handlers/auth.py index b68dddd..e218c40 100644 --- a/daemon/handlers/auth.py +++ b/daemon/handlers/auth.py @@ -34,7 +34,6 @@ from lib.auth import ( check_login_rate, check_webauthn_rate, clear_active_refresh_token, - decode_token, generate_tokens, get_access_ttl, validate_token, @@ -131,14 +130,6 @@ def auth_logout(request: Any, body: Any) -> dict[str, Any]: if 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") if username: blacklist_active_refresh_token(username) diff --git a/lib/auth_users.py b/lib/auth_users.py index f178850..b1f64e8 100644 --- a/lib/auth_users.py +++ b/lib/auth_users.py @@ -263,8 +263,10 @@ def delete_user(username: str) -> bool: if user is None: raise ValueError(f"User {username!r} not found") + blacklist_active_refresh_token(username) db = get_db() db.run(Q_DELETE_USER, (username,)) + _cleanup_blacklist() return True diff --git a/webui/static/hoover/api.js b/webui/static/hoover/api.js index f8a9b49..8b4cdfa 100644 --- a/webui/static/hoover/api.js +++ b/webui/static/hoover/api.js @@ -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} ``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. */ diff --git a/webui/static/hoover/websocket.js b/webui/static/hoover/websocket.js index 3d7b3c5..0b27746 100644 --- a/webui/static/hoover/websocket.js +++ b/webui/static/hoover/websocket.js @@ -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} 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);