Files
vacuum-wall/webui/static/hoover/auth_model.js
T
mteehan 183904faad fix: seed builtin admin only on empty DB; recover page-load sessions with one refresh
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
2026-08-18 00:00:09 +00:00

276 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 && 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;
}