// Short-term failure cache for API auth timeouts let lastAuthFailureTime: number = 0; const AUTH_FAILURE_CACHE_MS: number = 30000; // 30 seconds /** * API route authentication helper * Validates user session for protected API endpoints */ import { API_URL } from '@/config'; export interface User { id?: string; username?: string; roles?: string[]; [key: string]: unknown; } export interface AuthResult { user: User | null; error: Response | null; setCookieHeader: string | string[] | null; } /** * Check if the request has a valid authentication session * @param request - The incoming request * @returns Promise with user object, error response, or set-cookie header */ export async function requireApiAuth(request: Request): Promise { // If we recently failed due to API timeout, immediately fail closed if (Date.now() - lastAuthFailureTime < AUTH_FAILURE_CACHE_MS) { return { user: null, error: new Response(JSON.stringify({ error: 'Authentication error', message: 'API unreachable or timed out (cached)' }), { status: 503, headers: { 'Content-Type': 'application/json' }, }), setCookieHeader: null, }; } try { const cookieHeader = request.headers.get('cookie') ?? ''; if (!cookieHeader) { return { user: null, error: new Response(JSON.stringify({ error: 'Authentication required' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }), setCookieHeader: null, }; } // Try to get user identity // Add timeout to fetch let controller = new AbortController(); let timeout = setTimeout(() => controller.abort(), 3000); // 3s timeout let res; try { res = await fetch(`${API_URL}/auth/id`, { headers: { Cookie: cookieHeader }, credentials: 'include', signal: controller.signal, }); } catch (err) { clearTimeout(timeout); lastAuthFailureTime = Date.now(); return { user: null, error: new Response(JSON.stringify({ error: 'Authentication error', message: 'API unreachable or timed out' }), { status: 503, headers: { 'Content-Type': 'application/json' }, }), setCookieHeader: null, }; } clearTimeout(timeout); let newSetCookieHeader: string[] | null = null; // If unauthorized, try to refresh the token if (res.status === 401) { // Add timeout to refresh fetch controller = new AbortController(); timeout = setTimeout(() => controller.abort(), 3000); let refreshRes; try { refreshRes = await fetch(`${API_URL}/auth/refresh`, { method: 'POST', headers: { Cookie: cookieHeader }, credentials: 'include', signal: controller.signal, }); } catch (err) { clearTimeout(timeout); return { user: null, error: new Response(JSON.stringify({ error: 'Authentication error', message: 'API unreachable or timed out' }), { status: 503, headers: { 'Content-Type': 'application/json' }, }), setCookieHeader: null, }; } clearTimeout(timeout); if (!refreshRes.ok) { return { user: null, error: new Response(JSON.stringify({ error: 'Session expired' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }), setCookieHeader: null, }; } // Capture the Set-Cookie headers from the refresh response to forward to client if (typeof refreshRes.headers.getSetCookie === 'function') { newSetCookieHeader = refreshRes.headers.getSetCookie(); } else { const sc = refreshRes.headers.get('set-cookie'); if (sc) newSetCookieHeader = sc.split(/,(?=\s*\w+=)/).map(s => s.trim()); } let newCookieHeader = cookieHeader; if (newSetCookieHeader && newSetCookieHeader.length > 0) { // newSetCookieHeader is an array of cookie strings; build a cookie header for the retry const cookiesArray = Array.isArray(newSetCookieHeader) ? newSetCookieHeader : [newSetCookieHeader]; newCookieHeader = cookiesArray.map(c => c.split(';')[0]).join('; '); } else { return { user: null, error: new Response(JSON.stringify({ error: 'Session refresh failed' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }), setCookieHeader: null, }; } // Add timeout to retry fetch let retryController = new AbortController(); let retryTimeout = setTimeout(() => retryController.abort(), 3000); try { res = await fetch(`${API_URL}/auth/id`, { headers: { Cookie: newCookieHeader }, credentials: 'include', signal: retryController.signal, }); } catch (err) { clearTimeout(retryTimeout); lastAuthFailureTime = Date.now(); return { user: null, error: new Response(JSON.stringify({ error: 'Authentication error', message: 'API unreachable or timed out' }), { status: 503, headers: { 'Content-Type': 'application/json' }, }), setCookieHeader: null, }; } clearTimeout(retryTimeout); } if (!res.ok) { return { user: null, error: new Response(JSON.stringify({ error: 'Authentication failed' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }), setCookieHeader: null, }; } const user = await res.json(); return { user, error: null, setCookieHeader: newSetCookieHeader }; } catch (err) { console.error('API auth error:', err); return { user: null, error: new Response(JSON.stringify({ error: 'Authentication error' }), { status: 500, headers: { 'Content-Type': 'application/json' }, }), setCookieHeader: null, }; } } /** * Helper to create a response with optional Set-Cookie header forwarding * @param data - Response data * @param status - HTTP status code * @param setCookieHeader - Set-Cookie header from auth refresh */ export function createApiResponse( data: unknown, status: number = 200, setCookieHeader: string | string[] | null = null ): Response { const headers = new Headers({ 'Content-Type': 'application/json' }); if (setCookieHeader) { const cookies = Array.isArray(setCookieHeader) ? setCookieHeader : [setCookieHeader]; for (const c of cookies) { if (c) headers.append('Set-Cookie', c.trim()); } } return new Response(JSON.stringify(data), { status, headers }); }