import { createHmac, timingSafeEqual } from "node:crypto"; import type { Request, Response } from "express"; import type { Session, User } from "@supabase/supabase-js"; import { env, isProduction } from "./config.js"; import { supabaseAuth } from "./supabase.js"; import { singleFlight } from "./single-flight.js"; // Several page, image and polling requests arrive together. Validate once for // that burst, without extending a revoked token's lifetime through a TTL cache. const verifyAccess = singleFlight((token: string) => supabaseAuth.auth.getUser(token)); const refreshAccess = singleFlight((token: string) => supabaseAuth.auth.refreshSession({ refresh_token: token })); const accessCookie = "sp_access"; const refreshCookie = "sp_refresh"; const passwordRecoveryCookie = "sp_password_recovery"; const passwordRecoveryWindowMs = 15 * 60 * 1000; const sameSite = "lax" as const; const cookieOptions = { httpOnly: true, secure: isProduction, sameSite, path: "/" }; const passwordRecoveryCookieOptions = { httpOnly: true, secure: isProduction, sameSite, path: "/api/auth/password/recovery", }; function passwordRecoverySignature(userId: string, expiresAt: number) { return createHmac("sha256", env.SUPABASE_SERVICE_ROLE_KEY) .update(`sporalize-password-recovery:${userId}:${expiresAt}`) .digest("base64url"); } export function setPasswordRecoveryCookie(res: Response, userId: string) { const expiresAt = Date.now() + passwordRecoveryWindowMs; res.cookie(passwordRecoveryCookie, `${expiresAt}.${passwordRecoverySignature(userId, expiresAt)}`, { ...passwordRecoveryCookieOptions, maxAge: passwordRecoveryWindowMs, }); } export function hasPasswordRecoveryCookie(req: Request, userId: string) { const value = req.cookies?.[passwordRecoveryCookie]; if (typeof value !== "string") return false; const [expiresText, suppliedSignature, extra] = value.split("."); const expiresAt = Number(expiresText); if (extra !== undefined || !Number.isSafeInteger(expiresAt) || expiresAt <= Date.now() || expiresAt > Date.now() + passwordRecoveryWindowMs + 5_000 || !suppliedSignature) return false; const expected = Buffer.from(passwordRecoverySignature(userId, expiresAt)); const supplied = Buffer.from(suppliedSignature); return expected.length === supplied.length && timingSafeEqual(expected, supplied); } export function clearPasswordRecoveryCookie(res: Response) { res.clearCookie(passwordRecoveryCookie, passwordRecoveryCookieOptions); } export function setSessionCookies(res: Response, session: Session) { res.cookie(accessCookie, session.access_token, { ...cookieOptions, maxAge: Math.max(session.expires_in * 1000, 60_000) }); res.cookie(refreshCookie, session.refresh_token, { ...cookieOptions, maxAge: 1000 * 60 * 60 * 24 * 30 }); } export function clearSessionCookies(res: Response) { res.clearCookie(accessCookie, cookieOptions); res.clearCookie(refreshCookie, cookieOptions); } export async function authenticatedUser(req: Request, res: Response): Promise<{ user: User; session?: Session } | null> { const accessToken = req.cookies?.[accessCookie] as string | undefined; const refreshToken = req.cookies?.[refreshCookie] as string | undefined; if (!accessToken && !refreshToken) return null; if (accessToken) { const { data, error } = await verifyAccess(accessToken); if (data.user && !error) return { user: data.user }; // An outage is not evidence that credentials expired. Do not rotate or clear cookies. if (error && ![400, 401, 403].includes(error.status ?? 0)) throw error; } if (!refreshToken) { clearSessionCookies(res); return null; } const { data, error } = await refreshAccess(refreshToken); if (error && ![400, 401, 403].includes(error.status ?? 0)) throw error; if (error || !data.user || !data.session) { clearSessionCookies(res); return null; } setSessionCookies(res, data.session); return { user: data.user, session: data.session }; } export function safeRedirect(path: unknown) { const frontend = new URL(env.FRONTEND_URL); const fallback = new URL("/labs", frontend).toString(); if (typeof path !== "string" || !path.startsWith("/")) return fallback; try { const target = new URL(path, frontend); return target.origin === frontend.origin ? target.toString() : fallback; } catch { return fallback; } }