/** * 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; email?: string; name?: string; tenantId?: number; roles: string[]; } export interface AuthState { isAuthenticated: boolean; isLoading: boolean; user: User | null; 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 }; let keycloakInstance: Keycloak | null = null; // ───────────────────────────────────────────────────────── // 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; return null; }; /** Escribe una cookie no-HttpOnly */ const setCookie = (name: string, value: string, days: number = 7) => { if (!browser) return; 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}`; }; /** Elimina una cookie */ const deleteCookie = (name: string) => { if (!browser) return; const secure = window.location.protocol === 'https:' ? '; Secure' : ''; document.cookie = `${name}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; SameSite=Lax${secure}`; }; // ───────────────────────────────────────────────────────── // Auth store (tokens solo en memoria) // ───────────────────────────────────────────────────────── const createAuthStore = () => { const { subscribe, set, update } = writable({ isAuthenticated: false, isLoading: true, user: null, token: null }); return { subscribe, setAuthenticated: (authenticated: boolean) => update((s) => ({ ...s, isAuthenticated: authenticated })), setLoading: (loading: boolean) => 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({ isAuthenticated: false, isLoading: false, user: null, token: null }) }; }; export const authStore = createAuthStore(); export const isAuthenticated = derived(authStore, ($a) => $a.isAuthenticated); export const currentUser = derived(authStore, ($a) => $a.user); // ───────────────────────────────────────────────────────── // Inicialización // ───────────────────────────────────────────────────────── /** * 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; try { authStore.setLoading(true); // Restaurar token desde cookie no-HttpOnly (password login flow) const cookieToken = getCookie('access_token'); if (cookieToken) { authStore.setToken(cookieToken); authStore.setAuthenticated(true); await loadUserInfo(cookieToken).catch(() => {}); authStore.setLoading(false); return true; } // Sin token local, intentar Keycloak JS (flujo SSO) const authenticated = await initKeycloak(); authStore.setLoading(false); return authenticated; } catch (err) { console.error('[auth] Error en initAuth:', err); authStore.setLoading(false); return false; } }; /** Inicializa Keycloak JS para el flujo SSO con PKCE */ export const initKeycloak = async (): Promise => { if (!browser) return false; try { keycloakInstance = new Keycloak(keycloakConfig); const authenticated = await keycloakInstance.init({ onLoad: 'check-sso', silentCheckSsoRedirectUri: window.location.origin + '/silent-check-sso.html', pkceMethod: 'S256', checkLoginIframe: false }); if (authenticated) { await updateAuthState(); setupKeycloakTokenHooks(); } return authenticated; } catch (err) { console.error('[auth] Error inicializando Keycloak:', err); return false; } }; let previousTenantId: number | undefined = undefined; const updateAuthState = async () => { if (!keycloakInstance?.authenticated) { authStore.reset(); return; } try { const profile = await keycloakInstance.loadUserProfile(); const token = keycloakInstance.token ?? null; const parsed = keycloakInstance.tokenParsed as any; const roles: string[] = parsed?.realm_access?.roles ?? []; const tenantId: number | undefined = parsed?.tenant_id ? parseInt(parsed.tenant_id) : undefined; const tenantChanged = previousTenantId !== undefined && previousTenantId !== tenantId; const user: User = { id: profile.id ?? '', username: profile.username ?? '', email: profile.email, name: `${profile.firstName ?? ''} ${profile.lastName ?? ''}`.trim(), tenantId, roles }; authStore.setAuthenticated(true); authStore.setUser(user); authStore.setToken(token); if (tenantChanged && browser) { try { const { companyStore } = await import('./stores/company.svelte'); companyStore.clear(); } catch {} } previousTenantId = tenantId; } catch (err) { console.error('[auth] Error actualizando estado:', err); authStore.reset(); } }; // ───────────────────────────────────────────────────────── // Keycloak JS token hooks (solo para el flujo SSO) // ───────────────────────────────────────────────────────── /** * Configura los callbacks de Keycloak JS para notificar al SessionManager * sobre cambios de token y eventos de sesión SSO. */ const setupKeycloakTokenHooks = () => { if (!keycloakInstance) return; keycloakInstance.onTokenExpired = () => { keycloakInstance ?.updateToken(70) .then((refreshed) => { if (refreshed && keycloakInstance?.token) { authStore.setToken(keycloakInstance.token); import('./session-manager') .then(({ getSessionManager }) => { getSessionManager()?.updateToken(keycloakInstance!.token!); }) .catch(() => {}); } }) .catch(() => { 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(); }; }; // ───────────────────────────────────────────────────────── // Login // ───────────────────────────────────────────────────────── /** Inicia sesión con Keycloak (OAuth redirect flow) */ export const loginWithKeycloak = async (tenantSlug?: string) => { if (!keycloakInstance) { console.error('[auth] Keycloak no está inicializado'); return; } const options: any = { redirectUri: window.location.origin + '/callback' }; if (tenantSlug) options.loginHint = tenantSlug; await keycloakInstance.login(options); }; /** * 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; password: string; tenant_slug: string; }): Promise<{ success: boolean; error?: string; data?: any }> => { try { const { api } = await import('./api'); const response = await api.auth.login(credentials); if (response.error) { return { success: false, error: response.error }; } 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); 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 (err) { console.error('[auth] Error en login:', err); return { success: false, error: 'Error de conexión con el servidor' }; } }; // ───────────────────────────────────────────────────────── // User info // ───────────────────────────────────────────────────────── const loadUserInfo = async (token: string) => { try { authStore.setToken(token); const { api } = await import('./api'); const response = await api.auth.me(); if (response.data) { 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 (err) { console.error('[auth] Error cargando info del usuario:', err); } }; // ───────────────────────────────────────────────────────── // Logout // ───────────────────────────────────────────────────────── export const logout = async () => { if (!browser) return; try { // 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 {} // Limpiar estado en memoria authStore.reset(); // Eliminar cookie no-HttpOnly del access_token deleteCookie('access_token'); // La cookie HttpOnly del refresh_token la limpia el servidor // Logout unificado (SSO y password): POST al logout route del servidor. // Evita redirección visible al endpoint de Keycloak. if (keycloakInstance) { try { keycloakInstance.clearToken(); } catch {} } const form = document.createElement('form'); form.method = 'POST'; form.action = '/logout'; document.body.appendChild(form); form.submit(); } catch (err) { console.error('[auth] Error durante logout:', err); window.location.href = '/login'; } }; // ───────────────────────────────────────────────────────── // Token accessors // ───────────────────────────────────────────────────────── export const hasRole = (role: string): boolean => { if (!keycloakInstance?.authenticated) return false; return keycloakInstance.hasRealmRole(role); }; /** Obtiene el access token desde memoria (Keycloak JS o authStore) */ export const getToken = (): string | null => { // Prioridad 1: Keycloak JS en memoria if (keycloakInstance?.token) return keycloakInstance.token; // Prioridad 2: authStore en memoria let token: string | null = null; const unsub = authStore.subscribe((s) => { token = s.token; }); unsub(); if (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 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; // 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 resp = await fetch('/api-sveltekit/auth/silent-refresh', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' } }); if (!resp.ok) { await logout(); return false; } 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; } } catch (err) { console.error('[auth] Error en refreshAccessToken:', err); } await logout(); return false; }; /** @deprecated El refresh_token ya no se expone en JS. */ export const getRefreshToken = (): string | null => { console.warn('[auth] getRefreshToken() está deprecado — el refresh_token no se expone en JS.'); return null; }; export const getKeycloakInstance = (): Keycloak | null => keycloakInstance;