- 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.
506 lines
18 KiB
TypeScript
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;
|
|
}
|
|
}
|