diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ca30b42e..a525d200 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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 { 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; } } diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index e75c29ff..f35153e4 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -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({ 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 => { if (!browser) return false; @@ -116,38 +129,28 @@ export const initAuth = async (): Promise => { 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 => { if (!browser) return false; @@ -163,22 +166,18 @@ export const initKeycloak = async (): Promise => { 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 => { 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; diff --git a/frontend/src/lib/components/session-timeout-warning.svelte b/frontend/src/lib/components/session-timeout-warning.svelte new file mode 100644 index 00000000..94620811 --- /dev/null +++ b/frontend/src/lib/components/session-timeout-warning.svelte @@ -0,0 +1,128 @@ + + + + + + + + + + ⚠️ Sesión por expirar + + + Tu sesión cerrará automáticamente por inactividad en + + {formatTime(remainingSeconds)} + . +
+ ¿Deseas continuar trabajando? +
+
+ + + + + +
+
+
diff --git a/frontend/src/lib/server/api.ts b/frontend/src/lib/server/api.ts index a338dc06..a68524d0 100644 --- a/frontend/src/lib/server/api.ts +++ b/frontend/src/lib/server/api.ts @@ -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 diff --git a/frontend/src/lib/session-manager.ts b/frontend/src/lib/session-manager.ts new file mode 100644 index 00000000..b9157e89 --- /dev/null +++ b/frontend/src/lib/session-manager.ts @@ -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; + + // Timers + private refreshTimerId: ReturnType | null = null; + private idleTimerId: ReturnType | null = null; + private warningTimerId: ReturnType | null = null; + private ssoCheckIntervalId: ReturnType | null = null; + + // State + private currentToken: string | null = null; + private lastActivityAt = Date.now(); + private warningShown = false; + private isRefreshing = false; + private destroyed = false; + + // Activity listener cleanups + private removeListeners: Array<() => void> = []; + + constructor(options: SessionManagerOptions = {}) { + this.opts = { + refreshBeforeExpirySeconds: options.refreshBeforeExpirySeconds ?? 60, + idleTimeoutMs: options.idleTimeoutMs ?? 30 * 60 * 1000, + warningBeforeIdleMs: options.warningBeforeIdleMs ?? 5 * 60 * 1000, + ssoCheckIntervalMs: options.ssoCheckIntervalMs ?? 5 * 60 * 1000, + getKeycloakInstance: options.getKeycloakInstance ?? (() => null), + onTokenRefreshed: options.onTokenRefreshed ?? (() => {}), + onSessionExpired: options.onSessionExpired ?? (() => {}) + }; + } + + // ───────────────────────────────────────────────────── + // Public API + // ───────────────────────────────────────────────────── + + /** + * Inicia el gestor de sesión con el token actual. + * Debe llamarse una vez tras la autenticación exitosa. + */ + start(initialToken: string): void { + if (!browser || this.destroyed) return; + + this.currentToken = initialToken; + this.lastActivityAt = Date.now(); + this.warningShown = false; + + this.setupActivityListeners(); + this.scheduleRefresh(initialToken); + this.scheduleIdleTimers(); + this.startSsoCheckInterval(); + } + + /** + * Actualiza el token en memoria (llamar después de un refresh exitoso externo). + */ + updateToken(newToken: string): void { + if (this.destroyed) return; + this.currentToken = newToken; + this.warningShown = false; + this.cancelRefreshTimer(); + this.scheduleRefresh(newToken); + this.resetIdleTimers(); + } + + /** + * El usuario hizo clic en "Continuar sesión" en el diálogo de advertencia. + * Fuerza un refresh inmediato y reinicia los timers de inactividad. + */ + extendSession(): void { + if (this.destroyed) return; + this.recordActivity(); + if (this.currentToken) { + void this.doRefresh('extend'); + } + } + + /** Destruye el gestor y limpia todos los recursos. */ + destroy(): void { + this.destroyed = true; + this.cancelRefreshTimer(); + this.cancelIdleTimers(); + this.stopSsoCheckInterval(); + this.teardownActivityListeners(); + } + + // ───────────────────────────────────────────────────── + // Activity tracking + // ───────────────────────────────────────────────────── + + private setupActivityListeners(): void { + const events: (keyof WindowEventMap)[] = [ + 'mousedown', + 'mousemove', + 'keydown', + 'scroll', + 'touchstart', + 'click', + 'pointerdown' + ]; + + // Limitar actualizaciones de actividad a máximo una por segundo + let debounceTimer: ReturnType | null = null; + + const handler = () => { + if (debounceTimer) return; + debounceTimer = setTimeout(() => { + debounceTimer = null; + this.recordActivity(); + }, 1000); + }; + + events.forEach((event) => { + window.addEventListener(event, handler, { passive: true }); + this.removeListeners.push(() => window.removeEventListener(event, handler)); + }); + + // Al volver a la pestaña, registrar actividad y verificar si hace falta + // un refresh inmediato (el tiempo puede haber pasado con la pestaña en segundo plano) + const visibilityHandler = () => { + if (document.visibilityState === 'visible') { + this.recordActivity(); + void this.refreshIfExpiringSoon(); + } + }; + document.addEventListener('visibilitychange', visibilityHandler); + this.removeListeners.push(() => + document.removeEventListener('visibilitychange', visibilityHandler) + ); + } + + private teardownActivityListeners(): void { + this.removeListeners.forEach((fn) => fn()); + this.removeListeners = []; + } + + private recordActivity(): void { + const wasIdle = this.isIdle(); + this.lastActivityAt = Date.now(); + + if (this.warningShown) { + // El usuario volvió activo → descartar advertencia + this.warningShown = false; + this.resetIdleTimers(); + window.dispatchEvent(new CustomEvent(SESSION_EXTENDED_EVENT)); + } else if (wasIdle) { + // Volvemos de idle → reiniciar timers + this.resetIdleTimers(); + void this.refreshIfExpiringSoon(); + } + } + + private isIdle(): boolean { + return Date.now() - this.lastActivityAt > this.opts.idleTimeoutMs; + } + + // ───────────────────────────────────────────────────── + // Token refresh scheduling + // ───────────────────────────────────────────────────── + + private parseExpiry(token: string): number | null { + try { + const payload = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))); + return typeof payload.exp === 'number' ? payload.exp * 1000 : null; + } catch { + return null; + } + } + + private scheduleRefresh(token: string): void { + const expiry = this.parseExpiry(token); + if (!expiry) return; + + const msUntilRefresh = expiry - Date.now() - this.opts.refreshBeforeExpirySeconds * 1000; + + if (msUntilRefresh <= 0) { + void this.doRefresh('scheduled'); + return; + } + + this.refreshTimerId = setTimeout(() => { + if (!this.destroyed) void this.doRefresh('scheduled'); + }, msUntilRefresh); + } + + private cancelRefreshTimer(): void { + if (this.refreshTimerId !== null) { + clearTimeout(this.refreshTimerId); + this.refreshTimerId = null; + } + } + + /** Refresca el token si le quedan menos de `refreshBeforeExpirySeconds` */ + private async refreshIfExpiringSoon(): Promise { + if (!this.currentToken) return; + const expiry = this.parseExpiry(this.currentToken); + if (!expiry) return; + if (expiry - Date.now() < this.opts.refreshBeforeExpirySeconds * 1000) { + await this.doRefresh('on-demand'); + } + } + + // ───────────────────────────────────────────────────── + // Idle session timers + // ───────────────────────────────────────────────────── + + private scheduleIdleTimers(): void { + this.cancelIdleTimers(); + const now = Date.now(); + const idleAt = this.lastActivityAt + this.opts.idleTimeoutMs; + const warnAt = idleAt - this.opts.warningBeforeIdleMs; + + const msUntilWarn = warnAt - now; + const msUntilIdle = idleAt - now; + + if (msUntilWarn > 0) { + this.warningTimerId = setTimeout(() => { + if (!this.destroyed && !this.warningShown && this.isIdle() === false) { + this.showWarning(this.opts.warningBeforeIdleMs); + } + }, msUntilWarn); + } + + if (msUntilIdle > 0) { + this.idleTimerId = setTimeout(() => { + if (!this.destroyed && this.isIdle()) { + this.handleIdleExpiry(); + } + }, msUntilIdle); + } + } + + private cancelIdleTimers(): void { + if (this.warningTimerId !== null) { + clearTimeout(this.warningTimerId); + this.warningTimerId = null; + } + if (this.idleTimerId !== null) { + clearTimeout(this.idleTimerId); + this.idleTimerId = null; + } + } + + private resetIdleTimers(): void { + this.scheduleIdleTimers(); + } + + private showWarning(remainingMs: number): void { + this.warningShown = true; + const detail: SessionWarningDetail = { remainingMs }; + window.dispatchEvent(new CustomEvent(SESSION_WARNING_EVENT, { detail })); + } + + private handleIdleExpiry(): void { + const detail: SessionExpiredDetail = { reason: 'idle' }; + window.dispatchEvent(new CustomEvent(SESSION_EXPIRED_EVENT, { detail })); + this.opts.onSessionExpired('idle'); + } + + // ───────────────────────────────────────────────────── + // Periodic Keycloak SSO session check (iframe) + // ───────────────────────────────────────────────────── + + private startSsoCheckInterval(): void { + if (this.opts.ssoCheckIntervalMs <= 0) return; + + this.ssoCheckIntervalId = setInterval(() => { + if (!this.destroyed) void this.checkKeycloakSsoSession(); + }, this.opts.ssoCheckIntervalMs); + } + + private stopSsoCheckInterval(): void { + if (this.ssoCheckIntervalId !== null) { + clearInterval(this.ssoCheckIntervalId); + this.ssoCheckIntervalId = null; + } + } + + /** + * Comprueba silenciosamente si la sesión SSO de Keycloak sigue activa. + * Si el check falla (sesión terminada remotamente) → logout. + */ + private async checkKeycloakSsoSession(): Promise { + const kc = this.opts.getKeycloakInstance(); + if (!kc?.authenticated) return; // Solo aplica al flow SSO con Keycloak JS + + try { + // updateToken(0) fuerza a Keycloak JS a intentar refrescar via SSO + // Si la sesión SSO de Keycloak ha sido terminada, lanza un error + await kc.updateToken(0); + } catch { + console.warn('[SessionManager] Keycloak SSO session ended remotely'); + const detail: SessionExpiredDetail = { reason: 'keycloak_session_ended' }; + window.dispatchEvent(new CustomEvent(SESSION_EXPIRED_EVENT, { detail })); + this.opts.onSessionExpired('keycloak_session_ended'); + } + } + + // ───────────────────────────────────────────────────── + // Token refresh execution + // ───────────────────────────────────────────────────── + + private async doRefresh(reason: string): Promise { + if (this.destroyed || this.isRefreshing) return; + + // No refrescar automáticamente si el usuario está idle + // (excepto si es un refresh forzado por "extender sesión") + if (reason === 'scheduled' && this.isIdle()) { + console.info('[SessionManager] Omitiendo refresh — usuario inactivo'); + return; + } + + this.isRefreshing = true; + + try { + const kc = this.opts.getKeycloakInstance(); + let newToken: string | null = null; + + if (kc?.authenticated) { + // ── Keycloak JS flow ────────────────────────────────────────── + // updateToken intenta un silent refresh via iframe con la cookie + // de sesión SSO de Keycloak. + // Si el SSO session ha expirado, esto lanzará un error. + const minValidity = this.opts.refreshBeforeExpirySeconds + 10; + await kc.updateToken(minValidity); + newToken = kc.token ?? null; + } else { + // ── Password login flow ─────────────────────────────────────── + // Usar el endpoint server-side de SvelteKit que lee el refresh_token + // desde la cookie HttpOnly (el JS nunca ve el refresh_token). + newToken = await this.silentRefreshViaCookie(); + } + + if (newToken) { + this.currentToken = newToken; + this.cancelRefreshTimer(); + this.scheduleRefresh(newToken); + this.opts.onTokenRefreshed(newToken); + window.dispatchEvent( + new CustomEvent(SESSION_TOKEN_REFRESHED_EVENT, { detail: { token: newToken } }) + ); + } else { + this.handleRefreshFailure(); + } + } catch (err) { + console.error('[SessionManager] Error durante refresh:', err); + this.handleRefreshFailure(); + } finally { + this.isRefreshing = false; + } + } + + private handleRefreshFailure(): void { + console.warn('[SessionManager] Refresh fallido — la sesión SSO probablemente expiró'); + const detail: SessionExpiredDetail = { reason: 'refresh_failed' }; + window.dispatchEvent(new CustomEvent(SESSION_EXPIRED_EVENT, { detail })); + this.opts.onSessionExpired('refresh_failed'); + } + + /** + * Llama al endpoint server-side de SvelteKit para realizar el refresh + * usando la cookie HttpOnly del refresh_token. + * + * El servidor lee la cookie, llama a Keycloak, obtiene los nuevos tokens, + * actualiza las cookies HttpOnly y devuelve el nuevo access_token al cliente. + * El refresh_token NUNCA toca el código JavaScript del cliente. + */ + private async silentRefreshViaCookie(): Promise { + try { + const resp = await fetch('/api-sveltekit/auth/silent-refresh', { + method: 'POST', + credentials: 'include', // Envía todas las cookies HttpOnly + headers: { 'Content-Type': 'application/json' } + }); + + if (!resp.ok) return null; + + const data = await resp.json(); + return (data as { access_token?: string }).access_token ?? null; + } catch (err) { + console.error('[SessionManager] Error en silentRefreshViaCookie:', err); + return null; + } + } +} + +// ───────────────────────────────────────────────────────── +// Singleton helpers +// ───────────────────────────────────────────────────────── + +let _instance: SessionManager | null = null; + +/** Obtiene la instancia singleton del SessionManager */ +export function getSessionManager(): SessionManager | null { + return _instance; +} + +/** + * Crea (o recrea) el SessionManager singleton. + * Destruye la instancia anterior si existe. + */ +export function createSessionManager(options?: SessionManagerOptions): SessionManager { + if (_instance) { + _instance.destroy(); + } + _instance = new SessionManager(options); + return _instance; +} + +/** Destruye el SessionManager singleton y limpia todos los recursos */ +export function destroySessionManager(): void { + if (_instance) { + _instance.destroy(); + _instance = null; + } +} diff --git a/frontend/src/routes/api-sveltekit/auth/silent-refresh/+server.ts b/frontend/src/routes/api-sveltekit/auth/silent-refresh/+server.ts new file mode 100644 index 00000000..68366d84 --- /dev/null +++ b/frontend/src/routes/api-sveltekit/auth/silent-refresh/+server.ts @@ -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 }); + } +}; diff --git a/frontend/src/routes/auth/callback/+page.server.ts b/frontend/src/routes/auth/callback/+page.server.ts index 5f3372bc..b172d88c 100644 --- a/frontend/src/routes/auth/callback/+page.server.ts +++ b/frontend/src/routes/auth/callback/+page.server.ts @@ -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 diff --git a/frontend/src/routes/dashboard/+layout.svelte b/frontend/src/routes/dashboard/+layout.svelte index adc8e934..68d3011f 100644 --- a/frontend/src/routes/dashboard/+layout.svelte +++ b/frontend/src/routes/dashboard/+layout.svelte @@ -1,5 +1,5 @@ @@ -67,3 +116,6 @@ {#if !page.url.pathname.includes('/dashboard/invoices') && !page.url.pathname.includes('/dashboard/pedimentos')} {/if} + + + diff --git a/frontend/static/silent-check-sso.html b/frontend/static/silent-check-sso.html new file mode 100644 index 00000000..455d67bc --- /dev/null +++ b/frontend/static/silent-check-sso.html @@ -0,0 +1,14 @@ + + + + Silent SSO Check + + + + +