Skip to content

Commit 4fa011d

Browse files
committed
perf(webapp): make auto-logout enforcement zero-query on the read path
Move the session deadline from a per-request DB resolution to a User.nextSessionEnd column written at the rare moments it can change. Previously enforceSessionExpiry ran inside getUserId on every authenticated request, doing 2 replica queries (one of which joins OrgMember and sorts on maxSessionDuration). Remix runs matched layout loaders in parallel, so a single dashboard navigation produced ~12 replica queries before any page data was fetched, and resources/fetcher routes paid the same cost. - commitAuthenticatedSession resolves effective duration once and stamps User.nextSessionEnd at every login/MFA/user-setting change. - Admin org-cap route runs a single bulk UPDATE … LEAST(…) to shorten member deadlines without ever extending them. - requireUser/getUser check now > nextSessionEnd against the User row they were already fetching — no per-request DB query added. - requireUserId reverts to cookie-only (zero DB queries on fetcher routes). - Migration adds the column as nullable; metadata-only ALTER, no row rewrite. Null = no enforced deadline, equivalent to the default 1-year duration that matches cookie maxAge — existing sessions need no backfill. - Removed dead cookie-issuedAt machinery (commitAuthenticatedSessionLazy, isSessionExpired, ensureSessionIssuedAt, SESSION_ISSUED_AT_KEY). - Consolidated branch's .server-changes notes into one file.
1 parent bca5ec3 commit 4fa011d

13 files changed

Lines changed: 184 additions & 201 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
App auto session logout. Users can configure their own session duration; org admins can set a `maxSessionDuration` cap that takes the tightest value across an account's orgs. Sessions exceeding their effective duration are redirected to `/logout` with a HIPAA audit trail emitted to CloudWatch (`event: session.auto_logout`). Enforcement reads `User.nextSessionEnd` — written at login and bulk-updated when admins change the cap — so the auth path adds no per-request DB queries.

apps/webapp/app/root.tsx

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,6 @@ import { env } from "./env.server";
1515
import { featuresForRequest } from "./features.server";
1616
import { usePostHog } from "./hooks/usePostHog";
1717
import { getUser } from "./services/session.server";
18-
import { commitAuthenticatedSessionLazy } from "./services/sessionDuration.server";
19-
import { getUserSession } from "./services/sessionStorage.server";
2018
import { getTimezonePreference } from "./services/preferences/uiPreferences.server";
2119
import { appEnvTitleTag } from "./utils";
2220

@@ -65,15 +63,6 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
6563
const headers = new Headers();
6664
headers.append("Set-Cookie", await commitSession(session));
6765

68-
// Lazy-backfill the auth session's `issuedAt` for cookies issued before this
69-
// feature shipped. Returns null (and does not commit) once issuedAt is set,
70-
// so the cookie isn't re-written on every page load.
71-
if (user) {
72-
const authSession = await getUserSession(request);
73-
const lazyCookie = await commitAuthenticatedSessionLazy(authSession);
74-
if (lazyCookie) headers.append("Set-Cookie", lazyCookie);
75-
}
76-
7766
return typedjson(
7867
{
7968
user,

apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.session-duration.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,5 +50,21 @@ export async function action({ request, params }: ActionFunctionArgs) {
5050
select: { id: true, slug: true, maxSessionDuration: true },
5151
});
5252

53+
// Propagate the new cap to currently-logged-in members by shortening their
54+
// `nextSessionEnd`. We only ever shorten (`LEAST`): raising or removing the
55+
// cap leaves existing sessions alone — the larger window applies on next
56+
// login. If a member is in another org with a tighter cap that other cap
57+
// remains in effect via their existing `nextSessionEnd` (LEAST keeps it).
58+
if (body.maxSessionDuration !== null) {
59+
await prisma.$executeRaw`
60+
UPDATE "User"
61+
SET "nextSessionEnd" = LEAST(
62+
COALESCE("nextSessionEnd", 'infinity'::timestamp),
63+
NOW() + (LEAST("sessionDuration", ${body.maxSessionDuration}) * INTERVAL '1 second')
64+
)
65+
WHERE "id" IN (SELECT "userId" FROM "OrgMember" WHERE "organizationId" = ${organizationId})
66+
`;
67+
}
68+
5369
return json({ success: true, organization });
5470
}

apps/webapp/app/routes/auth.github.callback.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export let loader: LoaderFunction = async ({ request }) => {
5353
session.set(authenticator.sessionKey, auth);
5454

5555
const headers = new Headers();
56-
headers.append("Set-Cookie", await commitAuthenticatedSession(session));
56+
headers.append("Set-Cookie", await commitAuthenticatedSession(session, auth.userId));
5757
headers.append("Set-Cookie", await setLastAuthMethodHeader("github"));
5858

5959
await trackAndClearReferralSource(request, auth.userId, headers);

apps/webapp/app/routes/auth.google.callback.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export let loader: LoaderFunction = async ({ request }) => {
5353
session.set(authenticator.sessionKey, auth);
5454

5555
const headers = new Headers();
56-
headers.append("Set-Cookie", await commitAuthenticatedSession(session));
56+
headers.append("Set-Cookie", await commitAuthenticatedSession(session, auth.userId));
5757
headers.append("Set-Cookie", await setLastAuthMethodHeader("google"));
5858

5959
await trackAndClearReferralSource(request, auth.userId, headers);

apps/webapp/app/routes/login.mfa/route.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ async function completeLogin(request: Request, session: Session, userId: string)
163163
session.unset("pending-mfa-redirect-to");
164164

165165
const headers = new Headers();
166-
headers.append("Set-Cookie", await commitAuthenticatedSession(session));
166+
headers.append("Set-Cookie", await commitAuthenticatedSession(session, userId));
167167

168168
await trackAndClearReferralSource(request, userId, headers);
169169

apps/webapp/app/routes/magic.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
5656
session.set(authenticator.sessionKey, auth);
5757

5858
const headers = new Headers();
59-
headers.append("Set-Cookie", await commitAuthenticatedSession(session));
59+
headers.append("Set-Cookie", await commitAuthenticatedSession(session, auth.userId));
6060
headers.append("Set-Cookie", await setLastAuthMethodHeader("email"));
6161

6262
await trackAndClearReferralSource(request, auth.userId, headers);

apps/webapp/app/routes/resources.account.session-duration/route.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,10 @@ export async function action({ request }: ActionFunctionArgs) {
6161
});
6262

6363
// Re-issue the cookie with the new maxAge and reset issuedAt so the user
64-
// gets a fresh window matching their new selection right away.
64+
// gets a fresh window matching their new selection right away. This also
65+
// restamps `User.nextSessionEnd` against the new effective duration.
6566
const authSession = await getUserSession(request);
66-
const authCookie = await commitAuthenticatedSession(authSession);
67+
const authCookie = await commitAuthenticatedSession(authSession, userId);
6768

6869
const messageSession = await getMessageSession(request.headers.get("cookie"));
6970
setSuccessMessage(messageSession, "Session duration updated.");

apps/webapp/app/services/session.server.ts

Lines changed: 76 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,98 +1,93 @@
11
import { redirect } from "@remix-run/node";
2-
import { $replica } from "~/db.server";
32
import { getUserById } from "~/models/user.server";
43
import { sanitizeRedirectPath } from "~/utils";
54
import { extractClientIp } from "~/utils/extractClientIp.server";
65
import { authenticator } from "./auth.server";
76
import { getImpersonationId } from "./impersonation.server";
87
import { 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

5854
export 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

8865
export 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

9893
export async function requireUserId(request: Request, redirectTo?: string) {
@@ -112,28 +107,29 @@ export async function requireUserId(request: Request, redirectTo?: string) {
112107
export type UserFromSession = Awaited<ReturnType<typeof requireUser>>;
113108

114109
export 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

139135
export async function logout(request: Request) {

0 commit comments

Comments
 (0)