Files
plantillas-proyectos/frontend/src/lib/session-manager.ts
AlexeerCT b348be5ff3 feat(auth): implement session management and silent refresh for Keycloak
- Added session manager to handle token refresh and user activity tracking.
- Introduced session timeout warning dialog for user inactivity.
- Updated authentication token handling in cookies with security policies.
- Created server-side endpoint for silent refresh of access tokens using HttpOnly cookies.
- Added silent check SSO HTML page for Keycloak integration.
2026-03-05 08:13:19 -06:00

506 lines
18 KiB
TypeScript

/**
* 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<SessionManagerOptions>;
// Timers
private refreshTimerId: ReturnType<typeof setTimeout> | null = null;
private idleTimerId: ReturnType<typeof setTimeout> | null = null;
private warningTimerId: ReturnType<typeof setTimeout> | null = null;
private ssoCheckIntervalId: ReturnType<typeof setInterval> | 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<typeof setTimeout> | 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<void> {
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<void> {
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<void> {
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<string | null> {
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;
}
}