679 lines
28 KiB
TypeScript
679 lines
28 KiB
TypeScript
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<SetLoggedUserResult> {
|
|
const resolveTenant = async (): Promise<SetLoggedUserResult> => {
|
|
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<ActiveAdminSession>;
|
|
|
|
type ServerSessionValidation =
|
|
| { valid: true; session: ActiveServerSession }
|
|
| { valid: false; reason: "missing-token" | "inactive-token" | "owner-mismatch" | "store-error" };
|
|
|
|
const requestServerSessionValidation = new WeakMap<Request, { key: string; promise: Promise<ServerSessionValidation> }>();
|
|
|
|
async function validateServerSessionForRequest(request: Request, rawToken: unknown, expectedOwnerUserId: string): Promise<ServerSessionValidation> {
|
|
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<ServerSessionValidation> => {
|
|
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<object, WebSessionCookieSurface>();
|
|
|
|
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<T extends Session>(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<typeof appSessionStorage.commitSession>[1]) {
|
|
return storageForSurface(sessionSurface.get(session) ?? "app").commitSession(session, options);
|
|
},
|
|
destroySession(session: Session, options?: Parameters<typeof appSessionStorage.destroySession>[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<UserSession> {
|
|
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<ActiveServerSession | null> {
|
|
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);
|
|
}
|