From e99e3603395aaec37a44e757b6cd33ae1acf596c Mon Sep 17 00:00:00 2001 From: carlosmonastyrski Date: Mon, 28 Apr 2025 17:43:10 -0300 Subject: [PATCH] feat(user-auth): make users auth token expiration customizable for orgs --- ...6_add-org-user-token-expiration-setting.ts | 19 +++ backend/src/db/schemas/organizations.ts | 3 +- backend/src/server/routes/v1/auth-router.ts | 14 +- .../server/routes/v1/organization-router.ts | 14 +- backend/src/services/org/org-schema.ts | 3 +- backend/src/services/org/org-service.ts | 6 +- backend/src/services/org/org-types.ts | 1 + .../src/hooks/api/organization/queries.tsx | 6 +- frontend/src/hooks/api/organization/types.ts | 2 + .../components/OrgAuthTab/OrgAuthTab.tsx | 2 + .../OrgUserAccessTokenLimitSection.tsx | 158 ++++++++++++++++++ 11 files changed, 220 insertions(+), 8 deletions(-) create mode 100644 backend/src/db/migrations/20250428134716_add-org-user-token-expiration-setting.ts create mode 100644 frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgUserAccessTokenLimitSection.tsx diff --git a/backend/src/db/migrations/20250428134716_add-org-user-token-expiration-setting.ts b/backend/src/db/migrations/20250428134716_add-org-user-token-expiration-setting.ts new file mode 100644 index 000000000..96994273a --- /dev/null +++ b/backend/src/db/migrations/20250428134716_add-org-user-token-expiration-setting.ts @@ -0,0 +1,19 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.Organization, "userTokenExpiration"))) { + await knex.schema.alterTable(TableName.Organization, (t) => { + t.string("userTokenExpiration").defaultTo("30d").notNullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.Organization, "userTokenExpiration")) { + await knex.schema.alterTable(TableName.Organization, (t) => { + t.dropColumn("userTokenExpiration"); + }); + } +} diff --git a/backend/src/db/schemas/organizations.ts b/backend/src/db/schemas/organizations.ts index 902c564a7..b4331c5b1 100644 --- a/backend/src/db/schemas/organizations.ts +++ b/backend/src/db/schemas/organizations.ts @@ -28,7 +28,8 @@ export const OrganizationsSchema = z.object({ shouldUseNewPrivilegeSystem: z.boolean().default(true), privilegeUpgradeInitiatedByUsername: z.string().nullable().optional(), privilegeUpgradeInitiatedAt: z.date().nullable().optional(), - bypassOrgAuthEnabled: z.boolean().default(false) + bypassOrgAuthEnabled: z.boolean().default(false), + userTokenExpiration: z.string().default("30d") }); export type TOrganizations = z.infer; diff --git a/backend/src/server/routes/v1/auth-router.ts b/backend/src/server/routes/v1/auth-router.ts index 717c6f1b6..38934e1eb 100644 --- a/backend/src/server/routes/v1/auth-router.ts +++ b/backend/src/server/routes/v1/auth-router.ts @@ -79,6 +79,18 @@ export const registerAuthRoutes = async (server: FastifyZodProvider) => { handler: async (req) => { const { decodedToken, tokenVersion } = await server.services.authToken.validateRefreshToken(req.cookies.jid); const appCfg = getConfig(); + let expiresIn = appCfg.JWT_AUTH_LIFETIME; + if (decodedToken.organizationId) { + const org = await server.services.org.findOrganizationById( + decodedToken.userId, + decodedToken.organizationId, + decodedToken.authMethod, + decodedToken.organizationId + ); + if (org) { + expiresIn = org.userTokenExpiration; + } + } const token = jwt.sign( { @@ -92,7 +104,7 @@ export const registerAuthRoutes = async (server: FastifyZodProvider) => { mfaMethod: decodedToken.mfaMethod }, appCfg.AUTH_SECRET, - { expiresIn: appCfg.JWT_AUTH_LIFETIME } + { expiresIn } ); return { token, organizationId: decodedToken.organizationId }; diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index 22314b54b..7cde626bc 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -1,3 +1,4 @@ +import RE2 from "re2"; import { z } from "zod"; import { @@ -263,7 +264,18 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { enforceMfa: z.boolean().optional(), selectedMfaMethod: z.nativeEnum(MfaMethod).optional(), allowSecretSharingOutsideOrganization: z.boolean().optional(), - bypassOrgAuthEnabled: z.boolean().optional() + bypassOrgAuthEnabled: z.boolean().optional(), + userTokenExpiration: z + .string() + .regex(new RE2(/^\d+[mhdw]$/), "Must be a number followed by m, h, d, or w") + .refine( + (val) => { + const numericPart = val.slice(0, -1); + return parseInt(numericPart, 10) >= 1; + }, + { message: "Duration value must be at least 1" } + ) + .optional() }), response: { 200: z.object({ diff --git a/backend/src/services/org/org-schema.ts b/backend/src/services/org/org-schema.ts index 2aa793c04..5a1a4c333 100644 --- a/backend/src/services/org/org-schema.ts +++ b/backend/src/services/org/org-schema.ts @@ -17,5 +17,6 @@ export const sanitizedOrganizationSchema = OrganizationsSchema.pick({ shouldUseNewPrivilegeSystem: true, privilegeUpgradeInitiatedByUsername: true, privilegeUpgradeInitiatedAt: true, - bypassOrgAuthEnabled: true + bypassOrgAuthEnabled: true, + userTokenExpiration: true }); diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 3a6373575..4b7192b99 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -350,7 +350,8 @@ export const orgServiceFactory = ({ enforceMfa, selectedMfaMethod, allowSecretSharingOutsideOrganization, - bypassOrgAuthEnabled + bypassOrgAuthEnabled, + userTokenExpiration } }: TUpdateOrgDTO) => { const appCfg = getConfig(); @@ -451,7 +452,8 @@ export const orgServiceFactory = ({ enforceMfa, selectedMfaMethod, allowSecretSharingOutsideOrganization, - bypassOrgAuthEnabled + bypassOrgAuthEnabled, + userTokenExpiration }); if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` }); return org; diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index 8a1698015..702cd25bf 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -74,6 +74,7 @@ export type TUpdateOrgDTO = { selectedMfaMethod: MfaMethod; allowSecretSharingOutsideOrganization: boolean; bypassOrgAuthEnabled: boolean; + userTokenExpiration: string; }>; } & TOrgPermission; diff --git a/frontend/src/hooks/api/organization/queries.tsx b/frontend/src/hooks/api/organization/queries.tsx index 902bb6b09..06125b1d7 100644 --- a/frontend/src/hooks/api/organization/queries.tsx +++ b/frontend/src/hooks/api/organization/queries.tsx @@ -111,7 +111,8 @@ export const useUpdateOrg = () => { enforceMfa, selectedMfaMethod, allowSecretSharingOutsideOrganization, - bypassOrgAuthEnabled + bypassOrgAuthEnabled, + userTokenExpiration }) => { return apiRequest.patch(`/api/v1/organization/${orgId}`, { name, @@ -122,7 +123,8 @@ export const useUpdateOrg = () => { enforceMfa, selectedMfaMethod, allowSecretSharingOutsideOrganization, - bypassOrgAuthEnabled + bypassOrgAuthEnabled, + userTokenExpiration }); }, onSuccess: () => { diff --git a/frontend/src/hooks/api/organization/types.ts b/frontend/src/hooks/api/organization/types.ts index e0687922d..6f63d003e 100644 --- a/frontend/src/hooks/api/organization/types.ts +++ b/frontend/src/hooks/api/organization/types.ts @@ -18,6 +18,7 @@ export type Organization = { selectedMfaMethod?: MfaMethod; shouldUseNewPrivilegeSystem: boolean; allowSecretSharingOutsideOrganization?: boolean; + userTokenExpiration?: string; userRole: string; }; @@ -32,6 +33,7 @@ export type UpdateOrgDTO = { selectedMfaMethod?: MfaMethod; allowSecretSharingOutsideOrganization?: boolean; bypassOrgAuthEnabled?: boolean; + userTokenExpiration?: string; }; export type BillingDetails = { diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgAuthTab.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgAuthTab.tsx index 40d0e7840..9e542d821 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgAuthTab.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgAuthTab.tsx @@ -22,6 +22,7 @@ import { OrgLDAPSection } from "./OrgLDAPSection"; import { OrgOIDCSection } from "./OrgOIDCSection"; import { OrgScimSection } from "./OrgSCIMSection"; import { OrgSSOSection } from "./OrgSSOSection"; +import { OrgUserAccessTokenLimitSection } from "./OrgUserAccessTokenLimitSection"; import { SSOModal } from "./SSOModal"; export const OrgAuthTab = withPermission( @@ -166,6 +167,7 @@ export const OrgAuthTab = withPermission( return ( <> + {shouldShowCreateIdentityProviderView ? ( createIdentityProviderView ) : ( diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgUserAccessTokenLimitSection.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgUserAccessTokenLimitSection.tsx new file mode 100644 index 000000000..a4d074819 --- /dev/null +++ b/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgUserAccessTokenLimitSection.tsx @@ -0,0 +1,158 @@ +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Input, Select, SelectItem } from "@app/components/v2"; +import { useOrganization } from "@app/context"; +import { useUpdateOrg } from "@app/hooks/api"; + +const formSchema = z.object({ + expirationValue: z.number().min(1, "Value must be at least 1"), + expirationUnit: z.enum(["m", "h", "d", "w"], { + invalid_type_error: "Please select a valid time unit" + }) +}); + +type TForm = z.infer; + +// Function to parse duration string like "30d" into value and unit +const parseDuration = (duration: string): { value: number; unit: string } => { + const match = duration.match(/^(\d+)([mhdw])$/); + if (match) { + return { + value: parseInt(match[1], 10), + unit: match[2] + }; + } + // Default to 30 days if invalid format + return { value: 30, unit: "d" }; +}; + +// Function to format value and unit back to duration string +const formatDuration = (value: number, unit: string): string => { + return `${value}${unit}`; +}; + +export const OrgUserAccessTokenLimitSection = () => { + const { mutateAsync: updateUserTokenExpiration } = useUpdateOrg(); + const { currentOrg } = useOrganization(); + + // Parse the current duration or use default + const currentDuration = parseDuration(currentOrg?.userTokenExpiration || "30d"); + + const { + control, + formState: { isSubmitting, isDirty }, + handleSubmit + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + expirationValue: currentDuration.value, + expirationUnit: currentDuration.unit as "m" | "h" | "d" | "w" + } + }); + + if (!currentOrg) return null; + + const handleUserTokenExpirationSubmit = async (formData: TForm) => { + try { + const userTokenExpiration = formatDuration(formData.expirationValue, formData.expirationUnit); + + await updateUserTokenExpiration({ + userTokenExpiration, + orgId: currentOrg.id + }); + + createNotification({ + text: "Successfully updated user token expiration", + type: "success" + }); + } catch { + createNotification({ + text: "Failed updating user token expiration", + type: "error" + }); + } + }; + + // Units for the dropdown with readable labels + const timeUnits = [ + { value: "m", label: "Minutes" }, + { value: "h", label: "Hours" }, + { value: "d", label: "Days" }, + { value: "w", label: "Weeks" } + ]; + + return ( +
+
+

User Token Expiration

+
+

+ This defines the maximum time a user token will be valid. After this time, the user will + need to re-authenticate. +

+
+
+
+ ( + + field.onChange(parseInt(e.target.value, 10) || 1)} + /> + + )} + /> +
+
+ ( + + + + )} + /> +
+
+ +
+
+ ); +};