diff --git a/webui/static/hoover/api.js b/webui/static/hoover/api.js index 1d487b9..1db7a43 100644 --- a/webui/static/hoover/api.js +++ b/webui/static/hoover/api.js @@ -1,145 +1,26 @@ /** * Hoover — api.js * - * JSON-friendly fetch wrapper with automatic header management and JWT auth. + * JSON-friendly fetch wrapper with automatic header management (JWT headers + * injected from the auth model) and 401 re-authentication. * Toast notification system with auto-dismiss. * Modal processing guard for async form submissions. */ import { modelFetch } from './model.js'; +import { getAuthToken, getAuthData, refreshAuth } from './auth_model.js'; import { requestUpdate } from './reactivity.js'; import { isModalProcessing, setModalProcessing, refreshModals } from './components/modal.js'; -/** - * Global state — shared with auth.js component. - * - * ``window.__auth_token__`` — current access token (in memory). - * ``sessionStorage['vw:access']`` — persisted access token (tab-scoped). - * ``sessionStorage['vw:refresh']`` — refresh token (tab-scoped, cleared on close). - * ``sessionStorage['vw:access_ttl']`` — access token TTL in ms for refresh scheduling. - * ``sessionStorage['vw:session_id']`` — session binding ID for token validation. - */ - -/** - * Inject ``Authorization: Bearer `` header from ``window.__auth_token__``. - * Returns undefined when no token is available. - * - * @returns {string|undefined} - */ -function getAuthToken() { - return window.__auth_token__ || sessionStorage.getItem('vw:access'); -} - -/** - * Store access token in memory and schedule refresh. - * - * @param {string} token - */ -function setAuthToken(token) { - window.__auth_token__ = token; - sessionStorage.setItem('vw:access', token); -} - -/** - * Clear all auth tokens from memory and storage. - */ -function clearAuthTokens() { - window.__auth_token__ = undefined; - sessionStorage.removeItem('vw:refresh'); - sessionStorage.removeItem('vw:access_ttl'); - sessionStorage.removeItem('vw:session_id'); - sessionStorage.removeItem('vw:user'); - sessionStorage.removeItem('vw:permissions'); - sessionStorage.removeItem('vw:access'); - if (typeof window.__authRefreshTimer__ !== 'undefined') { - clearTimeout(window.__authRefreshTimer__); - window.__authRefreshTimer__ = undefined; - } -} - -/** - * Read refresh token and access TTL from sessionStorage. - * @returns {{refresh?: string, ttl?: number, session_id?: string}} - */ -function getStoredAuth() { - return { - refresh: sessionStorage.getItem('vw:refresh'), - ttl: parseInt(sessionStorage.getItem('vw:access_ttl'), 10) || 300000, - session_id: sessionStorage.getItem('vw:session_id'), - }; -} - -/** 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']``. - * On failure: clears all tokens. - * - * @returns {Promise} ``true`` if refresh succeeded - */ -async function tryRefreshToken() { - if (!_refreshPromise) { - _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, session_id: stored.session_id }), - 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; - setAuthToken(tokens.access_token); - sessionStorage.setItem('vw:refresh', tokens.refresh_token); - sessionStorage.setItem('vw:access_ttl', String((json.data.access_ttl || 300) * 1000)); - sessionStorage.setItem('vw:session_id', tokens.session_id); - if (json.data.user) { - sessionStorage.setItem('vw:user', JSON.stringify(json.data.user)); - } - return true; - } catch (err) { - console.warn('[Auth] Token refresh failed:', err); - clearAuthTokens(); - return false; - } - })(); - } - return _refreshPromise.finally(() => { _refreshPromise = null; }); -} - -/** - * Redirect to login page, clearing tokens. - */ -function redirectLogin() { - clearAuthTokens(); - window.location.href = '/#/login'; -} - /** * JSON-friendly fetch wrapper. * * Automatically sets Content-Type for object bodies, parses JSON * responses, and normalises the result to { ok, data, error, status }. - * Injects ``Authorization: Bearer`` and ``X-Session-Id`` headers when - * a token is present. On 401, tries token refresh once; on persistent - * failure, redirects to login. + * Injects ``Authorization: Bearer`` and ``X-Session-Id`` headers when a + * token is present (read from the auth model). On 401 it runs the auth + * model's refresh once and retries; a still-401 retry drives the model to + * the terminal logout state (clears storage, redirects to login). * * @param {string} url – Target URL * @param {object} [options] – Fetch options (method, body, headers, …) @@ -147,12 +28,15 @@ function redirectLogin() { */ export async function apiFetch(url, options = {}) { const { method = 'GET', body, ...opts } = options; - const headers = { 'Accept': 'application/json', ...opts.headers }; + // Drop the caller's raw headers so the merged object (with the injected + // Authorization / X-Session-Id) always wins when spread into the fetch options. + const { headers: _callerHeaders, ...safeOpts } = opts; + const headers = { 'Accept': 'application/json', ..._callerHeaders }; const token = getAuthToken(); if (token) { headers['Authorization'] = 'Bearer ' + token; - const stored = getStoredAuth(); - headers['X-Session-Id'] = stored.session_id; + const auth = getAuthData(); + if (auth?.session_id) headers['X-Session-Id'] = auth.session_id; } if (body && typeof body === 'object' && !(body instanceof FormData)) { @@ -161,29 +45,33 @@ export async function apiFetch(url, options = {}) { } try { - const res = await fetch(url, { method, headers, body: options.body, credentials: 'same-origin', ...opts }); - if (opts.signal?.aborted) { + const res = await fetch(url, { method, headers, body: options.body, credentials: 'same-origin', ...safeOpts }); + if (safeOpts.signal?.aborted) { return { ok: false, data: null, error: 'Aborted', status: 0 }; } - if (res.status === 401 && getAuthToken()) { - const refreshed = await tryRefreshToken(); - if (refreshed) { - const refreshedStored = getStoredAuth(); - headers['Authorization'] = 'Bearer ' + getAuthToken(); - headers['X-Session-Id'] = refreshedStored.session_id; - const retryRes = await fetch(url, { method, headers, body: options.body, credentials: 'same-origin', ...opts }); + if (res.status === 401 && token) { + await refreshAuth(); + const auth = getAuthData(); + if (auth?.token) { + headers['Authorization'] = 'Bearer ' + auth.token; + headers['X-Session-Id'] = auth.session_id; // rotated — re-read from model + const retryRes = await fetch(url, { method, headers, body: options.body, credentials: 'same-origin', ...safeOpts }); if (retryRes.ok) { const json = await retryRes.json().catch(() => null); return { ok: json?.ok ?? true, data: json ? (json.ok ? json.data : json) : null, error: null, status: retryRes.status }; } if (retryRes.status === 401) { - redirectLogin(); + // Refresh succeeded but the retry is still 401 — the session is + // dead. Drive the model to the terminal logout state; its + // onSuccess clears storage and redirects to #/login. + modelFetch('auth', { action: 'logout' }); return { ok: false, data: null, error: 'Session expired', status: 401 }; } const json = await retryRes.json().catch(() => null); return { ok: false, data: null, error: json?.error || `HTTP ${retryRes.status}`, status: retryRes.status }; } - redirectLogin(); + // No token after the refresh — onSuccess already cleared storage + // and redirected to #/login. return { ok: false, data: null, error: 'Session expired', status: 401 }; } const json = await res.json(); @@ -197,12 +85,6 @@ export async function apiFetch(url, options = {}) { } } -/** - * Export auth helpers for use by other modules. - */ -export { setAuthToken, clearAuthTokens, getAuthToken, tryRefreshToken, redirectLogin }; - - /** ─── Toast notifications ────────────────────────────────── */ /** Toast notification queue. Exported for ToastContainer component. */