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:
@@ -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}}
|
||||
|
||||
Reference in New Issue
Block a user