import type { TokenManager } from '../core/tokenManager'; import type { AuthService } from '../core/authService'; import { TokenState } from '../types'; /** * Configuration for the fetch interceptor */ export interface FetchInterceptorConfig { /** * Patterns to skip (won't be intercepted) */ skipPatterns?: string[]; /** * Backend URL to match (only intercept requests to this URL) * @deprecated Use `urls` instead for multiple URLs */ backendUrl?: string; /** * URLs to intercept (requests matching any of these origins will be intercepted) */ urls?: string[]; } /** * Default patterns to skip */ const DEFAULT_SKIP_PATTERNS = [ // Auth endpoints (Mana Core Auth) '/api/v1/auth/login', '/api/v1/auth/register', '/api/v1/auth/refresh', '/api/v1/auth/logout', '/api/v1/auth/forgot-password', '/api/v1/auth/reset-password', '/api/v1/auth/verify', '/api/v1/auth/google-signin', '/api/v1/auth/apple-signin', // Legacy auth patterns (for backwards compatibility) '/auth/signin', '/auth/signup', '/auth/refresh', '/auth/forgot-password', '/auth/reset-password', '/auth/verify', '/auth/logout', // Public endpoints '/health', '/ping', '/status', '/version', '/public/', // Storage endpoints '.supabase.co/storage/', '/storage/v1/', // External APIs 'googleapis.com', 'firebase.com', 'firebaseapp.com', 'replicate.com', 'openai.com', 'anthropic.com', ]; /** * Setup a global fetch interceptor for automatic token handling */ export function setupFetchInterceptor( authService: AuthService, tokenManager: TokenManager, config?: FetchInterceptorConfig ): void { if (typeof globalThis === 'undefined' || !globalThis.fetch) { console.warn('FetchInterceptor: globalThis.fetch not available'); return; } const originalFetch = globalThis.fetch; const skipPatterns = [...DEFAULT_SKIP_PATTERNS, ...(config?.skipPatterns || [])]; // Build the list of URLs to intercept: // 1. Explicit `urls` array takes priority // 2. Fall back to `backendUrl` (deprecated) wrapped in an array // 3. Default to the auth service base URL const interceptUrls: string[] = config?.urls ? config.urls : config?.backendUrl ? [config.backendUrl] : [authService.getBaseUrl()]; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = extractUrl(input); // Skip intercepting if URL doesn't match criteria if (shouldSkipInterception(url, skipPatterns, interceptUrls)) { return originalFetch(input, init); } console.debug('Fetch interceptor: Intercepting URL:', url); try { // Make request with current token const response = await makeRequestWithToken(originalFetch, authService, input, init); // Handle 401 responses if (response.status === 401) { const responseData = await response .clone() .json() .catch(() => ({})); console.debug('Fetch interceptor: Received 401 response:', responseData); if (isTokenExpiredResponse(responseData)) { console.debug('Fetch interceptor: Token expired, delegating to TokenManager'); return tokenManager.handle401Response(input, init); } } return response; } catch (error) { console.debug('Error in global fetch interceptor:', error); return originalFetch(input, init); } }) as typeof fetch; } /** * Setup token state observers for integrations (e.g., Supabase) */ export function setupTokenObservers( tokenManager: TokenManager, onValid?: (token: string) => void | Promise, onExpired?: () => void | Promise ): () => void { return tokenManager.subscribe(async (state, token) => { try { if (state === TokenState.VALID && token && onValid) { await onValid(token); } else if (state === TokenState.EXPIRED && onExpired) { await onExpired(); } } catch (error) { console.debug('Error in token observer:', error); } }); } /** * Extract URL from various input types */ function extractUrl(input: RequestInfo | URL): string { if (typeof input === 'string') { return input; } else if (input instanceof URL) { return input.toString(); } else if (input instanceof Request) { return input.url; } return ''; } /** * Check if request should skip interception */ function shouldSkipInterception( url: string, skipPatterns: string[], interceptUrls: string[] ): boolean { if (!url) return true; const lowerUrl = url.toLowerCase(); // Check skip patterns if (skipPatterns.some((pattern) => lowerUrl.includes(pattern.toLowerCase()))) { return true; } // Check if URL matches any of the intercept URLs (must include full host:port) try { const requestOrigin = new URL(url).origin.toLowerCase(); const matchesAny = interceptUrls.some((interceptUrl) => { try { return new URL(interceptUrl).origin.toLowerCase() === requestOrigin; } catch { return false; } }); if (!matchesAny) { return true; } } catch { // If URL parsing fails, skip interception return true; } return false; } /** * Make a request with the current token */ async function makeRequestWithToken( originalFetch: typeof fetch, authService: AuthService, input: RequestInfo | URL, init?: RequestInit ): Promise { const token = await authService.getAppToken(); const requestInit: RequestInit = { method: init?.method || 'GET', ...init, }; if (token) { const headers = new Headers(requestInit.headers || {}); headers.set('Authorization', `Bearer ${token}`); requestInit.headers = headers; } return originalFetch(input, requestInit); } /** * Check if response indicates a token issue that warrants a refresh attempt * Any 401 response should trigger a refresh attempt - if the refresh fails, * then we know the session is truly invalid */ function isTokenExpiredResponse(responseData: Record): boolean { const error = responseData.error as Record | undefined; const errorMessage = String( error?.message || responseData.message || responseData.error || '' ).toLowerCase(); const errorCode = String(responseData.code || error?.code || ''); // Trigger refresh for any token-related auth error // This includes: // - Explicit expiration: "jwt expired", "token expired" // - Generic validation failures: "invalid token", "token validation failed" // - Backend passthrough errors: "exp claim", "claim timestamp" return ( errorMessage.includes('jwt expired') || errorMessage.includes('token expired') || errorMessage.includes('token has expired') || errorMessage.includes('invalid token') || errorMessage.includes('token validation failed') || errorMessage.includes('claim') || // Catches jose errors like "exp claim timestamp check failed" errorCode === 'PGRST301' || errorCode === 'TOKEN_EXPIRED' || errorCode === 'ERR_JWT_EXPIRED' ); } /** * Get interceptor status for debugging */ export function getInterceptorStatus( authService: AuthService, tokenManager: TokenManager ): { isSetup: boolean; backendUrl: string; tokenManager: { size: number; state: string; refreshAttempts: number }; } { return { isSetup: typeof globalThis !== 'undefined' && globalThis.fetch !== undefined, backendUrl: authService.getBaseUrl(), tokenManager: tokenManager.getQueueStatus(), }; }