import { redirect } from "@remix-run/node"; import { getUserById } from "~/models/user.server"; import { sanitizeRedirectPath } from "~/utils"; import { extractClientIp } from "~/utils/extractClientIp.server"; import { authenticator } from "./auth.server"; import { getImpersonationId } from "./impersonation.server"; import { logger } from "./logger.server"; import { revalidateSsoSession } from "./ssoSessionRevalidation.server"; /** * Logs the user out when their session has lived past `User.nextSessionEnd`. * * The deadline is written at login (and any time the effective duration is * recomputed — see `commitAuthenticatedSession`) and shortened in bulk when * an admin lowers an org cap (see the admin `session-duration` route). * Non-impersonation `requireUserId`/`getUserId` paths do NOT enforce — * enforcement happens at the next page navigation (root.tsx calls `getUser`), * which matches HIPAA auto-logoff semantics: terminate at the navigation * boundary, not on every polling fetch. Impersonation paths DO enforce in * `getUserId` (against the admin's deadline) so a `requireUserId`-only route * can't keep operating as the impersonation target after the admin's session * has expired or their admin role was revoked. * * `nextSessionEnd === null` means "no enforced deadline" — applies to legacy * sessions from before this feature shipped. The default `User.sessionDuration` * is 1 year (matching the cookie's `Max-Age`), so a null deadline is * functionally identical to "natural cookie expiry" for users with default * settings. Every path that produces a sub-default effective duration — * fresh login, user setting change, admin cap change — also writes * `nextSessionEnd`, so there is no realistic state where an unenforced null * masks a tighter cap. */ function maybeAutoLogout( request: Request, user: { id: string; nextSessionEnd: Date | null }, impersonatedUserId: string | null = null ): void { if (user.nextSessionEnd === null) return; if (Date.now() <= user.nextSessionEnd.getTime()) return; // Don't redirect to /logout when we're already on /logout — root.tsx's // loader runs for every route (including /logout itself), so without this // guard an expired session would redirect to /logout in a loop and the // route's own loader (which destroys the session cookie) would never run. if (new URL(request.url).pathname === "/logout") return; // HIPAA audit trail: structured log lands in CloudWatch via stdout. Use // the stable `event` field to filter/aggregate auto-logout events. // `sourceIp` uses ALB's appended (last) X-Forwarded-For element, not the // first one, since the leading element is client-supplied and spoofable. logger.info("Auto-logout: session exceeded effective duration", { event: "session.auto_logout", userId: user.id, impersonatedUserId, nextSessionEnd: user.nextSessionEnd.toISOString(), requestPath: new URL(request.url).pathname, sourceIp: extractClientIp(request.headers.get("x-forwarded-for")), }); throw redirect("/logout"); } export async function getUserId(request: Request): Promise { const impersonatedUserId = await getImpersonationId(request); if (impersonatedUserId) { // Impersonating: verify the real user (the admin) is still an admin and // enforce against their deadline (the cap belongs to the cookie, not the // target). If admin status was revoked, fall through to operate as the // admin themselves — otherwise a `requireUserId`-only route would keep // letting them act as the impersonation target after losing the role. const authUser = await authenticator.isAuthenticated(request); if (!authUser?.userId) return undefined; const realUser = await getUserById(authUser.userId); if (!realUser) return authUser.userId; if (realUser.admin) { maybeAutoLogout(request, realUser, impersonatedUserId); return impersonatedUserId; } maybeAutoLogout(request, realUser); return authUser.userId; } // Non-impersonation fast path: zero DB queries. Auto-logout enforcement // for this path happens in `getUser`, where we already pay for the User // row fetch. `requireUserId` callers stay cookie-only. const authUser = await authenticator.isAuthenticated(request); // SSO session re-validation runs here so it covers both navigation // (getUser) and API fetches (requireUserId). It's single-flight and // throttled, so most requests do nothing; only SSO-marked sessions // touch Redis. Throws redirect("/logout") if the IdP says invalid. await revalidateSsoSession(request, authUser); return authUser?.userId; } export async function getUser(request: Request) { const userId = await getUserId(request); if (userId === undefined) return null; const user = await getUserById(userId); if (!user) { // Same loop-guard as in maybeAutoLogout: if /logout's loader is what // matched, let it destroy the cookie itself rather than redirecting in // circles via root.tsx. if (new URL(request.url).pathname === "/logout") return null; throw await logout(request); } // Auto-logout for the non-impersonation path. The impersonation path was // already enforced inside `getUserId` against the admin's deadline, so // skip re-checking against the (impersonation target's) row here. const impersonationId = await getImpersonationId(request); if (!impersonationId) maybeAutoLogout(request, user); return user; } export async function requireUserId(request: Request, redirectTo?: string) { const userId = await getUserId(request); if (!userId) { const url = new URL(request.url); // Only propagate the originating URL when it's a real user-navigable page. // Fetcher endpoints (e.g. /resources/*) and auth callbacks would render // blank or loop if used as a post-login destination. const finalRedirectTo = sanitizeRedirectPath(redirectTo ?? `${url.pathname}${url.search}`); const searchParams = new URLSearchParams([["redirectTo", finalRedirectTo]]); throw redirect(`/login?${searchParams}`); } return userId; } export type UserFromSession = Awaited>; export async function requireUser(request: Request) { const user = await getUser(request); if (!user) { const url = new URL(request.url); const finalRedirectTo = sanitizeRedirectPath(`${url.pathname}${url.search}`); const searchParams = new URLSearchParams([["redirectTo", finalRedirectTo]]); throw redirect(`/login?${searchParams}`); } const impersonationId = await getImpersonationId(request); return { id: user.id, email: user.email, name: user.name, displayName: user.displayName, avatarUrl: user.avatarUrl, admin: user.admin, createdAt: user.createdAt, updatedAt: user.updatedAt, dashboardPreferences: user.dashboardPreferences, confirmedBasicDetails: user.confirmedBasicDetails, mfaEnabledAt: user.mfaEnabledAt, isImpersonating: !!impersonationId && impersonationId === user.id, }; } export async function logout(request: Request) { return redirect("/logout"); }