Files
mteehan 2b7fe1f485 ui: per-container #comp lifecycle, exp-claim auth refresh TTL
- hoover: #comp registry + expanded-content cache now per render
  container; committing one root no longer unmounts/remounts
  components owned by another root (infinite load loop on pages
  whose load() re-mutates reactive state)
- auth_model: refresh timer scheduled from the token's remaining
  exp claim (unverified decode, mirrors lib/auth.py); falls back to
  the configured TTL for non-JWT/malformed/already-expired tokens
- docs: hoover.md documents both behaviors
- tests: exp-claim TTL cases in test-auth-model.js; new
  test-render-lifecycle.js regression suite
2026-09-03 17:25:22 +00:00

311 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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. TTL is the token's REMAINING
// lifetime (exp claim), not the full issued TTL — the in-memory
// timer must fire before the actual expiry even when the session
// was restored mid-life (page reload/restore).
return {
token: stored.access,
refresh: stored.refresh,
session_id: stored.session_id,
user: json.data.user,
permissions: json.data.permissions,
ttl: tokenRemainingTtlMs(stored.access, 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: tokenRemainingTtlMs(
payload.tokens.access_token,
(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: tokenRemainingTtlMs(
t.access_token,
json.data.access_ttl ? json.data.access_ttl * 1000 : (prev?.ttl || 900 * 1000)
),
};
}
/**
* Remaining lifetime (ms) of an access token from its unverified `exp` claim.
* The payload is decoded WITHOUT signature verification — this mirrors the
* server's own unverified-payload extraction (lib/auth.py) and is used only
* to schedule the refresh timer, never to trust the claim. Returns the
* fallback when the token is malformed, undecodable, or already expired.
* @param {string} token - JWT access token
* @param {number} fallbackMs - TTL in ms when the exp claim is unusable
* @returns {number} remaining ms (> 0) or fallbackMs
*/
function tokenRemainingTtlMs(token, fallbackMs) {
try {
const payloadB64 = String(token).split('.')[1];
if (!payloadB64) return fallbackMs;
const padded = payloadB64 + '===='.slice(0, (4 - (payloadB64.length % 4)) % 4);
const payload = JSON.parse(atob(padded.replace(/-/g, '+').replace(/_/g, '/')));
if (payload && typeof payload.exp === 'number') {
const remaining = payload.exp * 1000 - Date.now();
if (remaining > 0) return remaining;
}
} catch {
/* malformed token — fall back to the configured TTL */
}
return fallbackMs;
}
/**
* 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<void>}
*/
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;
}