diff --git a/apps/webapp/app/root.tsx b/apps/webapp/app/root.tsx index c6027b1a6d3..a17a4159bd1 100644 --- a/apps/webapp/app/root.tsx +++ b/apps/webapp/app/root.tsx @@ -15,6 +15,8 @@ import { env } from "./env.server"; import { featuresForRequest } from "./features.server"; import { usePostHog } from "./hooks/usePostHog"; import { getUser } from "./services/session.server"; +import { commitAuthenticatedSessionLazy } from "./services/sessionDuration.server"; +import { getUserSession } from "./services/sessionStorage.server"; import { getTimezonePreference } from "./services/preferences/uiPreferences.server"; import { appEnvTitleTag } from "./utils"; @@ -58,9 +60,23 @@ 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)); + + // Lazy-backfill the auth session's `issuedAt` for cookies issued before this + // feature shipped. Returns null (and does not commit) once issuedAt is set, + // so the cookie isn't re-written on every page load. + if (user) { + const authSession = await getUserSession(request); + const lazyCookie = await commitAuthenticatedSessionLazy(authSession); + if (lazyCookie) headers.append("Set-Cookie", lazyCookie); + } + return typedjson( { - user: await getUser(request), + user, toastMessage, posthogProjectKey, features, @@ -70,7 +86,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { kapa, timezone, }, - { headers: { "Set-Cookie": await commitSession(session) } } + { headers } ); }; diff --git a/apps/webapp/app/routes/account.security/route.tsx b/apps/webapp/app/routes/account.security/route.tsx index 18cf8b54ab7..68dc8d1fcf4 100644 --- a/apps/webapp/app/routes/account.security/route.tsx +++ b/apps/webapp/app/routes/account.security/route.tsx @@ -1,4 +1,6 @@ import { type MetaFunction } from "@remix-run/react"; +import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { MainHorizontallyCenteredContainer, PageBody, @@ -6,10 +8,14 @@ import { } 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 [ @@ -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); + return typedjson({ user, + sessionDuration: durationSeconds, + sessionDurationOptions, + orgCapSeconds, }); } export default function Page() { - const { user } = useTypedLoaderData(); + const { user, sessionDuration, sessionDurationOptions, orgCapSeconds } = + useTypedLoaderData(); return ( @@ -37,11 +50,20 @@ export default function Page() { - -
+ +
Security
- +
+ +
+
+ +
diff --git a/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.session-duration.ts b/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.session-duration.ts new file mode 100644 index 00000000000..5da26918693 --- /dev/null +++ b/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.session-duration.ts @@ -0,0 +1,54 @@ +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); + const parseResult = RequestBodySchema.safeParse(await request.json()); + if (!parseResult.success) { + return json({ success: false, errors: parseResult.error.flatten() }, { status: 400 }); + } + const body = parseResult.data; + + const organization = await prisma.organization.update({ + where: { id: organizationId }, + data: { maxSessionDuration: body.maxSessionDuration }, + select: { id: true, slug: true, maxSessionDuration: true }, + }); + + return json({ success: true, organization }); +} diff --git a/apps/webapp/app/routes/auth.github.callback.tsx b/apps/webapp/app/routes/auth.github.callback.tsx index 2313b348f4a..f0c8e9a4515 100644 --- a/apps/webapp/app/routes/auth.github.callback.tsx +++ b/apps/webapp/app/routes/auth.github.callback.tsx @@ -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"; @@ -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: { @@ -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)); headers.append("Set-Cookie", await setLastAuthMethodHeader("github")); await trackAndClearReferralSource(request, auth.userId, headers); diff --git a/apps/webapp/app/routes/auth.google.callback.tsx b/apps/webapp/app/routes/auth.google.callback.tsx index 65dabd605ce..732903ca0ef 100644 --- a/apps/webapp/app/routes/auth.google.callback.tsx +++ b/apps/webapp/app/routes/auth.google.callback.tsx @@ -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"; @@ -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: { @@ -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)); headers.append("Set-Cookie", await setLastAuthMethodHeader("google")); await trackAndClearReferralSource(request, auth.userId, headers); diff --git a/apps/webapp/app/routes/login.magic/route.tsx b/apps/webapp/app/routes/login.magic/route.tsx index 8c4323be54e..3ddbd47a4d0 100644 --- a/apps/webapp/app/routes/login.magic/route.tsx +++ b/apps/webapp/app/routes/login.magic/route.tsx @@ -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, @@ -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)); diff --git a/apps/webapp/app/routes/login.mfa/route.tsx b/apps/webapp/app/routes/login.mfa/route.tsx index 1a71cd7227a..c02aea0cfde 100644 --- a/apps/webapp/app/routes/login.mfa/route.tsx +++ b/apps/webapp/app/routes/login.mfa/route.tsx @@ -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"; @@ -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)); await trackAndClearReferralSource(request, userId, headers); diff --git a/apps/webapp/app/routes/magic.tsx b/apps/webapp/app/routes/magic.tsx index 682f0ef46e5..a3d677829c8 100644 --- a/apps/webapp/app/routes/magic.tsx +++ b/apps/webapp/app/routes/magic.tsx @@ -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 @@ -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)); headers.append("Set-Cookie", await setLastAuthMethodHeader("email")); await trackAndClearReferralSource(request, auth.userId, headers); diff --git a/apps/webapp/app/routes/resources.account.mfa.setup/MfaToggle.tsx b/apps/webapp/app/routes/resources.account.mfa.setup/MfaToggle.tsx index 8fecc0a6493..c63c2eb8f84 100644 --- a/apps/webapp/app/routes/resources.account.mfa.setup/MfaToggle.tsx +++ b/apps/webapp/app/routes/resources.account.mfa.setup/MfaToggle.tsx @@ -12,24 +12,24 @@ interface MfaToggleProps { export function MfaToggle({ isEnabled, onToggle }: MfaToggleProps) { return (
- - - - Enable an extra layer of security by requiring a one-time code from your authenticator - app (TOTP) each time you log in. - - -
- +
+ + + + Require a one-time code from your authenticator app (TOTP). + + +
+ +
); -} \ No newline at end of file +} diff --git a/apps/webapp/app/routes/resources.account.mfa.setup/route.tsx b/apps/webapp/app/routes/resources.account.mfa.setup/route.tsx index 33800cb842f..04f5c8f74e0 100644 --- a/apps/webapp/app/routes/resources.account.mfa.setup/route.tsx +++ b/apps/webapp/app/routes/resources.account.mfa.setup/route.tsx @@ -1,7 +1,11 @@ -import { ActionFunctionArgs } from "@remix-run/server-runtime"; +import { type ActionFunctionArgs } from "@remix-run/server-runtime"; import { typedjson } from "remix-typedjson"; import { z } from "zod"; -import { redirectWithSuccessMessage, redirectWithErrorMessage, typedJsonWithSuccessMessage } from "~/models/message.server"; +import { + redirectWithSuccessMessage, + redirectWithErrorMessage, + typedJsonWithSuccessMessage, +} from "~/models/message.server"; import { MultiFactorAuthenticationService } from "~/services/mfa/multiFactorAuthentication.server"; import { requireUserId } from "~/services/session.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; @@ -132,14 +136,15 @@ export async function action({ request }: ActionFunctionArgs) { if (error instanceof ServiceValidationError) { return redirectWithErrorMessage("/account/security", request, error.message); } - + // Re-throw unexpected errors throw error; } } export function MfaSetup({ isEnabled }: { isEnabled: boolean }) { - const { state, actions, isQrDialogOpen, isRecoveryDialogOpen, isDisableDialogOpen } = useMfaSetup(isEnabled); + const { state, actions, isQrDialogOpen, isRecoveryDialogOpen, isDisableDialogOpen } = + useMfaSetup(isEnabled); const handleToggle = (enabled: boolean) => { if (enabled && !state.isEnabled) { @@ -151,10 +156,7 @@ export function MfaSetup({ isEnabled }: { isEnabled: boolean }) { return ( <> - + o.value === orgCapSeconds); + + return ( +
+
+ + + + Automatically log out after a period of time. + {orgCapSeconds !== null ? ( + <> + {" "} + Your organization caps this at {orgCapOption?.label ?? `${orgCapSeconds} seconds`}. + + ) : null} + + +
+ +
+
+
+ ); +} diff --git a/apps/webapp/app/routes/resources.account.session-duration/route.tsx b/apps/webapp/app/routes/resources.account.session-duration/route.tsx new file mode 100644 index 00000000000..1b114f1d4bb --- /dev/null +++ b/apps/webapp/app/routes/resources.account.session-duration/route.tsx @@ -0,0 +1,77 @@ +import { redirect, type ActionFunctionArgs } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { + commitSession as commitMessageSession, + getSession as getMessageSession, + setErrorMessage, + setSuccessMessage, +} from "~/models/message.server"; +import { requireUserId } from "~/services/session.server"; +import { + commitAuthenticatedSession, + getAllowedSessionOptions, + getEffectiveSessionDuration, + isAllowedSessionDuration, +} from "~/services/sessionDuration.server"; +import { getUserSession } from "~/services/sessionStorage.server"; + +const FormSchema = z.object({ + sessionDuration: z.coerce.number().int().positive(), +}); + +const REDIRECT_PATH = "/account/security"; + +async function redirectWithError(request: Request, message: string) { + const messageSession = await getMessageSession(request.headers.get("cookie")); + setErrorMessage(messageSession, message); + return redirect(REDIRECT_PATH, { + headers: { "Set-Cookie": await commitMessageSession(messageSession) }, + }); +} + +export async function action({ request }: ActionFunctionArgs) { + const userId = await requireUserId(request); + + const formData = await request.formData(); + const parsed = FormSchema.safeParse(Object.fromEntries(formData)); + + if (!parsed.success) { + return redirectWithError(request, "Invalid session duration value."); + } + + const { sessionDuration } = parsed.data; + + if (!isAllowedSessionDuration(sessionDuration)) { + return redirectWithError(request, "Invalid session duration value."); + } + + const { orgCapSeconds, durationSeconds } = await getEffectiveSessionDuration(userId); + const allowed = getAllowedSessionOptions(orgCapSeconds, durationSeconds); + if (!allowed.some((o) => o.value === sessionDuration)) { + return redirectWithError( + request, + "Your organization's policy does not allow that session duration." + ); + } + + await prisma.user.update({ + where: { id: userId }, + data: { sessionDuration }, + }); + + // Re-issue the cookie with the new maxAge and reset issuedAt so the user + // gets a fresh window matching their new selection right away. + const authSession = await getUserSession(request); + const authCookie = await commitAuthenticatedSession(authSession); + + const messageSession = await getMessageSession(request.headers.get("cookie")); + setSuccessMessage(messageSession, "Session duration updated."); + const messageCookie = await commitMessageSession(messageSession); + + const headers = new Headers(); + headers.append("Set-Cookie", authCookie); + headers.append("Set-Cookie", messageCookie); + + return redirect(REDIRECT_PATH, { headers }); +} diff --git a/apps/webapp/app/services/session.server.ts b/apps/webapp/app/services/session.server.ts index ea6831265c7..c471b1f45c4 100644 --- a/apps/webapp/app/services/session.server.ts +++ b/apps/webapp/app/services/session.server.ts @@ -1,7 +1,59 @@ import { redirect } from "@remix-run/node"; +import { $replica } from "~/db.server"; import { getUserById } from "~/models/user.server"; +import { sanitizeRedirectPath } from "~/utils"; +import { extractClientIp } from "~/utils/extractClientIp.server"; import { authenticator } from "./auth.server"; import { getImpersonationId } from "./impersonation.server"; +import { logger } from "./logger.server"; +import { + getEffectiveSessionDuration, + getSessionIssuedAt, + isSessionExpired, +} from "./sessionDuration.server"; +import { getUserSession } from "./sessionStorage.server"; + +/** + * Enforces the user's effective session duration (User.sessionDuration capped + * by the most restrictive Organization.maxSessionDuration). If the session was + * issued longer ago than the cap allows, throws a redirect to `/logout` and + * emits a HIPAA audit log. `userId` is always the *session owner's* id (i.e. + * the real authenticated user), not an impersonated one — because the cap + * belongs to the cookie, not the impersonation target. + */ +async function enforceSessionExpiry( + request: Request, + userId: string, + impersonatedUserId: string | null = null +): Promise { + const session = await getUserSession(request); + // Hot path: every authenticated request runs this. Read from the replica + // when one is configured (falls back to primary). Stale-by-replica-lag is + // acceptable here because the worst case is a session living a few seconds + // past its cap on the very first request after a cap change. + const { durationSeconds, orgCapSeconds, cappingOrgId, userSettingSeconds } = + await getEffectiveSessionDuration(userId, $replica); + if (!isSessionExpired(session, durationSeconds)) return; + + const issuedAt = getSessionIssuedAt(session); + // HIPAA audit trail: structured log lands in CloudWatch via stdout. Use + // the stable `event` field to filter/aggregate auto-logout events. + // `sourceIp` uses ALB's appended (last) X-Forwarded-For element, not the + // first one, since the leading element is client-supplied and spoofable. + logger.info("Auto-logout: session exceeded effective duration", { + event: "session.auto_logout", + userId, + impersonatedUserId, + cappingOrgId, + effectiveDurationSeconds: durationSeconds, + userSettingSeconds, + orgCapSeconds, + sessionAgeMs: issuedAt === null ? null : Date.now() - issuedAt, + requestPath: new URL(request.url).pathname, + sourceIp: extractClientIp(request.headers.get("x-forwarded-for")), + }); + throw redirect("/logout"); +} export async function getUserId(request: Request): Promise { const impersonatedUserId = await getImpersonationId(request); @@ -12,15 +64,25 @@ export async function getUserId(request: Request): Promise { if (authUser?.userId) { const realUser = await getUserById(authUser.userId); if (realUser?.admin) { + // Enforce expiry against the admin's own session — impersonation must + // not be a way to bypass the admin's effective duration cap. + await enforceSessionExpiry(request, authUser.userId, impersonatedUserId); return impersonatedUserId; } } - // Admin revoked or session invalid — fall through to return the real user's ID + // Admin revoked or session invalid — fall through to return the real + // user's ID. Same enforcement as the regular auth path below. + if (authUser?.userId) { + await enforceSessionExpiry(request, authUser.userId); + } return authUser?.userId; } - let authUser = await authenticator.isAuthenticated(request); - return authUser?.userId; + const authUser = await authenticator.isAuthenticated(request); + if (!authUser?.userId) return undefined; + + await enforceSessionExpiry(request, authUser.userId); + return authUser.userId; } export async function getUser(request: Request) { @@ -37,9 +99,11 @@ export async function requireUserId(request: Request, redirectTo?: string) { const userId = await getUserId(request); if (!userId) { const url = new URL(request.url); - const searchParams = new URLSearchParams([ - ["redirectTo", redirectTo ?? `${url.pathname}${url.search}`], - ]); + // Only propagate the originating URL when it's a real user-navigable page. + // Fetcher endpoints (e.g. /resources/*) and auth callbacks would render + // blank or loop if used as a post-login destination. + const finalRedirectTo = sanitizeRedirectPath(redirectTo ?? `${url.pathname}${url.search}`); + const searchParams = new URLSearchParams([["redirectTo", finalRedirectTo]]); throw redirect(`/login?${searchParams}`); } return userId; diff --git a/apps/webapp/app/services/sessionDuration.server.ts b/apps/webapp/app/services/sessionDuration.server.ts new file mode 100644 index 00000000000..da1636ebba3 --- /dev/null +++ b/apps/webapp/app/services/sessionDuration.server.ts @@ -0,0 +1,214 @@ +import type { Session } from "@remix-run/node"; +import type { PrismaClientOrTransaction } from "@trigger.dev/database"; +import { prisma } from "~/db.server"; +import { commitSession, DEFAULT_SESSION_DURATION_SECONDS } from "./sessionStorage.server"; + +export { DEFAULT_SESSION_DURATION_SECONDS }; + +export const SESSION_ISSUED_AT_KEY = "session:issuedAt"; + +// Months and years use standard Gregorian-calendar conversions (365.2425 days/yr, +// 30.436875 days/month) so values produced by external "X months in seconds" +// calculators map cleanly to a labeled option. +const GREGORIAN_HALF_YEAR_SECONDS = 15_778_476; + +export type SessionDurationOption = { + value: number; + label: string; +}; + +export const SESSION_DURATION_OPTIONS: SessionDurationOption[] = [ + { value: 60 * 5, label: "5 minutes" }, + { value: 60 * 30, label: "30 minutes" }, + { value: 60 * 60, label: "1 hour" }, + { value: 60 * 60 * 24, label: "1 day" }, + { value: 60 * 60 * 24 * 30, label: "30 days" }, + { value: GREGORIAN_HALF_YEAR_SECONDS, label: "6 months" }, + { value: DEFAULT_SESSION_DURATION_SECONDS, label: "1 year" }, +]; + +export const ALLOWED_SESSION_DURATION_VALUES: ReadonlySet = new Set( + SESSION_DURATION_OPTIONS.map((o) => o.value) +); + +export function isAllowedSessionDuration(value: number): boolean { + return ALLOWED_SESSION_DURATION_VALUES.has(value); +} + +export type OrganizationSessionCap = { + /** The org cap in seconds. */ + orgCapSeconds: number; + /** The id of the org whose cap is currently the most restrictive. */ + cappingOrgId: string; +}; + +/** + * Returns the most restrictive max session duration across the user's orgs + * along with the id of the org that owns it, ignoring orgs where the cap is + * null. Returns null when no org has set a cap. + */ +export async function getOrganizationSessionCap( + userId: string, + client: PrismaClientOrTransaction = prisma +): Promise { + const tightest = await client.organization.findFirst({ + where: { + members: { some: { userId } }, + maxSessionDuration: { not: null }, + deletedAt: null, + }, + orderBy: { maxSessionDuration: "asc" }, + select: { id: true, maxSessionDuration: true }, + }); + if (!tightest || tightest.maxSessionDuration === null) return null; + return { orgCapSeconds: tightest.maxSessionDuration, cappingOrgId: tightest.id }; +} + +export type EffectiveSessionDuration = { + /** Effective session duration in seconds = min(user.sessionDuration, orgCap?). */ + durationSeconds: number; + /** The org cap in seconds, or null if no org caps the user. */ + orgCapSeconds: number | null; + /** The id of the org whose cap is currently in effect, or null. */ + cappingOrgId: string | null; + /** The raw user setting in seconds. */ + userSettingSeconds: number; +}; + +/** + * Computes the effective session duration for a user by combining their + * configured `User.sessionDuration` with the most restrictive cap across + * their organizations. + */ +export async function getEffectiveSessionDuration( + userId: string, + client: PrismaClientOrTransaction = prisma +): Promise { + const [user, orgCap] = await Promise.all([ + client.user.findFirst({ + where: { id: userId }, + select: { sessionDuration: true }, + }), + getOrganizationSessionCap(userId, client), + ]); + + const userSettingSeconds = user?.sessionDuration ?? DEFAULT_SESSION_DURATION_SECONDS; + const durationSeconds = + orgCap === null ? userSettingSeconds : Math.min(userSettingSeconds, orgCap.orgCapSeconds); + + return { + durationSeconds, + orgCapSeconds: orgCap?.orgCapSeconds ?? null, + cappingOrgId: orgCap?.cappingOrgId ?? null, + userSettingSeconds, + }; +} + +/** + * Returns the dropdown options the user is allowed to pick. Options strictly + * greater than the org cap are removed. + * + * `currentValueSeconds` should be the *effective* (clamped) duration — i.e. + * `EffectiveSessionDuration.durationSeconds`, which is guaranteed to be ≤ + * `orgCapSeconds`. Passing the clamped value makes the dropdown's selected + * option reflect what's actually in effect rather than the user's stored + * preference, which is the right UX when a stricter org cap supersedes a + * larger user setting (the raw user preference stays in the DB and is + * restored automatically if the cap is later removed). + * + * The tag-along branch below — appending `currentValueSeconds` to the option + * list when it isn't already present — is now defensive only. It exists so + * that any caller passing an out-of-range value (e.g. tests, or future + * callers wanting to surface the raw user preference) still gets a renderable + * form, rather than a dropdown whose `defaultValue` matches no option. + */ +export function getAllowedSessionOptions( + orgCapSeconds: number | null, + currentValueSeconds: number +): SessionDurationOption[] { + const allowed = SESSION_DURATION_OPTIONS.filter((opt) => { + if (orgCapSeconds === null) return true; + return opt.value <= orgCapSeconds; + }); + + if (!allowed.some((o) => o.value === currentValueSeconds)) { + const currentLabel = + SESSION_DURATION_OPTIONS.find((o) => o.value === currentValueSeconds)?.label ?? + `${currentValueSeconds} seconds`; + allowed.push({ value: currentValueSeconds, label: currentLabel }); + allowed.sort((a, b) => a.value - b.value); + } + + return allowed; +} + +export function getSessionIssuedAt(session: Session): number | null { + const raw = session.get(SESSION_ISSUED_AT_KEY); + if (typeof raw !== "number" || !Number.isFinite(raw)) return null; + return raw; +} + +/** + * Returns true when the session has an issuedAt timestamp older than the + * effective duration. Missing issuedAt is treated as not expired (legacy + * cookies from before this feature shipped will be lazily backfilled). + */ +export function isSessionExpired( + session: Session, + effectiveDurationSeconds: number, + now: number = Date.now() +): boolean { + const issuedAt = getSessionIssuedAt(session); + if (issuedAt === null) return false; + return now - issuedAt > effectiveDurationSeconds * 1000; +} + +/** Sets the session's issuedAt to `now` (epoch ms). */ +export function setSessionIssuedAt(session: Session, now: number = Date.now()): void { + session.set(SESSION_ISSUED_AT_KEY, now); +} + +/** + * If the session has no issuedAt set, sets it to `now` and returns true so the + * caller knows to commit the cookie. Returns false when nothing changed. + */ +export function ensureSessionIssuedAt(session: Session, now: number = Date.now()): boolean { + if (getSessionIssuedAt(session) !== null) return false; + setSessionIssuedAt(session, now); + return true; +} + +/** + * Commits the session for an authenticated user, setting `issuedAt = now`. + * Use this at every login/MFA-completion point so the session window starts + * fresh. + * + * The auth cookie's `Max-Age` is intentionally long + * (`DEFAULT_SESSION_DURATION_SECONDS`, 1 year) so the cookie always reaches + * the server. Actual session expiry is enforced server-side via + * `sessionIssuedAt` against the user's effective duration. If we let the + * cookie expire client-side, the user is silently logged out. + */ +export async function commitAuthenticatedSession( + session: Session, + now: number = Date.now() +): Promise { + setSessionIssuedAt(session, now); + return commitSession(session, { maxAge: DEFAULT_SESSION_DURATION_SECONDS }); +} + +/** + * Lazily backfills `issuedAt` on legacy auth sessions that predate the + * sessionDuration feature. Returns the cookie string when a backfill happened + * (caller must append it to the response's `Set-Cookie` headers), or `null` + * when the session already had `issuedAt` set — avoiding an unnecessary + * Set-Cookie on every authenticated page load and preventing the cookie's + * 1-year Max-Age from rolling forward indefinitely. + */ +export async function commitAuthenticatedSessionLazy( + session: Session, + now: number = Date.now() +): Promise { + if (!ensureSessionIssuedAt(session, now)) return null; + return commitSession(session, { maxAge: DEFAULT_SESSION_DURATION_SECONDS }); +} diff --git a/apps/webapp/app/services/sessionStorage.server.ts b/apps/webapp/app/services/sessionStorage.server.ts index 3bb9a51fa7e..c54561d647b 100644 --- a/apps/webapp/app/services/sessionStorage.server.ts +++ b/apps/webapp/app/services/sessionStorage.server.ts @@ -1,15 +1,22 @@ import { createCookieSessionStorage } from "@remix-run/node"; import { env } from "~/env.server"; +// Canonical "1 year in seconds", using Gregorian calendar conversion +// (365.2425 * 86400) so it matches the labeled "1 year" dropdown option in +// SESSION_DURATION_OPTIONS exactly. This is the cookie's hard upper-bound +// lifetime; the actual per-session value is enforced server-side via +// `sessionIssuedAt` against the user's effective duration. +export const DEFAULT_SESSION_DURATION_SECONDS = 31_556_952; + export const sessionStorage = createCookieSessionStorage({ cookie: { - name: "__session", // use any name you want here - sameSite: "lax", // this helps with CSRF - path: "/", // remember to add this so the cookie will work in all routes - httpOnly: true, // for security reasons, make this cookie http only + name: "__session", + sameSite: "lax", + path: "/", + httpOnly: true, secrets: [env.SESSION_SECRET], - secure: env.NODE_ENV === "production", // enable this in prod only - maxAge: 60 * 60 * 24 * 365, // 7 days + secure: env.NODE_ENV === "production", + maxAge: DEFAULT_SESSION_DURATION_SECONDS, }, }); diff --git a/apps/webapp/app/utils.ts b/apps/webapp/app/utils.ts index adb18292811..7551bef1b6f 100644 --- a/apps/webapp/app/utils.ts +++ b/apps/webapp/app/utils.ts @@ -3,10 +3,24 @@ import { useMatches } from "@remix-run/react"; const DEFAULT_REDIRECT = "/"; +// Pathnames that are NOT user-navigable destinations: fetcher endpoints, +// OAuth/auth callbacks, JSON APIs, the magic-link redemption route, and the +// auth flow routes themselves (which would create a redirect loop). Note +// `/admin/api/` covers admin JSON endpoints while leaving `/admin`, +// `/admin/back-office/*`, `/admin/orgs`, etc. navigable. +const NON_NAVIGABLE_PREFIXES = ["/resources/", "/auth/", "/admin/api/", "/api/", "/engine/"]; +const NON_NAVIGABLE_EXACT = new Set(["/magic", "/logout", "/login", "/login/magic", "/login/mfa"]); + +function isNavigablePath(pathname: string): boolean { + if (NON_NAVIGABLE_EXACT.has(pathname)) return false; + return !NON_NAVIGABLE_PREFIXES.some((prefix) => pathname.startsWith(prefix)); +} + /** * This should be used any time the redirect path is user-provided * (Like the query string on our login/signup pages). This avoids - * open-redirect vulnerabilities. + * open-redirect vulnerabilities and prevents redirecting users to + * non-page routes (e.g. fetcher endpoints) that would render blank. * @param {string} path The redirect destination * @param {string} defaultRedirect The redirect to use if the to is unsafe. */ @@ -28,16 +42,21 @@ export function sanitizeRedirectPath( return defaultRedirect; } catch {} + let parsed: URL; try { // ensure it's a valid relative path - const url = new URL(path, "https://example.com"); - if (url.hostname !== "example.com") { + parsed = new URL(path, "https://example.com"); + if (parsed.hostname !== "example.com") { return defaultRedirect; } } catch { return defaultRedirect; } + if (!isNavigablePath(parsed.pathname)) { + return defaultRedirect; + } + return path; } diff --git a/apps/webapp/test/sessionDuration.test.ts b/apps/webapp/test/sessionDuration.test.ts new file mode 100644 index 00000000000..5f6b9ab93c7 --- /dev/null +++ b/apps/webapp/test/sessionDuration.test.ts @@ -0,0 +1,218 @@ +import { containerTest } from "@internal/testcontainers"; +import { createCookieSessionStorage, type Session } from "@remix-run/node"; +import { describe, expect, it, vi } from "vitest"; + +vi.setConfig({ testTimeout: 60_000 }); +import { + DEFAULT_SESSION_DURATION_SECONDS, + ensureSessionIssuedAt, + getAllowedSessionOptions, + getEffectiveSessionDuration, + getOrganizationSessionCap, + isAllowedSessionDuration, + isSessionExpired, + SESSION_DURATION_OPTIONS, + SESSION_ISSUED_AT_KEY, + setSessionIssuedAt, +} from "../app/services/sessionDuration.server"; + +const oneHour = 60 * 60; +const oneDay = 60 * 60 * 24; +const oneYear = DEFAULT_SESSION_DURATION_SECONDS; + +const sessionStorage = createCookieSessionStorage({ + cookie: { name: "__test_session", secrets: ["test"] }, +}); + +async function makeEmptySession(): Promise { + return sessionStorage.getSession(); +} + +describe("isAllowedSessionDuration", () => { + it("accepts every value in the dropdown options", () => { + for (const option of SESSION_DURATION_OPTIONS) { + expect(isAllowedSessionDuration(option.value)).toBe(true); + } + }); + + it("rejects values not in the dropdown", () => { + expect(isAllowedSessionDuration(1)).toBe(false); + expect(isAllowedSessionDuration(7 * oneDay)).toBe(false); + expect(isAllowedSessionDuration(0)).toBe(false); + expect(isAllowedSessionDuration(-1)).toBe(false); + }); +}); + +describe("getAllowedSessionOptions", () => { + it("returns all options when there is no org cap", () => { + const options = getAllowedSessionOptions(null, oneYear); + expect(options).toEqual(SESSION_DURATION_OPTIONS); + }); + + it("filters out options larger than the org cap", () => { + const options = getAllowedSessionOptions(oneHour, oneHour); + expect(options.map((o) => o.value)).toEqual([60 * 5, 60 * 30, 60 * 60]); + }); + + it("includes the user's current value even when it exceeds the cap, so the form stays valid", () => { + const options = getAllowedSessionOptions(oneHour, oneYear); + expect(options.some((o) => o.value === oneYear)).toBe(true); + expect(options.some((o) => o.value === oneHour)).toBe(true); + }); + + it("does not duplicate the current value when it is already within the cap", () => { + const options = getAllowedSessionOptions(oneDay, oneHour); + const oneHourCount = options.filter((o) => o.value === oneHour).length; + expect(oneHourCount).toBe(1); + }); +}); + +describe("isSessionExpired", () => { + it("returns false when issuedAt is missing (legacy cookie)", async () => { + const session = await makeEmptySession(); + expect(isSessionExpired(session, oneHour)).toBe(false); + }); + + it("returns false when within the duration window", async () => { + const session = await makeEmptySession(); + const now = 1_000_000_000_000; + setSessionIssuedAt(session, now - 60 * 1000); + expect(isSessionExpired(session, oneHour, now)).toBe(false); + }); + + it("returns true when older than the duration window", async () => { + const session = await makeEmptySession(); + const now = 1_000_000_000_000; + setSessionIssuedAt(session, now - (oneHour + 1) * 1000); + expect(isSessionExpired(session, oneHour, now)).toBe(true); + }); +}); + +describe("ensureSessionIssuedAt", () => { + it("sets issuedAt and returns true when missing", async () => { + const session = await makeEmptySession(); + const now = 1_700_000_000_000; + expect(ensureSessionIssuedAt(session, now)).toBe(true); + expect(session.get(SESSION_ISSUED_AT_KEY)).toBe(now); + }); + + it("leaves issuedAt unchanged and returns false when already set", async () => { + const session = await makeEmptySession(); + const original = 1_500_000_000_000; + setSessionIssuedAt(session, original); + expect(ensureSessionIssuedAt(session, 1_700_000_000_000)).toBe(false); + expect(session.get(SESSION_ISSUED_AT_KEY)).toBe(original); + }); +}); + +async function createUser(prisma: any, email: string, sessionDuration?: number) { + return prisma.user.create({ + data: { + email, + authenticationMethod: "MAGIC_LINK", + ...(sessionDuration !== undefined ? { sessionDuration } : {}), + }, + }); +} + +async function createOrgWithMember( + prisma: any, + slug: string, + userId: string, + maxSessionDuration: number | null +) { + return prisma.organization.create({ + data: { + title: `Org ${slug}`, + slug, + maxSessionDuration, + members: { create: { userId, role: "ADMIN" } }, + }, + }); +} + +describe("getOrganizationSessionCap", () => { + containerTest("returns null when the user has no orgs with a cap set", async ({ prisma }) => { + const user = await createUser(prisma, "no-cap@test.com"); + await createOrgWithMember(prisma, "no-cap-org", user.id, null); + + const cap = await getOrganizationSessionCap(user.id, prisma); + expect(cap).toBeNull(); + }); + + containerTest( + "returns the most restrictive cap across orgs, ignoring nulls", + async ({ prisma }) => { + const user = await createUser(prisma, "multi-org@test.com"); + await createOrgWithMember(prisma, "loose-org", user.id, oneDay); + const tight = await createOrgWithMember(prisma, "tight-org", user.id, oneHour); + await createOrgWithMember(prisma, "uncapped-org", user.id, null); + + const cap = await getOrganizationSessionCap(user.id, prisma); + expect(cap).toEqual({ orgCapSeconds: oneHour, cappingOrgId: tight.id }); + } + ); + + containerTest("ignores soft-deleted organizations", async ({ prisma }) => { + const user = await createUser(prisma, "deleted-org-user@test.com"); + const tight = await createOrgWithMember(prisma, "deleted-tight", user.id, oneHour); + const loose = await createOrgWithMember(prisma, "active-loose", user.id, oneDay); + + await prisma.organization.update({ + where: { id: tight.id }, + data: { deletedAt: new Date() }, + }); + + const cap = await getOrganizationSessionCap(user.id, prisma); + expect(cap).toEqual({ orgCapSeconds: oneDay, cappingOrgId: loose.id }); + }); +}); + +describe("getEffectiveSessionDuration", () => { + containerTest( + "returns the user setting when no org cap is set", + async ({ prisma }) => { + const user = await createUser(prisma, "effective-no-cap@test.com", oneDay); + await createOrgWithMember(prisma, "effective-no-cap-org", user.id, null); + + const result = await getEffectiveSessionDuration(user.id, prisma); + expect(result.userSettingSeconds).toBe(oneDay); + expect(result.orgCapSeconds).toBeNull(); + expect(result.cappingOrgId).toBeNull(); + expect(result.durationSeconds).toBe(oneDay); + } + ); + + containerTest("caps the user setting at the most restrictive org cap", async ({ prisma }) => { + const user = await createUser(prisma, "effective-capped@test.com", oneYear); + const org = await createOrgWithMember(prisma, "effective-capped-org", user.id, oneHour); + + const result = await getEffectiveSessionDuration(user.id, prisma); + expect(result.userSettingSeconds).toBe(oneYear); + expect(result.orgCapSeconds).toBe(oneHour); + expect(result.cappingOrgId).toBe(org.id); + expect(result.durationSeconds).toBe(oneHour); + }); + + containerTest( + "returns the user setting when it is already smaller than the org cap", + async ({ prisma }) => { + const user = await createUser(prisma, "effective-user-smaller@test.com", 60 * 5); + await createOrgWithMember(prisma, "effective-user-smaller-org", user.id, oneHour); + + const result = await getEffectiveSessionDuration(user.id, prisma); + expect(result.durationSeconds).toBe(60 * 5); + } + ); + + containerTest( + "uses the default when the user has no row (defensive fallback)", + async ({ prisma }) => { + const result = await getEffectiveSessionDuration("nonexistent-user-id", prisma); + expect(result.userSettingSeconds).toBe(DEFAULT_SESSION_DURATION_SECONDS); + expect(result.orgCapSeconds).toBeNull(); + expect(result.cappingOrgId).toBeNull(); + expect(result.durationSeconds).toBe(DEFAULT_SESSION_DURATION_SECONDS); + } + ); +}); diff --git a/internal-packages/database/prisma/migrations/20260428140746_add_session_duration_columns/migration.sql b/internal-packages/database/prisma/migrations/20260428140746_add_session_duration_columns/migration.sql new file mode 100644 index 00000000000..f63a5d6d244 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260428140746_add_session_duration_columns/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "public"."User" ADD COLUMN "sessionDuration" INTEGER NOT NULL DEFAULT 31556952; + +-- AlterTable +ALTER TABLE "public"."Organization" ADD COLUMN "maxSessionDuration" INTEGER; diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index ee75ce82b5f..ca79bc67b5d 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -54,6 +54,10 @@ model User { /// Hash of the last used code to prevent replay attacks mfaLastUsedCode String? + /// Maximum session lifetime in seconds for this user. Default = 1 year (Gregorian). + /// May be further restricted by Organization.maxSessionDuration on any of the user's orgs. + sessionDuration Int @default(31556952) + invitationCode InvitationCode? @relation(fields: [invitationCodeId], references: [id]) invitationCodeId String? personalAccessTokens PersonalAccessToken[] @@ -220,6 +224,11 @@ model Organization { maximumProjectCount Int @default(25) + /// Maximum session lifetime in seconds for members of this organization. + /// When set, caps each member's User.sessionDuration. The most restrictive cap across + /// all of a user's orgs (excluding nulls) wins. + maxSessionDuration Int? + projects Project[] members OrgMember[] invites OrgMemberInvite[]