/** * Utilidades para llamadas a la API desde el servidor (SSR) * Centraliza la lógica de configuración de URL, autenticación y manejo de tokens */ import { redirect, type Cookies } from '@sveltejs/kit'; import { clearAccessTokenCookies, getAccessTokenFromCookies, setAccessTokenCookies } from '$lib/server/access-token-cookie'; /** * Obtiene y normaliza la URL base de la API para llamadas desde el servidor * Automáticamente reemplaza localhost/127.0.0.1 con 'backend' para Docker */ export function getServerApiUrl(): string { // Primero intentar con INTERNAL_API_URL (para llamadas server-side en Docker) let apiUrl = process.env.INTERNAL_API_URL; // Si no está definida, usar VITE_API_URL del entorno runtime (no import.meta.env) if (!apiUrl) { apiUrl = process.env.VITE_API_URL; } // Como último recurso, usar el valor de build-time if (!apiUrl) { apiUrl = import.meta.env.VITE_API_URL; // Reemplazar 'localhost' con 'backend' para llamadas desde el servidor (SSR) apiUrl = apiUrl?.replace('localhost', 'backend').replace('127.0.0.1', 'backend').replace('anexo76-dev.aduanasoft.com', 'backend'); } // Normalizar la URL: asegurar que termine con '/' return apiUrl?.endsWith('/') ? apiUrl : `${apiUrl}/`; } /** * Obtiene los tokens de autenticación de las cookies */ export function getAuthTokens(cookies: Cookies) { return { accessToken: getAccessTokenFromCookies(cookies), refreshToken: cookies.get('refresh_token') }; } /** * Establece los tokens de autenticación en las cookies * * Política de seguridad: * - access_token → NO HttpOnly (Bearer desde JS); si el JWT es muy grande, varias cookies fragmentadas * - refresh_token → HttpOnly=true (JS nunca lo lee; el servidor lo maneja via /api-sveltekit/auth/silent-refresh) */ export function setAuthTokens( cookies: Cookies, accessToken: string, refreshToken?: string ) { setAccessTokenCookies(cookies, accessToken, { secure: process.env.NODE_ENV === 'production', maxAge: 60 * 60 * 24 * 7 // 7 días }); if (refreshToken) { cookies.set('refresh_token', refreshToken, { path: '/', 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 }); } } /** * Limpia todos los tokens de autenticación de las cookies */ export function clearAuthTokens(cookies: Cookies) { clearAccessTokenCookies(cookies); cookies.delete('refresh_token', { path: '/' }); cookies.delete('active_company_id', { path: '/' }); } /** * Crea headers de autorización con el token Bearer */ export function createAuthHeaders(token: string, additionalHeaders?: Record, tenantOverride?: string) { return { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', ...(tenantOverride ? { 'X-Tenant-Override': tenantOverride } : {}), ...additionalHeaders }; } /** * Intenta refrescar el token de acceso usando el refresh token * @returns El nuevo access token o null si falla */ export async function refreshAccessToken( cookies: Cookies, fetch: typeof globalThis.fetch ): Promise { const { refreshToken } = getAuthTokens(cookies); if (!refreshToken) { return null; } 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) { return null; } const data = await response.json(); // Actualizar las cookies con los nuevos tokens setAuthTokens(cookies, data.access_token, data.refresh_token); return data.access_token; } catch (error) { console.error('🔄 [API] Error al refrescar token:', error); return null; } } /** * Realiza una petición autenticada a la API con manejo automático de refresh * @param endpoint - Endpoint relativo (ej: 'v1/auth/me') * @param options - Opciones de fetch * @param cookies - Objeto de cookies de SvelteKit * @param fetch - Función fetch de SvelteKit * @param redirectUrl - URL a la que redirigir si falla la autenticación (opcional) * @param timeout - Timeout en milisegundos (default: 30000ms) */ export async function authenticatedFetch( endpoint: string, options: RequestInit = {}, cookies: Cookies, fetch: typeof globalThis.fetch, redirectUrl?: string, timeout: number = 30000 ): Promise { try { const baseUrl = getServerApiUrl(); let { accessToken } = getAuthTokens(cookies); // Si no hay token, redirigir o lanzar error if (!accessToken) { if (redirectUrl) { throw redirect(303, redirectUrl); } throw new Error('No access token available'); } // Construir URL completa const url = endpoint.startsWith('http') ? endpoint : `${baseUrl}${endpoint}`; // Leer tenant override de cookie SSO (flujo multi-tenant relay) const tenantOverride = cookies.get('sso_tenant_id'); // Crear AbortController para timeout const controller = new AbortController(); const timeoutId = setTimeout(() => { console.error(`⏱️ [API] Timeout después de ${timeout}ms:`, endpoint); controller.abort(); }, timeout); // Realizar la petición inicial // Si el body es FormData, no incluir Content-Type (el navegador lo establece con el boundary) const isFormData = options.body instanceof FormData; const headers = isFormData ? { 'Authorization': `Bearer ${accessToken}`, ...(options.headers as Record || {}) } : createAuthHeaders(accessToken, options.headers as Record, tenantOverride); let response = await fetch(url, { ...options, headers, signal: controller.signal }); clearTimeout(timeoutId); // Si es 403, no intentar refrescar - es un problema de permisos if (response.status === 403) { console.warn('🚫 [API] Acceso denegado (403):', endpoint); return response; // Retornar directamente para que el llamador maneje el error } // Si es 401, intentar refrescar el token if (response.status === 401) { const newToken = await refreshAccessToken(cookies, fetch); if (newToken) { // Reintentar la petición con el nuevo token const newController = new AbortController(); const newTimeoutId = setTimeout(() => { console.error(`⏱️ [API] Timeout en retry después de ${timeout}ms:`, endpoint); newController.abort(); }, timeout); // Si el body es FormData, no incluir Content-Type const newHeaders = isFormData ? { 'Authorization': `Bearer ${newToken}`, ...(options.headers as Record || {}) } : createAuthHeaders(newToken, options.headers as Record, tenantOverride); response = await fetch(url, { ...options, headers: newHeaders, signal: newController.signal }); clearTimeout(newTimeoutId); } else { // No se pudo refrescar, limpiar y redirigir clearAuthTokens(cookies); if (redirectUrl) { throw redirect(303, redirectUrl); } } } return response; } catch (error) { // Si es un redirect, re-lanzarlo if (error && typeof error === 'object' && 'status' in error && 'location' in error) { throw error; } console.error('🔴 [API] Error en authenticatedFetch:', endpoint, error); // Retornar una respuesta de error simulada en lugar de lanzar return new Response(JSON.stringify({ error: 'Network error', details: String(error) }), { status: 500, headers: { 'Content-Type': 'application/json' } }); } } /** * Valida que el usuario esté autenticado y obtiene sus datos * @returns Los datos del usuario o null si no está autenticado */ export async function validateAuth( cookies: Cookies, fetch: typeof globalThis.fetch, redirectOnFail?: string ): Promise { const pickAvatar = (...candidates: Array): string | null => { for (const candidate of candidates) { if (typeof candidate === 'string' && candidate.trim().length > 0) { return candidate.trim(); } } return null; }; try { const response = await authenticatedFetch( 'v1/auth/me', {}, cookies, fetch, redirectOnFail ); if (!response.ok) { if (redirectOnFail) { clearAuthTokens(cookies); throw redirect(303, redirectOnFail); } return null; } const keycloakData = await response.json(); const workspaceAvatarFromAuthMe = pickAvatar( keycloakData.avatar_url, keycloakData.avatarUrl, keycloakData.picture, keycloakData.photo ); console.debug('[avatar][validateAuth] /v1/auth/me avatar_url recibido:', workspaceAvatarFromAuthMe ?? '(null)'); // Obtener perfil adicional del usuario (avatar, bio, etc.) try { const profileResponse = await authenticatedFetch( 'v1/core/users/me/profile', {}, cookies, fetch ); if (profileResponse.ok) { const profileData = await profileResponse.json(); const workspaceAvatarFromProfile = pickAvatar( profileData.workspaceAvatarUrl, profileData.workspace_avatar_url ); const legacyAvatar = pickAvatar( profileData.legacyAvatarUrl, profileData.legacy_avatar_url, profileData.avatarUrl, profileData.avatar_url, profileData.avatar, profileData.photo, profileData.picture ); const finalWorkspaceAvatar = pickAvatar(workspaceAvatarFromAuthMe, workspaceAvatarFromProfile); const finalAvatar = pickAvatar(finalWorkspaceAvatar, legacyAvatar); console.debug('[avatar][validateAuth] avatar final resuelto:', finalAvatar ?? '(null)'); // Combinar datos de Keycloak con datos del perfil. // Prioridad para nombre: caché local del perfil > JWT claims. return { ...keycloakData, id: profileData.id || keycloakData.id || keycloakData.sub, username: profileData.username || keycloakData.username || keycloakData.preferred_username || '', email: profileData.email || keycloakData.email || '', first_name: profileData.first_name || keycloakData.first_name || keycloakData.given_name || '', last_name: profileData.last_name || keycloakData.last_name || keycloakData.family_name || '', avatar_url: finalAvatar, avatarUrl: finalAvatar, workspace_avatar_url: finalWorkspaceAvatar, workspaceAvatarUrl: finalWorkspaceAvatar, legacy_avatar_url: legacyAvatar, legacyAvatarUrl: legacyAvatar, phone: profileData.phone || null, bio: profileData.bio || null, preferences: profileData.preferences || {} }; } } catch (profileError) { console.warn('⚠️ [API] No se pudo cargar el perfil del usuario, usando solo datos de Keycloak'); } // Fallback: map raw JWT claim names to the expected field names const nameParts = (keycloakData.name || '').split(' '); const finalAvatar = workspaceAvatarFromAuthMe; console.debug('[avatar][validateAuth] fallback auth/me avatar final:', finalAvatar ?? '(null)'); return { ...keycloakData, id: keycloakData.id || keycloakData.sub, username: keycloakData.username || keycloakData.preferred_username || '', first_name: keycloakData.first_name || keycloakData.given_name || nameParts[0] || '', last_name: keycloakData.last_name || keycloakData.family_name || nameParts.slice(1).join(' ') || '', avatar_url: finalAvatar, avatarUrl: finalAvatar, workspace_avatar_url: finalAvatar, workspaceAvatarUrl: finalAvatar, }; } catch (error) { // Si es un redirect, re-lanzarlo if (error && typeof error === 'object' && 'status' in error && 'location' in error) { throw error; } console.error('🔐 [API] Error validando autenticación:', error); if (redirectOnFail) { clearAuthTokens(cookies); throw redirect(303, redirectOnFail); } return null; } } /** * Obtiene las compañías del usuario autenticado */ export async function getUserCompanies( cookies: Cookies, fetch: typeof globalThis.fetch ): Promise { try { const response = await authenticatedFetch( 'v1/a76/company/my-companies', {}, cookies, fetch ); if (!response.ok) { console.error('🏢 [API] Error cargando compañías:', response.status); return []; } return await response.json(); } catch (error) { console.error('🏢 [API] Error cargando compañías:', error); return []; } } /** * Obtiene el ID de la compañía activa, o la primera disponible si no hay ninguna seleccionada */ export async function getActiveCompanyId( cookies: Cookies, fetch: typeof globalThis.fetch ): Promise { let companyId = cookies.get('active_company_id'); // Si no hay companyId en cookie, obtener las compañías del usuario y usar la primera if (!companyId) { const companies = await getUserCompanies(cookies, fetch); if (companies.length > 0) { companyId = companies[0].id.toString(); } } return companyId || null; } /** * Helper para manejar respuestas de API y convertir errores 403 en formato adecuado * para mostrar toasts en el cliente */ export async function handleApiResponse( response: Response ): Promise<{ data?: T; error?: { detail: string; status: number; isForbidden?: boolean } }> { if (response.ok) { // Para respuestas sin contenido (204) if (response.status === 204) { return { data: null as T }; } const data = await response.json(); return { data }; } // Manejar errores const errorData = await response.json().catch(() => ({ detail: 'Error desconocido' })); const error = { detail: errorData.detail || errorData.message || 'Error en la petición', status: response.status, isForbidden: response.status === 403 }; return { error }; }