Files
vacuum-wall/webui/static/hoover/components/auth.js
T
mteehan 3de82e3b9b Remove query-string cache-busting from static assets
Drop ?v=N version pins from all JS imports and HTML <link>/<script> tags.
Cache invalidation is now handled solely by server-side cache-control headers.
Update docs and AGENTS.md accordingly.
2026-07-28 13:50:22 +00:00

278 lines
8.9 KiB
JavaScript

/**
* 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<boolean>}
*/
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,
};
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<boolean>} 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));
if (tokens.session_id) {
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;
}
/* ─── 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<object>} 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<object>} 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,
},
};
}