/** * Servicio de autenticación con Keycloak * * Seguridad de tokens: * - access_token → en memoria (authStore) + cookies no-HttpOnly (una o varias si el JWT es grande) * - 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, get } from 'svelte/store'; import { browser } from '$app/environment'; import { clearAccessTokenOnDocument, getAccessTokenFromDocument, setAccessTokenOnDocument } from '$lib/access-token-cookie-browser'; // ───────────────────────────────────────────────────────── // Tipos // ───────────────────────────────────────────────────────── export interface User { id: string; username: string; email?: string; name?: string; tenantId?: number; roles: string[]; permissions: 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; // ───────────────────────────────────────────────────────── // 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); /** * Verifica si el usuario tiene un permiso específico. * `user.permissions` debe incluir códigos de la app (p. ej. `user.view`); suelen * cargarse vía {@link syncCompanyPermissions}, no solo desde /auth/me del Hub. */ export function userHasPermission(user: User | null, permission: string): boolean { if (!user) return false; return user.roles.includes('admin') || user.permissions.includes(permission); } // ───────────────────────────────────────────────────────── // 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 = getAccessTokenFromDocument(); 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; // Obtener permisos actuales para evitar que el SSO los borre si fallara el fetch posterior let currentPerms: string[] = []; try { const { get } = await import('svelte/store'); const currentState = get(authStore); currentPerms = currentState.user?.permissions || []; } catch { } const user: User = { id: profile.id ?? '', username: profile.username ?? '', email: profile.email, name: `${profile.firstName ?? ''} ${profile.lastName ?? ''}`.trim(), tenantId, roles, permissions: parsed?.permissions?.length ? parsed.permissions : currentPerms }; authStore.setAuthenticated(true); authStore.setUser(user); authStore.setToken(token); // ⚠️ IMPORTANTE: El SSO original de Keycloak no inyecta los permisos granulares // que viven en la base de datos de PostgreSQL en nuestro `permissions: parsed?.permissions`. // Necesitamos hacer polling a /auth/me para que `user.permissions` se rellene. if (token) { await loadUserInfo(token).catch(() => { }); } 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 no-HttpOnly (fragmentadas si hace falta) 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); setAccessTokenOnDocument(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 // ───────────────────────────────────────────────────────── /** * Permisos efectivos RBAC de la app para la compañía (backend: GET .../permissions/me). * Sin esto, `userHasPermission` solo ve lo que venga en /auth/me del Hub. */ export async function syncCompanyPermissions(companyId: number): Promise { if (!browser || !Number.isFinite(companyId)) return; try { const { api } = await import('./api'); const res = await api.get<{ permissions: string[] }>( `/v1/core/permissions/me?company_id=${companyId}` ); if (res.error || res.data === undefined) return; const perms = res.data.permissions; if (!Array.isArray(perms)) return; const state = get(authStore); if (!state.user) return; authStore.setUser({ ...state.user, permissions: perms }); } catch (e) { console.warn('[auth] syncCompanyPermissions:', e); } } export const refreshPermissions = async () => { const token = getToken(); if (token) { await loadUserInfo(token); try { const { companyStore } = await import('./stores/company.svelte'); const cid = companyStore.activeCompany?.id; if (cid) await syncCompanyPermissions(cid); } catch { // ignore } return true; } return false; }; 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.roles ?? [], permissions: d.permissions ?? [] }); } } 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(); clearAccessTokenOnDocument(); // 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 getAccessTokenFromDocument(); 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); setAccessTokenOnDocument(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;