/** * Hoover — auth.js * * Token refresh scheduler, session check, logout. */ import { apiFetch, setAuthToken, clearAuthTokens, redirectLogin, getAuthToken, tryRefreshToken, toast } from '../api.js'; /** * Schedule a token refresh based on the access token TTL stored in sessionStorage. * The refresh fires at TTL - 60 seconds to allow the browser to refresh smoothly. */ export function scheduleTokenRefresh() { if (typeof window.__authRefreshTimer__ !== 'undefined') { clearTimeout(window.__authRefreshTimer__); } const ttl = parseInt(sessionStorage.getItem('vw:access_ttl'), 10) || 300000; const delay = Math.max(ttl - 60000, 30000); window.__authRefreshTimer__ = setTimeout(async () => { const ok = await tryRefreshToken(); if (ok) { scheduleTokenRefresh(); } }, delay); } /** * Stop the refresh timer (e.g. user logs out or page unloads). */ export function cancelTokenRefresh() { if (typeof window.__authRefreshTimer__ !== 'undefined') { clearTimeout(window.__authRefreshTimer__); window.__authRefreshTimer__ = undefined; } } /** * Check the current session by calling GET /api/auth/session. * Returns true if the session is valid. * * @returns {Promise} */ export async function checkSession() { const result = await apiFetch('/api/auth/session'); if (result.ok) { const { user, permissions } = result.data || {}; if (user) { sessionStorage.setItem('vw:user', JSON.stringify(user)); if (permissions) { sessionStorage.setItem('vw:permissions', JSON.stringify(permissions)); } return true; } } return false; } /** * Logout: blacklist current token and clear auth state, then redirect to login. */ export async function logout() { const token = getAuthToken(); if (token) { try { const headers = { 'Content-Type': 'application/json', 'Accept': 'application/json', 'Authorization': 'Bearer ' + token, 'X-Session-Id': sessionStorage.getItem('vw:session_id'), }; const refresh = sessionStorage.getItem('vw:refresh'); await fetch('/api/auth/logout', { method: 'POST', headers, credentials: 'same-origin', body: JSON.stringify({ refresh_token: refresh || '' }), }); } catch { // ignore errors, we're clearing everything anyway } } cancelTokenRefresh(); clearAuthTokens(); redirectLogin(); } /** * Initialize auth state on page load. * Checks stored tokens, validates session, and schedules refresh. * Since sessionStorage is cleared on tab close, a closed/reopened tab * will always fall through to reauth. * * @returns {Promise} true if authenticated */ export async function initAuth() { const token = getAuthToken(); if (token) { const saved = JSON.parse(sessionStorage.getItem('vw:user') || 'null'); if (saved) { const ok = await checkSession(); if (ok) { scheduleTokenRefresh(); return true; } } } clearAuthTokens(); return false; } /** * Handle login response: store tokens, schedule refresh, redirect. * * @param {object} data — login/migrate response data * @param {string} [redirectPath] — where to navigate after login */ export function handleLoginSuccess(data, redirectPath = '/dashboard') { const { tokens, user, permissions } = data || {}; if (tokens) { setAuthToken(tokens.access_token); sessionStorage.setItem('vw:refresh', tokens.refresh_token); sessionStorage.setItem('vw:access_ttl', String((data.access_ttl || 300) * 1000)); sessionStorage.setItem('vw:session_id', tokens.session_id); if (user) { sessionStorage.setItem('vw:user', JSON.stringify(user)); if (permissions) { sessionStorage.setItem('vw:permissions', JSON.stringify(permissions)); } } scheduleTokenRefresh(); } window.location.hash = redirectPath; } /** * Check if WebAuthn (passkeys) is supported in this browser. * * @returns {boolean} */ export function webauthnSupported() { return typeof window !== 'undefined' && !!window.PublicKeyCredential; } /** * Check if WebAuthn is enabled and available on the current domain. * Calls GET /api/auth/webauthn/capable to query the server. * * @returns {Promise} { enabled, rp_id, rp_name, origin, reason? } */ export async function checkWebAuthnCapable() { const result = await apiFetch('/api/auth/webauthn/capable'); if (!result.ok) { return { enabled: false, reason: 'Unable to check WebAuthn capability' }; } return result.data || { enabled: false, reason: 'Server returned no data' }; } /* ─── 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, }, }; }