Files
pulse/lib/api/client.ts
Usman Baig 1c26e4cc6c fix: resolve intermittent auth errors when navigating between tabs
Token refresh race condition: when multiple requests got 401 simultaneously,
queued retries reused stale headers and the initiator fell through without
throwing on retry failure. Now retries regenerate headers (fresh request ID
and CSRF token), and both retry failure and refresh failure throw explicitly.

SWR cache is now invalidated after token refresh so stale error responses
are not served from cache.
2026-03-13 10:52:02 +01:00

379 lines
13 KiB
TypeScript

/**
* HTTP client wrapper for API calls
* Includes Request ID propagation for debugging across services
*/
import { authMessageFromStatus, AUTH_ERROR_MESSAGES } from '@ciphera-net/ui'
import { generateRequestId, getRequestIdHeader, setLastRequestId } from '@/lib/utils/requestId'
/** Request timeout in ms; network errors surface as user-facing "Network error, please try again." */
const FETCH_TIMEOUT_MS = 30_000
export const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8082'
export const AUTH_URL = process.env.NEXT_PUBLIC_AUTH_URL || 'http://localhost:3000'
export const APP_URL = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3003'
export const AUTH_API_URL = process.env.NEXT_PUBLIC_AUTH_API_URL || 'https://auth-api.ciphera.net'
export function getLoginUrl(redirectPath = '/auth/callback') {
const redirectUri = encodeURIComponent(`${APP_URL}${redirectPath}`)
return `${AUTH_URL}/login?client_id=pulse-app&redirect_uri=${redirectUri}&response_type=code`
}
export function getSignupUrl(redirectPath = '/auth/callback') {
const redirectUri = encodeURIComponent(`${APP_URL}${redirectPath}`)
return `${AUTH_URL}/signup?client_id=pulse-app&redirect_uri=${redirectUri}&response_type=code`
}
// * ============================================================================
// * CSRF Token Handling
// * ============================================================================
/**
* Get CSRF token from the csrf_token cookie (non-httpOnly)
* This is needed for state-changing requests to the Auth API
*/
function getCSRFToken(): string | null {
if (typeof document === 'undefined') return null
const cookies = document.cookie.split(';')
for (const cookie of cookies) {
const [name, value] = cookie.trim().split('=')
if (name === 'csrf_token') {
return decodeURIComponent(value)
}
}
return null
}
/**
* Check if a request method requires CSRF protection
* State-changing methods (POST, PUT, DELETE, PATCH) need CSRF tokens
*/
function isStateChangingMethod(method: string): boolean {
const stateChangingMethods = ['POST', 'PUT', 'DELETE', 'PATCH']
return stateChangingMethods.includes(method.toUpperCase())
}
export class ApiError extends Error {
status: number
data?: Record<string, unknown>
constructor(message: string, status: number, data?: Record<string, unknown>) {
super(message)
this.status = status
this.data = data
}
}
// * Mutex for token refresh
let isRefreshing = false
type RefreshSubscriber = { onSuccess: () => void; onFailure: (err: unknown) => void }
let refreshSubscribers: RefreshSubscriber[] = []
function subscribeToTokenRefresh(onSuccess: () => void, onFailure: (err: unknown) => void) {
refreshSubscribers.push({ onSuccess, onFailure })
}
function onRefreshed() {
refreshSubscribers.forEach((s) => s.onSuccess())
refreshSubscribers = []
}
function onRefreshFailed(err: unknown) {
refreshSubscribers.forEach((s) => {
try {
s.onFailure(err)
} catch {
// ignore
}
})
refreshSubscribers = []
}
// * ============================================================================
// * Request Deduplication & Caching
// * ============================================================================
/** Cache TTL in milliseconds (2 seconds) */
const CACHE_TTL_MS = 2_000
/** Stores in-flight requests for deduplication */
interface PendingRequest {
promise: Promise<unknown>
timestamp: number
}
const pendingRequests = new Map<string, PendingRequest>()
/** Stores cached responses */
interface CachedResponse {
data: unknown
timestamp: number
}
const responseCache = new Map<string, CachedResponse>()
/**
* Generate a unique key for a request based on endpoint and options
*/
function getRequestKey(endpoint: string, options: RequestInit): string {
const method = options.method || 'GET'
const body = options.body || ''
return `${method}:${endpoint}:${body}`
}
/**
* Clean up expired entries from pending requests and response cache
*/
function cleanupExpiredEntries(): void {
const now = Date.now()
// * Clean up stale pending requests (older than 30 seconds)
for (const [key, pending] of pendingRequests.entries()) {
if (now - pending.timestamp > 30_000) {
pendingRequests.delete(key)
}
}
// * Clean up stale cached responses (older than CACHE_TTL_MS)
for (const [key, cached] of responseCache.entries()) {
if (now - cached.timestamp > CACHE_TTL_MS) {
responseCache.delete(key)
}
}
}
/**
* Base API client with error handling, request deduplication, and short-term caching
*/
async function apiRequest<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
// * Skip deduplication for non-GET requests (mutations should always execute)
const method = options.method || 'GET'
const shouldDedupe = method === 'GET'
if (shouldDedupe) {
// * Clean up expired entries periodically
if (pendingRequests.size > 100 || responseCache.size > 100) {
cleanupExpiredEntries()
}
const requestKey = getRequestKey(endpoint, options)
// * Check if we have a recent cached response (within 2 seconds)
const cached = responseCache.get(requestKey)
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
return cached.data as T
}
// * Check if there's an identical request in flight
const pending = pendingRequests.get(requestKey)
if (pending && Date.now() - pending.timestamp < 30000) {
return pending.promise as Promise<T>
}
}
// * Determine base URL
const isAuthRequest = endpoint.startsWith('/auth')
const baseUrl = isAuthRequest ? AUTH_API_URL : API_URL
// * Handle legacy endpoints that already include /api/ prefix
const url = endpoint.startsWith('/api/')
? `${baseUrl}${endpoint}`
: `${baseUrl}/api/v1${endpoint}`
// * Generate and store request ID for tracing
const requestId = generateRequestId()
setLastRequestId(requestId)
const headers: Record<string, string> = {
'Content-Type': 'application/json',
[getRequestIdHeader()]: requestId,
}
// * Merge any additional headers from options
if (options.headers) {
const additionalHeaders = options.headers as Record<string, string>
Object.entries(additionalHeaders).forEach(([key, value]) => {
headers[key] = value
})
}
// * We rely on HttpOnly cookies, so no manual Authorization header injection.
// * We MUST set credentials: 'include' for the browser to send cookies cross-origin (or same-site).
// * Add CSRF token for all state-changing requests (Pulse API and Auth API).
// * Both backends enforce the double-submit cookie pattern server-side.
if (isStateChangingMethod(method)) {
const csrfToken = getCSRFToken()
if (csrfToken) {
headers['X-CSRF-Token'] = csrfToken
}
}
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
const signal = options.signal ?? controller.signal
// * Create the request promise
const requestPromise = (async (): Promise<T> => {
let response: Response
try {
response = await fetch(url, {
...options,
headers,
credentials: 'include', // * IMPORTANT: Send cookies
signal,
})
clearTimeout(timeoutId)
} catch (e) {
clearTimeout(timeoutId)
if (e instanceof Error && (e.name === 'AbortError' || e.name === 'TypeError')) {
throw new ApiError(AUTH_ERROR_MESSAGES.NETWORK, 0)
}
throw e
}
if (!response.ok) {
if (response.status === 401) {
// * Attempt Token Refresh if 401
if (typeof window !== 'undefined') {
// * Prevent infinite loop: Don't refresh if the failed request WAS a refresh request (unlikely via apiRequest but safe to check)
if (!endpoint.includes('/auth/refresh')) {
if (isRefreshing) {
// * If refresh is already in progress, wait for it to complete (or fail)
return new Promise<T>((resolve, reject) => {
subscribeToTokenRefresh(
() => {
// * Retry with fresh headers after refresh completes
const retryHeaders: Record<string, string> = {
'Content-Type': 'application/json',
[getRequestIdHeader()]: generateRequestId(),
}
if (options.headers) {
Object.entries(options.headers as Record<string, string>).forEach(([key, value]) => {
retryHeaders[key] = value
})
}
if (isStateChangingMethod(method)) {
const csrfToken = getCSRFToken()
if (csrfToken) retryHeaders['X-CSRF-Token'] = csrfToken
}
fetch(url, { ...options, headers: retryHeaders, credentials: 'include' })
.then(async (retryResponse) => {
if (retryResponse.ok) {
resolve(await retryResponse.json())
} else {
reject(new ApiError(authMessageFromStatus(retryResponse.status), retryResponse.status))
}
})
.catch((e) => reject(e))
},
(err) => reject(err)
)
})
}
isRefreshing = true
try {
// * Call our Next.js API route to handle refresh securely
const refreshRes = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
})
if (refreshRes.ok) {
// * Refresh successful, cookies updated
onRefreshed()
// * Retry original request with fresh headers
const retryHeaders: Record<string, string> = {
'Content-Type': 'application/json',
[getRequestIdHeader()]: generateRequestId(),
}
if (options.headers) {
Object.entries(options.headers as Record<string, string>).forEach(([key, value]) => {
retryHeaders[key] = value
})
}
if (isStateChangingMethod(method)) {
const csrfToken = getCSRFToken()
if (csrfToken) retryHeaders['X-CSRF-Token'] = csrfToken
}
const retryResponse = await fetch(url, {
...options,
headers: retryHeaders,
credentials: 'include',
})
if (retryResponse.ok) {
return retryResponse.json()
}
// * Retry failed — throw with the retry response status, not the original 401
const retryBody = await retryResponse.json().catch(() => ({}))
throw new ApiError(authMessageFromStatus(retryResponse.status), retryResponse.status, retryBody)
} else {
const sessionExpiredMsg = authMessageFromStatus(401)
onRefreshFailed(new ApiError(sessionExpiredMsg, 401))
localStorage.removeItem('user')
throw new ApiError(sessionExpiredMsg, 401)
}
} catch (e) {
if (e instanceof ApiError) throw e
const err = e instanceof Error && (e.name === 'AbortError' || e.name === 'TypeError')
? new ApiError(AUTH_ERROR_MESSAGES.NETWORK, 0)
: e
onRefreshFailed(err)
throw err
} finally {
isRefreshing = false
}
}
}
}
const errorBody = await response.json().catch(() => ({}))
const message = authMessageFromStatus(response.status)
throw new ApiError(message, response.status, errorBody)
}
return response.json()
})()
// * For GET requests, track the promise for deduplication and cache the result
if (shouldDedupe) {
const requestKey = getRequestKey(endpoint, options)
// * Store in pending requests
pendingRequests.set(requestKey, {
promise: requestPromise as Promise<unknown>,
timestamp: Date.now(),
})
// * Clean up pending request and cache the result when done
requestPromise
.then((data) => {
// * Cache successful response
responseCache.set(requestKey, {
data,
timestamp: Date.now(),
})
// * Remove from pending
pendingRequests.delete(requestKey)
return data
})
.catch((error) => {
// * Remove from pending on error too
pendingRequests.delete(requestKey)
throw error
})
}
return requestPromise
}
export const authFetch = apiRequest
export default apiRequest