Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
2ee9047
Implements new session logout
samejr Apr 28, 2026
0783ff7
Submit the form without a save button
samejr Apr 28, 2026
62fecbc
Better settings layout
samejr Apr 28, 2026
6082525
Show friendly labels for capped duration values
samejr Apr 28, 2026
4237c5d
Bug fix: When getting logged out, logging back in redirected you to /…
samejr Apr 28, 2026
fa06144
Fixes bug where session logout returned error.
samejr Apr 28, 2026
31595ee
Remove login page toast message logic
samejr Apr 28, 2026
d7d6b2b
Cloudwatch picks up session logout events
samejr Apr 28, 2026
dffeec0
small classname tweak
samejr Apr 28, 2026
c294c50
Aggregate the session length values
samejr Apr 28, 2026
05095c5
Merge the 2 db migrations
samejr Apr 28, 2026
abd7562
userId param is no longer used
samejr Apr 28, 2026
9234800
Fix for ensuring a DB change updates the availble duration options
samejr Apr 29, 2026
bad8895
Code review fixes and improvements
samejr Apr 30, 2026
309742e
Code review improvements
samejr Apr 30, 2026
7ca42aa
Merge branch 'main' into feat(webapp)-auto-app-logout
samejr Apr 30, 2026
44d55cd
Code review fix
samejr Apr 30, 2026
2e0d248
code comment update
samejr Apr 30, 2026
b2ca91e
Merge branch 'main' into feat(webapp)-auto-app-logout
samejr May 1, 2026
bca5ec3
Merge branch 'main' into feat(webapp)-auto-app-logout
samejr May 1, 2026
4fa011d
perf(webapp): make auto-logout enforcement zero-query on the read path
matt-aitken May 3, 2026
411ad27
fix(webapp): restore admin verification on impersonation in getUserId
matt-aitken May 3, 2026
723b13f
fix(webapp): prevent /logout redirect loop on auto-logout
matt-aitken May 3, 2026
b41e964
fix(webapp): return 400 (not 500) on malformed JSON in admin session-…
matt-aitken May 4, 2026
cbe150e
Mock the db server in the new tests
matt-aitken May 4, 2026
9190d1f
fix(webapp): align malformed-JSON error shape with Zod flatten() shape
matt-aitken May 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .server-changes/app-auto-session-logout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---

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.
9 changes: 7 additions & 2 deletions apps/webapp/app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,14 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
websiteId: env.KAPA_AI_WEBSITE_ID,
};

const user = await getUser(request);

const headers = new Headers();
headers.append("Set-Cookie", await commitSession(session));

return typedjson(
{
user: await getUser(request),
user,
toastMessage,
posthogProjectKey,
features,
Expand All @@ -70,7 +75,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
kapa,
timezone,
},
{ headers: { "Set-Cookie": await commitSession(session) } }
{ headers }
);
};

Expand Down
36 changes: 29 additions & 7 deletions apps/webapp/app/routes/account.security/route.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import { type MetaFunction } from "@remix-run/react";
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import {
MainHorizontallyCenteredContainer,
PageBody,
PageContainer,
} from "~/components/layout/AppLayout";
import { Header2 } from "~/components/primitives/Headers";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { MfaSetup } from "../resources.account.mfa.setup/route";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { $replica } from "~/db.server";
import { requireUser } from "~/services/session.server";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import {
getAllowedSessionOptions,
getEffectiveSessionDuration,
} from "~/services/sessionDuration.server";
import { MfaSetup } from "../resources.account.mfa.setup/route";
import { SessionDurationSetting } from "../resources.account.session-duration/SessionDurationSetting";

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

const { durationSeconds, orgCapSeconds } = await getEffectiveSessionDuration(user.id, $replica);
const sessionDurationOptions = getAllowedSessionOptions(orgCapSeconds, durationSeconds);
Comment thread
samejr marked this conversation as resolved.

return typedjson({
user,
sessionDuration: durationSeconds,
sessionDurationOptions,
orgCapSeconds,
});
}

export default function Page() {
const { user } = useTypedLoaderData<typeof loader>();
const { user, sessionDuration, sessionDurationOptions, orgCapSeconds } =
useTypedLoaderData<typeof loader>();

return (
<PageContainer>
Expand All @@ -37,11 +50,20 @@ export default function Page() {
</NavBar>

<PageBody>
<MainHorizontallyCenteredContainer className="grid place-items-center overflow-visible">
<div className="mb-3 w-full border-b border-grid-dimmed pb-3">
<MainHorizontallyCenteredContainer className="max-w-[37.5rem] overflow-visible">
<div className="w-full border-b border-grid-dimmed pb-3">
<Header2>Security</Header2>
</div>
<MfaSetup isEnabled={!!user.mfaEnabledAt} />
<div className="w-full border-b border-grid-dimmed py-4">
<MfaSetup isEnabled={!!user.mfaEnabledAt} />
</div>
<div className="w-full border-b border-grid-dimmed py-4">
<SessionDurationSetting
currentValue={sessionDuration}
options={sessionDurationOptions}
orgCapSeconds={orgCapSeconds}
/>
</div>
</MainHorizontallyCenteredContainer>
</PageBody>
</PageContainer>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { z } from "zod";
import { prisma } from "~/db.server";
import { requireAdminApiRequest } from "~/services/personalAccessToken.server";
import {
ALLOWED_SESSION_DURATION_VALUES,
isAllowedSessionDuration,
} from "~/services/sessionDuration.server";

const ParamsSchema = z.object({
organizationId: z.string(),
});

const RequestBodySchema = z.object({
/**
* Maximum session lifetime (seconds) for members of this organization, or
* null to remove the cap. When set, this caps each member's
* `User.sessionDuration` and is enforced on the user's next request.
*
* Must be one of the values in `SESSION_DURATION_OPTIONS` so the cap always
* maps to a labeled dropdown option for users — otherwise users see fallback
* labels like "7200 seconds" in the UI. To allow a new value, add it to
* `SESSION_DURATION_OPTIONS`.
*/
maxSessionDuration: z
.number()
.int()
.positive()
.nullable()
.refine((v) => v === null || isAllowedSessionDuration(v), {
message: `maxSessionDuration must be one of: ${[...ALLOWED_SESSION_DURATION_VALUES]
.sort((a, b) => a - b)
.join(", ")}`,
}),
});

export async function action({ request, params }: ActionFunctionArgs) {
await requireAdminApiRequest(request);

const { organizationId } = ParamsSchema.parse(params);
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return json(
{ success: false, errors: { formErrors: ["Invalid JSON body"], fieldErrors: {} } },
{ status: 400 }
);
}
const parseResult = RequestBodySchema.safeParse(rawBody);
if (!parseResult.success) {
return json({ success: false, errors: parseResult.error.flatten() }, { status: 400 });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
const body = parseResult.data;

const organization = await prisma.organization.update({
where: { id: organizationId },
data: { maxSessionDuration: body.maxSessionDuration },
select: { id: true, slug: true, maxSessionDuration: true },
});
Comment thread
samejr marked this conversation as resolved.

// Propagate the new cap to currently-logged-in members by shortening their
// `nextSessionEnd`. We only ever shorten (`LEAST`): raising or removing the
// cap leaves existing sessions alone — the larger window applies on next
// login. If a member is in another org with a tighter cap that other cap
// remains in effect via their existing `nextSessionEnd` (LEAST keeps it).
if (body.maxSessionDuration !== null) {
await prisma.$executeRaw`
UPDATE "User"
SET "nextSessionEnd" = LEAST(
COALESCE("nextSessionEnd", 'infinity'::timestamp),
NOW() + (LEAST("sessionDuration", ${body.maxSessionDuration}) * INTERVAL '1 second')
)
WHERE "id" IN (SELECT "userId" FROM "OrgMember" WHERE "organizationId" = ${organizationId})
`;
Comment thread
matt-aitken marked this conversation as resolved.
}

return json({ success: true, organization });
}
9 changes: 5 additions & 4 deletions apps/webapp/app/routes/auth.github.callback.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { LoaderFunction } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { prisma } from "~/db.server";
import { getSession, redirectWithErrorMessage } from "~/models/message.server";
import { redirectWithErrorMessage } from "~/models/message.server";
import { authenticator } from "~/services/auth.server";
import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server";
import { commitSession } from "~/services/sessionStorage.server";
import { commitSession, getUserSession } from "~/services/sessionStorage.server";
import { commitAuthenticatedSession } from "~/services/sessionDuration.server";
import { trackAndClearReferralSource } from "~/services/referralSource.server";
import { redirectCookie } from "./auth.github";
import { sanitizeRedirectPath } from "~/utils";
Expand All @@ -18,7 +19,7 @@ export let loader: LoaderFunction = async ({ request }) => {
failureRedirect: "/login", // If auth fails, the failureRedirect will be thrown as a Response
});

const session = await getSession(request.headers.get("cookie"));
const session = await getUserSession(request);

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

const headers = new Headers();
headers.append("Set-Cookie", await commitSession(session));
headers.append("Set-Cookie", await commitAuthenticatedSession(session, auth.userId));
headers.append("Set-Cookie", await setLastAuthMethodHeader("github"));

await trackAndClearReferralSource(request, auth.userId, headers);
Expand Down
9 changes: 5 additions & 4 deletions apps/webapp/app/routes/auth.google.callback.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { LoaderFunction } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { prisma } from "~/db.server";
import { getSession, redirectWithErrorMessage } from "~/models/message.server";
import { redirectWithErrorMessage } from "~/models/message.server";
import { authenticator } from "~/services/auth.server";
import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server";
import { commitSession } from "~/services/sessionStorage.server";
import { commitSession, getUserSession } from "~/services/sessionStorage.server";
import { commitAuthenticatedSession } from "~/services/sessionDuration.server";
import { trackAndClearReferralSource } from "~/services/referralSource.server";
import { redirectCookie } from "./auth.google";
import { sanitizeRedirectPath } from "~/utils";
Expand All @@ -18,7 +19,7 @@ export let loader: LoaderFunction = async ({ request }) => {
failureRedirect: "/login", // If auth fails, the failureRedirect will be thrown as a Response
});

const session = await getSession(request.headers.get("cookie"));
const session = await getUserSession(request);

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

const headers = new Headers();
headers.append("Set-Cookie", await commitSession(session));
headers.append("Set-Cookie", await commitAuthenticatedSession(session, auth.userId));
headers.append("Set-Cookie", await setLastAuthMethodHeader("google"));

await trackAndClearReferralSource(request, auth.userId, headers);
Expand Down
10 changes: 7 additions & 3 deletions apps/webapp/app/routes/login.magic/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { TextLink } from "~/components/primitives/TextLink";
import { authenticator } from "~/services/auth.server";
import { commitSession, getUserSession } from "~/services/sessionStorage.server";
import { setRedirectTo, commitSession as commitRedirectSession } from "~/services/redirectTo.server";
import { sanitizeRedirectPath } from "~/utils";
import {
checkMagicLinkEmailRateLimit,
checkMagicLinkEmailDailyRateLimit,
Expand Down Expand Up @@ -60,11 +61,14 @@ export async function loader({ request }: LoaderFunctionArgs) {
const session = await getUserSession(request);
const error = session.get("auth:error");

// Get redirectTo from URL params and store in session if present
// Get redirectTo from URL params and store in session if present.
// Sanitize to drop non-page paths (fetcher routes, callbacks) which would
// render blank if the user was sent there post-login.
const url = new URL(request.url);
const redirectTo = url.searchParams.get("redirectTo");
const sanitized = sanitizeRedirectPath(url.searchParams.get("redirectTo"));
const redirectTo = sanitized === "/" ? null : sanitized;
const headers = new Headers();

if (redirectTo) {
const redirectSession = await setRedirectTo(request, redirectTo);
headers.append("Set-Cookie", await commitRedirectSession(redirectSession));
Expand Down
3 changes: 2 additions & 1 deletion apps/webapp/app/routes/login.mfa/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { Paragraph } from "~/components/primitives/Paragraph";
import { Spinner } from "~/components/primitives/Spinner";
import { authenticator } from "~/services/auth.server";
import { commitSession, getUserSession } from "~/services/sessionStorage.server";
import { commitAuthenticatedSession } from "~/services/sessionDuration.server";
import { getSession as getMessageSession } from "~/models/message.server";
import { MultiFactorAuthenticationService } from "~/services/mfa/multiFactorAuthentication.server";
import { redirectWithErrorMessage, redirectBackWithErrorMessage } from "~/models/message.server";
Expand Down Expand Up @@ -162,7 +163,7 @@ async function completeLogin(request: Request, session: Session, userId: string)
session.unset("pending-mfa-redirect-to");

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

await trackAndClearReferralSource(request, userId, headers);

Expand Down
9 changes: 7 additions & 2 deletions apps/webapp/app/routes/magic.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,15 @@ import { authenticator } from "~/services/auth.server";
import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server";
import { getRedirectTo } from "~/services/redirectTo.server";
import { commitSession, getSession } from "~/services/sessionStorage.server";
import { commitAuthenticatedSession } from "~/services/sessionDuration.server";
import { trackAndClearReferralSource } from "~/services/referralSource.server";
import { sanitizeRedirectPath } from "~/utils";

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

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

const headers = new Headers();
headers.append("Set-Cookie", await commitSession(session));
headers.append("Set-Cookie", await commitAuthenticatedSession(session, auth.userId));
headers.append("Set-Cookie", await setLastAuthMethodHeader("email"));

await trackAndClearReferralSource(request, auth.userId, headers);
Expand Down
36 changes: 18 additions & 18 deletions apps/webapp/app/routes/resources.account.mfa.setup/MfaToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,24 @@ interface MfaToggleProps {
export function MfaToggle({ isEnabled, onToggle }: MfaToggleProps) {
return (
<Form method="post" className="w-full">
<InputGroup className="mb-4">
<Label>Multi-factor authentication</Label>
<Paragraph variant="small">
Enable an extra layer of security by requiring a one-time code from your authenticator
app (TOTP) each time you log in.
</Paragraph>
</InputGroup>
<div className="flex items-center justify-between">
<Switch
id="mfa"
variant="medium"
label={isEnabled ? "Enabled" : "Enable"}
labelPosition="right"
className="-ml-2 w-fit pr-3"
checked={isEnabled}
onCheckedChange={onToggle}
/>
<div className="flex w-full items-center justify-between gap-4">
<InputGroup className="flex-1">
<Label htmlFor="mfa">Multi-factor authentication</Label>
<Paragraph variant="small">
Require a one-time code from your authenticator app (TOTP).
</Paragraph>
</InputGroup>
<div className="flex flex-none items-center">
<Switch
id="mfa"
variant="medium"
labelPosition="right"
className="w-fit pr-3"
checked={isEnabled}
onCheckedChange={onToggle}
/>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</div>
</div>
</Form>
);
}
}
Loading
Loading