Skip to content

Commit 45ec23c

Browse files
samejrmatt-aitkenclaude
authored
feat(webapp): app auto session logout (#3473)
<img width="2284" height="2028" alt="CleanShot 2026-05-01 at 18 53 50@2x" src="https://github.com/user-attachments/assets/4f58cbb1-0168-40fb-a523-017f2ba625a1" /> ## Performance - **Per-request DB hit**: `getUserId` runs `getEffectiveSessionDuration` (User lookup + Org `aggregate`) on *every* authenticated request, including each fetcher poll. Consider caching the effective duration in the session cookie with a short TTL (e.g. 60s) and revalidating in the background. - **Double session commit in `root.tsx`**: `getUser` already runs the expiry check; then `commitAuthenticatedSessionLazy` commits the cookie again. Fine, but doubles `Set-Cookie` headers on every page load — worth a quick perf check. ## Correctness / Edge cases - **Lazy backfill assumes a root.tsx hit first**: users whose first post-deploy request is a fetcher/API route (`/resources/*`) skip the backfill until they navigate to a page. Not a security hole, but `getUserId` could backfill itself for completeness. - **No upper bound on `Organization.maxSessionDuration`**: admin API accepts `1` second, which would instant-logout every member on next request. Add a `min(60)` (or `min(300)` to match the lowest user option) to the Zod schema. - **No clock-skew tolerance**: `isSessionExpired` is exact-millisecond. Multi-instance deploys with skewed clocks could log users out a few seconds early/late. Probably fine for the 5-min minimum, but worth noting. ## Security - **Auto-logout audit log lacks IP/orgId**: HIPAA forensics typically wants source IP and which org context. Currently logs only `userId` + path. IP isn't PII for audit purposes; orgIds help correlate. Add both. - **Cookie `Max-Age` is 1 year regardless of user's setting**: intentional (server-side `issuedAt` is the source of truth), but reviewers will ask. Add a one-line comment on the cookie config explaining why. ## API surface - **`maxSessionDuration` is admin-PAT only**: no in-app UI for org owners to set/change their own cap. If this is "Trigger staff sets it during HIPAA onboarding", say so in the PR description; otherwise add an org-settings UI. - **Auto-submit dropdown has no confirmation**: misclicking "5 minutes" immediately shortens the user's session window with no undo. Consider a save button or 3-sec undo toast. ## Schema / migration - **`User.sessionDuration NOT NULL DEFAULT 31556952`**: instant on PG 11+ (metadata-only), but call out in the PR description so reviewers don't worry about a table rewrite on the User table. - **No DB-level constraint matching `SESSION_DURATION_OPTIONS`**: if the option list changes, existing users keep orphaned values. The dropdown's tag-along behaviour hides this — fine for now, but if you ever drop an option you'll need a backfill. ## UX - **Session expiry only fires on next request**: an idle authenticated tab keeps showing UI past the cap (until SSE/polling catches it, ~60s). Add a client-side timer based on the user's effective duration that triggers a fetcher to `/account` or `/logout` at expiry. - **No "you were signed out" message on logout**: users hitting their cap are bounced to `/` with no explanation. Was intentionally reverted in this PR — call that out so reviewers don't request it. ## Tests - Unit coverage on `sessionDuration.server.ts` is solid (215 lines). Missing: integration test for `getUserId` → expired session → redirect to `/logout`, and one for the loader's clamping fix (the most recent bug). Add at least the second one to lock in the regression. --------- Co-authored-by: Matt Aitken <matt@mattaitken.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8c56d85 commit 45ec23c

21 files changed

Lines changed: 900 additions & 94 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: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,14 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
5858
websiteId: env.KAPA_AI_WEBSITE_ID,
5959
};
6060

61+
const user = await getUser(request);
62+
63+
const headers = new Headers();
64+
headers.append("Set-Cookie", await commitSession(session));
65+
6166
return typedjson(
6267
{
63-
user: await getUser(request),
68+
user,
6469
toastMessage,
6570
posthogProjectKey,
6671
features,
@@ -70,7 +75,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
7075
kapa,
7176
timezone,
7277
},
73-
{ headers: { "Set-Cookie": await commitSession(session) } }
78+
{ headers }
7479
);
7580
};
7681

apps/webapp/app/routes/account.security/route.tsx

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
import { type MetaFunction } from "@remix-run/react";
2+
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
3+
import { typedjson, useTypedLoaderData } from "remix-typedjson";
24
import {
35
MainHorizontallyCenteredContainer,
46
PageBody,
57
PageContainer,
68
} from "~/components/layout/AppLayout";
79
import { Header2 } from "~/components/primitives/Headers";
810
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
9-
import { MfaSetup } from "../resources.account.mfa.setup/route";
10-
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
11+
import { $replica } from "~/db.server";
1112
import { requireUser } from "~/services/session.server";
12-
import { typedjson, useTypedLoaderData } from "remix-typedjson";
13+
import {
14+
getAllowedSessionOptions,
15+
getEffectiveSessionDuration,
16+
} from "~/services/sessionDuration.server";
17+
import { MfaSetup } from "../resources.account.mfa.setup/route";
18+
import { SessionDurationSetting } from "../resources.account.session-duration/SessionDurationSetting";
1319

1420
export const meta: MetaFunction = () => {
1521
return [
@@ -22,13 +28,20 @@ export const meta: MetaFunction = () => {
2228
export async function loader({ request }: LoaderFunctionArgs) {
2329
const user = await requireUser(request);
2430

31+
const { durationSeconds, orgCapSeconds } = await getEffectiveSessionDuration(user.id, $replica);
32+
const sessionDurationOptions = getAllowedSessionOptions(orgCapSeconds, durationSeconds);
33+
2534
return typedjson({
2635
user,
36+
sessionDuration: durationSeconds,
37+
sessionDurationOptions,
38+
orgCapSeconds,
2739
});
2840
}
2941

3042
export default function Page() {
31-
const { user } = useTypedLoaderData<typeof loader>();
43+
const { user, sessionDuration, sessionDurationOptions, orgCapSeconds } =
44+
useTypedLoaderData<typeof loader>();
3245

3346
return (
3447
<PageContainer>
@@ -37,11 +50,20 @@ export default function Page() {
3750
</NavBar>
3851

3952
<PageBody>
40-
<MainHorizontallyCenteredContainer className="grid place-items-center overflow-visible">
41-
<div className="mb-3 w-full border-b border-grid-dimmed pb-3">
53+
<MainHorizontallyCenteredContainer className="max-w-[37.5rem] overflow-visible">
54+
<div className="w-full border-b border-grid-dimmed pb-3">
4255
<Header2>Security</Header2>
4356
</div>
44-
<MfaSetup isEnabled={!!user.mfaEnabledAt} />
57+
<div className="w-full border-b border-grid-dimmed py-4">
58+
<MfaSetup isEnabled={!!user.mfaEnabledAt} />
59+
</div>
60+
<div className="w-full border-b border-grid-dimmed py-4">
61+
<SessionDurationSetting
62+
currentValue={sessionDuration}
63+
options={sessionDurationOptions}
64+
orgCapSeconds={orgCapSeconds}
65+
/>
66+
</div>
4567
</MainHorizontallyCenteredContainer>
4668
</PageBody>
4769
</PageContainer>
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
2+
import { z } from "zod";
3+
import { prisma } from "~/db.server";
4+
import { requireAdminApiRequest } from "~/services/personalAccessToken.server";
5+
import {
6+
ALLOWED_SESSION_DURATION_VALUES,
7+
isAllowedSessionDuration,
8+
} from "~/services/sessionDuration.server";
9+
10+
const ParamsSchema = z.object({
11+
organizationId: z.string(),
12+
});
13+
14+
const RequestBodySchema = z.object({
15+
/**
16+
* Maximum session lifetime (seconds) for members of this organization, or
17+
* null to remove the cap. When set, this caps each member's
18+
* `User.sessionDuration` and is enforced on the user's next request.
19+
*
20+
* Must be one of the values in `SESSION_DURATION_OPTIONS` so the cap always
21+
* maps to a labeled dropdown option for users — otherwise users see fallback
22+
* labels like "7200 seconds" in the UI. To allow a new value, add it to
23+
* `SESSION_DURATION_OPTIONS`.
24+
*/
25+
maxSessionDuration: z
26+
.number()
27+
.int()
28+
.positive()
29+
.nullable()
30+
.refine((v) => v === null || isAllowedSessionDuration(v), {
31+
message: `maxSessionDuration must be one of: ${[...ALLOWED_SESSION_DURATION_VALUES]
32+
.sort((a, b) => a - b)
33+
.join(", ")}`,
34+
}),
35+
});
36+
37+
export async function action({ request, params }: ActionFunctionArgs) {
38+
await requireAdminApiRequest(request);
39+
40+
const { organizationId } = ParamsSchema.parse(params);
41+
let rawBody: unknown;
42+
try {
43+
rawBody = await request.json();
44+
} catch {
45+
return json(
46+
{ success: false, errors: { formErrors: ["Invalid JSON body"], fieldErrors: {} } },
47+
{ status: 400 }
48+
);
49+
}
50+
const parseResult = RequestBodySchema.safeParse(rawBody);
51+
if (!parseResult.success) {
52+
return json({ success: false, errors: parseResult.error.flatten() }, { status: 400 });
53+
}
54+
const body = parseResult.data;
55+
56+
const organization = await prisma.organization.update({
57+
where: { id: organizationId },
58+
data: { maxSessionDuration: body.maxSessionDuration },
59+
select: { id: true, slug: true, maxSessionDuration: true },
60+
});
61+
62+
// Propagate the new cap to currently-logged-in members by shortening their
63+
// `nextSessionEnd`. We only ever shorten (`LEAST`): raising or removing the
64+
// cap leaves existing sessions alone — the larger window applies on next
65+
// login. If a member is in another org with a tighter cap that other cap
66+
// remains in effect via their existing `nextSessionEnd` (LEAST keeps it).
67+
if (body.maxSessionDuration !== null) {
68+
await prisma.$executeRaw`
69+
UPDATE "User"
70+
SET "nextSessionEnd" = LEAST(
71+
COALESCE("nextSessionEnd", 'infinity'::timestamp),
72+
NOW() + (LEAST("sessionDuration", ${body.maxSessionDuration}) * INTERVAL '1 second')
73+
)
74+
WHERE "id" IN (SELECT "userId" FROM "OrgMember" WHERE "organizationId" = ${organizationId})
75+
`;
76+
}
77+
78+
return json({ success: true, organization });
79+
}

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import type { LoaderFunction } from "@remix-run/node";
22
import { redirect } from "@remix-run/node";
33
import { prisma } from "~/db.server";
4-
import { getSession, redirectWithErrorMessage } from "~/models/message.server";
4+
import { redirectWithErrorMessage } from "~/models/message.server";
55
import { authenticator } from "~/services/auth.server";
66
import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server";
7-
import { commitSession } from "~/services/sessionStorage.server";
7+
import { commitSession, getUserSession } from "~/services/sessionStorage.server";
8+
import { commitAuthenticatedSession } from "~/services/sessionDuration.server";
89
import { trackAndClearReferralSource } from "~/services/referralSource.server";
910
import { redirectCookie } from "./auth.github";
1011
import { sanitizeRedirectPath } from "~/utils";
@@ -18,7 +19,7 @@ export let loader: LoaderFunction = async ({ request }) => {
1819
failureRedirect: "/login", // If auth fails, the failureRedirect will be thrown as a Response
1920
});
2021

21-
const session = await getSession(request.headers.get("cookie"));
22+
const session = await getUserSession(request);
2223

2324
const userRecord = await prisma.user.findFirst({
2425
where: {
@@ -52,7 +53,7 @@ export let loader: LoaderFunction = async ({ request }) => {
5253
session.set(authenticator.sessionKey, auth);
5354

5455
const headers = new Headers();
55-
headers.append("Set-Cookie", await commitSession(session));
56+
headers.append("Set-Cookie", await commitAuthenticatedSession(session, auth.userId));
5657
headers.append("Set-Cookie", await setLastAuthMethodHeader("github"));
5758

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

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import type { LoaderFunction } from "@remix-run/node";
22
import { redirect } from "@remix-run/node";
33
import { prisma } from "~/db.server";
4-
import { getSession, redirectWithErrorMessage } from "~/models/message.server";
4+
import { redirectWithErrorMessage } from "~/models/message.server";
55
import { authenticator } from "~/services/auth.server";
66
import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server";
7-
import { commitSession } from "~/services/sessionStorage.server";
7+
import { commitSession, getUserSession } from "~/services/sessionStorage.server";
8+
import { commitAuthenticatedSession } from "~/services/sessionDuration.server";
89
import { trackAndClearReferralSource } from "~/services/referralSource.server";
910
import { redirectCookie } from "./auth.google";
1011
import { sanitizeRedirectPath } from "~/utils";
@@ -18,7 +19,7 @@ export let loader: LoaderFunction = async ({ request }) => {
1819
failureRedirect: "/login", // If auth fails, the failureRedirect will be thrown as a Response
1920
});
2021

21-
const session = await getSession(request.headers.get("cookie"));
22+
const session = await getUserSession(request);
2223

2324
const userRecord = await prisma.user.findFirst({
2425
where: {
@@ -52,7 +53,7 @@ export let loader: LoaderFunction = async ({ request }) => {
5253
session.set(authenticator.sessionKey, auth);
5354

5455
const headers = new Headers();
55-
headers.append("Set-Cookie", await commitSession(session));
56+
headers.append("Set-Cookie", await commitAuthenticatedSession(session, auth.userId));
5657
headers.append("Set-Cookie", await setLastAuthMethodHeader("google"));
5758

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

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { TextLink } from "~/components/primitives/TextLink";
2323
import { authenticator } from "~/services/auth.server";
2424
import { commitSession, getUserSession } from "~/services/sessionStorage.server";
2525
import { setRedirectTo, commitSession as commitRedirectSession } from "~/services/redirectTo.server";
26+
import { sanitizeRedirectPath } from "~/utils";
2627
import {
2728
checkMagicLinkEmailRateLimit,
2829
checkMagicLinkEmailDailyRateLimit,
@@ -60,11 +61,14 @@ export async function loader({ request }: LoaderFunctionArgs) {
6061
const session = await getUserSession(request);
6162
const error = session.get("auth:error");
6263

63-
// Get redirectTo from URL params and store in session if present
64+
// Get redirectTo from URL params and store in session if present.
65+
// Sanitize to drop non-page paths (fetcher routes, callbacks) which would
66+
// render blank if the user was sent there post-login.
6467
const url = new URL(request.url);
65-
const redirectTo = url.searchParams.get("redirectTo");
68+
const sanitized = sanitizeRedirectPath(url.searchParams.get("redirectTo"));
69+
const redirectTo = sanitized === "/" ? null : sanitized;
6670
const headers = new Headers();
67-
71+
6872
if (redirectTo) {
6973
const redirectSession = await setRedirectTo(request, redirectTo);
7074
headers.append("Set-Cookie", await commitRedirectSession(redirectSession));

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { Paragraph } from "~/components/primitives/Paragraph";
2121
import { Spinner } from "~/components/primitives/Spinner";
2222
import { authenticator } from "~/services/auth.server";
2323
import { commitSession, getUserSession } from "~/services/sessionStorage.server";
24+
import { commitAuthenticatedSession } from "~/services/sessionDuration.server";
2425
import { getSession as getMessageSession } from "~/models/message.server";
2526
import { MultiFactorAuthenticationService } from "~/services/mfa/multiFactorAuthentication.server";
2627
import { redirectWithErrorMessage, redirectBackWithErrorMessage } from "~/models/message.server";
@@ -162,7 +163,7 @@ async function completeLogin(request: Request, session: Session, userId: string)
162163
session.unset("pending-mfa-redirect-to");
163164

164165
const headers = new Headers();
165-
headers.append("Set-Cookie", await commitSession(session));
166+
headers.append("Set-Cookie", await commitAuthenticatedSession(session, userId));
166167

167168
await trackAndClearReferralSource(request, userId, headers);
168169

apps/webapp/app/routes/magic.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,15 @@ import { authenticator } from "~/services/auth.server";
66
import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server";
77
import { getRedirectTo } from "~/services/redirectTo.server";
88
import { commitSession, getSession } from "~/services/sessionStorage.server";
9+
import { commitAuthenticatedSession } from "~/services/sessionDuration.server";
910
import { trackAndClearReferralSource } from "~/services/referralSource.server";
11+
import { sanitizeRedirectPath } from "~/utils";
1012

1113
export async function loader({ request }: LoaderFunctionArgs) {
12-
const redirectTo = await getRedirectTo(request);
14+
// Defense-in-depth: sanitize the cookie value to drop non-page paths in case
15+
// a stale cookie from before sanitization shipped is still in the browser.
16+
const sanitized = sanitizeRedirectPath(await getRedirectTo(request));
17+
const redirectTo = sanitized === "/" ? undefined : sanitized;
1318

1419
const auth = await authenticator.authenticate("email-link", request, {
1520
failureRedirect: "/login/magic", // If auth fails, the failureRedirect will be thrown as a Response
@@ -51,7 +56,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
5156
session.set(authenticator.sessionKey, auth);
5257

5358
const headers = new Headers();
54-
headers.append("Set-Cookie", await commitSession(session));
59+
headers.append("Set-Cookie", await commitAuthenticatedSession(session, auth.userId));
5560
headers.append("Set-Cookie", await setLastAuthMethodHeader("email"));
5661

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

apps/webapp/app/routes/resources.account.mfa.setup/MfaToggle.tsx

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,24 +12,24 @@ interface MfaToggleProps {
1212
export function MfaToggle({ isEnabled, onToggle }: MfaToggleProps) {
1313
return (
1414
<Form method="post" className="w-full">
15-
<InputGroup className="mb-4">
16-
<Label>Multi-factor authentication</Label>
17-
<Paragraph variant="small">
18-
Enable an extra layer of security by requiring a one-time code from your authenticator
19-
app (TOTP) each time you log in.
20-
</Paragraph>
21-
</InputGroup>
22-
<div className="flex items-center justify-between">
23-
<Switch
24-
id="mfa"
25-
variant="medium"
26-
label={isEnabled ? "Enabled" : "Enable"}
27-
labelPosition="right"
28-
className="-ml-2 w-fit pr-3"
29-
checked={isEnabled}
30-
onCheckedChange={onToggle}
31-
/>
15+
<div className="flex w-full items-center justify-between gap-4">
16+
<InputGroup className="flex-1">
17+
<Label htmlFor="mfa">Multi-factor authentication</Label>
18+
<Paragraph variant="small">
19+
Require a one-time code from your authenticator app (TOTP).
20+
</Paragraph>
21+
</InputGroup>
22+
<div className="flex flex-none items-center">
23+
<Switch
24+
id="mfa"
25+
variant="medium"
26+
labelPosition="right"
27+
className="w-fit pr-3"
28+
checked={isEnabled}
29+
onCheckedChange={onToggle}
30+
/>
31+
</div>
3232
</div>
3333
</Form>
3434
);
35-
}
35+
}

0 commit comments

Comments
 (0)