183904faad
Auth seeding (last-resort guard) - `_seed_builtin_admin()` in get_db() now skips when VACUUM_WALL_SEED_BUILTIN_ADMIN=0 or when the users table already contains any user — previously a fresh service start after a non-default bootstrap (e.g. --mgmt-user alice) seeded a hard-coded `admin` with an unrecoverable random password, shadowing the operator's account - bootstrap_auth.py sets VACUUM_WALL_SEED_BUILTIN_ADMIN=0: bootstrap creates the operator user itself on a fresh install, so exactly one account exists and no seeded admin can appear Frontend (session recovery) - on page load/restore the in-memory TTL timer is gone, so a valid 7-day refresh token could sit in sessionStorage while the access token is already expired server-side: the session `check` now attempts exactly one refresh (POST /api/auth/refresh with the stored refresh token) on 401 before treating the session as dead - extract shared `_doRefresh()` used by both the `check` 401 fallback and the `refresh` action (removes the duplicated rotation logic) Tests - update seeding tests to the new any-user-present check; add test_seed_skipped_when_users_exist, test_seed_skipped_via_env, test_bootstrap_flow_creates_exactly_one_user, and the auth-model JS test suite (tests/test-auth-model.js) Docs - AGENTS.md: document VACUUM_WALL_SEED_BUILTIN_ADMIN - architecture.md / hoover.md / security.md: describe the bootstrap check 401 → one-refresh fallback path
276 lines
11 KiB
JavaScript
276 lines
11 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.
|
||
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<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;
|
||
}
|