/** * Hoover — auth.js (components) * * Thin ceremony layer over the auth model (hoover/auth_model.js), which is * the single source of truth for token storage, refresh scheduling, session * validation, and login/logout state transitions. * * This file only contains: * - logout() — POST /api/auth/logout then drive the model to terminal * - doLogin() — drive the model through the 'login' action, then navigate * - WebAuthn (passkey) ceremony helpers — not state management */ import { modelFetch } from '../model.js'; import { getAuthData } from '../auth_model.js'; /** * Logout: blacklist the current tokens server-side, then drive the model to * the terminal all-nulls state. The model's onSuccess clears storage, * redirects to #/login, and dispatches auth:logout (app.js closes the WS * socket via disconnect() — no explicit WS close here). The server reads * jti/username from the request context; refresh_token is sent in the body * per the blueprint contract (webui/api/auth.py:75). */ export async function logout() { const auth = getAuthData(); if (auth?.token) { try { await fetch('/api/auth/logout', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'Authorization': 'Bearer ' + auth.token, 'X-Session-Id': auth.session_id || '', }, credentials: 'same-origin', body: JSON.stringify({ refresh_token: auth.refresh || '' }), }); } catch { /* ignore — we're clearing everything anyway */ } } modelFetch('auth', { action: 'logout' }); } /** * Handle a successful login: drive the auth model through the 'login' * action (onSuccess persists the session, schedules the TTL refresh, and * fires auth:login), then navigate. * * @param {object} data — login response data ({ tokens, user, permissions, access_ttl }) * @param {string} [redirectPath] — where to navigate after login */ export function doLogin(data, redirectPath = '/dashboard') { modelFetch('auth', { action: 'login', payload: data }); window.location.hash = redirectPath; } /** * Check if WebAuthn (passkeys) is supported in this browser. * * @returns {boolean} */ export function webauthnSupported() { return typeof window !== 'undefined' && !!window.PublicKeyCredential; } /* ─── Base64url helpers ──────────────────────────────────────────────── */ /** * Convert base64url string to ArrayBuffer. * @param {string} b64url * @returns {ArrayBuffer} */ function b64urlToArrayBuffer(b64url) { const bin = atob(b64url.replace(/-/g, '+').replace(/_/g, '/')); const arr = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) { arr[i] = bin.charCodeAt(i); } return arr.buffer; } /** * Convert ArrayBuffer to base64url string. * @param {ArrayBuffer} buffer * @returns {string} */ function arrayBufferToB64url(buffer) { const bytes = new Uint8Array(buffer); const chunks = []; for (let i = 0; i < bytes.length; i += 0x8000) { chunks.push(String.fromCharCode.apply(null, bytes.slice(i, i + 0x8000))); } const bin = chunks.join(''); return btoa(bin) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=/g, ''); } /* ─── WebAuthn navigator wrappers ────────────────────────────────────── */ /** * Start a WebAuthn registration ceremony. * * Calls ``navigator.credentials.create()`` with the provided options, * then returns the credential response as a JSON-serializable dict * suitable for sending to the server. * * @param {object} registrationOptions — options from /webauthn/register-begin * @returns {Promise} credential response (id, rawId, type, response) */ export async function startRegistration(registrationOptions) { if (!webauthnSupported()) { throw new Error('WebAuthn is not supported in this browser'); } const publicKey = { challenge: b64urlToArrayBuffer(registrationOptions.challenge), rp: registrationOptions.rp, user: { id: b64urlToArrayBuffer(registrationOptions.user.id), name: registrationOptions.user.name, displayName: registrationOptions.user.displayName, }, pubKeyCredParams: registrationOptions.pubKeyCredParams, timeout: registrationOptions.timeout, }; if (registrationOptions.excludeCredentials) { publicKey.excludeCredentials = registrationOptions.excludeCredentials.map(c => ({ ...c, id: b64urlToArrayBuffer(c.id), })); } if (registrationOptions.authenticatorSelection) { publicKey.authenticatorSelection = registrationOptions.authenticatorSelection; } const credential = await navigator.credentials.create({ publicKey }); const { id, rawId, type, response } = credential; return { id: arrayBufferToB64url(rawId), rawId: arrayBufferToB64url(rawId), type, response: { clientDataJSON: arrayBufferToB64url(response.clientDataJSON), attestationObject: arrayBufferToB64url(response.attestationObject), transports: response.getTransports ? response.getTransports() : [], }, }; } /** * Start a WebAuthn authentication ceremony. * * Calls ``navigator.credentials.get()`` with the provided options, * then returns the assertion response as a JSON-serializable dict. * * @param {object} authenticationOptions — options from /webauthn/authenticate-begin * @returns {Promise} assertion response (id, rawId, type, response) */ export async function startAuthentication(authenticationOptions) { if (!webauthnSupported()) { throw new Error('WebAuthn is not supported in this browser'); } const publicKey = { challenge: b64urlToArrayBuffer(authenticationOptions.challenge), timeout: authenticationOptions.timeout, userVerification: authenticationOptions.userVerification || 'preferred', }; if (authenticationOptions.allowCredentials) { publicKey.allowCredentials = authenticationOptions.allowCredentials.map(c => ({ ...c, id: b64urlToArrayBuffer(c.id), })); } const credential = await navigator.credentials.get({ publicKey }); const { id, rawId, type, response } = credential; return { id: arrayBufferToB64url(rawId), rawId: arrayBufferToB64url(rawId), type, response: { clientDataJSON: arrayBufferToB64url(response.clientDataJSON), authenticatorData: arrayBufferToB64url(response.authenticatorData), signature: arrayBufferToB64url(response.signature), userHandle: response.userHandle ? arrayBufferToB64url(response.userHandle) : null, }, }; }