/** * Servicio de autenticación con Keycloak */ 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 }; // Instancia de Keycloak let keycloakInstance: Keycloak | null = null; // Helper para obtener cookies 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; }; // Helper para establecer cookies con las opciones correctas según el entorno 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); }; // Helper para eliminar cookies 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}`; }; // Store de autenticación const createAuthStore = () => { const { subscribe, set, update } = writable({ isAuthenticated: false, isLoading: true, user: null, token: null }); return { subscribe, setAuthenticated: (authenticated: boolean) => update((state) => ({ ...state, 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); } }, reset: () => set({ isAuthenticated: false, isLoading: false, user: null, token: null }) }; }; export const authStore = createAuthStore(); // Derived store para verificar si está autenticado export const isAuthenticated = derived(authStore, ($auth) => $auth.isAuthenticated); // Derived store para obtener el usuario export const currentUser = derived(authStore, ($auth) => $auth.user); /** * Inicializa la autenticación (Keycloak o token-based) */ export const initAuth = async (): Promise => { if (!browser) return false; try { authStore.setLoading(true); // Primero intentar restaurar sesión desde localStorage const token = localStorage.getItem('access_token'); if (token) { authStore.setToken(token); authStore.setAuthenticated(true); // Sincronizar con cookies si no existe const cookieToken = getCookie('access_token'); if (!cookieToken) { setCookie('access_token', token); } await loadUserInfo(token); authStore.setLoading(false); return true; } // Si no hay token local, intentar con Keycloak await initKeycloak(); authStore.setLoading(false); return false; } catch (error) { console.error('Error inicializando autenticación:', error); authStore.setLoading(false); return false; } }; /** * Inicializa Keycloak */ 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(); setupTokenRefresh(); } return authenticated; } catch (error) { console.error('Error inicializando Keycloak:', error); 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(); return; } try { const profile = await keycloakInstance.loadUserProfile(); const token = keycloakInstance.token || null; const tokenParsed = 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; // Detectar si cambió el tenant const tenantChanged = previousTenantId !== undefined && previousTenantId !== newTenantId; const user: User = { id: profile.id || '', username: profile.username || '', email: profile.email, name: `${profile.firstName || ''} ${profile.lastName || ''}`.trim(), tenantId: newTenantId, roles }; authStore.setAuthenticated(true); 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); } } // Actualizar el tenant anterior previousTenantId = newTenantId; } catch (error) { console.error('Error actualizando estado de autenticación:', error); authStore.reset(); } }; /** * Configura el refresh automático del token */ const setupTokenRefresh = () => { 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); } }) .catch(() => { console.error('Error refrescando token'); logout(); }); }; }; /** * Inicia sesión con Keycloak (OAuth flow) */ export const loginWithKeycloak = async (tenantSlug?: string) => { if (!keycloakInstance) { console.error('Keycloak no está inicializado'); return; } 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. */ export const login = async (credentials: { username: string; password: string; 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 }; } // Guardar tokens y actualizar estado const loginData = response.data; if (loginData?.access_token) { 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 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' }; } }; /** * Carga la información del usuario desde el token */ 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); } } catch (error) { console.error('Error cargando información del usuario:', error); } }; /** * Cierra sesión */ export const logout = async () => { if (!browser) return; try { // Obtener el refresh token si existe const refreshToken = localStorage.getItem('refresh_token'); // Si hay refresh token, intentar invalidarlo en el backend if (refreshToken) { try { const { api } = await import('./api'); await api.auth.logout({ refresh_token: refreshToken }); } catch (error) { console.error('Error al invalidar refresh token:', error); // Continuar con el logout aunque falle } } // 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); } // Limpiar estado local authStore.reset(); localStorage.removeItem('access_token'); localStorage.removeItem('refresh_token'); deleteCookie('access_token'); deleteCookie('refresh_token'); // Si hay instancia de Keycloak, hacer logout de Keycloak if (keycloakInstance?.authenticated) { 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 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 window.location.href = '/login'; } }; /** * Verifica si el usuario tiene un rol específico */ export const hasRole = (role: string): boolean => { if (!keycloakInstance?.authenticated) return false; return keycloakInstance.hasRealmRole(role); }; /** * Obtiene el token de acceso actual */ export const getToken = (): string | null => { // Intentar obtener de Keycloak primero if (keycloakInstance?.token) { return keycloakInstance.token; } // Si no, intentar de localStorage if (browser) { let token = localStorage.getItem('access_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; } return null; }; /** * Refresca el access token usando el refresh token */ export const refreshAccessToken = async (): Promise => { if (!browser) return false; const refreshToken = localStorage.getItem('refresh_token'); if (!refreshToken) { return false; } try { const { api } = await import('./api'); const response = await api.auth.refresh(refreshToken); if (response.error || !response.data) { console.error('Failed to refresh token:', response.error); // Si falla el refresh, hacer logout 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); } // Actualizar también la cookie setCookie('access_token', newAccessToken); return true; } catch (error) { await logout(); return false; } }; /** * Obtiene el refresh token */ export const getRefreshToken = (): string | null => { if (!browser) return null; return localStorage.getItem('refresh_token'); }; /** * Obtiene la instancia de Keycloak */ export const getKeycloakInstance = (): Keycloak | null => { return keycloakInstance; };