b69ca330f4
Session binding was bypassable: if the X-Session-Id header was absent, validate_token skipped the check entirely, allowing a stolen JWT to be used without the originating session. Server-side: reject 401 early in Flask middleware and daemon WebSocket handler when X-Session-Id is missing, before calling validate_token. Updated validate_token to always enforce session_id matching for access tokens (refresh tokens are unaffected as they carry no session_id claim). Frontend: removed dead if (stored.session_id) guards in api.js since the header is now always required. Added X-Session-Id to logout request headers and always store session_id on login/refresh.
291 lines
9.5 KiB
JavaScript
291 lines
9.5 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,
|
|
'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<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));
|
|
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<object>} { 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<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,
|
|
},
|
|
};
|
|
}
|