diff --git a/webui/static/hoover/auth_model.js b/webui/static/hoover/auth_model.js new file mode 100644 index 0000000..1963872 --- /dev/null +++ b/webui/static/hoover/auth_model.js @@ -0,0 +1,256 @@ +/** + * Hoover — auth_model.js + * + * First-class Hoover model for the token/session lifecycle. Single source + * of truth for: + * - token storage and retrieval (sessionStorage via internal helpers) + * - refresh scheduling (TTL timer) and execution + * - session validation ('check' action) + * - login/logout state transitions + * - WS reconnection coordination (refreshAuth for websocket.js) + * + * Registered at app bootstrap: modelRegister('auth', createAuthModel()). + * + * Invariants: + * - subsystem topic 'auth' is silent — the daemon only broadcasts topics + * for its collectors and never emits 'auth', so refreshByTopic() cannot + * touch this model. Refresh is timer/401/WS-fail driven only. + * - fetch() uses vanilla fetch() — never apiFetch — preventing recursion + * (apiFetch 401 → refreshAuth → auth fetch → apiFetch → 401 …). + * - This module never imports api.js or websocket.js (would cycle: + * websocket.js imports the auth model's exports). + * - modelFetch() never rejects — consumers branch on model state + * (getAuthToken / isAuthenticated), never on promise rejection. + */ + +import { modelFetch, getModel } from './model.js'; + +/** Refresh timer handle. Not reactive — only set/cleared in lifecycle hooks. */ +let _refreshTimer = null; + +/** In-flight refresh guard — prevents concurrent refresh attempts (timer path). */ +let _refreshing = false; + +/** All-nulls data shape — returned by the 'logout' action and used as the canonical + * "not authenticated" state. */ +const EMPTY = { token: null, refresh: null, session_id: null, user: null, permissions: null, ttl: null }; + +/** + * Return the model definition object for `modelRegister('auth', ...)`. + * @returns {object} Hoover model definition + */ +export function createAuthModel() { + return { + // Silent topic — the daemon never broadcasts 'auth', so refreshByTopic() + // will never fetch this model. Refresh is timer/401/WS-fail driven only. + subsystem: 'auth', + defaultData: { ...EMPTY }, + async fetch(signal, param) { + // Param-less calls are treated as 'check' (defensive; refreshByTopic + // never reaches this model thanks to subsystem: 'auth'). + const action = param?.action || 'check'; + + if (action === 'check') { + const stored = readStorage(); + if (!stored.access) return null; // no stored session + const r = await fetch('/api/auth/session', { + headers: { + 'Accept': 'application/json', + 'Authorization': 'Bearer ' + stored.access, + 'X-Session-Id': stored.session_id || '', + }, + credentials: 'same-origin', + }); + if (!r.ok) return null; + const json = await r.json(); + if (!json.ok || !json.data?.user) return null; + // Server returns ONLY { user, permissions } — merge verified identity + // onto the stored token state. + return { + token: stored.access, + refresh: stored.refresh, + session_id: stored.session_id, + user: json.data.user, + permissions: json.data.permissions, + ttl: stored.ttl || 900 * 1000, + }; + } + + if (action === 'refresh') { + const stored = readStorage(); + if (!stored.refresh) return null; + const r = await fetch('/api/auth/refresh', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ + refresh_token: stored.refresh, + session_id: stored.session_id, + }), + }); + if (!r.ok) return null; + const json = await r.json(); + if (!json.ok || !json.data?.tokens) return null; + const t = json.data.tokens; + const prev = getModel('auth').data; // fallback for any field the server omits + // NOTE: the server mints a NEW session_id on every refresh — the rotated + // binding must win over `prev`. + return { + token: t.access_token, + refresh: t.refresh_token, + session_id: t.session_id, + user: json.data.user ?? prev?.user, + permissions: json.data.permissions ?? prev?.permissions, + ttl: json.data.access_ttl ? json.data.access_ttl * 1000 : (prev?.ttl || 900 * 1000), + }; + } + + if (action === 'login') { + const payload = param.payload; + // Defensive: successful logins always carry tokens — a malformed payload + // is treated as terminal (null → clear storage + redirect). + if (!payload?.tokens?.access_token) return null; + return { + token: payload.tokens.access_token, + refresh: payload.tokens.refresh_token, + session_id: payload.tokens.session_id, + user: payload.user, + permissions: payload.permissions, + ttl: (payload.access_ttl || 900) * 1000, + }; + } + + if (action === 'logout') { + // Intentional logout: fresh all-nulls object so model.data ends up in + // the canonical "not authenticated" state (isAuthenticated() → false). + return { ...EMPTY }; + } + + return null; + }, + onSuccess(name, data, param) { + if (!data || !data.token) { + // Logout, failed check, or failed refresh — all terminal: + // clear storage, cancel timer, redirect if needed. + clearStorage(); + if (_refreshTimer) { clearTimeout(_refreshTimer); _refreshTimer = null; } + // location.hash includes the '#' — compare against '#/login', not '/login'. + if (name === 'auth' && document.location.hash !== '#/login') { + document.location.hash = '/login'; + } + // Terminal transition — notify the app to tear down session-scoped resources + // (the WS socket; the daemon validates it only at handshake, so it would + // otherwise stay open and be reused by a same-tab relogin). The model never + // imports websocket.js (would cycle), so teardown is event-driven — the + // listener lives in app.js (see Phases 4 and 6). + window.dispatchEvent(new CustomEvent('auth:logout')); + return; + } + writeStorage(data); + scheduleRefresh(data.ttl); + // Transition event — fires ONLY for the 'login' action. Rationale: + // * 'check' (bootstrap): initApp() already calls fetchInitialData() + // and connect() on the authenticated branch — firing the event too + // would double the work. + // * 'refresh' (TTL timer, apiFetch 401, WS fail×3): the session is + // already established; re-firing would re-run fetchInitialData() + // on every ~14-minute silent refresh. + // The app.js listener defers its path check to a macrotask (see + // Phase 6), so it runs after doLogin's hashchange has landed. + if (param?.action === 'login') { + window.dispatchEvent(new CustomEvent('auth:login', { + detail: { permissions: data.permissions }, + })); + } + }, + onFailure(name, error) { + // Silent — data stays as-is. Only reachable on a real throw (network error + // inside fetch()). The no-token branch above covers the normal failure path. + }, + }; +} + +/** + * Read stored token state from sessionStorage. + * @returns {{access: string|null, refresh: string|null, session_id: string|null, ttl: number|null}} + */ +function readStorage() { + return { + access: sessionStorage.getItem('vw:access'), + refresh: sessionStorage.getItem('vw:refresh'), + session_id: sessionStorage.getItem('vw:session_id'), + ttl: parseInt(sessionStorage.getItem('vw:access_ttl'), 10) || null, + }; +} + +/** Persist token state to sessionStorage. @param {object} data - auth model data */ +function writeStorage(data) { + sessionStorage.setItem('vw:access', data.token); + sessionStorage.setItem('vw:refresh', data.refresh); + sessionStorage.setItem('vw:session_id', data.session_id); + sessionStorage.setItem('vw:access_ttl', String(data.ttl)); + sessionStorage.setItem('vw:user', JSON.stringify(data.user)); + sessionStorage.setItem('vw:permissions', JSON.stringify(data.permissions)); +} + +/** Remove all stored token state. */ +function clearStorage() { + sessionStorage.removeItem('vw:access'); + sessionStorage.removeItem('vw:refresh'); + sessionStorage.removeItem('vw:session_id'); + sessionStorage.removeItem('vw:access_ttl'); + sessionStorage.removeItem('vw:user'); + sessionStorage.removeItem('vw:permissions'); +} + +/** + * Schedule a silent refresh TTL − 60s out (min 30s). Resets any pending timer. + * @param {number} [ttl] - access token TTL in ms + */ +function scheduleRefresh(ttl) { + if (_refreshTimer) clearTimeout(_refreshTimer); + const delay = Math.max((ttl || 900 * 1000) - 60000, 30000); + _refreshTimer = setTimeout(() => { + if (!_refreshing) { + _refreshing = true; + modelFetch('auth', { action: 'refresh' }).finally(() => { _refreshing = false; }); + } + }, delay); +} + +/** + * Current access token from the auth model. + * @returns {string|undefined} + */ +export function getAuthToken() { + return getModel('auth').data?.token; +} + +/** + * Authenticated only when the model carries both a token and a user. + * @returns {boolean} + */ +export function isAuthenticated() { + const d = getModel('auth').data; + return !!(d && d.token && d.user); +} + +/** + * Trigger a token refresh via the model. + * Resolves (never rejects) — callers branch on getAuthToken() afterwards. + * modelFetch() returns undefined for unregistered models — normalize to a + * resolved promise so the result is always a thenable (defensive: the auth + * model is registered at app bootstrap before any consumer can run). + * @returns {Promise} + */ +export function refreshAuth() { + return Promise.resolve(modelFetch('auth', { action: 'refresh' })); +} + +/** + * Whole auth model data object. + * @returns {{token, refresh, session_id, user, permissions, ttl}|null} + */ +export function getAuthData() { + return getModel('auth').data; +}