2b7fe1f485
- 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
311 lines
13 KiB
JavaScript
311 lines
13 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|