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:
+127
-7
@@ -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;
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* Hoover — auth.js
|
||||
*
|
||||
* Token refresh scheduler, session check, logout.
|
||||
*/
|
||||
|
||||
import { apiFetch, setAuthToken, clearAuthTokens, redirectLogin, getAuthToken, tryRefreshToken, toast } from '../api.js?v=12';
|
||||
|
||||
/**
|
||||
* Schedule a token refresh based on the access token TTL stored in localStorage.
|
||||
* 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(localStorage.getItem('vw:access_ttl'), 10) || 900000;
|
||||
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) {
|
||||
localStorage.setItem('vw:user', JSON.stringify(user));
|
||||
if (permissions) {
|
||||
localStorage.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 = localStorage.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.
|
||||
*
|
||||
* @returns {Promise<boolean>} true if authenticated
|
||||
*/
|
||||
export async function initAuth() {
|
||||
const token = getAuthToken();
|
||||
if (token) {
|
||||
const saved = JSON.parse(localStorage.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);
|
||||
localStorage.setItem('vw:refresh', tokens.refresh_token);
|
||||
localStorage.setItem('vw:access_ttl', String((data.access_ttl || 900) * 1000));
|
||||
if (user) {
|
||||
localStorage.setItem('vw:user', JSON.stringify(user));
|
||||
if (permissions) {
|
||||
localStorage.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 bin = String.fromCharCode.apply(null, Array.from(bytes));
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -191,7 +191,8 @@ export function formModal(inner, title, fields, actions) {
|
||||
if (a.handler) {
|
||||
const origHandler = a.handler;
|
||||
btn.addEventListener('click', () => {
|
||||
refreshModals();
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="btn-spinner"></span>';
|
||||
origHandler();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,10 +23,13 @@ export { definePage, hComp } from './component.js?v=9';
|
||||
export { createRouter, Link } from './router.js?v=9';
|
||||
|
||||
/* ── WebSocket ───────────────────────────────────────────────── */
|
||||
export { connect, onMessage } from './websocket.js?v=9';
|
||||
export { connect, onMessage } from './websocket.js?v=10';
|
||||
|
||||
/* ── API & Toast ─────────────────────────────────────────────── */
|
||||
export { apiFetch, toast, dismissToast, apiSubmit, checkAbort, poll, refactorLoad, formAction } from './api.js?v=9';
|
||||
export { apiFetch, toast, dismissToast, apiSubmit, checkAbort, poll, refactorLoad, formAction, setAuthToken, clearAuthTokens, getAuthToken } from './api.js?v=12';
|
||||
|
||||
/* ── UI Components: Auth ──────────────────────────────────────── */
|
||||
export { scheduleTokenRefresh, cancelTokenRefresh, checkSession, logout, initAuth, handleLoginSuccess, webauthnSupported, startRegistration, startAuthentication } from './components/auth.js?v=2';
|
||||
|
||||
/* ── Model ───────────────────────────────────────────────────── */
|
||||
export { modelRegister, getModel, modelFetch, collectLoadingModels } from './model.js?v=9';
|
||||
@@ -41,7 +44,7 @@ export { PageHeader, renderGuard, renderGuardMulti, Tabs, SectionTitle, ActionGr
|
||||
export { Badge, StatusDot, Empty, Card, ConfirmDelete, Table, ActionButton, certStatusBadge, serviceStatusBadge, StatCard, StatusText, ServiceStatus, ActionCell, MonoText, ZoneSelect } from './components/data.js?v=9';
|
||||
|
||||
/* ── UI Components: Modal ────────────────────────────────────── */
|
||||
export { openModal, closeModal, closeAllModals, modalVNodes, refreshModals, formModal, MultiSelectModal, QuickModal, isModalProcessing, setModalProcessing } from './components/modal.js?v=9';
|
||||
export { openModal, closeModal, closeAllModals, modalVNodes, refreshModals, formModal, MultiSelectModal, QuickModal, isModalProcessing, setModalProcessing } from './components/modal.js?v=10';
|
||||
|
||||
/* ── UI Components: Apply ────────────────────────────────────── */
|
||||
export { ApplyConfirm } from './components/applyconfirm.js?v=9';
|
||||
|
||||
@@ -10,10 +10,41 @@ import { refreshByTopic } from './model.js?v=9';
|
||||
|
||||
let _wsConn = null;
|
||||
let _wsReconnectMs = 0;
|
||||
let _wsFailCount = 0;
|
||||
|
||||
/** Direct onMessage handlers: { topics, handler, unsubscribed }[] */
|
||||
/** Direct onMessage handlers — { topics, handler, unsubscribed }[] */
|
||||
const _directHandlers = [];
|
||||
|
||||
/**
|
||||
* Refresh the access token. Does NOT redirect on failure — the caller
|
||||
* decides what to do when refresh fails.
|
||||
*
|
||||
* @returns {Promise<boolean>} true if token was refreshed
|
||||
*/
|
||||
async function _tryRefreshToken() {
|
||||
const refresh = localStorage.getItem('vw:refresh');
|
||||
if (!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: refresh }),
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (res.status !== 200) return false;
|
||||
const json = await res.json();
|
||||
if (!json.ok || !json.data?.tokens) 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 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the WebSocket URL. Supports an override via `window.__WS_URL__`
|
||||
* (useful for proxy setups). Falls back to port 9091 when the current
|
||||
@@ -25,17 +56,43 @@ function _wsUrl() {
|
||||
return proto + '//' + location.host + '/ws';
|
||||
}
|
||||
|
||||
/** Attempt a WebSocket connection. */
|
||||
/** Attempt a WebSocket connection.
|
||||
* Passes the JWT in the WebSocket subprotocol header (Sec-WebSocket-Protocol)
|
||||
* instead of a query parameter, keeping it out of logs and browser history.
|
||||
*/
|
||||
function _wsConnect() {
|
||||
if (_wsConn && _wsConn.readyState <= 1) return;
|
||||
|
||||
_wsConn = new WebSocket(_wsUrl());
|
||||
const token = window.__auth_token__;
|
||||
if (token) {
|
||||
_wsConn = new WebSocket(_wsUrl(), ['Bearer ' + token]);
|
||||
} else {
|
||||
_wsConn = new WebSocket(_wsUrl());
|
||||
}
|
||||
|
||||
_wsConn.onopen = () => {
|
||||
_wsReconnectMs = 0;
|
||||
_wsFailCount = 0;
|
||||
};
|
||||
|
||||
_wsConn.onclose = () => {
|
||||
if (!window.__auth_token__) return;
|
||||
_wsFailCount++;
|
||||
|
||||
if (_wsFailCount >= 3) {
|
||||
// Attempt token refresh after repeated failures. No redirect
|
||||
// on failure — the reconnect loop continues.
|
||||
(async () => {
|
||||
const ok = await _tryRefreshToken();
|
||||
if (ok) {
|
||||
_wsFailCount = 0;
|
||||
_wsReconnectMs = 0;
|
||||
_wsConn = null;
|
||||
setTimeout(_wsConnect, 100);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
_wsReconnectMs = Math.min(_wsReconnectMs * 2 + 1000, 15000);
|
||||
setTimeout(_wsConnect, _wsReconnectMs);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user