/** * 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 && stored.refresh) { // Stale access token (e.g. page reload/restore: the in-memory // TTL timer is gone and the token may have expired server-side, // while the 7-day refresh token is still in sessionStorage) — // attempt exactly one refresh before treating the session // as dead. A failed refresh falls through to the terminal path. return _doRefresh(); } 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') { return _doRefresh(); } 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. }, }; } /** * Rotate the token pair via POST /api/auth/refresh using the stored refresh * token. Shared by the 'refresh' action and the 'check' 401 fallback. * @returns {Promise<{token, refresh, session_id, user, permissions, ttl}|null>} * The rotated token state, or null when the refresh token is missing, * invalid, expired, or blacklisted. */ async function _doRefresh() { 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), }; } /** * 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; }