11import { redirect } from "@remix-run/node" ;
2- import { $replica } from "~/db.server" ;
32import { getUserById } from "~/models/user.server" ;
43import { sanitizeRedirectPath } from "~/utils" ;
54import { extractClientIp } from "~/utils/extractClientIp.server" ;
65import { authenticator } from "./auth.server" ;
76import { getImpersonationId } from "./impersonation.server" ;
87import { logger } from "./logger.server" ;
9- import {
10- getEffectiveSessionDuration ,
11- getSessionIssuedAt ,
12- isSessionExpired ,
13- } from "./sessionDuration.server" ;
14- import { getUserSession } from "./sessionStorage.server" ;
158
169/**
17- * Enforces the user's effective session duration (User.sessionDuration capped
18- * by the most restrictive Organization.maxSessionDuration). If the session was
19- * issued longer ago than the cap allows, throws a redirect to `/logout` and
20- * emits a HIPAA audit log. `userId` is always the *session owner's* id (i.e.
21- * the real authenticated user), not an impersonated one — because the cap
22- * belongs to the cookie, not the impersonation target.
10+ * Logs the user out when their session has lived past `User.nextSessionEnd`.
11+ *
12+ * The deadline is written at login (and any time the effective duration is
13+ * recomputed — see `commitAuthenticatedSession`) and shortened in bulk when
14+ * an admin lowers an org cap (see the admin `session-duration` route).
15+ * Reading here is a free piggyback on the User row that `requireUser`/
16+ * `getUser` already fetches — there is no per-request DB query added by this
17+ * check. `requireUserId`/`getUserId` deliberately do NOT enforce: enforcement
18+ * happens at the next page navigation (root.tsx loader calls `getUser`),
19+ * which matches HIPAA auto-logoff semantics — terminate sessions at the
20+ * navigation boundary, not on every polling fetch.
21+ *
22+ * `nextSessionEnd === null` means "no enforced deadline" — applies to legacy
23+ * sessions from before this feature shipped. The default `User.sessionDuration`
24+ * is 1 year (matching the cookie's `Max-Age`), so a null deadline is
25+ * functionally identical to "natural cookie expiry" for users with default
26+ * settings. Every path that produces a sub-default effective duration —
27+ * fresh login, user setting change, admin cap change — also writes
28+ * `nextSessionEnd`, so there is no realistic state where an unenforced null
29+ * masks a tighter cap.
2330 */
24- async function enforceSessionExpiry (
31+ function maybeAutoLogout (
2532 request : Request ,
26- userId : string ,
33+ user : { id : string ; nextSessionEnd : Date | null } ,
2734 impersonatedUserId : string | null = null
28- ) : Promise < void > {
29- const session = await getUserSession ( request ) ;
30- // Hot path: every authenticated request runs this. Read from the replica
31- // when one is configured (falls back to primary). Stale-by-replica-lag is
32- // acceptable here because the worst case is a session living a few seconds
33- // past its cap on the very first request after a cap change.
34- const { durationSeconds, orgCapSeconds, cappingOrgId, userSettingSeconds } =
35- await getEffectiveSessionDuration ( userId , $replica ) ;
36- if ( ! isSessionExpired ( session , durationSeconds ) ) return ;
35+ ) : void {
36+ if ( user . nextSessionEnd === null ) return ;
37+ if ( Date . now ( ) <= user . nextSessionEnd . getTime ( ) ) return ;
3738
38- const issuedAt = getSessionIssuedAt ( session ) ;
3939 // HIPAA audit trail: structured log lands in CloudWatch via stdout. Use
4040 // the stable `event` field to filter/aggregate auto-logout events.
4141 // `sourceIp` uses ALB's appended (last) X-Forwarded-For element, not the
4242 // first one, since the leading element is client-supplied and spoofable.
4343 logger . info ( "Auto-logout: session exceeded effective duration" , {
4444 event : "session.auto_logout" ,
45- userId,
45+ userId : user . id ,
4646 impersonatedUserId,
47- cappingOrgId,
48- effectiveDurationSeconds : durationSeconds ,
49- userSettingSeconds,
50- orgCapSeconds,
51- sessionAgeMs : issuedAt === null ? null : Date . now ( ) - issuedAt ,
47+ nextSessionEnd : user . nextSessionEnd . toISOString ( ) ,
5248 requestPath : new URL ( request . url ) . pathname ,
5349 sourceIp : extractClientIp ( request . headers . get ( "x-forwarded-for" ) ) ,
5450 } ) ;
5551 throw redirect ( "/logout" ) ;
5652}
5753
5854export async function getUserId ( request : Request ) : Promise < string | undefined > {
55+ // Cookie-only fast path: zero DB queries. Impersonation admin-verification
56+ // and auto-logout enforcement happen in `getUser`/`requireUser`, where we
57+ // already pay for a User row fetch.
5958 const impersonatedUserId = await getImpersonationId ( request ) ;
60-
61- if ( impersonatedUserId ) {
62- // Verify the real user (from the session cookie) is still an admin
63- const authUser = await authenticator . isAuthenticated ( request ) ;
64- if ( authUser ?. userId ) {
65- const realUser = await getUserById ( authUser . userId ) ;
66- if ( realUser ?. admin ) {
67- // Enforce expiry against the admin's own session — impersonation must
68- // not be a way to bypass the admin's effective duration cap.
69- await enforceSessionExpiry ( request , authUser . userId , impersonatedUserId ) ;
70- return impersonatedUserId ;
71- }
72- }
73- // Admin revoked or session invalid — fall through to return the real
74- // user's ID. Same enforcement as the regular auth path below.
75- if ( authUser ?. userId ) {
76- await enforceSessionExpiry ( request , authUser . userId ) ;
77- }
78- return authUser ?. userId ;
79- }
59+ if ( impersonatedUserId ) return impersonatedUserId ;
8060
8161 const authUser = await authenticator . isAuthenticated ( request ) ;
82- if ( ! authUser ?. userId ) return undefined ;
83-
84- await enforceSessionExpiry ( request , authUser . userId ) ;
85- return authUser . userId ;
62+ return authUser ?. userId ;
8663}
8764
8865export async function getUser ( request : Request ) {
89- const userId = await getUserId ( request ) ;
90- if ( userId === undefined ) return null ;
66+ const impersonatedUserId = await getImpersonationId ( request ) ;
67+ const authUser = await authenticator . isAuthenticated ( request ) ;
9168
92- const user = await getUserById ( userId ) ;
93- if ( user ) return user ;
69+ if ( impersonatedUserId && authUser ?. userId ) {
70+ // Impersonating: verify the real user is still an admin and enforce the
71+ // *admin's* deadline (the cap belongs to the cookie, not the
72+ // impersonation target). If the admin is no longer admin, fall back to
73+ // operating as the admin themselves — same defense-in-depth as before.
74+ const realUser = await getUserById ( authUser . userId ) ;
75+ if ( ! realUser ) throw await logout ( request ) ;
76+ if ( realUser . admin ) {
77+ maybeAutoLogout ( request , realUser , impersonatedUserId ) ;
78+ const target = await getUserById ( impersonatedUserId ) ;
79+ if ( ! target ) throw await logout ( request ) ;
80+ return target ;
81+ }
82+ maybeAutoLogout ( request , realUser ) ;
83+ return realUser ;
84+ }
9485
95- throw await logout ( request ) ;
86+ if ( ! authUser ?. userId ) return null ;
87+ const user = await getUserById ( authUser . userId ) ;
88+ if ( ! user ) throw await logout ( request ) ;
89+ maybeAutoLogout ( request , user ) ;
90+ return user ;
9691}
9792
9893export async function requireUserId ( request : Request , redirectTo ?: string ) {
@@ -112,28 +107,29 @@ export async function requireUserId(request: Request, redirectTo?: string) {
112107export type UserFromSession = Awaited < ReturnType < typeof requireUser > > ;
113108
114109export async function requireUser ( request : Request ) {
115- const userId = await requireUserId ( request ) ;
116-
117- const impersonationId = await getImpersonationId ( request ) ;
118- const user = await getUserById ( userId ) ;
119- if ( user ) {
120- return {
121- id : user . id ,
122- email : user . email ,
123- name : user . name ,
124- displayName : user . displayName ,
125- avatarUrl : user . avatarUrl ,
126- admin : user . admin ,
127- createdAt : user . createdAt ,
128- updatedAt : user . updatedAt ,
129- dashboardPreferences : user . dashboardPreferences ,
130- confirmedBasicDetails : user . confirmedBasicDetails ,
131- mfaEnabledAt : user . mfaEnabledAt ,
132- isImpersonating : ! ! impersonationId && impersonationId === userId ,
133- } ;
110+ const user = await getUser ( request ) ;
111+ if ( ! user ) {
112+ const url = new URL ( request . url ) ;
113+ const finalRedirectTo = sanitizeRedirectPath ( `${ url . pathname } ${ url . search } ` ) ;
114+ const searchParams = new URLSearchParams ( [ [ "redirectTo" , finalRedirectTo ] ] ) ;
115+ throw redirect ( `/login?${ searchParams } ` ) ;
134116 }
135117
136- throw await logout ( request ) ;
118+ const impersonationId = await getImpersonationId ( request ) ;
119+ return {
120+ id : user . id ,
121+ email : user . email ,
122+ name : user . name ,
123+ displayName : user . displayName ,
124+ avatarUrl : user . avatarUrl ,
125+ admin : user . admin ,
126+ createdAt : user . createdAt ,
127+ updatedAt : user . updatedAt ,
128+ dashboardPreferences : user . dashboardPreferences ,
129+ confirmedBasicDetails : user . confirmedBasicDetails ,
130+ mfaEnabledAt : user . mfaEnabledAt ,
131+ isImpersonating : ! ! impersonationId && impersonationId === user . id ,
132+ } ;
137133}
138134
139135export async function logout ( request : Request ) {
0 commit comments