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