2025-12-17 13:33:31 -05:00
|
|
|
// Short-term failure cache for API auth timeouts
|
2025-12-19 11:59:00 -05:00
|
|
|
let lastAuthFailureTime: number = 0;
|
|
|
|
|
const AUTH_FAILURE_CACHE_MS: number = 30000; // 30 seconds
|
2025-12-03 13:27:37 -05:00
|
|
|
/**
|
|
|
|
|
* API route authentication helper
|
|
|
|
|
* Validates user session for protected API endpoints
|
|
|
|
|
*/
|
|
|
|
|
import { API_URL } from '@/config';
|
|
|
|
|
|
2025-12-19 11:59:00 -05:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-03 13:27:37 -05:00
|
|
|
/**
|
|
|
|
|
* Check if the request has a valid authentication session
|
2025-12-19 11:59:00 -05:00
|
|
|
* @param request - The incoming request
|
|
|
|
|
* @returns Promise with user object, error response, or set-cookie header
|
2025-12-03 13:27:37 -05:00
|
|
|
*/
|
2025-12-19 11:59:00 -05:00
|
|
|
export async function requireApiAuth(request: Request): Promise<AuthResult> {
|
2025-12-17 13:33:31 -05:00
|
|
|
// 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,
|
|
|
|
|
};
|
|
|
|
|
}
|
2025-12-03 13:27:37 -05:00
|
|
|
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
|
2025-12-17 13:33:31 -05:00
|
|
|
// 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);
|
2025-12-03 13:27:37 -05:00
|
|
|
|
|
|
|
|
let newSetCookieHeader = null;
|
|
|
|
|
|
|
|
|
|
// If unauthorized, try to refresh the token
|
|
|
|
|
if (res.status === 401) {
|
2025-12-17 13:33:31 -05:00
|
|
|
// 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);
|
2025-12-03 13:27:37 -05:00
|
|
|
|
|
|
|
|
if (!refreshRes.ok) {
|
|
|
|
|
return {
|
|
|
|
|
user: null,
|
|
|
|
|
error: new Response(JSON.stringify({ error: 'Session expired' }), {
|
|
|
|
|
status: 401,
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
}),
|
|
|
|
|
setCookieHeader: null,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-17 13:33:31 -05:00
|
|
|
// 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());
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-03 13:27:37 -05:00
|
|
|
let newCookieHeader = cookieHeader;
|
|
|
|
|
|
2025-12-17 13:33:31 -05:00
|
|
|
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];
|
2025-12-03 13:27:37 -05:00
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-17 13:33:31 -05:00
|
|
|
// 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);
|
2025-12-03 13:27:37 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
2025-12-19 11:59:00 -05:00
|
|
|
* @param data - Response data
|
|
|
|
|
* @param status - HTTP status code
|
|
|
|
|
* @param setCookieHeader - Set-Cookie header from auth refresh
|
2025-12-03 13:27:37 -05:00
|
|
|
*/
|
2025-12-19 11:59:00 -05:00
|
|
|
export function createApiResponse(
|
|
|
|
|
data: unknown,
|
|
|
|
|
status: number = 200,
|
|
|
|
|
setCookieHeader: string | string[] | null = null
|
|
|
|
|
): Response {
|
2025-12-17 13:33:31 -05:00
|
|
|
const headers = new Headers({ 'Content-Type': 'application/json' });
|
2025-12-03 13:27:37 -05:00
|
|
|
if (setCookieHeader) {
|
2025-12-17 13:33:31 -05:00
|
|
|
const cookies = Array.isArray(setCookieHeader) ? setCookieHeader : [setCookieHeader];
|
|
|
|
|
for (const c of cookies) {
|
|
|
|
|
if (c) headers.append('Set-Cookie', c.trim());
|
|
|
|
|
}
|
2025-12-03 13:27:37 -05:00
|
|
|
}
|
|
|
|
|
return new Response(JSON.stringify(data), { status, headers });
|
|
|
|
|
}
|