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
This commit is contained in:
2026-08-17 23:56:54 +00:00
parent 0ed275835d
commit 183904faad
9 changed files with 343 additions and 43 deletions
+1
View File
@@ -71,6 +71,7 @@ Conventions:
- `VACUUM_WALLD_WS_PORT` — override WebSocket port (default `9091`) - `VACUUM_WALLD_WS_PORT` — override WebSocket port (default `9091`)
- `VACUUM_WALL_POLL_INTERVALS` — override poll intervals, e.g. `firewall:60,wireguard:5` - `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_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 ## Local Dev
+1 -1
View File
@@ -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 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 Authenticated ──→ mounts #sidebar and #main render roots
apiFetch() ──→ injects Authorization: Bearer <token> header ──→ Flask REST API apiFetch() ──→ injects Authorization: Bearer <token> header ──→ Flask REST API
Flask before_request ──→ validates JWT from header, checks blacklist, verifies permissions Flask before_request ──→ validates JWT from header, checks blacklist, verifies permissions
+7 -2
View File
@@ -270,7 +270,10 @@ storage cleared, refresh timer cancelled, redirect to `#/login` if not already t
``` ```
app bootstrap → modelFetch('auth', { action: 'check' }) 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) (no auth:login — initApp() calls fetchInitialData()/connect() directly)
apiFetch 401 → refreshAuth() → modelFetch('auth', { action: 'refresh' }) apiFetch 401 → refreshAuth() → modelFetch('auth', { action: 'refresh' })
→ onSuccess stores rotated tokens (new session_id) or clears + redirects → 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 - **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, (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 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`. - **No recursion** — the auth model's `fetch` uses vanilla `fetch()`, never `apiFetch`.
- **`modelFetch()` never rejects** — errors land in `model.error`; consumers branch on model - **`modelFetch()` never rejects** — errors land in `model.error`; consumers branch on model
state (`getAuthToken()` / `isAuthenticated()`), not on promise rejection. state (`getAuthToken()` / `isAuthenticated()`), not on promise rejection.
+1 -1
View File
@@ -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. 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 <token>`. The `before_request` middleware validates the token signature, checks expiry, queries the SQLite `token_blacklist` table, and verifies per-subsystem permissions. 2. **Validation**: Every request to Flask includes `Authorization: Bearer <token>`. 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()`. 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: Token theft protection:
+17 -4
View File
@@ -288,17 +288,30 @@ def get_db() -> Database:
def _seed_builtin_admin(db: Database) -> None: 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 The builtin admin has full (rw) access to all subsystems and cannot
be deleted or have permissions modified through the normal API. 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.auth_users import ALL_SUBSYSTEMS, BUILTIN_ADMIN_USERNAME
from lib.password import hash_password from lib.password import hash_password
# Check if admin already exists # Last-resort guard: only seed when no users exist at all.
rows = db.query(Q_SELECT_USER_BY_NAME, (BUILTIN_ADMIN_USERNAME,)) if db.query(Q_SELECT_ALL_USERS):
if rows:
return return
# Generate a random password — this fallback should only fire if # Generate a random password — this fallback should only fire if
+7
View File
@@ -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 - Creates the admin user if missing; if the user already exists, updates
the admin password to the provided value (docs/deployment.md: "On the admin password to the provided value (docs/deployment.md: "On
re-run, updates the admin password if already present"). 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: Usage:
python scripts/bootstrap_auth.py --project-dir /path/to/project \ 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") db_path = str(project_dir / "data" / "auth.db")
os.environ["VACUUM_WALL_DB_BACKEND"] = "sqlite" os.environ["VACUUM_WALL_DB_BACKEND"] = "sqlite"
os.environ["VACUUM_WALL_DB_PATH"] = db_path 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 ( from lib.auth_users import (
ALL_SUBSYSTEMS, ALL_SUBSYSTEMS,
+203
View File
@@ -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;
})();
+61 -9
View File
@@ -26,6 +26,7 @@ from lib.auth import (
from lib.auth_users import ( from lib.auth_users import (
create_user, create_user,
delete_user, delete_user,
find_user,
get_user, get_user,
list_users, list_users,
reset_password, reset_password,
@@ -53,6 +54,7 @@ def _db_reset():
reset_db_for_test() reset_db_for_test()
old_backend = os.environ.pop("VACUUM_WALL_DB_BACKEND", None) old_backend = os.environ.pop("VACUUM_WALL_DB_BACKEND", None)
old_path = os.environ.pop("VACUUM_WALL_DB_PATH", 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_BACKEND"] = "sqlite"
os.environ["VACUUM_WALL_DB_PATH"] = ":memory:" os.environ["VACUUM_WALL_DB_PATH"] = ":memory:"
@@ -64,6 +66,8 @@ def _db_reset():
os.environ["VACUUM_WALL_DB_BACKEND"] = old_backend os.environ["VACUUM_WALL_DB_BACKEND"] = old_backend
if old_path is not None: if old_path is not None:
os.environ["VACUUM_WALL_DB_PATH"] = old_path 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 @pytest.fixture
@@ -1372,8 +1376,8 @@ class TestBuiltinAdminSeeding:
assert user["permissions"] == {s: "rw" for s in ALL_SUBSYSTEMS} assert user["permissions"] == {s: "rw" for s in ALL_SUBSYSTEMS}
def test_seed_noop_when_admin_exists(self) -> None: def test_seed_noop_when_admin_exists(self) -> None:
"""Seeding is a no-op when the admin user already exists.""" """Seeding is a no-op when any user (here: admin) already exists."""
from lib.db import Q_SELECT_USER_BY_NAME, _seed_builtin_admin from lib.db import Q_SELECT_ALL_USERS, _seed_builtin_admin
db = get_db() db = get_db()
_seed_builtin_admin(db) _seed_builtin_admin(db)
@@ -1382,19 +1386,20 @@ class TestBuiltinAdminSeeding:
calls = {"n": 0} calls = {"n": 0}
def counting_query(query_id, params=()): def counting_query(query_id, params=()):
if query_id == Q_SELECT_USER_BY_NAME: if query_id == Q_SELECT_ALL_USERS:
calls["n"] += 1 calls["n"] += 1
return real_query(query_id, params) return real_query(query_id, params)
with patch.object(db, "query", side_effect=counting_query): with patch.object(db, "query", side_effect=counting_query):
_seed_builtin_admin(db) _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 assert calls["n"] >= 1
def test_seed_concurrent_lose_race(self) -> None: def test_seed_concurrent_lose_race(self) -> None:
"""Concurrent seeding: if the insert loses a race, the loser re-checks, """Concurrent seeding: if the users-present check sees a stale (empty)
finds the winner's admin, and returns instead of raising IntegrityError.""" view and the insert then loses the race, the loser re-checks, finds
from lib.db import Q_SELECT_USER_BY_NAME, _seed_builtin_admin the winner's admin, and returns instead of raising IntegrityError."""
from lib.db import Q_SELECT_ALL_USERS, _seed_builtin_admin
db = get_db() db = get_db()
# get_db() already seeded admin for this fresh in-memory DB. # get_db() already seeded admin for this fresh in-memory DB.
@@ -1404,13 +1409,60 @@ class TestBuiltinAdminSeeding:
calls = {"n": 0} calls = {"n": 0}
def fake_query(query_id, params=()): 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 calls["n"] += 1
if 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) return real_query(query_id, params)
with patch.object(db, "query", side_effect=fake_query): with patch.object(db, "query", side_effect=fake_query):
_seed_builtin_admin(db) # must not raise _seed_builtin_admin(db) # must not raise
assert get_user("admin") is not None 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
+45 -26
View File
@@ -61,6 +61,14 @@ export function createAuthModel() {
}, },
credentials: 'same-origin', 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; if (!r.ok) return null;
const json = await r.json(); const json = await r.json();
if (!json.ok || !json.data?.user) return null; if (!json.ok || !json.data?.user) return null;
@@ -77,32 +85,7 @@ export function createAuthModel() {
} }
if (action === 'refresh') { if (action === 'refresh') {
const stored = readStorage(); return _doRefresh();
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') { 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. * Read stored token state from sessionStorage.
* @returns {{access: string|null, refresh: string|null, session_id: string|null, ttl: number|null}} * @returns {{access: string|null, refresh: string|null, session_id: string|null, ttl: number|null}}