// ========================================== // Switchboard Core — SDK: Auth Module // ========================================== // Owns token storage, login/logout/refresh lifecycle, // and the permissions/teams/policies cache. // // Factory: createAuth() // Wire after creation: _setRestClient(client), _setEmit(fn) // ========================================== const BASE = window.__BASE__ || ''; const _storageKey = BASE ? `sb_auth_${BASE.replace(/\//g, '')}` : 'sb_auth'; /** * Create the auth module. * Must call _setRestClient() and _setEmit() before use. * * @returns {object} auth */ export function createAuth() { // ── Private state ─────────────────────────── let _accessToken = null; let _refreshToken = null; let _user = null; let _permissions = new Set(); let _teams = []; let _groups = []; let _policies = {}; let _refreshTimer = null; let _refreshPromise = null; let _booting = false; let _restClient = null; let _emit = () => {}; // ── Token persistence ─────────────────────── function _saveTokens() { localStorage.setItem(_storageKey, JSON.stringify({ accessToken: _accessToken, refreshToken: _refreshToken, user: _user, })); // Cookie sync for Go template page auth if (_accessToken) { document.cookie = `sb_token=${_accessToken}; path=/; max-age=900; SameSite=Strict`; } else { document.cookie = 'sb_token=; path=/; max-age=0'; } } function _loadTokens() { try { const saved = JSON.parse(localStorage.getItem(_storageKey) || 'null'); if (saved) { _accessToken = saved.accessToken || null; _refreshToken = saved.refreshToken || null; _user = saved.user || null; } } catch (_) { /* corrupt storage */ } } function _clearTokens() { _accessToken = null; _refreshToken = null; _user = null; _permissions = new Set(); _teams = []; _groups = []; _policies = {}; localStorage.removeItem(_storageKey); sessionStorage.removeItem('sw-chat-active'); document.cookie = 'sb_token=; path=/; max-age=0'; if (_refreshTimer) { clearTimeout(_refreshTimer); _refreshTimer = null; } } // ── Token lifecycle ───────────────────────── function _setAuth(data) { _accessToken = data.access_token; _refreshToken = data.refresh_token; _user = data.user; _saveTokens(); _scheduleRefresh(data.expires_in || 900); } function _scheduleRefresh(expiresIn) { if (_refreshTimer) clearTimeout(_refreshTimer); // Refresh at 80% of expiry (e.g., 12min for 15min token) const ms = Math.max((expiresIn * 0.8) * 1000, 30000); _refreshTimer = setTimeout(async () => { if (!_refreshToken) return; console.debug('[sw.auth] Proactive token refresh'); try { const data = await _restClient.post('/api/v1/auth/refresh', { refresh_token: _refreshToken, }); _setAuth(data); _emit('auth.refresh', {}, { localOnly: true }); } catch (e) { console.warn('[sw.auth] Proactive refresh failed, token still valid'); } }, ms); } // ── Permissions fetch ─────────────────────── async function _fetchPermissions() { try { const data = await _restClient.get('/api/v1/profile/permissions'); _permissions = new Set(data.permissions || []); _teams = Object.freeze(data.teams || []); _groups = Object.freeze(data.groups || []); _policies = Object.freeze(data.policies || {}); _emit('auth.permissions.changed', {}, { localOnly: true }); } catch (e) { console.warn('[sw.auth] Failed to fetch permissions:', e.message); throw e; } } // ── Public API ────────────────────────────── const auth = { // ── Getters ───────────────────────────── get isAuthenticated() { return !!_accessToken; }, get user() { if (!_user) return null; return Object.freeze({ id: _user.id, username: _user.username, display_name: _user.display_name || _user.username, email: _user.email || null, role: _user.role || 'user', avatar: _user.avatar_url || _user.avatar || null, }); }, get permissions() { return _permissions; }, get teams() { return _teams; }, get groups() { return _groups; }, get policies() { return _policies; }, // ── Lifecycle ─────────────────────────── /** * Login with credentials. Stores tokens, fetches permissions, emits event. */ async login(login, password) { const data = await _restClient.post('/api/v1/auth/login', { login, password }); _setAuth(data); await _fetchPermissions(); _emit('auth.login', { user: auth.user }, { localOnly: true }); return auth.user; }, /** * Logout. Clears tokens, emits event. Server call is best-effort. */ async logout() { try { await _restClient.post('/api/v1/auth/logout', { refresh_token: _refreshToken }); } catch (_) { /* best effort */ } _clearTokens(); _emit('auth.logout', {}, { localOnly: true }); }, /** * Manual token refresh. Returns true on success, false on failure. */ async refresh() { if (_refreshPromise) return _refreshPromise; _refreshPromise = (async () => { try { const data = await _restClient.post('/api/v1/auth/refresh', { refresh_token: _refreshToken, }); _setAuth(data); return true; } catch (_) { _clearTokens(); return false; } finally { _refreshPromise = null; } })(); return _refreshPromise; }, /** * Boot sequence. Called by index.js on startup. * Loads tokens from localStorage, validates, fetches permissions. */ async boot() { _loadTokens(); if (!_refreshToken) { // No tokens in localStorage — clear any stale sb_token cookie // so Go SSR middleware doesn't trust a leftover cookie. document.cookie = 'sb_token=; path=/; max-age=0'; return; } // Unknown token age — schedule refresh soon _scheduleRefresh(60); // Suppress _on401Failure redirect during boot — boot handles 401 itself. _booting = true; // Fetch permissions (validates the access token) try { await _fetchPermissions(); } catch (e) { if (e.status === 401) { // Token expired — try refresh const ok = await auth.refresh(); if (ok) { try { await _fetchPermissions(); } catch (_) { _clearTokens(); } } // If refresh failed, _clearTokens already called } else { // Network error or other — keep tokens, user may be offline console.warn('[sw.auth] Boot: permissions fetch failed (keeping tokens):', e.message); } } finally { _booting = false; } if (_accessToken) { _emit('auth.boot', { user: auth.user }, { localOnly: true }); } }, // ── Internal (called by rest-client / index.js) ── _getToken() { return _accessToken; }, async _onRefresh() { if (_refreshToken) return auth.refresh(); return false; }, _on401Failure() { _clearTokens(); // During boot(), auth.boot() handles 401 gracefully — don't redirect. if (_booting) return; // Already on login page — don't redirect (prevents blank-page reload loop // when stale localStorage tokens trigger 401 during SDK boot on /login). const base = window.__BASE__ || ''; const loginPath = base + '/login'; const here = window.location.pathname.replace(/\/+$/, ''); if (here === loginPath) return; window.location.href = loginPath; }, _setAuth(data) { _setAuth(data); }, _setRestClient(client) { _restClient = client; }, _setEmit(fn) { _emit = fn; }, }; return auth; }