chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
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<string | undefined> {
|
||||
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<ReturnType<typeof requireUser>>;
|
||||
|
||||
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");
|
||||
}
|
||||
Reference in New Issue
Block a user