/** * 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; firstName?: string | null; lastName?: string | null; displayName?: string | null; avatarUrl?: string | null; workspaceAvatarUrl?: string | null; legacyAvatarUrl?: string | null; tenantId?: number; roles: string[]; permissions: string[]; // Cache management profileSyncedAt?: number; // timestamp en ms para cache TTL } 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; const AUTH_USER_SESSION_KEY = 'anexo76_auth_user_v1'; function pickAvatar(...candidates: Array): string | null { for (const candidate of candidates) { if (typeof candidate === 'string' && candidate.trim().length > 0) { return candidate.trim(); } } return null; } function pickText(...candidates: Array): string | null { for (const candidate of candidates) { if (typeof candidate === 'string') { const value = candidate.trim(); if (value.length > 0) { return value; } } } return null; } function readUserFromSession(): User | null { if (!browser) return null; try { const raw = sessionStorage.getItem(AUTH_USER_SESSION_KEY); if (!raw) return null; const parsed = JSON.parse(raw) as User; if (!parsed || typeof parsed !== 'object') return null; if (!parsed.id || !parsed.username) return null; return parsed; } catch { return null; } } function persistUserInSession(user: User | null): void { if (!browser) return; if (!user) { sessionStorage.removeItem(AUTH_USER_SESSION_KEY); return; } sessionStorage.setItem(AUTH_USER_SESSION_KEY, JSON.stringify(user)); } // ───────────────────────────────────────────────────────── // 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) => { persistUserInSession(user); 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: () => { persistUserInSession(null); 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); const sessionUser = readUserFromSession(); if (sessionUser) { authStore.setUser(sessionUser); } // 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(); persistUserInSession(null); 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 previousUser = get(authStore).user; const firstName = pickText(profile.firstName, parsed?.given_name, previousUser?.firstName); const lastName = pickText(profile.lastName, parsed?.family_name, previousUser?.lastName); const fullNameFromParts = pickText( firstName && lastName ? `${firstName} ${lastName}` : null, firstName, lastName ); const username = pickText( profile.username, parsed?.preferred_username, parsed?.username, previousUser?.username ) ?? ''; const name = pickText( profile.firstName || profile.lastName ? `${profile.firstName ?? ''} ${profile.lastName ?? ''}` : null, fullNameFromParts, parsed?.name, previousUser?.name, username ) ?? username; const user: User = { id: pickText(profile.id, parsed?.sub, previousUser?.id) ?? '', username, email: pickText(profile.email, parsed?.email, previousUser?.email) ?? undefined, name, firstName, lastName, displayName: pickText(name, previousUser?.displayName, username), avatarUrl: pickAvatar(previousUser?.avatarUrl), workspaceAvatarUrl: pickAvatar(previousUser?.workspaceAvatarUrl), legacyAvatarUrl: pickAvatar(previousUser?.legacyAvatarUrl), tenantId, roles, permissions: parsed?.permissions?.length ? parsed.permissions : currentPerms, profileSyncedAt: previousUser?.profileSyncedAt }; 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 previousUser = get(authStore).user; const d = response.data; const workspaceAvatarUrl = pickAvatar( d.workspaceAvatarUrl, d.workspace_avatar_url, d.avatar_url, d.avatarUrl, d.picture, d.photo, previousUser?.workspaceAvatarUrl ); const legacyAvatarUrl = pickAvatar( d.legacyAvatarUrl, d.legacy_avatar_url, d.avatar, d.photo, d.picture, previousUser?.legacyAvatarUrl ); const avatarUrl = pickAvatar(workspaceAvatarUrl, legacyAvatarUrl, previousUser?.avatarUrl); // Merge no destructivo: nunca pisar datos válidos con campos vacíos const firstName = pickText(d.first_name, d.firstName, previousUser?.firstName); const lastName = pickText(d.last_name, d.lastName, previousUser?.lastName); const username = pickText( d.preferred_username, d.username, previousUser?.username ) ?? ''; const nameFromParts = pickText( firstName && lastName ? `${firstName} ${lastName}` : null, firstName, lastName ); const name = pickText( d.name, nameFromParts, previousUser?.name, username ) ?? username; const displayName = pickText( d.displayName, d.display_name, name, username ) ?? username; const email = pickText(d.email, previousUser?.email) ?? undefined; const userId = pickText(d.sub, d.id, previousUser?.id) ?? ''; console.debug('[avatar][auth.loadUserInfo] /v1/auth/me avatar_url recibido:', workspaceAvatarUrl ?? '(null)'); console.debug('[avatar][auth.loadUserInfo] avatar final para authStore:', avatarUrl ?? '(null)'); console.debug('[profile][auth.loadUserInfo] first_name:', firstName ?? '(null)', 'last_name:', lastName ?? '(null)', 'avatar_url:', workspaceAvatarUrl ?? '(null)'); authStore.setUser({ id: userId, username, email, name, firstName, lastName, displayName, avatarUrl, workspaceAvatarUrl, legacyAvatarUrl, tenantId: d.tenant_id ?? previousUser?.tenantId, roles: d.roles ?? previousUser?.roles ?? [], permissions: d.permissions ?? previousUser?.permissions ?? [], profileSyncedAt: Date.now() }); } } catch (err) { console.error('[auth] Error cargando info del usuario:', err); } }; /** * Sincroniza el perfil del usuario desde /v1/auth/me * - Valida cache TTL (5 minutos) antes de hacer fetch * - Extrae first_name, last_name, avatar_url * - Retorna objeto con campos de perfil para UI o update de store * * Uso: * ``` * const profile = await syncUserProfile(accessToken); * if (profile) { * // profile.firstName, profile.lastName, profile.displayName, profile.avatarUrl * } * ``` */ export const syncUserProfile = async (accessToken?: string): Promise<{ firstName: string | null; lastName: string | null; displayName: string | null; avatarUrl: string | null; workspaceAvatarUrl: string | null; legacyAvatarUrl: string | null; rawProfileSyncedAt: number; } | null> => { if (!browser) return null; try { // Validar cache TTL: 5 minutos (300000ms) const PROFILE_CACHE_TTL = 5 * 60 * 1000; const { get } = await import('svelte/store'); const currentState = get(authStore); const now = Date.now(); if ( currentState.user?.profileSyncedAt && (now - currentState.user.profileSyncedAt) < PROFILE_CACHE_TTL ) { console.debug('[profile][sync] Cache válido, no re-fetching /v1/auth/me'); return { firstName: currentState.user.firstName ?? null, lastName: currentState.user.lastName ?? null, displayName: currentState.user.displayName ?? null, avatarUrl: currentState.user.avatarUrl ?? null, workspaceAvatarUrl: currentState.user.workspaceAvatarUrl ?? null, legacyAvatarUrl: currentState.user.legacyAvatarUrl ?? null, rawProfileSyncedAt: currentState.user.profileSyncedAt }; } const token = accessToken || getToken(); if (!token) { console.warn('[profile][sync] No token disponible para sincronizar'); return null; } // Llamar a loadUserInfo que hace fetch a /v1/auth/me await loadUserInfo(token); // Retornar los nuevos valores desde el store const updatedState = get(authStore); if (updatedState.user) { console.debug('[profile][sync] Perfil sincronizado exitosamente'); return { firstName: updatedState.user.firstName ?? null, lastName: updatedState.user.lastName ?? null, displayName: updatedState.user.displayName ?? null, avatarUrl: updatedState.user.avatarUrl ?? null, workspaceAvatarUrl: updatedState.user.workspaceAvatarUrl ?? null, legacyAvatarUrl: updatedState.user.legacyAvatarUrl ?? null, rawProfileSyncedAt: updatedState.user.profileSyncedAt ?? 0 }; } return null; } catch (err) { console.error('[profile][sync] Error sincronizando perfil:', err); return null; } }; // ───────────────────────────────────────────────────────── // 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(); persistUserInSession(null); 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); const hubBase = (import.meta.env.VITE_HUB_URL || 'https://workspace.aduanasoft.com').replace(/\/+$/, ''); window.location.href = `${hubBase}/login?return_to=${encodeURIComponent(window.location.origin + '/login?sso_verified=1')}`; } }; // ───────────────────────────────────────────────────────── // 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;