/** * Gestor de sesión SSO para Keycloak * * Responsabilidades: * - Refresh silencioso del access token SOLO cuando el usuario está activo. * - Detección de actividad del usuario (evita polling innecesario cuando está idle). * - Idle timeout: si el usuario está inactivo, no refrescar → el token y la sesión * expiran en Keycloak de forma natural → siguiente petición 401 → logout. * - Logout automático cuando el refresh falla (sesión SSO terminada por Keycloak, * admin forzado, max session alcanzado, etc.). * - Verificación de sesión SSO usando el iframe silencioso de Keycloak JS. * * Flujo de tokens: * - Access token: en memoria (authStore) + cookie no-HttpOnly (password login) * o en instancia Keycloak JS (SSO flow) * - Refresh token: cookie HttpOnly únicamente (el JS nunca lo toca) * - El refresh se hace server-side via /api-sveltekit/auth/silent-refresh */ import { browser } from '$app/environment'; import type Keycloak from 'keycloak-js'; // ───────────────────────────────────────────────────────── // Eventos DOM personalizados // ───────────────────────────────────────────────────────── /** Se emite cuando la sesión está próxima a expirar por inactividad */ export const SESSION_WARNING_EVENT = 'session:warning'; /** Se emite cuando la sesión ha expirado (idle, max-session o refresh fallido) */ export const SESSION_EXPIRED_EVENT = 'session:expired'; /** Se emite cuando el usuario extiende la sesión desde el diálogo de advertencia */ export const SESSION_EXTENDED_EVENT = 'session:extended'; /** Se emite después de un refresh silencioso exitoso */ export const SESSION_TOKEN_REFRESHED_EVENT = 'session:token-refreshed'; // ───────────────────────────────────────────────────────── // Tipos // ───────────────────────────────────────────────────────── export type SessionExpiredReason = 'idle' | 'refresh_failed' | 'keycloak_session_ended' | 'error' | 'manual'; export interface SessionExpiredDetail { reason: SessionExpiredReason; } export interface SessionWarningDetail { remainingMs: number; } export interface SessionManagerOptions { /** * Segundos antes de la expiración del token para intentar el refresh. * Default: 60 */ refreshBeforeExpirySeconds?: number; /** * Tiempo de inactividad (ms) después del cual NO se refresca el token, * permitiendo que la sesión de Keycloak expire de forma natural. * Default: 30 minutos (1_800_000 ms) */ idleTimeoutMs?: number; /** * Milisegundos antes del idle timeout para mostrar el diálogo de advertencia. * Default: 5 minutos (300_000 ms) */ warningBeforeIdleMs?: number; /** * Intervalo (ms) para verificar silenciosamente la sesión SSO de Keycloak. * Solo se usa cuando hay una instancia de Keycloak JS autenticada. * Default: 5 minutos (300_000 ms). 0 para deshabilitar. */ ssoCheckIntervalMs?: number; /** Función para obtener la instancia de Keycloak JS (si usa el SSO flow) */ getKeycloakInstance?: () => Keycloak | null; /** Callback cuando el token se refresca exitosamente */ onTokenRefreshed?: (newToken: string) => void; /** Callback cuando la sesión expira */ onSessionExpired?: (reason: SessionExpiredReason) => void; } // ───────────────────────────────────────────────────────── // SessionManager // ───────────────────────────────────────────────────────── export class SessionManager { private opts: Required; // Timers private refreshTimerId: ReturnType | null = null; private idleTimerId: ReturnType | null = null; private warningTimerId: ReturnType | null = null; private ssoCheckIntervalId: ReturnType | null = null; // State private currentToken: string | null = null; private lastActivityAt = Date.now(); private warningShown = false; private isRefreshing = false; private destroyed = false; // Activity listener cleanups private removeListeners: Array<() => void> = []; constructor(options: SessionManagerOptions = {}) { this.opts = { refreshBeforeExpirySeconds: options.refreshBeforeExpirySeconds ?? 60, idleTimeoutMs: options.idleTimeoutMs ?? 30 * 60 * 1000, warningBeforeIdleMs: options.warningBeforeIdleMs ?? 5 * 60 * 1000, ssoCheckIntervalMs: options.ssoCheckIntervalMs ?? 5 * 60 * 1000, getKeycloakInstance: options.getKeycloakInstance ?? (() => null), onTokenRefreshed: options.onTokenRefreshed ?? (() => {}), onSessionExpired: options.onSessionExpired ?? (() => {}) }; } // ───────────────────────────────────────────────────── // Public API // ───────────────────────────────────────────────────── /** * Inicia el gestor de sesión con el token actual. * Debe llamarse una vez tras la autenticación exitosa. */ start(initialToken: string): void { if (!browser || this.destroyed) return; this.currentToken = initialToken; this.lastActivityAt = Date.now(); this.warningShown = false; this.setupActivityListeners(); this.scheduleRefresh(initialToken); this.scheduleIdleTimers(); this.startSsoCheckInterval(); } /** * Actualiza el token en memoria (llamar después de un refresh exitoso externo). */ updateToken(newToken: string): void { if (this.destroyed) return; this.currentToken = newToken; this.warningShown = false; this.cancelRefreshTimer(); this.scheduleRefresh(newToken); this.resetIdleTimers(); } /** * El usuario hizo clic en "Continuar sesión" en el diálogo de advertencia. * Fuerza un refresh inmediato y reinicia los timers de inactividad. */ extendSession(): void { if (this.destroyed) return; this.recordActivity(); if (this.currentToken) { void this.doRefresh('extend'); } } /** Destruye el gestor y limpia todos los recursos. */ destroy(): void { this.destroyed = true; this.cancelRefreshTimer(); this.cancelIdleTimers(); this.stopSsoCheckInterval(); this.teardownActivityListeners(); } // ───────────────────────────────────────────────────── // Activity tracking // ───────────────────────────────────────────────────── private setupActivityListeners(): void { const events: (keyof WindowEventMap)[] = [ 'mousedown', 'mousemove', 'keydown', 'scroll', 'touchstart', 'click', 'pointerdown' ]; // Limitar actualizaciones de actividad a máximo una por segundo let debounceTimer: ReturnType | null = null; const handler = () => { if (debounceTimer) return; debounceTimer = setTimeout(() => { debounceTimer = null; this.recordActivity(); }, 1000); }; events.forEach((event) => { window.addEventListener(event, handler, { passive: true }); this.removeListeners.push(() => window.removeEventListener(event, handler)); }); // Al volver a la pestaña, registrar actividad y verificar si hace falta // un refresh inmediato (el tiempo puede haber pasado con la pestaña en segundo plano) const visibilityHandler = () => { if (document.visibilityState === 'visible') { this.recordActivity(); void this.refreshIfExpiringSoon(); } }; document.addEventListener('visibilitychange', visibilityHandler); this.removeListeners.push(() => document.removeEventListener('visibilitychange', visibilityHandler) ); } private teardownActivityListeners(): void { this.removeListeners.forEach((fn) => fn()); this.removeListeners = []; } private recordActivity(): void { const wasIdle = this.isIdle(); this.lastActivityAt = Date.now(); if (this.warningShown) { // El usuario volvió activo → descartar advertencia this.warningShown = false; this.resetIdleTimers(); window.dispatchEvent(new CustomEvent(SESSION_EXTENDED_EVENT)); } else if (wasIdle) { // Volvemos de idle → reiniciar timers this.resetIdleTimers(); void this.refreshIfExpiringSoon(); } } private isIdle(): boolean { return Date.now() - this.lastActivityAt > this.opts.idleTimeoutMs; } // ───────────────────────────────────────────────────── // Token refresh scheduling // ───────────────────────────────────────────────────── private parseExpiry(token: string): number | null { try { const payload = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))); return typeof payload.exp === 'number' ? payload.exp * 1000 : null; } catch { return null; } } private scheduleRefresh(token: string): void { const expiry = this.parseExpiry(token); if (!expiry) return; const msUntilRefresh = expiry - Date.now() - this.opts.refreshBeforeExpirySeconds * 1000; if (msUntilRefresh <= 0) { void this.doRefresh('scheduled'); return; } this.refreshTimerId = setTimeout(() => { if (!this.destroyed) void this.doRefresh('scheduled'); }, msUntilRefresh); } private cancelRefreshTimer(): void { if (this.refreshTimerId !== null) { clearTimeout(this.refreshTimerId); this.refreshTimerId = null; } } /** Refresca el token si le quedan menos de `refreshBeforeExpirySeconds` */ private async refreshIfExpiringSoon(): Promise { if (!this.currentToken) return; const expiry = this.parseExpiry(this.currentToken); if (!expiry) return; if (expiry - Date.now() < this.opts.refreshBeforeExpirySeconds * 1000) { await this.doRefresh('on-demand'); } } // ───────────────────────────────────────────────────── // Idle session timers // ───────────────────────────────────────────────────── private scheduleIdleTimers(): void { this.cancelIdleTimers(); const now = Date.now(); const idleAt = this.lastActivityAt + this.opts.idleTimeoutMs; const warnAt = idleAt - this.opts.warningBeforeIdleMs; const msUntilWarn = warnAt - now; const msUntilIdle = idleAt - now; if (msUntilWarn > 0) { this.warningTimerId = setTimeout(() => { if (!this.destroyed && !this.warningShown && this.isIdle() === false) { this.showWarning(this.opts.warningBeforeIdleMs); } }, msUntilWarn); } if (msUntilIdle > 0) { this.idleTimerId = setTimeout(() => { if (!this.destroyed && this.isIdle()) { this.handleIdleExpiry(); } }, msUntilIdle); } } private cancelIdleTimers(): void { if (this.warningTimerId !== null) { clearTimeout(this.warningTimerId); this.warningTimerId = null; } if (this.idleTimerId !== null) { clearTimeout(this.idleTimerId); this.idleTimerId = null; } } private resetIdleTimers(): void { this.scheduleIdleTimers(); } private showWarning(remainingMs: number): void { this.warningShown = true; const detail: SessionWarningDetail = { remainingMs }; window.dispatchEvent(new CustomEvent(SESSION_WARNING_EVENT, { detail })); } private handleIdleExpiry(): void { const detail: SessionExpiredDetail = { reason: 'idle' }; window.dispatchEvent(new CustomEvent(SESSION_EXPIRED_EVENT, { detail })); this.opts.onSessionExpired('idle'); } // ───────────────────────────────────────────────────── // Periodic Keycloak SSO session check (iframe) // ───────────────────────────────────────────────────── private startSsoCheckInterval(): void { if (this.opts.ssoCheckIntervalMs <= 0) return; this.ssoCheckIntervalId = setInterval(() => { if (!this.destroyed) void this.checkKeycloakSsoSession(); }, this.opts.ssoCheckIntervalMs); } private stopSsoCheckInterval(): void { if (this.ssoCheckIntervalId !== null) { clearInterval(this.ssoCheckIntervalId); this.ssoCheckIntervalId = null; } } /** * Comprueba silenciosamente si la sesión SSO de Keycloak sigue activa. * Si el check falla (sesión terminada remotamente) → logout. */ private async checkKeycloakSsoSession(): Promise { const kc = this.opts.getKeycloakInstance(); if (!kc?.authenticated) return; // Solo aplica al flow SSO con Keycloak JS try { // updateToken(0) fuerza a Keycloak JS a intentar refrescar via SSO // Si la sesión SSO de Keycloak ha sido terminada, lanza un error await kc.updateToken(0); } catch { console.warn('[SessionManager] Keycloak SSO session ended remotely'); const detail: SessionExpiredDetail = { reason: 'keycloak_session_ended' }; window.dispatchEvent(new CustomEvent(SESSION_EXPIRED_EVENT, { detail })); this.opts.onSessionExpired('keycloak_session_ended'); } } // ───────────────────────────────────────────────────── // Token refresh execution // ───────────────────────────────────────────────────── private async doRefresh(reason: string): Promise { if (this.destroyed || this.isRefreshing) return; // No refrescar automáticamente si el usuario está idle // (excepto si es un refresh forzado por "extender sesión") if (reason === 'scheduled' && this.isIdle()) { console.info('[SessionManager] Omitiendo refresh — usuario inactivo'); return; } this.isRefreshing = true; try { const kc = this.opts.getKeycloakInstance(); let newToken: string | null = null; if (kc?.authenticated) { // ── Keycloak JS flow ────────────────────────────────────────── // updateToken intenta un silent refresh via iframe con la cookie // de sesión SSO de Keycloak. // Si el SSO session ha expirado, esto lanzará un error. const minValidity = this.opts.refreshBeforeExpirySeconds + 10; await kc.updateToken(minValidity); newToken = kc.token ?? null; } else { // ── Password login flow ─────────────────────────────────────── // Usar el endpoint server-side de SvelteKit que lee el refresh_token // desde la cookie HttpOnly (el JS nunca ve el refresh_token). newToken = await this.silentRefreshViaCookie(); } if (newToken) { this.currentToken = newToken; this.cancelRefreshTimer(); this.scheduleRefresh(newToken); this.opts.onTokenRefreshed(newToken); window.dispatchEvent( new CustomEvent(SESSION_TOKEN_REFRESHED_EVENT, { detail: { token: newToken } }) ); } else { this.handleRefreshFailure(); } } catch (err) { console.error('[SessionManager] Error durante refresh:', err); this.handleRefreshFailure(); } finally { this.isRefreshing = false; } } private handleRefreshFailure(): void { console.warn('[SessionManager] Refresh fallido — la sesión SSO probablemente expiró'); const detail: SessionExpiredDetail = { reason: 'refresh_failed' }; window.dispatchEvent(new CustomEvent(SESSION_EXPIRED_EVENT, { detail })); this.opts.onSessionExpired('refresh_failed'); } /** * Llama al endpoint server-side de SvelteKit para realizar el refresh * usando la cookie HttpOnly del refresh_token. * * El servidor lee la cookie, llama a Keycloak, obtiene los nuevos tokens, * actualiza las cookies HttpOnly y devuelve el nuevo access_token al cliente. * El refresh_token NUNCA toca el código JavaScript del cliente. */ private async silentRefreshViaCookie(): Promise { try { const resp = await fetch('/api-sveltekit/auth/silent-refresh', { method: 'POST', credentials: 'include', // Envía todas las cookies HttpOnly headers: { 'Content-Type': 'application/json' } }); if (!resp.ok) return null; const data = await resp.json(); return (data as { access_token?: string }).access_token ?? null; } catch (err) { console.error('[SessionManager] Error en silentRefreshViaCookie:', err); return null; } } } // ───────────────────────────────────────────────────────── // Singleton helpers // ───────────────────────────────────────────────────────── let _instance: SessionManager | null = null; /** Obtiene la instancia singleton del SessionManager */ export function getSessionManager(): SessionManager | null { return _instance; } /** * Crea (o recrea) el SessionManager singleton. * Destruye la instancia anterior si existe. */ export function createSessionManager(options?: SessionManagerOptions): SessionManager { if (_instance) { _instance.destroy(); } _instance = new SessionManager(options); return _instance; } /** Destruye el SessionManager singleton y limpia todos los recursos */ export function destroySessionManager(): void { if (_instance) { _instance.destroy(); _instance = null; } }