From 1980043afd9b2ee9d261bcd97c10c2a770008698 Mon Sep 17 00:00:00 2001 From: Mike Teehan Date: Sat, 15 Aug 2026 08:25:40 +0000 Subject: [PATCH] docs: update hoover.md, audit residual storage reads, verify --- docs/hoover.md | 81 ++++++++++++++++++++++++++++++++++++- webui/static/pages/users.js | 6 +-- 2 files changed, 82 insertions(+), 5 deletions(-) diff --git a/docs/hoover.md b/docs/hoover.md index 744ae49..de4f74f 100644 --- a/docs/hoover.md +++ b/docs/hoover.md @@ -12,7 +12,8 @@ Hoover is the custom reactive SPA framework used by the Vacuum Wall web UI. It p | Component | `component.js` | Page definitions, lifecycle hooks, state caching | | Router | `router.js` | Hash-based SPA router, `Link` navigation component | | Model | `model.js` | **Central** reactive store per subsystem, fetch, WS invalidation, loading states | -| WebSocket | `websocket.js` | Auto-reconnect WS, topic routing to model refresh | +| Auth model | `auth_model.js` | Token/session lifecycle model: storage, refresh scheduling, session validation, login/logout transitions | +| WebSocket | `websocket.js` | Auto-reconnect WS, topic routing to model refresh, `disconnect()` (terminal-auth socket teardown) | | API | `api.js` | JSON fetch wrapper, toast notifications, form submissions | | Helpers | `helpers.js` | Escaping, DOM value helpers, zone parsing | | Components | `components/*.js` | Reusable UI: layout, data tables, modals, toasts | @@ -246,6 +247,72 @@ render(state) { Returns `{ loading, refreshing, error }` derived from the union of all passed models. +## Auth model + +`auth_model.js` is a first-class Hoover model (`modelRegister('auth', createAuthModel())`) promoted +to the single source of truth for the token/session lifecycle: token storage (sessionStorage via +internal `readStorage`/`writeStorage`/`clearStorage` helpers), refresh scheduling (TTL − 60s timer), +session validation, login/logout transitions, and WS reconnection coordination. + +Exports: `createAuthModel()` (the model definition), `getAuthToken()`, `isAuthenticated()` +(requires **both** `token` and `user`), `refreshAuth()` (always resolves — callers branch on +`getAuthToken()` afterwards, never on promise rejection), `getAuthData()` (whole data object). + +**State:** `data.token`, `data.refresh`, `data.session_id`, `data.user`, `data.permissions`, +`data.ttl` (ms), plus the standard `loading`/`refreshing`/`error` model flags and +`onSuccess`/`onFailure` lifecycle hooks. `fetch(signal, param)` takes a param object +`{ action, payload? }` — `check`, `refresh`, `login`, `logout` (param-less calls are treated as +`check`). Any fetch result without a token (`null`, or the all-nulls logout shape) is **terminal**: +storage cleared, refresh timer cancelled, redirect to `#/login` if not already there, and an +`auth:logout` window event. + +**Lifecycle:** + +``` +app bootstrap → modelFetch('auth', { action: 'check' }) + → stores verified user/permissions + stored tokens → schedules refresh + (no auth:login — initApp() calls fetchInitialData()/connect() directly) +apiFetch 401 → refreshAuth() → modelFetch('auth', { action: 'refresh' }) + → onSuccess stores rotated tokens (new session_id) or clears + redirects + (no auth:login dispatch) +timer fires (TTL − 60s) → refreshAuth() → same path +WS fail×3 → refreshAuth() → same path (branch on getAuthToken(), never on rejection) +login → modelFetch('auth', { action: 'login', payload: data }) + → onSuccess stores + schedules + fires auth:login (login action only) + → app.js listener (deferred to macrotask) → fetchInitialData() + connect() +logout → modelFetch('auth', { action: 'logout' }) → onSuccess clears + redirects +any terminal no-token result → onSuccess dispatches auth:logout + → app.js listener → disconnect() closes the WS socket +``` + +**Invariants:** + +- **Silent topic** — the subsystem topic is `'auth'` and the daemon never broadcasts it + (collectors in `lib/state.py` cover `firewall, dnsmasq, nginx, acme, wireguard, networkd, + system` only), so `refreshByTopic()` never fetches the auth model. Auth refresh is driven + exclusively by the TTL timer, `apiFetch` 401, and WS fail×3. +- **No recursion** — the auth model's `fetch` uses vanilla `fetch()`, never `apiFetch`. +- **`modelFetch()` never rejects** — errors land in `model.error`; consumers branch on model + state (`getAuthToken()` / `isAuthenticated()`), not on promise rejection. +- **Single storage writer** — all `vw:*` sessionStorage keys are read/written through the + model's internal helpers only. +- **Event gating** — `auth:login` fires only for the `login` action (the `param.action` gate in + `onSuccess`); the bootstrap `check` and silent TTL `refresh`es must not re-fire it, or the + app.js listener would re-run `fetchInitialData()`/`connect()` on top of `initApp`'s direct + calls. `auth:logout` fires on every terminal (no-token) transition; its only listener + (app.js) calls `disconnect()` from `websocket.js`. The model never imports `websocket.js` + (would cycle) — the event inverts the dependency. +- **Session binding rotation** — the server mints a new `session_id` on every refresh; any + post-refresh request (the `apiFetch` 401 retry, the WS handshake) must re-read **both** + `Authorization` and `X-Session-Id` from `getAuthData()`. +- **Concurrent refresh guard** — `modelFetch`'s in-flight dedup (distinct key per param object: + `name + ':' + JSON.stringify(param)`) is the primary guard shared by all refresh paths + (timer, 401, WS fail×3); a module-level `_refreshing` flag in `auth_model.js` is a redundant + secondary guard for the timer path. +- **Socket teardown necessity** — the daemon validates the WS token only at handshake, so + without the terminal `auth:logout` → `disconnect()` path the previous user's socket would + survive logout and be reused by a same-tab relogin (`connect()` no-ops on a live socket). + ## Virtual DOM ### `h(tag, props, ...children)` @@ -479,6 +546,15 @@ Link({ path: '/zones', class: 'active', children: ['Zones'] }) Start the WebSocket connection to the daemon at `ws:///ws` (auto-detects `wss:` for HTTPS). Set `window.__WS_URL__` to override. Auto-reconnects with exponential backoff (max 15s). +The JWT is read from the auth model and sent in the WebSocket subprotocol header (`Bearer `). With no token, no socket is created (the daemon 401s unauthenticated WS connections). After 3 consecutive close failures a token refresh is triggered through the auth model; reconnection branches on the model's token state (`getAuthToken()`), never on the refresh promise. + +### `disconnect()` + +Close the WS socket (terminal auth transition — logout, failed session check, failed refresh, +or the 401 session-death path). The daemon validates the WS token only at handshake, so the +socket must be closed explicitly on a terminal transition; `app.js` listens for the +`auth:logout` event and calls `disconnect()`. + ### WS Message Types | Type | Fields | Effect | @@ -526,7 +602,8 @@ const res = await apiFetch('/api/firewall/zones', { method: 'GET' }); - Automatically sets `Accept: application/json`. - If `body` is a plain object (not `FormData`), stringifies it and sets `Content-Type: application/json`. -- On HTTP 401, reloads the page (session expired). +- When authenticated, injects `Authorization: Bearer ` and `X-Session-Id` headers from the auth model. Caller-passed `options.headers` are merged under the injected values — they can never override them. +- On HTTP 401 (with a token present), triggers a model-driven token refresh via the auth model, then retries the request with the rotated `Authorization` and `X-Session-Id` (the session binding rotates on every refresh). If the retry still 401s (session dead) or the refresh fails, the model is driven to the terminal state: storage is cleared and the user is redirected to `#/login`. - On non-2xx, returns `{ ok: false, error: "message", status }`. - On network error, returns `{ ok: false, error: "Network error", status: 0 }`. - Passes `credentials: 'same-origin'` by default. diff --git a/webui/static/pages/users.js b/webui/static/pages/users.js index ce09b7c..c71a89e 100644 --- a/webui/static/pages/users.js +++ b/webui/static/pages/users.js @@ -5,7 +5,7 @@ * Requires auth: rw permission. */ -import { h, definePage, reactive, html, PageHeader, Table, Badge, ConfirmDelete, openModal, closeModal, formModal, apiFetch, toast, esc } from '/static/hoover/index.js'; +import { h, definePage, reactive, html, PageHeader, Table, Badge, ConfirmDelete, openModal, closeModal, formModal, apiFetch, toast, esc, getAuthData } from '/static/hoover/index.js'; const BUILTIN_ADMIN = 'admin'; @@ -22,12 +22,12 @@ const SUBSYSTEMS = [ ]; function currentUser() { - const u = JSON.parse(sessionStorage.getItem('vw:user') || 'null'); + const u = getAuthData()?.user; return u ? u.username : ''; } function hasAuthAdmin() { - const perms = JSON.parse(sessionStorage.getItem('vw:permissions') || 'null'); + const perms = getAuthData()?.permissions; return perms && perms.auth === 'rw'; }