// requireAuthHook.ts import { API_URL } from "@/config"; import type { AstroGlobal } from 'astro'; export interface AuthUser { id?: string; username?: string; roles?: string[]; [key: string]: unknown; } // Short-term failure cache for auth timeouts/errors let lastAuthFailureTime: number = 0; const AUTH_FAILURE_CACHE_MS: number = 30000; // 30 seconds // WeakMap to cache auth promises per Astro context (request) const authCache = new WeakMap>(); export const requireAuthHook = async (Astro: AstroGlobal): Promise => { // If we recently failed due to API timeout/unreachability, fail closed quickly if (Date.now() - lastAuthFailureTime < AUTH_FAILURE_CACHE_MS) { return null; } // Check if we already have a cached promise for this request if (authCache.has(Astro)) { return authCache.get(Astro) ?? null; } // Create a promise and cache it immediately to prevent race conditions const authPromise = performAuth(Astro); authCache.set(Astro, authPromise); // Return the promise - all callers will await the same promise return authPromise; }; async function performAuth(Astro: AstroGlobal): Promise { try { const cookieHeader = Astro.request.headers.get("cookie") ?? ""; // Add timeout to avoid hanging SSR render let controller = new AbortController(); let timeout = setTimeout(() => controller.abort(), 3000); 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(); console.error("[SSR] auth/id failed or timed out", err); return null; } clearTimeout(timeout); if (res.status === 401) { // Check if we even have a refresh token before attempting refresh if (!cookieHeader.includes('refresh_token=')) { return null; } // Try refresh with timeout 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); lastAuthFailureTime = Date.now(); console.error("[SSR] auth/refresh failed or timed out", err); return null; } clearTimeout(timeout); if (!refreshRes.ok) { let errorDetail = ''; try { const errorBody = await refreshRes.text(); errorDetail = ` - ${errorBody}`; } catch {} console.error(`[SSR] Token refresh failed ${refreshRes.status}${errorDetail}`); return null; } // Get all Set-Cookie headers (getSetCookie returns an array) let setCookies: string[] = []; if (typeof refreshRes.headers.getSetCookie === 'function') { setCookies = refreshRes.headers.getSetCookie(); } else { // Fallback for older Node versions const setCookieHeader = refreshRes.headers.get("set-cookie"); if (setCookieHeader) { // Split on comma followed by a cookie name (word=), avoiding splitting on Expires dates setCookies = setCookieHeader.split(/,(?=\s*[a-zA-Z_][a-zA-Z0-9_]*=)/); } } if (setCookies.length === 0) { console.error("No set-cookie header found in refresh response"); return null; } // Forward cookies to client setCookies.forEach((c) => Astro.response.headers.append("set-cookie", c.trim())); // Build new cookie header for the retry request const newCookieHeader = setCookies.map(c => c.split(";")[0].trim()).join("; "); controller = new AbortController(); timeout = setTimeout(() => controller.abort(), 3000); try { res = await fetch(`${API_URL}/auth/id`, { headers: { Cookie: newCookieHeader }, credentials: "include", signal: controller.signal, }); } catch (err) { clearTimeout(timeout); lastAuthFailureTime = Date.now(); console.error("[SSR] auth/id retry failed or timed out", err); return null; } clearTimeout(timeout); } if (!res.ok) { console.error("Failed to fetch user ID after token refresh", res.status); return null; } const user = await res.json(); return user; } catch (err) { console.error("[SSR] requireAuthHook error:", err); return null; } }