auth: create auth model definition (hoover/auth_model.js)
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Hoover — auth_model.js
|
||||
*
|
||||
* First-class Hoover model for the token/session lifecycle. Single source
|
||||
* of truth for:
|
||||
* - token storage and retrieval (sessionStorage via internal helpers)
|
||||
* - refresh scheduling (TTL timer) and execution
|
||||
* - session validation ('check' action)
|
||||
* - login/logout state transitions
|
||||
* - WS reconnection coordination (refreshAuth for websocket.js)
|
||||
*
|
||||
* Registered at app bootstrap: modelRegister('auth', createAuthModel()).
|
||||
*
|
||||
* Invariants:
|
||||
* - subsystem topic 'auth' is silent — the daemon only broadcasts topics
|
||||
* for its collectors and never emits 'auth', so refreshByTopic() cannot
|
||||
* touch this model. Refresh is timer/401/WS-fail driven only.
|
||||
* - fetch() uses vanilla fetch() — never apiFetch — preventing recursion
|
||||
* (apiFetch 401 → refreshAuth → auth fetch → apiFetch → 401 …).
|
||||
* - This module never imports api.js or websocket.js (would cycle:
|
||||
* websocket.js imports the auth model's exports).
|
||||
* - modelFetch() never rejects — consumers branch on model state
|
||||
* (getAuthToken / isAuthenticated), never on promise rejection.
|
||||
*/
|
||||
|
||||
import { modelFetch, getModel } from './model.js';
|
||||
|
||||
/** Refresh timer handle. Not reactive — only set/cleared in lifecycle hooks. */
|
||||
let _refreshTimer = null;
|
||||
|
||||
/** In-flight refresh guard — prevents concurrent refresh attempts (timer path). */
|
||||
let _refreshing = false;
|
||||
|
||||
/** All-nulls data shape — returned by the 'logout' action and used as the canonical
|
||||
* "not authenticated" state. */
|
||||
const EMPTY = { token: null, refresh: null, session_id: null, user: null, permissions: null, ttl: null };
|
||||
|
||||
/**
|
||||
* Return the model definition object for `modelRegister('auth', ...)`.
|
||||
* @returns {object} Hoover model definition
|
||||
*/
|
||||
export function createAuthModel() {
|
||||
return {
|
||||
// Silent topic — the daemon never broadcasts 'auth', so refreshByTopic()
|
||||
// will never fetch this model. Refresh is timer/401/WS-fail driven only.
|
||||
subsystem: 'auth',
|
||||
defaultData: { ...EMPTY },
|
||||
async fetch(signal, param) {
|
||||
// Param-less calls are treated as 'check' (defensive; refreshByTopic
|
||||
// never reaches this model thanks to subsystem: 'auth').
|
||||
const action = param?.action || 'check';
|
||||
|
||||
if (action === 'check') {
|
||||
const stored = readStorage();
|
||||
if (!stored.access) return null; // no stored session
|
||||
const r = await fetch('/api/auth/session', {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Authorization': 'Bearer ' + stored.access,
|
||||
'X-Session-Id': stored.session_id || '',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (!r.ok) return null;
|
||||
const json = await r.json();
|
||||
if (!json.ok || !json.data?.user) return null;
|
||||
// Server returns ONLY { user, permissions } — merge verified identity
|
||||
// onto the stored token state.
|
||||
return {
|
||||
token: stored.access,
|
||||
refresh: stored.refresh,
|
||||
session_id: stored.session_id,
|
||||
user: json.data.user,
|
||||
permissions: json.data.permissions,
|
||||
ttl: stored.ttl || 900 * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
if (action === 'refresh') {
|
||||
const stored = readStorage();
|
||||
if (!stored.refresh) return null;
|
||||
const r = await fetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
refresh_token: stored.refresh,
|
||||
session_id: stored.session_id,
|
||||
}),
|
||||
});
|
||||
if (!r.ok) return null;
|
||||
const json = await r.json();
|
||||
if (!json.ok || !json.data?.tokens) return null;
|
||||
const t = json.data.tokens;
|
||||
const prev = getModel('auth').data; // fallback for any field the server omits
|
||||
// NOTE: the server mints a NEW session_id on every refresh — the rotated
|
||||
// binding must win over `prev`.
|
||||
return {
|
||||
token: t.access_token,
|
||||
refresh: t.refresh_token,
|
||||
session_id: t.session_id,
|
||||
user: json.data.user ?? prev?.user,
|
||||
permissions: json.data.permissions ?? prev?.permissions,
|
||||
ttl: json.data.access_ttl ? json.data.access_ttl * 1000 : (prev?.ttl || 900 * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
if (action === 'login') {
|
||||
const payload = param.payload;
|
||||
// Defensive: successful logins always carry tokens — a malformed payload
|
||||
// is treated as terminal (null → clear storage + redirect).
|
||||
if (!payload?.tokens?.access_token) return null;
|
||||
return {
|
||||
token: payload.tokens.access_token,
|
||||
refresh: payload.tokens.refresh_token,
|
||||
session_id: payload.tokens.session_id,
|
||||
user: payload.user,
|
||||
permissions: payload.permissions,
|
||||
ttl: (payload.access_ttl || 900) * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
if (action === 'logout') {
|
||||
// Intentional logout: fresh all-nulls object so model.data ends up in
|
||||
// the canonical "not authenticated" state (isAuthenticated() → false).
|
||||
return { ...EMPTY };
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
onSuccess(name, data, param) {
|
||||
if (!data || !data.token) {
|
||||
// Logout, failed check, or failed refresh — all terminal:
|
||||
// clear storage, cancel timer, redirect if needed.
|
||||
clearStorage();
|
||||
if (_refreshTimer) { clearTimeout(_refreshTimer); _refreshTimer = null; }
|
||||
// location.hash includes the '#' — compare against '#/login', not '/login'.
|
||||
if (name === 'auth' && document.location.hash !== '#/login') {
|
||||
document.location.hash = '/login';
|
||||
}
|
||||
// Terminal transition — notify the app to tear down session-scoped resources
|
||||
// (the WS socket; the daemon validates it only at handshake, so it would
|
||||
// otherwise stay open and be reused by a same-tab relogin). The model never
|
||||
// imports websocket.js (would cycle), so teardown is event-driven — the
|
||||
// listener lives in app.js (see Phases 4 and 6).
|
||||
window.dispatchEvent(new CustomEvent('auth:logout'));
|
||||
return;
|
||||
}
|
||||
writeStorage(data);
|
||||
scheduleRefresh(data.ttl);
|
||||
// Transition event — fires ONLY for the 'login' action. Rationale:
|
||||
// * 'check' (bootstrap): initApp() already calls fetchInitialData()
|
||||
// and connect() on the authenticated branch — firing the event too
|
||||
// would double the work.
|
||||
// * 'refresh' (TTL timer, apiFetch 401, WS fail×3): the session is
|
||||
// already established; re-firing would re-run fetchInitialData()
|
||||
// on every ~14-minute silent refresh.
|
||||
// The app.js listener defers its path check to a macrotask (see
|
||||
// Phase 6), so it runs after doLogin's hashchange has landed.
|
||||
if (param?.action === 'login') {
|
||||
window.dispatchEvent(new CustomEvent('auth:login', {
|
||||
detail: { permissions: data.permissions },
|
||||
}));
|
||||
}
|
||||
},
|
||||
onFailure(name, error) {
|
||||
// Silent — data stays as-is. Only reachable on a real throw (network error
|
||||
// inside fetch()). The no-token branch above covers the normal failure path.
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read stored token state from sessionStorage.
|
||||
* @returns {{access: string|null, refresh: string|null, session_id: string|null, ttl: number|null}}
|
||||
*/
|
||||
function readStorage() {
|
||||
return {
|
||||
access: sessionStorage.getItem('vw:access'),
|
||||
refresh: sessionStorage.getItem('vw:refresh'),
|
||||
session_id: sessionStorage.getItem('vw:session_id'),
|
||||
ttl: parseInt(sessionStorage.getItem('vw:access_ttl'), 10) || null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Persist token state to sessionStorage. @param {object} data - auth model data */
|
||||
function writeStorage(data) {
|
||||
sessionStorage.setItem('vw:access', data.token);
|
||||
sessionStorage.setItem('vw:refresh', data.refresh);
|
||||
sessionStorage.setItem('vw:session_id', data.session_id);
|
||||
sessionStorage.setItem('vw:access_ttl', String(data.ttl));
|
||||
sessionStorage.setItem('vw:user', JSON.stringify(data.user));
|
||||
sessionStorage.setItem('vw:permissions', JSON.stringify(data.permissions));
|
||||
}
|
||||
|
||||
/** Remove all stored token state. */
|
||||
function clearStorage() {
|
||||
sessionStorage.removeItem('vw:access');
|
||||
sessionStorage.removeItem('vw:refresh');
|
||||
sessionStorage.removeItem('vw:session_id');
|
||||
sessionStorage.removeItem('vw:access_ttl');
|
||||
sessionStorage.removeItem('vw:user');
|
||||
sessionStorage.removeItem('vw:permissions');
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a silent refresh TTL − 60s out (min 30s). Resets any pending timer.
|
||||
* @param {number} [ttl] - access token TTL in ms
|
||||
*/
|
||||
function scheduleRefresh(ttl) {
|
||||
if (_refreshTimer) clearTimeout(_refreshTimer);
|
||||
const delay = Math.max((ttl || 900 * 1000) - 60000, 30000);
|
||||
_refreshTimer = setTimeout(() => {
|
||||
if (!_refreshing) {
|
||||
_refreshing = true;
|
||||
modelFetch('auth', { action: 'refresh' }).finally(() => { _refreshing = false; });
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Current access token from the auth model.
|
||||
* @returns {string|undefined}
|
||||
*/
|
||||
export function getAuthToken() {
|
||||
return getModel('auth').data?.token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticated only when the model carries both a token and a user.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isAuthenticated() {
|
||||
const d = getModel('auth').data;
|
||||
return !!(d && d.token && d.user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a token refresh via the model.
|
||||
* Resolves (never rejects) — callers branch on getAuthToken() afterwards.
|
||||
* modelFetch() returns undefined for unregistered models — normalize to a
|
||||
* resolved promise so the result is always a thenable (defensive: the auth
|
||||
* model is registered at app bootstrap before any consumer can run).
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export function refreshAuth() {
|
||||
return Promise.resolve(modelFetch('auth', { action: 'refresh' }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole auth model data object.
|
||||
* @returns {{token, refresh, session_id, user, permissions, ttl}|null}
|
||||
*/
|
||||
export function getAuthData() {
|
||||
return getModel('auth').data;
|
||||
}
|
||||
Reference in New Issue
Block a user