chore: baseline plantilla-proyectos como base del CRM
This commit is contained in:
505
frontend/src/lib/session-manager.ts
Normal file
505
frontend/src/lib/session-manager.ts
Normal file
@@ -0,0 +1,505 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user