Barazo default frontend barazo.forum
at main 112 lines 3.1 kB view raw
1/** 2 * Auth-aware fetch wrapper with 401 interception and silent token refresh. 3 * Wraps apiFetch to automatically retry on 401 after refreshing the session. 4 * @see specs/prd-web.md Section M3 (Auth Flow) 5 */ 6 7import { refreshSession } from './client' 8import type { AuthSession } from './types' 9 10/** Client: relative URLs (empty string). Server: internal Docker network URL. */ 11const API_URL = 12 typeof window === 'undefined' ? (process.env.API_INTERNAL_URL ?? 'http://localhost:3000') : '' 13 14interface AuthFetchOptions { 15 method?: 'GET' | 'POST' | 'PUT' | 'DELETE' 16 headers?: Record<string, string> 17 body?: unknown 18 signal?: AbortSignal 19} 20 21interface AuthFetchDeps { 22 getToken: () => string | null 23 setToken: (session: AuthSession) => void 24 onAuthFailure: () => void 25} 26 27class ApiError extends Error { 28 constructor( 29 public readonly status: number, 30 message: string 31 ) { 32 super(message) 33 this.name = 'ApiError' 34 } 35} 36 37async function rawFetch( 38 path: string, 39 accessToken: string | null, 40 options: AuthFetchOptions = {} 41): Promise<Response> { 42 const url = `${API_URL}${path}` 43 const headers: Record<string, string> = { 44 'Content-Type': 'application/json', 45 ...options.headers, 46 } 47 if (accessToken) { 48 headers['Authorization'] = `Bearer ${accessToken}` 49 } 50 51 return fetch(url, { 52 method: options.method ?? 'GET', 53 headers, 54 signal: options.signal, 55 ...(options.body !== undefined ? { body: JSON.stringify(options.body) } : {}), 56 }) 57} 58 59/** 60 * Creates an auth-aware fetch function that automatically handles 401 responses 61 * by refreshing the session token and retrying the request once. 62 */ 63export function createAuthFetch(deps: AuthFetchDeps) { 64 let refreshPromise: Promise<AuthSession> | null = null 65 66 return async function authFetch<T>(path: string, options: AuthFetchOptions = {}): Promise<T> { 67 const token = deps.getToken() 68 const response = await rawFetch(path, token, options) 69 70 if (response.ok) { 71 if (response.status === 204) { 72 return undefined as T 73 } 74 return response.json() as Promise<T> 75 } 76 77 if (response.status !== 401 || !token) { 78 const body = await response.text().catch(() => 'Unknown error') 79 throw new ApiError(response.status, `API ${response.status}: ${body}`) 80 } 81 82 // 401 -- attempt refresh (deduplicate concurrent refreshes) 83 try { 84 if (!refreshPromise) { 85 refreshPromise = refreshSession() 86 } 87 const session = await refreshPromise 88 deps.setToken(session) 89 } catch { 90 deps.onAuthFailure() 91 throw new ApiError(401, 'Session expired') 92 } finally { 93 refreshPromise = null 94 } 95 96 // Retry with new token 97 const retryToken = deps.getToken() 98 const retryResponse = await rawFetch(path, retryToken, options) 99 100 if (retryResponse.ok) { 101 if (retryResponse.status === 204) { 102 return undefined as T 103 } 104 return retryResponse.json() as Promise<T> 105 } 106 107 const body = await retryResponse.text().catch(() => 'Unknown error') 108 throw new ApiError(retryResponse.status, `API ${retryResponse.status}: ${body}`) 109 } 110} 111 112export { ApiError }