Files
vacuum-wall/webui/static/hoover/auth_model.js
T

257 lines
11 KiB
JavaScript
Raw 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) 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<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;
}