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.
This commit is contained in:
2026-03-05 08:13:19 -06:00
parent 82f6205e2a
commit b348be5ff3
9 changed files with 1006 additions and 325 deletions

View File

@@ -40,94 +40,49 @@ function onTokenRefreshed(token: string) {
}
/**
* Intenta refrescar el token usando el refresh token
* Refresca el token silenciosamente usando el endpoint server-side.
*
* El servidor lee el refresh_token desde la cookie HttpOnly,
* llama a Keycloak, actualiza las cookies y devuelve el nuevo access_token.
* El refresh_token NUNCA es leído por este código JavaScript.
*/
async function refreshToken(): Promise<string | null> {
if (!browser) return null;
let refreshTokenValue = localStorage.getItem('refresh_token');
// Si no está en localStorage, intentar obtenerlo de las cookies
if (!refreshTokenValue) {
const getCookie = (name: string): string | null => {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop()?.split(';').shift() || null;
return null;
};
refreshTokenValue = getCookie('refresh_token');
if (refreshTokenValue) {
localStorage.setItem('refresh_token', refreshTokenValue);
}
}
if (!refreshTokenValue) {
console.error('❌ [API] No hay refresh token disponible');
return null;
}
try {
const response = await fetch(`${API_BASE_URL}/v1/auth/refresh`, {
const response = await fetch('/api-sveltekit/auth/silent-refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ refresh_token: refreshTokenValue }),
credentials: 'include'
credentials: 'include', // Envía cookies HttpOnly automáticamente
headers: { 'Content-Type': 'application/json' }
});
if (!response.ok) {
console.error('❌ [API] Refresh token expirado o inválido, status:', response.status);
// Si el refresh token también está expirado, limpiar todo
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
// Limpiar cookies también
console.error('❌ [API] Silent refresh falló, status:', response.status);
// Limpiar la cookie del access_token (no HttpOnly) para forzar re-login
document.cookie = 'access_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC';
document.cookie = 'refresh_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC';
// Redirigir al login después de un pequeño delay para que el usuario vea el mensaje
setTimeout(() => {
if (browser) {
window.location.href = '/login';
}
}, 2000);
setTimeout(() => { window.location.href = '/login'; }, 1500);
return null;
}
const data = await response.json();
const data = await response.json() as { access_token?: string };
// Guardar los nuevos tokens
if (data.access_token) {
localStorage.setItem('access_token', data.access_token);
// Actualizar cookie no-HttpOnly del access_token
const secure = window.location.protocol === 'https:' ? '; Secure' : '';
document.cookie = `access_token=${data.access_token}; path=/; max-age=${60 * 60 * 24 * 7}; SameSite=Lax${secure}`;
if (data.refresh_token) {
localStorage.setItem('refresh_token', data.refresh_token);
}
// Actualizar también las cookies
const isSecure = window.location.protocol === 'https:';
const secureFlag = isSecure ? '; Secure' : '';
document.cookie = `access_token=${data.access_token}; path=/; max-age=${60 * 60 * 24 * 7}; SameSite=Lax${secureFlag}`;
if (data.refresh_token) {
document.cookie = `refresh_token=${data.refresh_token}; path=/; max-age=${60 * 60 * 24 * 30}; SameSite=Lax${secureFlag}`;
}
// Actualizar el authStore si está disponible
// Actualizar authStore en memoria
try {
const { authStore } = await import('./auth');
authStore.setToken(data.access_token);
} catch (e) {
// Si no se puede importar authStore, no es crítico
console.warn('⚠️ [API] No se pudo actualizar authStore:', e);
}
} catch {}
return data.access_token;
}
return null;
} catch (error) {
console.error('❌ [API] Error refreshing token:', error);
console.error('❌ [API] Error en silent refresh:', error);
return null;
}
}

View File

@@ -1,11 +1,21 @@
/**
* Servicio de autenticación con Keycloak
*
* Seguridad de tokens:
* - access_token → en memoria (authStore) + cookie no-HttpOnly (para SSR)
* - refresh_token → cookie HttpOnly únicamente (JS nunca lo lee directamente)
* - El refresh se hace server-side via /api-sveltekit/auth/silent-refresh
* - NO se usa localStorage para tokens
*/
import Keycloak from 'keycloak-js';
import { writable, derived } from 'svelte/store';
import { browser } from '$app/environment';
// ─────────────────────────────────────────────────────────
// Tipos
// ─────────────────────────────────────────────────────────
export interface User {
id: string;
username: string;
@@ -22,51 +32,51 @@ export interface AuthState {
token: string | null;
}
// ─────────────────────────────────────────────────────────
// Configuración de Keycloak
// ─────────────────────────────────────────────────────────
const keycloakConfig = {
url: import.meta.env.VITE_KEYCLOAK_URL,
realm: import.meta.env.VITE_KEYCLOAK_REALM,
clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID
};
// Instancia de Keycloak
let keycloakInstance: Keycloak | null = null;
// Helper para obtener cookies
// ─────────────────────────────────────────────────────────
// Cookie helpers (solo para access_token no-HttpOnly)
// ─────────────────────────────────────────────────────────
/** Lee el valor de una cookie no-HttpOnly */
const getCookie = (name: string): string | null => {
if (!browser) return null;
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop()?.split(';').shift() || null;
if (parts.length === 2) return parts.pop()?.split(';').shift() ?? null;
return null;
};
// Helper para establecer cookies con las opciones correctas según el entorno
/** Escribe una cookie no-HttpOnly */
const setCookie = (name: string, value: string, days: number = 7) => {
if (!browser) return;
const expirationDate = new Date();
expirationDate.setDate(expirationDate.getDate() + days);
// En desarrollo (localhost), no usar Secure flag
const isSecure = window.location.protocol === 'https:';
const secureFlag = isSecure ? '; Secure' : '';
const cookieString = `${name}=${value}; path=/; expires=${expirationDate.toUTCString()}; SameSite=Lax${secureFlag}`;
document.cookie = cookieString;
// Verificar que se estableció
const verification = getCookie(name);
const exp = new Date();
exp.setDate(exp.getDate() + days);
const secure = window.location.protocol === 'https:' ? '; Secure' : '';
document.cookie = `${name}=${value}; path=/; expires=${exp.toUTCString()}; SameSite=Lax${secure}`;
};
// Helper para eliminar cookies
/** Elimina una cookie */
const deleteCookie = (name: string) => {
if (!browser) return;
const isSecure = window.location.protocol === 'https:';
const secureFlag = isSecure ? '; Secure' : '';
document.cookie = `${name}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; SameSite=Lax${secureFlag}`;
const secure = window.location.protocol === 'https:' ? '; Secure' : '';
document.cookie = `${name}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; SameSite=Lax${secure}`;
};
// Store de autenticación
// ─────────────────────────────────────────────────────────
// Auth store (tokens solo en memoria)
// ─────────────────────────────────────────────────────────
const createAuthStore = () => {
const { subscribe, set, update } = writable<AuthState>({
isAuthenticated: false,
@@ -78,16 +88,16 @@ const createAuthStore = () => {
return {
subscribe,
setAuthenticated: (authenticated: boolean) =>
update((state) => ({ ...state, isAuthenticated: authenticated })),
update((s) => ({ ...s, isAuthenticated: authenticated })),
setLoading: (loading: boolean) =>
update((state) => ({ ...state, isLoading: loading })),
setUser: (user: User | null) => update((state) => ({ ...state, user })),
setToken: (token: string | null) => update((state) => ({ ...state, token })),
setTokens: (accessToken: string, refreshToken?: string) => {
update((state) => ({ ...state, token: accessToken }));
if (browser && refreshToken) {
localStorage.setItem('refresh_token', refreshToken);
}
update((s) => ({ ...s, isLoading: loading })),
setUser: (user: User | null) => update((s) => ({ ...s, user })),
setToken: (token: string | null) => update((s) => ({ ...s, token })),
/** ⚠️ Los tokens ya NO se guardan en localStorage; solo en memoria. */
setTokens: (accessToken: string, _refreshToken?: string) => {
update((s) => ({ ...s, token: accessToken }));
// El refresh_token llega en cookie HttpOnly desde el servidor;
// el cliente no lo almacena ni lo lee en ningún momento.
},
reset: () =>
set({
@@ -101,14 +111,17 @@ const createAuthStore = () => {
export const authStore = createAuthStore();
// Derived store para verificar si está autenticado
export const isAuthenticated = derived(authStore, ($auth) => $auth.isAuthenticated);
export const isAuthenticated = derived(authStore, ($a) => $a.isAuthenticated);
export const currentUser = derived(authStore, ($a) => $a.user);
// Derived store para obtener el usuario
export const currentUser = derived(authStore, ($auth) => $auth.user);
// ─────────────────────────────────────────────────────────
// Inicialización
// ─────────────────────────────────────────────────────────
/**
* Inicializa la autenticación (Keycloak o token-based)
* Inicializa el estado de autenticación en el cliente.
* - Si hay un access_token en la cookie no-HttpOnly, lo usa.
* - En cualquier caso intenta inicializar Keycloak JS (para el flujo SSO).
*/
export const initAuth = async (): Promise<boolean> => {
if (!browser) return false;
@@ -116,38 +129,28 @@ export const initAuth = async (): Promise<boolean> => {
try {
authStore.setLoading(true);
// Primero intentar restaurar sesión desde localStorage
const token = localStorage.getItem('access_token');
if (token) {
authStore.setToken(token);
// Restaurar token desde cookie no-HttpOnly (password login flow)
const cookieToken = getCookie('access_token');
if (cookieToken) {
authStore.setToken(cookieToken);
authStore.setAuthenticated(true);
// Sincronizar con cookies si no existe
const cookieToken = getCookie('access_token');
if (!cookieToken) {
setCookie('access_token', token);
}
await loadUserInfo(token);
await loadUserInfo(cookieToken).catch(() => {});
authStore.setLoading(false);
return true;
}
// Si no hay token local, intentar con Keycloak
await initKeycloak();
// Sin token local, intentar Keycloak JS (flujo SSO)
const authenticated = await initKeycloak();
authStore.setLoading(false);
return false;
} catch (error) {
console.error('Error inicializando autenticación:', error);
return authenticated;
} catch (err) {
console.error('[auth] Error en initAuth:', err);
authStore.setLoading(false);
return false;
}
};
/**
* Inicializa Keycloak
*/
/** Inicializa Keycloak JS para el flujo SSO con PKCE */
export const initKeycloak = async (): Promise<boolean> => {
if (!browser) return false;
@@ -163,22 +166,18 @@ export const initKeycloak = async (): Promise<boolean> => {
if (authenticated) {
await updateAuthState();
setupTokenRefresh();
setupKeycloakTokenHooks();
}
return authenticated;
} catch (error) {
console.error('Error inicializando Keycloak:', error);
} catch (err) {
console.error('[auth] Error inicializando Keycloak:', err);
return false;
}
};
// Variable para rastrear el tenant anterior
let previousTenantId: number | undefined = undefined;
/**
* Actualiza el estado de autenticación con los datos de Keycloak
*/
const updateAuthState = async () => {
if (!keycloakInstance?.authenticated) {
authStore.reset();
@@ -187,22 +186,22 @@ const updateAuthState = async () => {
try {
const profile = await keycloakInstance.loadUserProfile();
const token = keycloakInstance.token || null;
const tokenParsed = keycloakInstance.tokenParsed as any;
const token = keycloakInstance.token ?? null;
const parsed = keycloakInstance.tokenParsed as any;
const roles = tokenParsed?.realm_access?.roles || [];
const tenantId = tokenParsed?.tenant_id || tokenParsed?.attributes?.tenant_id;
const newTenantId = tenantId ? parseInt(tenantId) : undefined;
const roles: string[] = parsed?.realm_access?.roles ?? [];
const tenantId: number | undefined = parsed?.tenant_id
? parseInt(parsed.tenant_id)
: undefined;
// Detectar si cambió el tenant
const tenantChanged = previousTenantId !== undefined && previousTenantId !== newTenantId;
const tenantChanged = previousTenantId !== undefined && previousTenantId !== tenantId;
const user: User = {
id: profile.id || '',
username: profile.username || '',
id: profile.id ?? '',
username: profile.username ?? '',
email: profile.email,
name: `${profile.firstName || ''} ${profile.lastName || ''}`.trim(),
tenantId: newTenantId,
name: `${profile.firstName ?? ''} ${profile.lastName ?? ''}`.trim(),
tenantId,
roles
};
@@ -210,71 +209,83 @@ const updateAuthState = async () => {
authStore.setUser(user);
authStore.setToken(token);
// Si cambió el tenant, limpiar el store de compañías
if (tenantChanged && browser) {
try {
const { companyStore } = await import('./stores/company.svelte');
companyStore.clear();
} catch (error) {
console.error('Error al limpiar store de compañías:', error);
}
} catch {}
}
// Actualizar el tenant anterior
previousTenantId = newTenantId;
} catch (error) {
console.error('Error actualizando estado de autenticación:', error);
previousTenantId = tenantId;
} catch (err) {
console.error('[auth] Error actualizando estado:', err);
authStore.reset();
}
};
// ─────────────────────────────────────────────────────────
// Keycloak JS token hooks (solo para el flujo SSO)
// ─────────────────────────────────────────────────────────
/**
* Configura el refresh automático del token
* Configura los callbacks de Keycloak JS para notificar al SessionManager
* sobre cambios de token y eventos de sesión SSO.
*/
const setupTokenRefresh = () => {
const setupKeycloakTokenHooks = () => {
if (!keycloakInstance) return;
// Refrescar token cada 60 segundos si está cerca de expirar
keycloakInstance.onTokenExpired = () => {
keycloakInstance
?.updateToken(70)
.then((refreshed) => {
if (refreshed) {
authStore.setToken(keycloakInstance?.token || null);
if (refreshed && keycloakInstance?.token) {
authStore.setToken(keycloakInstance.token);
import('./session-manager')
.then(({ getSessionManager }) => {
getSessionManager()?.updateToken(keycloakInstance!.token!);
})
.catch(() => {});
}
})
.catch(() => {
console.error('Error refrescando token');
logout();
console.error('[auth] No se pudo refrescar el token de Keycloak');
void logout();
});
};
keycloakInstance.onAuthRefreshSuccess = () => {
if (keycloakInstance?.token) authStore.setToken(keycloakInstance.token);
};
keycloakInstance.onAuthRefreshError = () => {
console.error('[auth] Error en refresh de Keycloak — cerrando sesión');
void logout();
};
keycloakInstance.onAuthLogout = () => {
authStore.reset();
};
};
/**
* Inicia sesión con Keycloak (OAuth flow)
*/
// ─────────────────────────────────────────────────────────
// Login
// ─────────────────────────────────────────────────────────
/** Inicia sesión con Keycloak (OAuth redirect flow) */
export const loginWithKeycloak = async (tenantSlug?: string) => {
if (!keycloakInstance) {
console.error('Keycloak no está inicializado');
console.error('[auth] Keycloak no está inicializado');
return;
}
const options: any = {
redirectUri: window.location.origin + '/callback'
};
if (tenantSlug) {
options.loginHint = tenantSlug;
}
const options: any = { redirectUri: window.location.origin + '/callback' };
if (tenantSlug) options.loginHint = tenantSlug;
await keycloakInstance.login(options);
};
/**
* Inicia sesión con credenciales (username/password)
* Nota: Esta función ya no se usa directamente desde el login form,
* el login ahora se hace mediante form actions del servidor.
* Se mantiene para compatibilidad con SSO y otros flujos.
* Login con usuario/contraseña (legacy — el login principal es via form action del servidor).
* Los tokens se guardan en cookies (vía setCookie) y en memoria (authStore).
* NO se guardan en localStorage.
*/
export const login = async (credentials: {
username: string;
@@ -282,235 +293,185 @@ export const login = async (credentials: {
tenant_slug: string;
}): Promise<{ success: boolean; error?: string; data?: any }> => {
try {
// Usar la API centralizada
const { api } = await import('./api');
const response = await api.auth.login(credentials);
// Si hay error en la respuesta
if (response.error) {
return {
success: false,
error: response.error
};
return { success: false, error: response.error };
}
// Guardar tokens y actualizar estado
const loginData = response.data;
if (loginData?.access_token) {
// Guardar en memoria y en cookie no-HttpOnly para SSR
authStore.setToken(loginData.access_token);
authStore.setAuthenticated(true);
// Guardar también en localStorage para persistencia
if (browser) {
localStorage.setItem('access_token', loginData.access_token);
if (loginData.refresh_token) {
localStorage.setItem('refresh_token', loginData.refresh_token);
}
// Guardar en cookies para que el servidor pueda acceder
setCookie('access_token', loginData.access_token);
if (loginData.refresh_token) {
setCookie('refresh_token', loginData.refresh_token);
}
}
// Cargar información del usuario
setCookie('access_token', loginData.access_token);
// El refresh_token llega en cookie HttpOnly desde el servidor.
// NO lo guardamos en JS.
await loadUserInfo(loginData.access_token);
}
return {
success: true,
data: loginData
};
} catch (error) {
console.error('Error en login:', error);
return {
success: false,
error: 'Error de conexión con el servidor'
};
return { success: true, data: loginData };
} catch (err) {
console.error('[auth] Error en login:', err);
return { success: false, error: 'Error de conexión con el servidor' };
}
};
/**
* Carga la información del usuario desde el token
*/
// ─────────────────────────────────────────────────────────
// User info
// ─────────────────────────────────────────────────────────
const loadUserInfo = async (token: string) => {
try {
// Guardar temporalmente el token para que api.ts lo use
authStore.setToken(token);
// Usar la API centralizada
const { api } = await import('./api');
const response = await api.auth.me();
if (response.data) {
const data = response.data;
const user: User = {
id: data.sub || '',
username: data.preferred_username || data.username || '',
email: data.email,
name: data.name,
tenantId: data.tenant_id,
roles: data.realm_access?.roles || []
};
authStore.setUser(user);
const d = response.data;
authStore.setUser({
id: d.sub ?? '',
username: d.preferred_username ?? d.username ?? '',
email: d.email,
name: d.name,
tenantId: d.tenant_id,
roles: d.realm_access?.roles ?? []
});
}
} catch (error) {
console.error('Error cargando información del usuario:', error);
} catch (err) {
console.error('[auth] Error cargando info del usuario:', err);
}
};
/**
* Cierra sesión
*/
// ─────────────────────────────────────────────────────────
// Logout
// ─────────────────────────────────────────────────────────
export const logout = async () => {
if (!browser) return;
try {
// Capturar tokens antes de limpiar nada
const refreshToken = localStorage.getItem('refresh_token');
const accessToken = localStorage.getItem('access_token');
// Detener el SessionManager
try {
const { destroySessionManager } = await import('./session-manager');
destroySessionManager();
} catch {}
// Limpiar store de compañías
try {
const { companyStore } = await import('./stores/company.svelte');
companyStore.clear();
} catch (error) {
console.error('Error al limpiar store de compañías:', error);
}
} catch {}
// Limpiar estado local
// Limpiar estado en memoria
authStore.reset();
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
// Eliminar cookie no-HttpOnly del access_token
deleteCookie('access_token');
deleteCookie('refresh_token');
// La cookie HttpOnly del refresh_token la limpia el servidor
// Si hay instancia de Keycloak, hacer logout de Keycloak
// Logout de Keycloak JS si estaba autenticado con SSO
if (keycloakInstance?.authenticated) {
// Primero notificamos al servidor para limpieza de cookies (SvelteKit)
try {
await fetch('/logout', {
method: 'POST'
});
} catch (e) {
console.error("Error calling server logout:", e);
}
await fetch('/logout', { method: 'POST' });
} catch {}
await keycloakInstance.logout({
redirectUri: window.location.origin + '/login'
});
return;
}
// Llamar al endpoint del servidor para limpiar cookies de SvelteKit
// Usar un formulario para hacer POST y permitir la redirección
// Para login con password: POST al logout route del servidor
const form = document.createElement('form');
form.method = 'POST';
form.action = '/logout';
document.body.appendChild(form);
form.submit();
} catch (error) {
console.error('Error durante logout:', error);
// Asegurar que se redirija al login aunque haya error
} catch (err) {
console.error('[auth] Error durante logout:', err);
window.location.href = '/login';
}
};
/**
* Verifica si el usuario tiene un rol específico
*/
// ─────────────────────────────────────────────────────────
// Token accessors
// ─────────────────────────────────────────────────────────
export const hasRole = (role: string): boolean => {
if (!keycloakInstance?.authenticated) return false;
return keycloakInstance.hasRealmRole(role);
};
/**
* Obtiene el token de acceso actual
*/
/** Obtiene el access token desde memoria (Keycloak JS o authStore) */
export const getToken = (): string | null => {
// Intentar obtener de Keycloak primero
if (keycloakInstance?.token) {
return keycloakInstance.token;
}
// Prioridad 1: Keycloak JS en memoria
if (keycloakInstance?.token) return keycloakInstance.token;
// Si no, intentar de localStorage
if (browser) {
let token = localStorage.getItem('access_token');
// Prioridad 2: authStore en memoria
let token: string | null = null;
const unsub = authStore.subscribe((s) => { token = s.token; });
unsub();
if (token) return token;
// Si no hay token en localStorage, intentar de las cookies
if (!token) {
token = getCookie('access_token');
// Si lo encontramos en cookies, sincronizarlo a localStorage
if (token) {
localStorage.setItem('access_token', token);
}
}
return token;
}
// Prioridad 3: cookie no-HttpOnly (fallback para acceso inicial antes del onMount)
if (browser) return getCookie('access_token');
return null;
};
/**
* Refresca el access token usando el refresh token
* Refresca el access token usando el endpoint server-side seguro.
* El servidor lee el refresh_token de la cookie HttpOnly.
* @returns true si el refresh fue exitoso
*/
export const refreshAccessToken = async (): Promise<boolean> => {
if (!browser) return false;
const refreshToken = localStorage.getItem('refresh_token');
if (!refreshToken) {
return false;
// Con Keycloak JS activo, usar su mecanismo nativo
if (keycloakInstance?.authenticated) {
try {
const refreshed = await keycloakInstance.updateToken(70);
if (refreshed || keycloakInstance.token) {
authStore.setToken(keycloakInstance.token ?? null);
return true;
}
} catch {
await logout();
return false;
}
}
// Flujo de contraseña: usar el endpoint server-side seguro
try {
const { api } = await import('./api');
const response = await api.auth.refresh(refreshToken);
const resp = await fetch('/api-sveltekit/auth/silent-refresh', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' }
});
if (response.error || !response.data) {
console.error('Failed to refresh token:', response.error);
// Si falla el refresh, hacer logout
if (!resp.ok) {
await logout();
return false;
}
// Actualizar tokens
const newAccessToken = response.data.access_token;
const newRefreshToken = response.data.refresh_token;
authStore.setToken(newAccessToken);
localStorage.setItem('access_token', newAccessToken);
if (newRefreshToken) {
localStorage.setItem('refresh_token', newRefreshToken);
const data = await resp.json() as { access_token?: string };
if (data.access_token) {
authStore.setToken(data.access_token);
setCookie('access_token', data.access_token);
return true;
}
// Actualizar también la cookie
setCookie('access_token', newAccessToken);
return true;
} catch (error) {
await logout();
return false;
} catch (err) {
console.error('[auth] Error en refreshAccessToken:', err);
}
await logout();
return false;
};
/**
* Obtiene el refresh token
*/
/** @deprecated El refresh_token ya no se expone en JS. */
export const getRefreshToken = (): string | null => {
if (!browser) return null;
return localStorage.getItem('refresh_token');
console.warn('[auth] getRefreshToken() está deprecado — el refresh_token no se expone en JS.');
return null;
};
/**
* Obtiene la instancia de Keycloak
*/
export const getKeycloakInstance = (): Keycloak | null => {
return keycloakInstance;
};
export const getKeycloakInstance = (): Keycloak | null => keycloakInstance;

View File

@@ -0,0 +1,128 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { browser } from '$app/environment';
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import {
SESSION_WARNING_EVENT,
SESSION_EXPIRED_EVENT,
SESSION_EXTENDED_EVENT,
getSessionManager
} from '$lib/session-manager';
import type { SessionWarningDetail } from '$lib/session-manager';
// ─── State ────────────────────────────────────────────────────────────────
let open = $state(false);
let remainingSeconds = $state(300);
let countdownId: ReturnType<typeof setInterval> | null = null;
// ─── Helpers ──────────────────────────────────────────────────────────────
function formatTime(secs: number): string {
const m = Math.floor(secs / 60);
const s = secs % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
function clearCountdown() {
if (countdownId !== null) {
clearInterval(countdownId);
countdownId = null;
}
}
function startCountdown() {
clearCountdown();
countdownId = setInterval(() => {
remainingSeconds = Math.max(0, remainingSeconds - 1);
if (remainingSeconds === 0) clearCountdown();
}, 1000);
}
// ─── Event handlers ───────────────────────────────────────────────────────
function onWarning(e: Event) {
const { remainingMs } = (e as CustomEvent<SessionWarningDetail>).detail;
remainingSeconds = Math.floor(remainingMs / 1000);
open = true;
startCountdown();
}
function onExpired() {
open = false;
clearCountdown();
}
function onExtended() {
open = false;
clearCountdown();
}
// ─── User actions ─────────────────────────────────────────────────────────
function continueSession() {
const mgr = getSessionManager();
mgr?.extendSession();
open = false;
clearCountdown();
}
function logoutNow() {
open = false;
clearCountdown();
// Dispara el evento de sesión expirada para que el layout gestione el logout
window.dispatchEvent(
new CustomEvent(SESSION_EXPIRED_EVENT, { detail: { reason: 'manual' } })
);
}
// ─── Lifecycle ────────────────────────────────────────────────────────────
onMount(() => {
if (!browser) return;
window.addEventListener(SESSION_WARNING_EVENT, onWarning);
window.addEventListener(SESSION_EXPIRED_EVENT, onExpired);
window.addEventListener(SESSION_EXTENDED_EVENT, onExtended);
});
onDestroy(() => {
if (!browser) return;
clearCountdown();
window.removeEventListener(SESSION_WARNING_EVENT, onWarning);
window.removeEventListener(SESSION_EXPIRED_EVENT, onExpired);
window.removeEventListener(SESSION_EXTENDED_EVENT, onExtended);
});
</script>
<!--
session-timeout-warning.svelte
Diálogo que avisa al usuario cuando su sesión está a punto de expirar
por inactividad. Se controla completamente a través de eventos DOM.
-->
<Dialog.Root bind:open>
<Dialog.Portal>
<Dialog.Overlay class="fixed inset-0 z-[9998] bg-black/40 backdrop-blur-sm" />
<Dialog.Content
class="fixed left-1/2 top-1/2 z-[9999] w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg bg-background p-6 shadow-xl"
>
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2 text-lg font-semibold">
⚠️ Sesión por expirar
</Dialog.Title>
<Dialog.Description class="mt-2 text-sm text-muted-foreground">
Tu sesión cerrará automáticamente por inactividad en
<span class="font-mono font-bold text-foreground">
{formatTime(remainingSeconds)}
</span>.
<br />
¿Deseas continuar trabajando?
</Dialog.Description>
</Dialog.Header>
<Dialog.Footer class="mt-6 flex gap-3">
<Button variant="outline" class="flex-1" onclick={logoutNow}>
Cerrar sesión
</Button>
<Button class="flex-1" onclick={continueSession}>
Continuar sesión
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>

View File

@@ -41,6 +41,10 @@ export function getAuthTokens(cookies: Cookies) {
/**
* Establece los tokens de autenticación en las cookies
*
* Política de seguridad:
* - access_token → NO HttpOnly (el cliente necesita incluirlo en el header Authorization)
* - refresh_token → HttpOnly=true (JS nunca lo lee; el servidor lo maneja via /api-sveltekit/auth/silent-refresh)
*/
export function setAuthTokens(
cookies: Cookies,
@@ -49,7 +53,7 @@ export function setAuthTokens(
) {
cookies.set('access_token', accessToken, {
path: '/',
httpOnly: false,
httpOnly: false, // El cliente JS necesita leerlo para el header Bearer
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 7 // 7 días
@@ -58,7 +62,7 @@ export function setAuthTokens(
if (refreshToken) {
cookies.set('refresh_token', refreshToken, {
path: '/',
httpOnly: false,
httpOnly: true, // *** HttpOnly: JS nunca lee el refresh_token ***
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 30 // 30 días

View 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;
}
}

View File

@@ -0,0 +1,61 @@
/**
* Endpoint server-side para el refresh silencioso del access token.
*
* Flujo de seguridad:
* 1. El cliente llama a POST /api-sveltekit/auth/silent-refresh con credentials:'include'
* (las cookies HttpOnly se envían automáticamente, sin que JS las lea).
* 2. Este servidor lee el refresh_token de la cookie HttpOnly.
* 3. Llama al backend FastAPI /v1/auth/refresh con el refresh_token.
* 4. Si es exitoso, actualiza las cookies HttpOnly con los nuevos tokens.
* 5. Retorna solo el access_token al cliente (el refresh_token permanece en HttpOnly).
*
* De este modo el refresh_token NUNCA toca el código JavaScript del cliente.
*/
import { json } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import { getServerApiUrl, setAuthTokens } from '$lib/server/api';
export const POST = async ({ cookies, fetch }: RequestEvent) => {
const refreshToken = cookies.get('refresh_token');
if (!refreshToken) {
return json({ error: 'No refresh token available' }, { status: 401 });
}
try {
const baseUrl = getServerApiUrl();
const response = await fetch(`${baseUrl}v1/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken })
});
if (!response.ok) {
// El refresh token expiró o fue invalidado por Keycloak (sesión terminada).
// Limpiar las cookies para que el servidor redirigir al login en la siguiente carga.
cookies.delete('refresh_token', { path: '/' });
cookies.delete('access_token', { path: '/' });
cookies.delete('active_company_id', { path: '/' });
const status = response.status === 401 ? 401 : 400;
return json({ error: 'Refresh token expired or invalid' }, { status });
}
const data = (await response.json()) as {
access_token: string;
refresh_token?: string;
expires_in?: number;
};
// Actualizar las cookies HttpOnly con los nuevos tokens
setAuthTokens(cookies, data.access_token, data.refresh_token);
// Devolver solo el access_token al cliente
return json({ access_token: data.access_token });
} catch (error) {
console.error('[silent-refresh] Error inesperado:', error);
return json({ error: 'Internal server error' }, { status: 500 });
}
};

View File

@@ -56,13 +56,14 @@ export const load: PageServerLoad = async ({ url, cookies, fetch }) => {
const tokens = await tokenResponse.json();
// Establecer las cookies en el servidor (esto es lo importante)
// Las cookies deben ser HttpOnly y Secure en producción
// Establecer las cookies en el servidor
// access_token → NO HttpOnly (el cliente JS lo usa para el header Authorization)
// refresh_token → HttpOnly (el JS nunca lo lee; el servidor lo gestiona)
const isProduction = process.env.NODE_ENV === 'production';
cookies.set('access_token', tokens.access_token, {
path: '/',
httpOnly: true,
httpOnly: false, // El cliente necesita leerlo para Bearer
secure: isProduction,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7 // 7 días
@@ -71,7 +72,7 @@ export const load: PageServerLoad = async ({ url, cookies, fetch }) => {
if (tokens.refresh_token) {
cookies.set('refresh_token', tokens.refresh_token, {
path: '/',
httpOnly: true,
httpOnly: true, // *** HttpOnly: nunca expuesto a JS ***
secure: isProduction,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30 // 30 días

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { setContext, onMount } from 'svelte';
import { setContext, onMount, onDestroy } from 'svelte';
import { invalidateAll } from '$app/navigation';
import type { LayoutData } from './$types';
import AppSidebar from '$lib/components/sidebar/app-sidebar.svelte';
@@ -8,29 +8,78 @@
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import { companyStore } from '$lib/stores/company.svelte';
import ExchangeRateGuard from '$lib/components/dashboard/exchange_rate/exchange-rate-guard.svelte';
import SessionTimeoutWarning from '$lib/components/session-timeout-warning.svelte';
import { page } from '$app/state';
import {
createSessionManager,
destroySessionManager,
SESSION_EXPIRED_EVENT
} from '$lib/session-manager';
import type { SessionExpiredDetail } from '$lib/session-manager';
import { authStore } from '$lib/auth';
import { logout, getKeycloakInstance } from '$lib/auth';
let { data, children }: { data: LayoutData; children: any } = $props();
// Hacer disponible el usuario en el contexto para los componentes hijos
setContext('user', data.user);
// Inicializar el store con las compañías pre-cargadas desde el servidor
// ── Manejar expiración de sesión ────────────────────────────────────────
function handleSessionExpired(e: Event) {
const { reason } = (e as CustomEvent<SessionExpiredDetail>).detail;
console.info(`[Dashboard] Sesión expirada (motivo: ${reason}) — cerrando sesión`);
void logout();
}
onMount(() => {
// ── Inicializar el token en el authStore desde los datos del servidor ──
// El servidor valida la cookie y pasa el access_token a través de data.user.token.
// Lo almacenamos en memoria (authStore) sin tocar localStorage.
if (data.user?.token) {
authStore.setToken(data.user.token);
authStore.setAuthenticated(true);
}
// ── Inicializar el SessionManager ─────────────────────────────────────
if (data.user?.token) {
const mgr = createSessionManager({
refreshBeforeExpirySeconds: 60, // Refrescar 60s antes de que expire
idleTimeoutMs: 30 * 60 * 1000, // Idle timeout: 30 minutos
warningBeforeIdleMs: 5 * 60 * 1000, // Advertencia: 5 min antes del idle
ssoCheckIntervalMs: 5 * 60 * 1000, // Verificar sesión SSO cada 5 min
getKeycloakInstance,
onTokenRefreshed: (newToken) => {
authStore.setToken(newToken);
},
onSessionExpired: (reason) => {
void logout();
}
});
mgr.start(data.user.token);
}
// ── Escuchar evento global de expiración de sesión ────────────────────
window.addEventListener(SESSION_EXPIRED_EVENT, handleSessionExpired);
// ── Inicializar compañías ─────────────────────────────────────────────
if (data.companies) {
companyStore.initialize(data.companies);
}
// Escuchar cambios de compañía y recargar datos
const handleCompanyChange = () => {
invalidateAll();
};
// ── Escuchar cambios de compañía y recargar datos ─────────────────────
const handleCompanyChange = () => invalidateAll();
window.addEventListener('companyChanged', handleCompanyChange);
return () => {
window.removeEventListener('companyChanged', handleCompanyChange);
window.removeEventListener(SESSION_EXPIRED_EVENT, handleSessionExpired);
};
});
onDestroy(() => {
destroySessionManager();
});
</script>
<Sidebar.Provider>
@@ -67,3 +116,6 @@
{#if !page.url.pathname.includes('/dashboard/invoices') && !page.url.pathname.includes('/dashboard/pedimentos')}
<ExchangeRateGuard overlayClass="bg-black/5" />
{/if}
<!-- Diálogo de advertencia de sesión por inactividad -->
<SessionTimeoutWarning />

View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<title>Silent SSO Check</title>
</head>
<body>
<script>
// Required by Keycloak JS for silent SSO check.
// This page is loaded in a hidden iframe; it posts the full URL back
// to the parent window so Keycloak can parse the SSO response.
parent.postMessage(location.href, location.origin);
</script>
</body>
</html>