/** * 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'; /** * 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: cookies.get('access_token'), refreshToken: cookies.get('refresh_token') }; } /** * Establece los tokens de autenticación en las cookies * * Política de seguridad: * - access_token → NO HttpOnly (el cliente necesita incluirlo en el header Authorization) * - 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 ) { cookies.set('access_token', accessToken, { path: '/', httpOnly: false, // El cliente JS necesita leerlo para el header Bearer sameSite: 'lax', 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) { cookies.delete('access_token', { path: '/' }); 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) { return { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', ...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}`; // 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); 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); 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 { 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(); // 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(); // Combinar datos de Keycloak con datos del perfil return { ...keycloakData, avatar_url: profileData.avatar_url, phone: profileData.phone, bio: profileData.bio, preferences: profileData.preferences }; } } catch (profileError) { console.warn('⚠️ [API] No se pudo cargar el perfil del usuario, usando solo datos de Keycloak'); } return keycloakData; } 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 }; }