-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
feat(webapp): app auto session logout #3473
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
2ee9047
0783ff7
62fecbc
6082525
4237c5d
fa06144
31595ee
d7d6b2b
dffeec0
c294c50
05095c5
abd7562
9234800
bad8895
309742e
7ca42aa
44d55cd
2e0d248
b2ca91e
bca5ec3
4fa011d
411ad27
723b13f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,70 @@ | ||||||||||||||||||||||||||||||||||||||
| 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 }); | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+41
to
+44
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Malformed JSON currently escapes as a 500.
🛡️ Proposed fix- const parseResult = RequestBodySchema.safeParse(await request.json());
+ let rawBody: unknown;
+ try {
+ rawBody = await request.json();
+ } catch {
+ return json(
+ { success: false, errors: { formErrors: ["Request body must be valid JSON"], fieldErrors: {} } },
+ { status: 400 }
+ );
+ }
+
+ const parseResult = RequestBodySchema.safeParse(rawBody);📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
| const body = parseResult.data; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| const organization = await prisma.organization.update({ | ||||||||||||||||||||||||||||||||||||||
| where: { id: organizationId }, | ||||||||||||||||||||||||||||||||||||||
| data: { maxSessionDuration: body.maxSessionDuration }, | ||||||||||||||||||||||||||||||||||||||
| select: { id: true, slug: true, maxSessionDuration: true }, | ||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||
|
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 on lines
+58
to
+66
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Recompute the tightest cap across all memberships in this bulk update. This query only uses 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| return json({ success: true, organization }); | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.