import { createCookieSessionStorage, redirect, Session } from "react-router"; import { loadRequiredWebConfig, resolveWebSessionCookiePolicy, type WebSessionCookieSurface } from "@hivegrid/config"; import { getTenantSessionIdentityForUser, type TenantSessionIdentity } from "./db/tenants.db.server"; import { randomBytes, timingSafeEqual } from "crypto"; import { getUser } from "./db/users.db.server"; import { getActiveSessionByToken } from "~/services/auth/sessionStore.server"; import { endServerSession, startServerSession } from "~/services/auth/sessionLifecycle.server"; import type { ActiveUserSessionRow } from "~/services/auth/sessionStore.server"; import { getActiveAdminSessionByToken, type ActiveAdminSession } from "@hivegrid/db/admin-sessions.server"; import { getRlsExecutionContext, runWithRlsContext } from "@hivegrid/db/rls-context.server"; import { createScopedLogger } from "./logger.server"; import { getHostRoutingContext, getRequestRoutingHost } from "./app/HostRouting.server"; import { safeSessionRedirectTarget } from "./app/SessionRedirectSafety.server"; import { defaultLocale, normalizeLocaleCandidate } from "@hivegrid/i18n"; const sessionLogger = createScopedLogger("session"); // AK-P19 (DoD #4): platform-operator impersonation is capped at 30 minutes. // `validateImpersonationSessionState` reports `stale_started_at` past this // window so the impersonated tenant session can no longer act. export const IMPERSONATION_SESSION_TTL_MS = 30 * 60 * 1000; export const PENDING_MFA_LOGIN_TTL_MS = 10 * 60 * 1000; export type ImpersonationSessionValidationReason = | "not_impersonating" | "missing_actor" | "missing_started_at" | "malformed_started_at" | "future_started_at" | "stale_started_at" | "wrong_actor" | "valid"; export type ImpersonationSessionValidation = { valid: boolean; reason: ImpersonationSessionValidationReason; actorUserId?: string; startedAt?: string; }; export type UserSession = { userId: string; lightOrDarkMode: string; lng: string; csrf?: string; cookies: { category: string; allowed: boolean }[]; metrics?: { enabled: boolean; logToConsole: boolean; saveToDatabase: boolean; ignoreUrls: string[]; }; impersonatingFromUserId?: string; impersonatingFromAdminUserId?: string; impersonatingFromAdminEmail?: string; impersonatingStartedAt?: string; theme?: string; /** * Phase 01 / #27 — set at login time when the password the user just * submitted no longer satisfies the current password policy. Drives the * dashboard soft-banner. Never gates the login itself. */ passwordPolicyOutdated?: boolean; /** Every authenticated cookie must reference an active UserSession row. */ sessionToken?: string; /** * D1 (2FA-Login-Gate) — Zwischenzustand zwischen Passwort-OK und TOTP-Verifikation. Traegt das * Cookie `pendingMfaUserId` (NICHT `userId`!), hat der Nutzer das Passwort bestanden, aber noch * keinen zweiten Faktor geliefert. `requireUserId` liest weiterhin nur `userId` und gewaehrt * deshalb KEINEN Zugriff, solange nur der Pending-Zustand gesetzt ist. `pendingMfaRedirectTo` * haelt das rohe redirectTo, damit die Verify-Route nach Erfolg ans Ziel weiterleiten kann. */ pendingMfaUserId?: string; pendingMfaRedirectTo?: string; pendingMfaStartedAt?: string; pendingMfaPrimaryMethod?: PendingMfaPrimaryMethod; }; export const PENDING_MFA_USER_ID_KEY = "pendingMfaUserId"; export const PENDING_MFA_REDIRECT_KEY = "pendingMfaRedirectTo"; export const PENDING_MFA_STARTED_AT_KEY = "pendingMfaStartedAt"; export const PENDING_MFA_PRIMARY_METHOD_KEY = "pendingMfaPrimaryMethod"; export type PendingMfaPrimaryMethod = "admin-sso" | "password" | "magic-link" | "password-reset" | "invitation"; export function isPendingMfaPrimaryMethod(value: unknown): value is PendingMfaPrimaryMethod { return value === "admin-sso" || value === "password" || value === "magic-link" || value === "password-reset" || value === "invitation"; } export function isPendingMfaLoginFresh(value: unknown, nowMs = Date.now()): value is string { if (typeof value !== "string") return false; const startedAtMs = Date.parse(value); return Number.isFinite(startedAtMs) && startedAtMs <= nowMs && nowMs - startedAtMs < PENDING_MFA_LOGIN_TTL_MS; } export type SetLoggedUserResult = { userId: string; defaultTenantId: string; /** * The tenant resolved during setLoggedUser, if any. Returned so callers can * reuse the membership-proven safe projection immediately after sign-in or * impersonation without a second cache-backed tenant lookup. */ defaultTenant: TenantSessionIdentity | null; }; export function setLoggedUser(user: { id: string; email: string; defaultTenantId: string | null }): Promise { const resolveTenant = async (): Promise => { if (user.defaultTenantId) { const tenant = await getTenantSessionIdentityForUser({ userId: user.id, tenant: user.defaultTenantId }); if (tenant) { return { userId: user.id, defaultTenantId: tenant.id, defaultTenant: tenant, }; } } const fallback = await getTenantSessionIdentityForUser({ userId: user.id }); return { userId: user.id, defaultTenantId: fallback?.id ?? "", defaultTenant: fallback, }; }; // Login and magic-link callbacks run before the request middleware has an // authenticated identity. Bind the just-authenticated user so Tenant and // TenantUser policies can resolve the initial workspace. An existing // context (for example an operator impersonation flow) remains authoritative. if (getRlsExecutionContext()?.userId) { return resolveTenant(); } return runWithRlsContext({ userId: user.id }, resolveTenant); } export function createImpersonationStartedAt(now: Date = new Date()) { return now.toISOString(); } export function validateImpersonationSessionState( state: { impersonatingFromUserId?: unknown; impersonatingStartedAt?: unknown; userId?: unknown }, options: { now?: Date; expectedActorUserId?: string } = {} ): ImpersonationSessionValidation { const actorUserId = typeof state.impersonatingFromUserId === "string" ? state.impersonatingFromUserId : undefined; const startedAt = typeof state.impersonatingStartedAt === "string" ? state.impersonatingStartedAt : undefined; if (!actorUserId && !startedAt) { return { valid: false, reason: "not_impersonating" }; } if (!actorUserId) { return { valid: false, reason: "missing_actor" }; } if (options.expectedActorUserId && actorUserId !== options.expectedActorUserId) { return { valid: false, reason: "wrong_actor" }; } if (!startedAt) { return { valid: false, reason: "missing_started_at" }; } const issuedAtMs = Date.parse(startedAt); if (!Number.isFinite(issuedAtMs)) { return { valid: false, reason: "malformed_started_at" }; } const nowMs = options.now?.getTime() ?? Date.now(); if (issuedAtMs > nowMs) { return { valid: false, reason: "future_started_at" }; } if (nowMs - issuedAtMs > IMPERSONATION_SESSION_TTL_MS) { return { valid: false, reason: "stale_started_at" }; } return { valid: true, reason: "valid", actorUserId, startedAt }; } export function validateAdminImpersonationSessionState( state: { impersonatingFromAdminUserId?: unknown; impersonatingFromAdminEmail?: unknown; impersonatingStartedAt?: unknown; userId?: unknown; }, now = new Date() ): boolean { const actorId = state.impersonatingFromAdminUserId; const actorEmail = state.impersonatingFromAdminEmail; const targetId = state.userId; const startedAt = state.impersonatingStartedAt; if (![actorId, actorEmail, targetId, startedAt].every((value) => typeof value === "string" && value.length > 0)) return false; const issuedAt = Date.parse(startedAt as string); return Number.isFinite(issuedAt) && issuedAt <= now.getTime() && now.getTime() - issuedAt <= IMPERSONATION_SESSION_TTL_MS; } function resolveEffectiveSessionUserId({ userId, impersonatingFromUserId, impersonatingStartedAt, validation, }: { userId: unknown; impersonatingFromUserId: unknown; impersonatingStartedAt: unknown; validation: ImpersonationSessionValidation; }): string | null { const hasImpersonationMarker = typeof impersonatingFromUserId === "string" || typeof impersonatingStartedAt === "string"; if (validation.valid || !hasImpersonationMarker) { return typeof userId === "string" && userId.length > 0 ? userId : null; } // A malformed or expired impersonation cookie must never degrade into an // ordinary session for the target user. The signed actor id is the only // safe identity to restore; if it is unavailable, fail closed. return typeof impersonatingFromUserId === "string" && impersonatingFromUserId.length > 0 ? impersonatingFromUserId : null; } type ActiveServerSession = ActiveUserSessionRow | NonNullable; type ServerSessionValidation = | { valid: true; session: ActiveServerSession } | { valid: false; reason: "missing-token" | "inactive-token" | "owner-mismatch" | "store-error" }; const requestServerSessionValidation = new WeakMap }>(); async function validateServerSessionForRequest(request: Request, rawToken: unknown, expectedOwnerUserId: string): Promise { if (typeof rawToken !== "string" || rawToken.length === 0) { return { valid: false, reason: "missing-token" }; } const key = `${expectedOwnerUserId}\u0000${rawToken}`; const cached = requestServerSessionValidation.get(request); if (cached?.key === key) { return cached.promise; } const promise = (async (): Promise => { let activeSession: ActiveServerSession | null; try { activeSession = surfaceForRequest(request) === "admin" ? await getActiveAdminSessionByToken(rawToken) : await getActiveSessionByToken(rawToken); } catch (error) { sessionLogger.warn( { op: "session.validation.failed", err: { name: error instanceof Error ? error.name : "Error" } }, "Server-session validation failed closed." ); return { valid: false, reason: "store-error" }; } if (!activeSession) { return { valid: false, reason: "inactive-token" }; } const ownerId = "adminUserId" in activeSession ? activeSession.adminUserId : activeSession.userId; if (ownerId !== expectedOwnerUserId) { return { valid: false, reason: "owner-mismatch" }; } return { valid: true, session: activeSession }; })(); requestServerSessionValidation.set(request, { key, promise }); return promise; } const webConfig = loadRequiredWebConfig(); const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; function createSurfaceSessionStorage(surface: WebSessionCookieSurface) { return createCookieSessionStorage({ cookie: { ...resolveWebSessionCookiePolicy({ nodeEnv: webConfig.nodeEnv, routing: webConfig.routing, surface, maxAge: SESSION_MAX_AGE_SECONDS, }), secrets: webConfig.sessionSecret ? [webConfig.sessionSecret] : [], }, }); } const appSessionStorage = createSurfaceSessionStorage("app"); const adminSessionStorage = createSurfaceSessionStorage("admin"); const sessionSurface = new WeakMap(); function storageForSurface(surface: WebSessionCookieSurface) { return surface === "admin" ? adminSessionStorage : appSessionStorage; } function surfaceForRequest(request: Request): WebSessionCookieSurface { const url = new URL(request.url); const host = getRequestRoutingHost(request, url, webConfig.routing); return getHostRoutingContext(host, webConfig.routing) === "admin" ? "admin" : "app"; } function rememberSessionSurface(session: T, surface: WebSessionCookieSurface): T { sessionSurface.set(session, surface); return session; } async function getSurfaceSession(surface: WebSessionCookieSurface, cookieHeader?: string | null) { return rememberSessionSurface(await storageForSurface(surface).getSession(cookieHeader), surface); } /** * Compatibility facade for existing call sites. New request-bound reads must * use `getUserSession(request)` so the app/admin cookie namespace is selected * from the canonical host. Sessions returned by that helper retain their * surface for subsequent commit/destroy calls. */ export const storage = { getSession(cookieHeader?: string | null) { return getSurfaceSession("app", cookieHeader); }, commitSession(session: Session, options?: Parameters[1]) { return storageForSurface(sessionSurface.get(session) ?? "app").commitSession(session, options); }, destroySession(session: Session, options?: Parameters[1]) { return storageForSurface(sessionSurface.get(session) ?? "app").destroySession(session, options); }, }; export function getUserSession(request: Request) { return getSurfaceSession(surfaceForRequest(request), request.headers.get("Cookie")); } export async function getUserInfo(request: Request): Promise { const session = await getUserSession(request); const storedUserId = session.get("userId") ?? null; const lightOrDarkMode = session.get("lightOrDarkMode") ?? ""; const candidateLocale = normalizeLocaleCandidate(session.get("lng")); const lng = candidateLocale && webConfig.i18n.enabledLocales.includes(candidateLocale) ? candidateLocale : defaultLocale; const csrf = session.get("csrf"); const metrics = session.get("metrics") ?? { enabled: false, logToConsole: false, saveToDatabase: false, ignoreUrls: [] }; const cookies = session.get("cookies") ?? []; const impersonatingFromUserId = session.get("impersonatingFromUserId"); const impersonatingFromAdminUserId = session.get("impersonatingFromAdminUserId"); const impersonatingFromAdminEmail = session.get("impersonatingFromAdminEmail"); const impersonatingStartedAt = session.get("impersonatingStartedAt"); const impersonationValidation = validateImpersonationSessionState({ impersonatingFromUserId, impersonatingStartedAt, userId: storedUserId, }); const legacyUserId = resolveEffectiveSessionUserId({ userId: storedUserId, impersonatingFromUserId, impersonatingStartedAt, validation: impersonationValidation, }); const hasAdminImpersonationMarker = typeof impersonatingFromAdminUserId === "string" || typeof impersonatingFromAdminEmail === "string"; const adminImpersonationValid = validateAdminImpersonationSessionState({ impersonatingFromAdminUserId, impersonatingFromAdminEmail, impersonatingStartedAt, userId: storedUserId, }); const userId = hasAdminImpersonationMarker ? (adminImpersonationValid ? storedUserId : null) : legacyUserId; const theme = session.get("theme"); const passwordPolicyOutdated = session.get("passwordPolicyOutdated") === true; const sessionToken = session.get("sessionToken"); const expectedSessionOwnerUserId = impersonationValidation.valid ? impersonationValidation.actorUserId : userId; const serverSessionValidation = expectedSessionOwnerUserId ? await validateServerSessionForRequest(request, sessionToken, expectedSessionOwnerUserId) : null; const authenticated = Boolean(userId && serverSessionValidation?.valid); const pendingMfaUserId = session.get(PENDING_MFA_USER_ID_KEY); const pendingMfaRedirectTo = session.get(PENDING_MFA_REDIRECT_KEY); const pendingMfaStartedAt = session.get(PENDING_MFA_STARTED_AT_KEY); const pendingMfaPrimaryMethod = session.get(PENDING_MFA_PRIMARY_METHOD_KEY); const pendingMfaFresh = isPendingMfaLoginFresh(pendingMfaStartedAt); return { userId: (authenticated ? userId : null) as UserSession["userId"], lightOrDarkMode, lng, csrf, metrics, cookies, impersonatingFromUserId: authenticated && impersonationValidation.valid ? impersonatingFromUserId : undefined, impersonatingFromAdminUserId: authenticated && adminImpersonationValid ? impersonatingFromAdminUserId : undefined, impersonatingFromAdminEmail: authenticated && adminImpersonationValid ? impersonatingFromAdminEmail : undefined, impersonatingStartedAt: authenticated && (impersonationValidation.valid || adminImpersonationValid) ? impersonatingStartedAt : undefined, theme, passwordPolicyOutdated: passwordPolicyOutdated || undefined, sessionToken: authenticated && typeof sessionToken === "string" ? sessionToken : undefined, pendingMfaUserId: pendingMfaFresh && typeof pendingMfaUserId === "string" ? pendingMfaUserId : undefined, pendingMfaRedirectTo: pendingMfaFresh && typeof pendingMfaRedirectTo === "string" ? pendingMfaRedirectTo : undefined, pendingMfaStartedAt: pendingMfaFresh ? pendingMfaStartedAt : undefined, pendingMfaPrimaryMethod: pendingMfaFresh && isPendingMfaPrimaryMethod(pendingMfaPrimaryMethod) ? pendingMfaPrimaryMethod : undefined, }; } export async function getValidatedServerSession(request: Request, userInfo?: UserSession): Promise { const info = userInfo ?? (await getUserInfo(request)); if (!info.userId || !info.sessionToken) { return null; } const expectedOwnerUserId = info.impersonatingFromUserId ?? info.userId; const validation = await validateServerSessionForRequest(request, info.sessionToken, expectedOwnerUserId); return validation.valid ? validation.session : null; } /** * D1 (2FA-Login-Gate) — schreibt den Pending-MFA-Zustand in das SIGNIERTE Session-Cookie und gibt * den Set-Cookie-String zurueck. Bewusst KEINE Session: `userId`/`sessionToken` werden entfernt, * gesetzt wird nur `pendingMfaUserId` (+ rohes `pendingMfaRedirectTo`). Das bestehende Cookie wird * weiterverwendet, damit der bereits ausgehandelte `csrf`-Token erhalten bleibt und die Verify-Route * die CSRF-Pruefung besteht. */ export async function commitPendingMfaLoginCookie( request: Request, { userId, redirectTo, primaryMethod }: { userId: string; redirectTo?: string; primaryMethod: PendingMfaPrimaryMethod } ) { const session = await getUserSession(request); session.unset("userId"); session.unset("sessionToken"); session.set(PENDING_MFA_USER_ID_KEY, userId); session.set(PENDING_MFA_STARTED_AT_KEY, new Date().toISOString()); session.set(PENDING_MFA_PRIMARY_METHOD_KEY, primaryMethod); if (typeof redirectTo === "string" && redirectTo.length > 0) { session.set(PENDING_MFA_REDIRECT_KEY, redirectTo); } else { session.unset(PENDING_MFA_REDIRECT_KEY); } return storage.commitSession(session); } export async function requireUserId(request: Request, redirectTo: string = new URL(request.url).pathname) { const session = await getUserSession(request); const storedUserId = session.get("userId"); const hadAuthenticatedCookie = typeof storedUserId === "string" && storedUserId.length > 0; const userInfo = await getUserInfo(request); if (!userInfo.userId) { const searchParams = new URLSearchParams([["redirectTo", redirectTo]]); throw redirect(`/login?${searchParams}`, { headers: hadAuthenticatedCookie ? { "Set-Cookie": await storage.destroySession(session) } : undefined, }); } return userInfo.userId; } export async function requireUser({ request }: { request: Request }) { const userId = await requireUserId(request); const user = await getUser(userId); if (!user) { throw redirect("/login"); } return user; } export async function logout(request: Request) { const session = await getUserSession(request); // R-P2a S4b — die serverseitige Session-Zeile mit beenden (best-effort), damit ein abgemeldetes // Geraet auch in der Geraeteliste verschwindet und der Token nicht weiterlebt. const sessionToken = session.get("sessionToken"); if (typeof sessionToken === "string" && sessionToken.length > 0) { await endServerSession(sessionToken, request).catch((error: unknown) => { sessionLogger.warn( { op: "session.logout.revokeFailed", err: { name: error instanceof Error ? error.name : "Error" } }, "Logout could not revoke the server session row; cookie is destroyed regardless." ); }); } return redirect("/login", { headers: { "Set-Cookie": await storage.destroySession(session), }, }); } export type CreateUserSessionOptions = { request: Request; rotate?: boolean; adminAuthenticationMethods?: readonly string[]; adminMfaAssuredAt?: Date; }; export async function createUserSession(userSession: UserSession, redirectTo: string, options: CreateUserSessionOptions) { const safeRedirectTo = safeSessionRedirectTarget(redirectTo, webConfig.routing); const session = await getSurfaceSession(surfaceForRequest(options.request)); const impersonationValidation = validateImpersonationSessionState(userSession); const hasAdminImpersonationMarker = typeof userSession.impersonatingFromAdminUserId === "string" || typeof userSession.impersonatingFromAdminEmail === "string"; const adminImpersonationValid = validateAdminImpersonationSessionState(userSession); const effectiveUserId = resolveEffectiveSessionUserId({ userId: userSession.userId, impersonatingFromUserId: userSession.impersonatingFromUserId, impersonatingStartedAt: userSession.impersonatingStartedAt, validation: impersonationValidation, }); const finalEffectiveUserId = hasAdminImpersonationMarker ? (adminImpersonationValid ? userSession.userId : null) : effectiveUserId; if (finalEffectiveUserId) { session.set("userId", finalEffectiveUserId); } else { session.unset("userId"); } const expectedSessionOwnerUserId = impersonationValidation.valid ? impersonationValidation.actorUserId : finalEffectiveUserId; let sessionToken: string | undefined; if (expectedSessionOwnerUserId) { if (options.rotate) { if (typeof userSession.sessionToken === "string" && userSession.sessionToken.length > 0) { await endServerSession(userSession.sessionToken, options.request); } sessionToken = await startServerSession({ request: options.request, userId: expectedSessionOwnerUserId, ...(options.adminAuthenticationMethods && options.adminMfaAssuredAt ? { adminAuthenticationMethods: options.adminAuthenticationMethods, adminMfaAssuredAt: options.adminMfaAssuredAt, } : {}), }); } else if (typeof userSession.sessionToken === "string" && userSession.sessionToken.length > 0) { const validation = await validateServerSessionForRequest(options.request, userSession.sessionToken, expectedSessionOwnerUserId); if (!validation.valid) { throw new Error(`Cannot issue authenticated cookie from invalid server session: ${validation.reason}`); } sessionToken = userSession.sessionToken; } else { sessionToken = await startServerSession({ request: options.request, userId: expectedSessionOwnerUserId, ...(options.adminAuthenticationMethods && options.adminMfaAssuredAt ? { adminAuthenticationMethods: options.adminAuthenticationMethods, adminMfaAssuredAt: options.adminMfaAssuredAt, } : {}), }); } } session.set("lightOrDarkMode", userSession.lightOrDarkMode); session.set("lng", userSession.lng); session.set("csrf", userSession.csrf); session.set("metrics", userSession.metrics); session.set("cookies", userSession.cookies); session.set("impersonatingFromUserId", impersonationValidation.valid ? userSession.impersonatingFromUserId : undefined); session.set("impersonatingFromAdminUserId", adminImpersonationValid ? userSession.impersonatingFromAdminUserId : undefined); session.set("impersonatingFromAdminEmail", adminImpersonationValid ? userSession.impersonatingFromAdminEmail : undefined); session.set( "impersonatingStartedAt", impersonationValidation.valid || adminImpersonationValid ? userSession.impersonatingStartedAt : undefined ); session.set("theme", userSession.theme); session.set("passwordPolicyOutdated", userSession.passwordPolicyOutdated === true ? true : undefined); session.set("sessionToken", sessionToken); // D1 — ein vollwertiger Login loescht den Pending-MFA-Zustand. createUserSession baut zwar ein // frisches Cookie (ohne diese Keys), das explizite Loeschen dokumentiert die Invariante und // schuetzt, falls hier kuenftig eine bestehende Session weiterverwendet wuerde. session.unset(PENDING_MFA_USER_ID_KEY); session.unset(PENDING_MFA_REDIRECT_KEY); session.unset(PENDING_MFA_STARTED_AT_KEY); session.unset(PENDING_MFA_PRIMARY_METHOD_KEY); return redirect(safeRedirectTo, { headers: { "Set-Cookie": await storage.commitSession(session), }, }); } export async function commitSession(session: Session) { return await storage.commitSession(session); } export function generateCSRFToken() { // 32 bytes = 256 bits of entropy (NIST-recommended for CSRF tokens). The // previous 100-byte payload was overkill and bloated every cookie/response. // Hex encoding keeps the token URL/header safe without padding chars. return randomBytes(32).toString("hex"); } export function setCSRFToken(session: Session, csrf: string = generateCSRFToken()) { session.set("csrf", csrf); return csrf; } function getStringCSRFToken(value: unknown) { if (typeof value !== "string") { return undefined; } const token = value.trim(); return token.length > 0 ? token : undefined; } export type ValidateCSRFTokenOptions = { /** * Already-parsed form data from the caller. When provided, the helper will * NEVER touch the request body. This is the safe pattern for action handlers * that have already consumed `request.formData()` to read form fields — * cloning a consumed body raises `TypeError: unusable` at runtime (the * B-009 follow-up bug). */ formData?: FormData; /** * Skip the body fallback entirely. The header must carry a valid token. * Used by background or bot-driven contexts (e.g. lighthouse session * capture) where the body is reserved for a different purpose. */ headerOnly?: boolean; }; async function getCSRFTokenFromRequest(request: Request, options?: ValidateCSRFTokenOptions) { const headerToken = getStringCSRFToken(request.headers.get("x-csrf-token") ?? request.headers.get("csrf-token")); if (headerToken) { return headerToken; } if (options?.headerOnly) { return undefined; } if (options?.formData) { // Caller already parsed the body — read the token from the injected // FormData and short-circuit. Never clone the request: its underlying // stream is already consumed. return getStringCSRFToken(options.formData.get("csrf")); } const contentType = request.headers.get("content-type")?.toLowerCase() ?? ""; if (contentType.includes("multipart/form-data") || contentType.includes("application/x-www-form-urlencoded")) { try { const formData = await request.clone().formData(); const token = getStringCSRFToken(formData.get("csrf")); if (token) { return token; } } catch { // Fall through to text parsing for malformed or non-standard requests. } } let rawBody: string; try { rawBody = await request.clone().text(); } catch { // The request body was already consumed by the caller without supplying // `options.formData`. Surface a clear error instead of leaking the opaque // `TypeError: unusable` from undici. throw new Error("CSRF body already consumed; pass { formData } to validateCSRFToken."); } if (!rawBody) { return undefined; } if (contentType.includes("application/json")) { try { const parsed = JSON.parse(rawBody) as { csrf?: unknown }; const token = getStringCSRFToken(parsed.csrf); if (token) { return token; } } catch { // Fall through to URLSearchParams for clients that send form bodies with // incomplete content-type headers. } } return getStringCSRFToken(new URLSearchParams(rawBody).get("csrf")); } export async function validateCSRFToken(request: Request, options?: ValidateCSRFTokenOptions) { const session = await getUserSession(request); const sessionToken = session.get("csrf"); const requestToken = await getCSRFTokenFromRequest(request, options); if (!sessionToken) throw new Error("CSRF Session Token not included."); if (!requestToken) throw new Error("CSRF Request Token not included."); if (!timingSafeStringEqual(requestToken, sessionToken)) throw new Error("CSRF tokens do not match, try refreshing the page."); } function timingSafeStringEqual(a: string, b: string) { const aBuffer = Buffer.from(a); const bBuffer = Buffer.from(b); if (aBuffer.byteLength !== bBuffer.byteLength) { return false; } return timingSafeEqual(aBuffer, bBuffer); }