Skip to content

Commit 6813adf

Browse files
committed
Implements new session logout
1 parent e134da7 commit 6813adf

15 files changed

Lines changed: 669 additions & 15 deletions

File tree

apps/webapp/app/root.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ 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";
1820
import { getTimezonePreference } from "./services/preferences/uiPreferences.server";
1921
import { appEnvTitleTag } from "./utils";
2022

@@ -58,9 +60,22 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
5860
websiteId: env.KAPA_AI_WEBSITE_ID,
5961
};
6062

63+
const user = await getUser(request);
64+
65+
const headers = new Headers();
66+
headers.append("Set-Cookie", await commitSession(session));
67+
68+
// Lazy-backfill the auth session's `issuedAt` for cookies issued before this
69+
// feature shipped, and refresh the cookie's Max-Age to track the user's
70+
// current effective session duration.
71+
if (user) {
72+
const authSession = await getUserSession(request);
73+
headers.append("Set-Cookie", await commitAuthenticatedSessionLazy(authSession, user.id));
74+
}
75+
6176
return typedjson(
6277
{
63-
user: await getUser(request),
78+
user,
6479
toastMessage,
6580
posthogProjectKey,
6681
features,
@@ -70,7 +85,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
7085
kapa,
7186
timezone,
7287
},
73-
{ headers: { "Set-Cookie": await commitSession(session) } }
88+
{ headers }
7489
);
7590
};
7691

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

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,16 @@ import {
77
import { Header2 } from "~/components/primitives/Headers";
88
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
99
import { MfaSetup } from "../resources.account.mfa.setup/route";
10+
import { SessionDurationSetting } from "../resources.account.session-duration/SessionDurationSetting";
1011
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
1112
import { requireUser } from "~/services/session.server";
1213
import { typedjson, useTypedLoaderData } from "remix-typedjson";
14+
import { prisma } from "~/db.server";
15+
import {
16+
getAllowedSessionOptions,
17+
getOrganizationSessionCap,
18+
DEFAULT_SESSION_DURATION_SECONDS,
19+
} from "~/services/sessionDuration.server";
1320

1421
export const meta: MetaFunction = () => {
1522
return [
@@ -22,13 +29,28 @@ export const meta: MetaFunction = () => {
2229
export async function loader({ request }: LoaderFunctionArgs) {
2330
const user = await requireUser(request);
2431

32+
const [userRecord, orgCapSeconds] = await Promise.all([
33+
prisma.user.findUnique({
34+
where: { id: user.id },
35+
select: { sessionDuration: true },
36+
}),
37+
getOrganizationSessionCap(user.id),
38+
]);
39+
40+
const sessionDuration = userRecord?.sessionDuration ?? DEFAULT_SESSION_DURATION_SECONDS;
41+
const sessionDurationOptions = getAllowedSessionOptions(orgCapSeconds, sessionDuration);
42+
2543
return typedjson({
2644
user,
45+
sessionDuration,
46+
sessionDurationOptions,
47+
orgCapSeconds,
2748
});
2849
}
2950

3051
export default function Page() {
31-
const { user } = useTypedLoaderData<typeof loader>();
52+
const { user, sessionDuration, sessionDurationOptions, orgCapSeconds } =
53+
useTypedLoaderData<typeof loader>();
3254

3355
return (
3456
<PageContainer>
@@ -42,6 +64,13 @@ export default function Page() {
4264
<Header2>Security</Header2>
4365
</div>
4466
<MfaSetup isEnabled={!!user.mfaEnabledAt} />
67+
<div className="mt-6 w-full border-t border-grid-dimmed pt-6">
68+
<SessionDurationSetting
69+
currentValue={sessionDuration}
70+
options={sessionDurationOptions}
71+
orgCapSeconds={orgCapSeconds}
72+
/>
73+
</div>
4574
</MainHorizontallyCenteredContainer>
4675
</PageBody>
4776
</PageContainer>
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
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+
6+
const ParamsSchema = z.object({
7+
organizationId: z.string(),
8+
});
9+
10+
const RequestBodySchema = z.object({
11+
/**
12+
* Maximum session lifetime (seconds) for members of this organization, or
13+
* null to remove the cap. When set, this caps each member's
14+
* `User.sessionDuration` and is enforced on the user's next request.
15+
*/
16+
maxSessionDuration: z.number().int().positive().nullable(),
17+
});
18+
19+
export async function action({ request, params }: ActionFunctionArgs) {
20+
await requireAdminApiRequest(request);
21+
22+
const { organizationId } = ParamsSchema.parse(params);
23+
const body = RequestBodySchema.parse(await request.json());
24+
25+
const organization = await prisma.organization.update({
26+
where: { id: organizationId },
27+
data: { maxSessionDuration: body.maxSessionDuration },
28+
select: { id: true, slug: true, maxSessionDuration: true },
29+
});
30+
31+
return json({ success: true, organization });
32+
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getSession, redirectWithErrorMessage } from "~/models/message.server";
55
import { authenticator } from "~/services/auth.server";
66
import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server";
77
import { commitSession } 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";
@@ -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: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getSession, redirectWithErrorMessage } from "~/models/message.server";
55
import { authenticator } from "~/services/auth.server";
66
import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server";
77
import { commitSession } 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";
@@ -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.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: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ 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";
1011

1112
export async function loader({ request }: LoaderFunctionArgs) {
@@ -51,7 +52,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
5152
session.set(authenticator.sessionKey, auth);
5253

5354
const headers = new Headers();
54-
headers.append("Set-Cookie", await commitSession(session));
55+
headers.append("Set-Cookie", await commitAuthenticatedSession(session, auth.userId));
5556
headers.append("Set-Cookie", await setLastAuthMethodHeader("email"));
5657

5758
await trackAndClearReferralSource(request, auth.userId, headers);
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { Form, useNavigation } from "@remix-run/react";
2+
import { Button } from "~/components/primitives/Buttons";
3+
import { InputGroup } from "~/components/primitives/InputGroup";
4+
import { Label } from "~/components/primitives/Label";
5+
import { Paragraph } from "~/components/primitives/Paragraph";
6+
import { Select, SelectItem } from "~/components/primitives/Select";
7+
import type { SessionDurationOption } from "~/services/sessionDuration.server";
8+
9+
interface SessionDurationSettingProps {
10+
currentValue: number;
11+
options: SessionDurationOption[];
12+
orgCapSeconds: number | null;
13+
}
14+
15+
export function SessionDurationSetting({
16+
currentValue,
17+
options,
18+
orgCapSeconds,
19+
}: SessionDurationSettingProps) {
20+
const navigation = useNavigation();
21+
const isSubmitting =
22+
navigation.state !== "idle" &&
23+
navigation.formAction === "/resources/account/session-duration";
24+
25+
const orgCapOption =
26+
orgCapSeconds === null ? null : options.find((o) => o.value === orgCapSeconds);
27+
28+
return (
29+
<Form method="post" action="/resources/account/session-duration" className="w-full">
30+
<InputGroup className="mb-4">
31+
<Label>Automatic logout</Label>
32+
<Paragraph variant="small">
33+
Automatically log out after this period of time. Choose a shorter duration for added
34+
security on shared or unattended devices.
35+
{orgCapSeconds !== null ? (
36+
<>
37+
{" "}
38+
Your organization caps this at {orgCapOption?.label ?? `${orgCapSeconds} seconds`}.
39+
</>
40+
) : null}
41+
</Paragraph>
42+
</InputGroup>
43+
<div className="flex items-center gap-2">
44+
<Select
45+
name="sessionDuration"
46+
variant="tertiary/medium"
47+
defaultValue={String(currentValue)}
48+
text={(value: string) =>
49+
options.find((o) => String(o.value) === value)?.label ?? "Select a duration"
50+
}
51+
dropdownIcon
52+
>
53+
{options.map((option) => (
54+
<SelectItem key={option.value} value={String(option.value)}>
55+
{option.label}
56+
</SelectItem>
57+
))}
58+
</Select>
59+
<Button
60+
type="submit"
61+
variant="primary/medium"
62+
disabled={isSubmitting}
63+
data-action="save session duration"
64+
>
65+
{isSubmitting ? "Saving…" : "Save"}
66+
</Button>
67+
</div>
68+
</Form>
69+
);
70+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { redirect, type ActionFunctionArgs } from "@remix-run/server-runtime";
2+
import { z } from "zod";
3+
import { prisma } from "~/db.server";
4+
import {
5+
commitSession as commitMessageSession,
6+
getSession as getMessageSession,
7+
setErrorMessage,
8+
setSuccessMessage,
9+
} from "~/models/message.server";
10+
import { requireUserId } from "~/services/session.server";
11+
import {
12+
commitAuthenticatedSession,
13+
getAllowedSessionOptions,
14+
getEffectiveSessionDuration,
15+
isAllowedSessionDuration,
16+
} from "~/services/sessionDuration.server";
17+
import { getUserSession } from "~/services/sessionStorage.server";
18+
19+
const FormSchema = z.object({
20+
sessionDuration: z.coerce.number().int().positive(),
21+
});
22+
23+
const REDIRECT_PATH = "/account/security";
24+
25+
async function redirectWithError(request: Request, message: string) {
26+
const messageSession = await getMessageSession(request.headers.get("cookie"));
27+
setErrorMessage(messageSession, message);
28+
return redirect(REDIRECT_PATH, {
29+
headers: { "Set-Cookie": await commitMessageSession(messageSession) },
30+
});
31+
}
32+
33+
export async function action({ request }: ActionFunctionArgs) {
34+
const userId = await requireUserId(request);
35+
36+
const formData = await request.formData();
37+
const parsed = FormSchema.safeParse(Object.fromEntries(formData));
38+
39+
if (!parsed.success) {
40+
return redirectWithError(request, "Invalid session duration value.");
41+
}
42+
43+
const { sessionDuration } = parsed.data;
44+
45+
if (!isAllowedSessionDuration(sessionDuration)) {
46+
return redirectWithError(request, "Invalid session duration value.");
47+
}
48+
49+
const { orgCapSeconds, userSettingSeconds } = await getEffectiveSessionDuration(userId);
50+
const allowed = getAllowedSessionOptions(orgCapSeconds, userSettingSeconds);
51+
if (!allowed.some((o) => o.value === sessionDuration)) {
52+
return redirectWithError(
53+
request,
54+
"Your organization's policy does not allow that session duration."
55+
);
56+
}
57+
58+
await prisma.user.update({
59+
where: { id: userId },
60+
data: { sessionDuration },
61+
});
62+
63+
// 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.
65+
const authSession = await getUserSession(request);
66+
const authCookie = await commitAuthenticatedSession(authSession, userId);
67+
68+
const messageSession = await getMessageSession(request.headers.get("cookie"));
69+
setSuccessMessage(messageSession, "Session duration updated.");
70+
const messageCookie = await commitMessageSession(messageSession);
71+
72+
const headers = new Headers();
73+
headers.append("Set-Cookie", authCookie);
74+
headers.append("Set-Cookie", messageCookie);
75+
76+
return redirect(REDIRECT_PATH, { headers });
77+
}

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { redirect } from "@remix-run/node";
22
import { getUserById } from "~/models/user.server";
33
import { authenticator } from "./auth.server";
44
import { getImpersonationId } from "./impersonation.server";
5+
import { getEffectiveSessionDuration, isSessionExpired } from "./sessionDuration.server";
6+
import { getUserSession } from "./sessionStorage.server";
57

68
export async function getUserId(request: Request): Promise<string | undefined> {
79
const impersonatedUserId = await getImpersonationId(request);
@@ -19,8 +21,19 @@ export async function getUserId(request: Request): Promise<string | undefined> {
1921
return authUser?.userId;
2022
}
2123

22-
let authUser = await authenticator.isAuthenticated(request);
23-
return authUser?.userId;
24+
const authUser = await authenticator.isAuthenticated(request);
25+
if (!authUser?.userId) return undefined;
26+
27+
// Enforce the user's effective session duration (User.sessionDuration capped
28+
// by the most restrictive Organization.maxSessionDuration). If the session
29+
// was issued longer ago than the cap allows, force a logout.
30+
const session = await getUserSession(request);
31+
const { durationSeconds } = await getEffectiveSessionDuration(authUser.userId);
32+
if (isSessionExpired(session, durationSeconds)) {
33+
throw await logout(request);
34+
}
35+
36+
return authUser.userId;
2437
}
2538

2639
export async function getUser(request: Request) {

0 commit comments

Comments
 (0)