From 183904faadc5ba8ce978e64a799d93b93124df1b Mon Sep 17 00:00:00 2001 From: Mike Teehan Date: Mon, 17 Aug 2026 23:56:54 +0000 Subject: [PATCH] fix: seed builtin admin only on empty DB; recover page-load sessions with one refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- AGENTS.md | 1 + docs/architecture.md | 2 +- docs/hoover.md | 9 +- docs/security.md | 2 +- lib/db.py | 21 +++- scripts/bootstrap_auth.py | 7 ++ tests/test-auth-model.js | 203 ++++++++++++++++++++++++++++++ tests/test_auth.py | 70 +++++++++-- webui/static/hoover/auth_model.js | 71 +++++++---- 9 files changed, 343 insertions(+), 43 deletions(-) create mode 100644 tests/test-auth-model.js diff --git a/AGENTS.md b/AGENTS.md index 7ca96d0..69fd303 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,6 +71,7 @@ Conventions: - `VACUUM_WALLD_WS_PORT` — override WebSocket port (default `9091`) - `VACUUM_WALL_POLL_INTERVALS` — override poll intervals, e.g. `firewall:60,wireguard:5` - `VACUUM_WALL_EXTERNAL_IP_URL` — custom URL for external IP detection (acme handler) +- `VACUUM_WALL_SEED_BUILTIN_ADMIN` — set to `0` to skip the last-resort builtin admin seed in `get_db()`. The seed only runs on a completely empty DB (no users); `scripts/bootstrap_auth.py` always sets this since bootstrap creates the operator user itself. ## Local Dev diff --git a/docs/architecture.md b/docs/architecture.md index 039983e..498e1f3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -329,7 +329,7 @@ The web UI is a single-page application built on **Hoover**, a custom lightweigh ``` Client requests / ──→ nginx ──→ Flask (serves index.html) -Client loads /static/app.js ──→ Hoover initializes, checkSession() → if no valid session, render #login +Client loads /static/app.js ──→ Hoover initializes, checkSession() (401 with valid refresh token → one refresh) → if no valid session, render #login Authenticated ──→ mounts #sidebar and #main render roots apiFetch() ──→ injects Authorization: Bearer header ──→ Flask REST API Flask before_request ──→ validates JWT from header, checks blacklist, verifies permissions diff --git a/docs/hoover.md b/docs/hoover.md index 7cd8c18..9a7241c 100644 --- a/docs/hoover.md +++ b/docs/hoover.md @@ -270,7 +270,10 @@ storage cleared, refresh timer cancelled, redirect to `#/login` if not already t ``` app bootstrap → modelFetch('auth', { action: 'check' }) - → stores verified user/permissions + stored tokens → schedules refresh + → 200: stores verified user/permissions + stored tokens → schedules refresh + → 401 with a stored refresh token (stale access token after page + reload/restore): exactly one refresh attempt, then the same + success or terminal path (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 @@ -290,7 +293,9 @@ any terminal no-token result → onSuccess dispatches auth:logout - **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. + by the TTL timer, `apiFetch` 401, WS fail×3, and the bootstrap `check` 401 fallback + (exactly one refresh when the stored access token is rejected at page load while a + refresh token is still present). - **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. diff --git a/docs/security.md b/docs/security.md index f8deca6..fa1f74c 100644 --- a/docs/security.md +++ b/docs/security.md @@ -96,7 +96,7 @@ JWT-based authentication replaces HTTP Basic Auth for the management WebUI. The 1. **Login**: User submits credentials via `POST /api/auth/login`. The daemon verifies the password hash (Argon2id) against `data/auth.db`. On success, an access token (15 min) and refresh token (7 days) are issued. 2. **Validation**: Every request to Flask includes `Authorization: Bearer `. The `before_request` middleware validates the token signature, checks expiry, queries the SQLite `token_blacklist` table, and verifies per-subsystem permissions. -3. **Auto-refresh**: Before the access token expires, the frontend's `refreshScheduler()` calls `POST /api/auth/refresh` with the refresh token. The old refresh token is blacklisted and a new pair is issued. +3. **Auto-refresh**: Before the access token expires, the frontend's `refreshScheduler()` calls `POST /api/auth/refresh` with the refresh token. The old refresh token is blacklisted and a new pair is issued. At page load/restore, if the stored access token is rejected (401) on the session check, the frontend performs exactly one refresh from the stored refresh token before falling to the login page. 4. **Blacklist**: On logout (`POST /api/auth/logout`), password change, or user deletion, the affected token's `jti` is inserted into `token_blacklist`. On refresh rotation the old refresh token's `jti` is blacklisted and the new token replaces the stored row in `refresh_tokens`. One row per user means each user has a single active refresh session: a refresh from a second tab overwrites the first tab's row, and logout blacklists whichever token is currently stored. Expired blacklist entries are cleaned by the daemon's polling loop (default 60s) and by a probabilistic check inside `blacklist_token()`. Token theft protection: diff --git a/lib/db.py b/lib/db.py index dc61ab1..f505eb6 100644 --- a/lib/db.py +++ b/lib/db.py @@ -288,17 +288,30 @@ def get_db() -> Database: def _seed_builtin_admin(db: Database) -> None: - """Create the builtin admin user if they don't exist. + """Create the builtin admin user as a last-resort fallback. + + Seeding is skipped when: + + - ``VACUUM_WALL_SEED_BUILTIN_ADMIN`` is set to ``0`` — bootstrap_auth.py + sets this, since bootstrap creates the operator user itself and must + not leave a hardcoded ``admin`` with an unrecoverable random password, or + - the users table already contains users — accounts exist, so bootstrap + (or a prior start) has run. + + The seed therefore only fires on a completely empty database, i.e. + bootstrap was genuinely skipped and a service starts first. The builtin admin has full (rw) access to all subsystems and cannot be deleted or have permissions modified through the normal API. """ + if os.environ.get("VACUUM_WALL_SEED_BUILTIN_ADMIN", "1") == "0": + return + from lib.auth_users import ALL_SUBSYSTEMS, BUILTIN_ADMIN_USERNAME from lib.password import hash_password - # Check if admin already exists - rows = db.query(Q_SELECT_USER_BY_NAME, (BUILTIN_ADMIN_USERNAME,)) - if rows: + # Last-resort guard: only seed when no users exist at all. + if db.query(Q_SELECT_ALL_USERS): return # Generate a random password — this fallback should only fire if diff --git a/scripts/bootstrap_auth.py b/scripts/bootstrap_auth.py index e5330be..502b4fc 100644 --- a/scripts/bootstrap_auth.py +++ b/scripts/bootstrap_auth.py @@ -7,6 +7,10 @@ Idempotent — safe to run on every install (and re-install): - Creates the admin user if missing; if the user already exists, updates the admin password to the provided value (docs/deployment.md: "On re-run, updates the admin password if already present"). +- Suppresses the last-resort builtin admin seed (VACUUM_WALL_SEED_BUILTIN_ADMIN=0): + bootstrap is the operator user's creator on a fresh install, so exactly + one account exists and no hardcoded admin with an unrecoverable random + password is left behind. Usage: python scripts/bootstrap_auth.py --project-dir /path/to/project \ @@ -39,6 +43,9 @@ def main() -> None: db_path = str(project_dir / "data" / "auth.db") os.environ["VACUUM_WALL_DB_BACKEND"] = "sqlite" os.environ["VACUUM_WALL_DB_PATH"] = db_path + # Suppress the last-resort builtin admin seed in get_db(): bootstrap + # creates the operator user itself, so no seeded admin may shadow it. + os.environ["VACUUM_WALL_SEED_BUILTIN_ADMIN"] = "0" from lib.auth_users import ( ALL_SUBSYSTEMS, diff --git a/tests/test-auth-model.js b/tests/test-auth-model.js new file mode 100644 index 0000000..288cff3 --- /dev/null +++ b/tests/test-auth-model.js @@ -0,0 +1,203 @@ +/** + * Tests for hoover/auth_model.js + * + * Model-level tests: the 'check' action (session validation, including the + * single refresh fallback on a 401) and the 'refresh' action (rotation). + * + * auth_model.js only pulls in model.js → reactivity.js (no DOM at import), + * so the tests run under plain node with stubbed globals (fetch, + * sessionStorage, document, window, timers). + * + * Run with `node tests/test-auth-model.js`. + */ + +import { createAuthModel } from '../webui/static/hoover/auth_model.js'; +import { modelRegister, modelFetch, getModel } from '../webui/static/hoover/model.js'; + +let passed = 0; +let failed = 0; +const tests = []; + +function test(name, fn) { + tests.push({ name, fn }); +} + +function assert(cond, msg) { + if (!cond) throw new Error(msg || 'Assertion failed'); +} + +function assertEq(a, b, msg) { + if (a !== b) throw new Error((msg || 'Assertion failed') + `: got ${JSON.stringify(a)}, want ${JSON.stringify(b)}`); +} + +/* ── Stubs ─────────────────────────────────────────────────── */ + +function makeStorage(initial = {}) { + const m = new Map(Object.entries(initial)); + return { + getItem: (k) => (m.has(k) ? m.get(k) : null), + setItem: (k, v) => m.set(k, String(v)), + removeItem: (k) => m.delete(k), + keys: () => [...m.keys()], + }; +} + +/** Programmed URL → response queue. Missing routes are 500. */ +function makeFetch() { + const byUrl = new Map(); + globalThis.fetch = async (url) => { + const arr = byUrl.get(url) || []; + const r = arr.length ? arr.shift() : { status: 500, body: { ok: false, error: 'unprogrammed route' } }; + return { + ok: r.status >= 200 && r.status < 300, + status: r.status, + json: async () => r.body, + }; + }; + const state = { calls: [], route: (url, status, body) => { if (!byUrl.has(url)) byUrl.set(url, []); state.calls.push(url); byUrl.get(url).push({ status, body }); } }; + return state; +} + +/** Stub the TTL refresh timer so the node process never waits on it. */ +const _timers = []; +globalThis.setTimeout = (fn, ms) => { _timers.push({ fn, ms }); return _timers.length; }; +globalThis.clearTimeout = (id) => { if (id && _timers[id - 1]) _timers[id - 1] = undefined; }; + +const storedSession = { + 'vw:access': 'access-old', + 'vw:refresh': 'refresh-old', + 'vw:session_id': 'sess-old', + 'vw:access_ttl': '900000', +}; + +/** + * Stubs + model registration for one scenario. + * @param {{initialStorage?: object, hash?: string}} [cfg] + * @returns {{calls: string[], route: function, events: Array}} + */ +function setup({ initialStorage = storedSession, hash = '#/dashboard' } = {}) { + const { calls, route } = makeFetch(); + globalThis.sessionStorage = makeStorage(initialStorage); + globalThis.document = { location: { hash } }; + const events = []; + globalThis.window = { + dispatchEvent: (e) => events.push(e), + addEventListener: () => {}, + }; + modelRegister('auth', createAuthModel()); + return { calls, route, events }; +} + +/** Run the auth model action through the real modelFetch (dedup, hooks). */ +async function act(action) { + await modelFetch('auth', { action }); +} + +/* ── Tests ─────────────────────────────────────────────────── */ + +test('check 200 returns verified identity on the stored tokens', async () => { + const s = setup(); + s.route('/api/auth/session', 200, { + ok: true, + data: { user: { username: 'alice' }, permissions: { firewall: 'rw' } }, + }); + await act('check'); + assertEq(s.calls.length, 1, 'session check only'); + assertEq(s.calls[0], '/api/auth/session'); + const data = getModel('auth').data; + assertEq(data.token, 'access-old', 'stored access token kept'); + assertEq(data.refresh, 'refresh-old', 'stored refresh token kept'); + assertEq(data.session_id, 'sess-old', 'stored session_id kept'); + assertEq(data.user?.username, 'alice', 'verified user merged'); + assert(data.permissions?.firewall === 'rw', 'verified permissions merged'); +}); + +test('check 401 with stored refresh token: one refresh, session preserved', async () => { + const s = setup(); + s.route('/api/auth/session', 401, { ok: false, error: 'unauthorized' }); + s.route('/api/auth/refresh', 200, { + ok: true, + data: { + tokens: { access_token: 'access-new', refresh_token: 'refresh-new', session_id: 'sess-new' }, + user: { username: 'alice' }, + permissions: { firewall: 'rw' }, + access_ttl: 300, + }, + }); + await act('check'); + assertEq(s.calls.length, 2, 'exactly one refresh attempt'); + assertEq(s.calls[0], '/api/auth/session', 'session check first'); + assertEq(s.calls[1], '/api/auth/refresh', 'refresh fallback second'); + const data = getModel('auth').data; + assertEq(data.token, 'access-new', 'rotated access token'); + assertEq(data.refresh, 'refresh-new', 'rotated refresh token'); + assertEq(data.session_id, 'sess-new', 'rotated session_id binding wins'); + assertEq(data.user?.username, 'alice', 'refreshed identity'); + assertEq(globalThis.sessionStorage.getItem('vw:session_id'), 'sess-new', 'storage carries rotated session_id'); + assert(s.events.length === 0, 'no terminal event on recovered session'); +}); + +test('check 401 without refresh token: terminal, no refresh attempted', async () => { + const s = setup({ initialStorage: { 'vw:access': 'access-old', 'vw:session_id': 'sess-old' } }); + s.route('/api/auth/session', 401, { ok: false, error: 'unauthorized' }); + await act('check'); + assertEq(s.calls.length, 1, 'session check only'); + assertEq(s.calls[0], '/api/auth/session'); + const data = getModel('auth').data; + assert(!data?.token, 'terminal: no token'); + assert(globalThis.sessionStorage.keys().length === 0, 'storage cleared'); + assertEq(globalThis.document.location.hash, '/login', 'redirected to login'); + assert(s.events.some((e) => e.type === 'auth:logout'), 'terminal auth:logout dispatched'); +}); + +test('check 401 with failed refresh: terminal', async () => { + const s = setup(); + s.route('/api/auth/session', 401, { ok: false, error: 'unauthorized' }); + s.route('/api/auth/refresh', 401, { ok: false, error: 'invalid refresh token' }); + await act('check'); + assertEq(s.calls.length, 2, 'refresh was attempted'); + assert(!getModel('auth').data?.token, 'terminal: no token'); + assert(globalThis.sessionStorage.keys().length === 0, 'storage cleared'); + assertEq(globalThis.document.location.hash, '/login', 'redirected to login'); + assert(s.events.some((e) => e.type === 'auth:logout'), 'terminal auth:logout dispatched'); +}); + +test('refresh action rotates tokens; new session_id wins, omitted fields fall back', async () => { + const s = setup(); + s.route('/api/auth/refresh', 200, { + ok: true, + data: { + tokens: { access_token: 'a2', refresh_token: 'r2', session_id: 's2' }, + access_ttl: 300, + user: { username: 'alice' }, + permissions: { firewall: 'rw' }, + }, + }); + await act('refresh'); + assertEq(s.calls.length, 1, 'single refresh call'); + assertEq(s.calls[0], '/api/auth/refresh'); + const data = getModel('auth').data; + assertEq(data.token, 'a2', 'rotated access token'); + assertEq(data.refresh, 'r2', 'rotated refresh token'); + assertEq(data.session_id, 's2', 'new session_id wins'); + assertEq(data.ttl, 300 * 1000, 'ttl from access_ttl seconds → ms'); + assertEq(globalThis.sessionStorage.getItem('vw:session_id'), 's2', 'storage rotated'); + assertEq(data.user?.username, 'alice', 'user from response'); +}); + +/* ── Runner ────────────────────────────────────────────────── */ + +(async () => { + for (const { name, fn } of tests) { + try { + await fn(); + console.log(` \u2713 ${name}`); + passed++; + } catch (e) { + console.error(` \u2717 ${name}: ${e.message}`); + failed++; + } + } + console.log(`${passed + failed} tests: ${passed} passed, ${failed} failed`); + process.exitCode = failed ? 1 : 0; +})(); diff --git a/tests/test_auth.py b/tests/test_auth.py index 57e56d5..a912a77 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -26,6 +26,7 @@ from lib.auth import ( from lib.auth_users import ( create_user, delete_user, + find_user, get_user, list_users, reset_password, @@ -53,6 +54,7 @@ def _db_reset(): reset_db_for_test() old_backend = os.environ.pop("VACUUM_WALL_DB_BACKEND", None) old_path = os.environ.pop("VACUUM_WALL_DB_PATH", None) + old_seed = os.environ.pop("VACUUM_WALL_SEED_BUILTIN_ADMIN", None) os.environ["VACUUM_WALL_DB_BACKEND"] = "sqlite" os.environ["VACUUM_WALL_DB_PATH"] = ":memory:" @@ -64,6 +66,8 @@ def _db_reset(): os.environ["VACUUM_WALL_DB_BACKEND"] = old_backend if old_path is not None: os.environ["VACUUM_WALL_DB_PATH"] = old_path + if old_seed is not None: + os.environ["VACUUM_WALL_SEED_BUILTIN_ADMIN"] = old_seed @pytest.fixture @@ -1372,8 +1376,8 @@ class TestBuiltinAdminSeeding: assert user["permissions"] == {s: "rw" for s in ALL_SUBSYSTEMS} def test_seed_noop_when_admin_exists(self) -> None: - """Seeding is a no-op when the admin user already exists.""" - from lib.db import Q_SELECT_USER_BY_NAME, _seed_builtin_admin + """Seeding is a no-op when any user (here: admin) already exists.""" + from lib.db import Q_SELECT_ALL_USERS, _seed_builtin_admin db = get_db() _seed_builtin_admin(db) @@ -1382,19 +1386,20 @@ class TestBuiltinAdminSeeding: calls = {"n": 0} def counting_query(query_id, params=()): - if query_id == Q_SELECT_USER_BY_NAME: + if query_id == Q_SELECT_ALL_USERS: calls["n"] += 1 return real_query(query_id, params) with patch.object(db, "query", side_effect=counting_query): _seed_builtin_admin(db) - # Early-return path: only the existence check runs. + # Early-return path: only the users-present check runs. assert calls["n"] >= 1 def test_seed_concurrent_lose_race(self) -> None: - """Concurrent seeding: if the insert loses a race, the loser re-checks, - finds the winner's admin, and returns instead of raising IntegrityError.""" - from lib.db import Q_SELECT_USER_BY_NAME, _seed_builtin_admin + """Concurrent seeding: if the users-present check sees a stale (empty) + view and the insert then loses the race, the loser re-checks, finds + the winner's admin, and returns instead of raising IntegrityError.""" + from lib.db import Q_SELECT_ALL_USERS, _seed_builtin_admin db = get_db() # get_db() already seeded admin for this fresh in-memory DB. @@ -1404,13 +1409,60 @@ class TestBuiltinAdminSeeding: calls = {"n": 0} def fake_query(query_id, params=()): - if query_id == Q_SELECT_USER_BY_NAME and params and params[0] == "admin": + if query_id == Q_SELECT_ALL_USERS: calls["n"] += 1 if calls["n"] == 1: - return [] # stale view: existence check misses concurrent seeder + return [] # stale view: users-present check misses seeder return real_query(query_id, params) with patch.object(db, "query", side_effect=fake_query): _seed_builtin_admin(db) # must not raise assert get_user("admin") is not None + + def test_seed_skipped_when_users_exist(self, tmp_path) -> None: + """Last-resort rule: a DB that already has users gets no seeded + admin — a non-default bootstrap user must not be shadowed.""" + from lib.auth_users import ALL_SUBSYSTEMS + from lib.db_sqlite import SQLiteBackend + + db_file = tmp_path / "auth.db" + + # A prior process (the bootstrap run) created the operator account. + first = SQLiteBackend(str(db_file)) + first.init_tables() + first.run(Q_INSERT_USER, ("alice", hash_password("alice-pw"), "jwt-secret")) + for sub in ALL_SUBSYSTEMS: + first.run(Q_UPSERT_PERMISSION, ("alice", sub, "rw")) + + # A fresh service process initializes the same DB file. + reset_db_for_test() + os.environ["VACUUM_WALL_DB_PATH"] = str(db_file) + db = get_db() + + usernames = {row["username"] for row in db.query(Q_SELECT_ALL_USERS)} + assert usernames == {"alice"} + assert get_user("admin") is None + + def test_seed_skipped_via_env(self) -> None: + """VACUUM_WALL_SEED_BUILTIN_ADMIN=0 (set by bootstrap_auth.py) + suppresses the seed even on a completely empty DB.""" + os.environ["VACUUM_WALL_SEED_BUILTIN_ADMIN"] = "0" + db = get_db() + assert db.query(Q_SELECT_ALL_USERS) == [] + assert find_user("admin") is None + + def test_bootstrap_flow_creates_exactly_one_user(self) -> None: + """Simulates bootstrap_auth.py's main() on a fresh DB: with the seed + suppressed, bootstrap creates exactly the operator account and no + hardcoded admin shadow (regression for --mgmt-user != admin leaving + an unrecoverable superuser).""" + from lib.auth_users import ALL_SUBSYSTEMS + + os.environ["VACUUM_WALL_SEED_BUILTIN_ADMIN"] = "0" + assert find_user("alice") is None + create_user("alice", "secret123", {sub: "rw" for sub in ALL_SUBSYSTEMS}) + + usernames = {row["username"] for row in get_db().query(Q_SELECT_ALL_USERS)} + assert usernames == {"alice"} + assert find_user("admin") is None diff --git a/webui/static/hoover/auth_model.js b/webui/static/hoover/auth_model.js index 1963872..1fa8290 100644 --- a/webui/static/hoover/auth_model.js +++ b/webui/static/hoover/auth_model.js @@ -61,6 +61,14 @@ export function createAuthModel() { }, 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; @@ -77,32 +85,7 @@ export function createAuthModel() { } 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), - }; + return _doRefresh(); } if (action === 'login') { @@ -170,6 +153,42 @@ export function createAuthModel() { }; } +/** + * 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}}