feat: add auth subsystem with WebAuthn passkeys support

New modules: lib/auth, lib/auth_users, lib/db, lib/db_sqlite, lib/password,
lib/webauthn, daemon/handlers/auth, scripts/bootstrap_auth, tests/test_auth

Frontend: webui/api/auth, hoover/components/auth, pages/login, passkeys, users

Updates: daemon/iface and server, lib/common and nginx, pyproject.toml deps,
install script, server.py, app.js, and websocket/api clients
This commit is contained in:
2026-07-24 01:21:39 +00:00
parent 04417cf05c
commit 56b200d233
28 changed files with 4900 additions and 82 deletions
+127 -7
View File
@@ -1,20 +1,123 @@
/**
* Hoover — api.js
*
* JSON-friendly fetch wrapper with automatic header management.
* JSON-friendly fetch wrapper with automatic header management and JWT auth.
* Toast notification system with auto-dismiss.
* Modal processing guard for async form submissions.
*/
import { modelFetch } from './model.js?v=9';
import { modelFetch } from './model.js?v=10';
import { requestUpdate } from './reactivity.js?v=9';
import { isModalProcessing, setModalProcessing, refreshModals } from './components/modal.js?v=9';
/**
* Global state — shared with auth.js component.
*
* ``window.__auth_token__`` — current access token (in memory, cleared on reload).
* ``localStorage['vw:refresh']`` — refresh token (survives reload).
* ``localStorage['vw:access_ttl']`` — access token TTL in ms for refresh scheduling.
*/
/**
* Inject ``Authorization: Bearer <token>`` header from ``window.__auth_token__``.
* Returns undefined when no token is available.
*
* @returns {string|undefined}
*/
function getAuthToken() {
return window.__auth_token__;
}
/**
* Store access token in memory and schedule refresh.
*
* @param {string} token
*/
function setAuthToken(token) {
window.__auth_token__ = token;
}
/**
* Clear all auth tokens from memory and storage.
*/
function clearAuthTokens() {
window.__auth_token__ = undefined;
localStorage.removeItem('vw:refresh');
localStorage.removeItem('vw:access_ttl');
localStorage.removeItem('vw:user');
if (typeof window.__authRefreshTimer__ !== 'undefined') {
clearTimeout(window.__authRefreshTimer__);
window.__authRefreshTimer__ = undefined;
}
}
/**
* Read refresh token and access TTL from localStorage.
* @returns {{refresh?: string, ttl?: number}}
*/
function getStoredAuth() {
return {
refresh: localStorage.getItem('vw:refresh'),
ttl: parseInt(localStorage.getItem('vw:access_ttl'), 10) || 900000,
};
}
/**
* Attempt to refresh the access token using the stored refresh token.
*
* Sends: POST /api/auth/refresh { refresh_token: ... }
* On success: updates ``window.__auth_token__`` and ``localStorage['vw:refresh']``.
* On failure: clears all tokens.
*
* @returns {Promise<boolean>} ``true`` if refresh succeeded
*/
async function tryRefreshToken() {
const stored = getStoredAuth();
if (!stored.refresh) return false;
try {
const res = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ refresh_token: stored.refresh }),
credentials: 'same-origin',
});
if (res.status !== 200) {
clearAuthTokens();
return false;
}
const json = await res.json();
if (!json.ok || !json.data?.tokens) {
clearAuthTokens();
return false;
}
const tokens = json.data.tokens;
window.__auth_token__ = tokens.access_token;
localStorage.setItem('vw:refresh', tokens.refresh_token);
localStorage.setItem('vw:access_ttl', String((json.data.access_ttl || 900) * 1000));
localStorage.setItem('vw:user', JSON.stringify(json.data.user));
return true;
} catch {
clearAuthTokens();
return false;
}
}
/**
* Redirect to login page, clearing tokens.
*/
function redirectLogin() {
clearAuthTokens();
window.location.href = '/#/login';
}
/**
* JSON-friendly fetch wrapper.
*
* Automatically sets Content-Type for object bodies, parses JSON
* responses, and normalises the result to { ok, data, error, status }.
* Injects ``Authorization: Bearer`` header when a token is present.
* On 401, tries token refresh once; on persistent failure, redirects to login.
*
* @param {string} url Target URL
* @param {object} [options] Fetch options (method, body, headers, …)
@@ -23,6 +126,10 @@ import { isModalProcessing, setModalProcessing, refreshModals } from './componen
export async function apiFetch(url, options = {}) {
const { method = 'GET', body, ...opts } = options;
const headers = { 'Accept': 'application/json', ...opts.headers };
const token = getAuthToken();
if (token) {
headers['Authorization'] = 'Bearer ' + token;
}
if (body && typeof body === 'object' && !(body instanceof FormData)) {
headers['Content-Type'] = 'application/json';
@@ -30,12 +137,21 @@ export async function apiFetch(url, options = {}) {
}
try {
const res = await fetch(url, { method, headers, body: options.body, credentials: 'same-origin', ...opts });
const res = await fetch(url, { method, headers, body: options.body, credentials: 'same-origin', ...opts });
if (opts.signal?.aborted) {
return { ok: false, data: null, error: 'Aborted', status: 0 };
}
if (res.status === 401) {
window.location.reload();
if (res.status === 401 && getAuthToken()) {
const refreshed = await tryRefreshToken();
if (refreshed) {
headers['Authorization'] = 'Bearer ' + getAuthToken();
const retryRes = await fetch(url, { method, headers, body: options.body, credentials: 'same-origin', ...opts });
const json = await retryRes.json();
if (retryRes.ok) {
return { ok: json.ok, data: json.ok ? json.data : json, error: null, status: retryRes.status };
}
}
redirectLogin();
return { ok: false, data: null, error: 'Session expired', status: 401 };
}
const json = await res.json();
@@ -49,6 +165,11 @@ export async function apiFetch(url, options = {}) {
}
}
/**
* Export auth helpers for use by other modules.
*/
export { setAuthToken, clearAuthTokens, getAuthToken, tryRefreshToken, redirectLogin };
/** ─── Toast notifications ────────────────────────────────── */
/** Toast notification queue. Exported for ToastContainer component. */
@@ -195,7 +316,6 @@ export function formAction(fn) {
return async () => {
if (isModalProcessing()) return;
setModalProcessing(true);
refreshModals();
try {
await fn();
} catch (e) {
@@ -244,13 +364,13 @@ export function apiSubmit(opts) {
handler: async () => {
if (isModalProcessing()) return;
setModalProcessing(true);
refreshModals();
try {
const b = body ? body() : {};
if (validate) {
const err = validate(b);
if (err) { toast(err, 'error'); return; }
}
refreshModals();
const res = await apiFetch(url, { method, body: b });
if (res.ok) {
const synced = res.data?.synced;