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..3f24e4f2c --- /dev/null +++ b/backend/src/db/migrations/20250428134716_add-org-user-token-expiration-setting.ts @@ -0,0 +1,27 @@ +import { Knex } from "knex"; + +import { getConfig } from "@app/lib/config/env"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const appCfg = getConfig(); + const tokenDuration = appCfg?.JWT_REFRESH_LIFETIME; + + if (!(await knex.schema.hasColumn(TableName.Organization, "userTokenExpiration"))) { + await knex.schema.alterTable(TableName.Organization, (t) => { + t.string("userTokenExpiration"); + }); + if (tokenDuration) { + await knex(TableName.Organization).update({ userTokenExpiration: tokenDuration }); + } + } +} + +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..bc6f0b7af 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().nullable().optional() }); export type TOrganizations = z.infer; diff --git a/backend/src/lib/fn/index.ts b/backend/src/lib/fn/index.ts index 82a4c4914..ae704cf9f 100644 --- a/backend/src/lib/fn/index.ts +++ b/backend/src/lib/fn/index.ts @@ -6,4 +6,5 @@ export * from "./array"; export * from "./dates"; export * from "./object"; export * from "./string"; +export * from "./time"; export * from "./undefined"; diff --git a/backend/src/lib/fn/time.ts b/backend/src/lib/fn/time.ts new file mode 100644 index 000000000..27bd8f8a6 --- /dev/null +++ b/backend/src/lib/fn/time.ts @@ -0,0 +1,21 @@ +import ms, { StringValue } from "ms"; + +const convertToMilliseconds = (exp: string | number): number => { + if (typeof exp === "number") { + return exp * 1000; + } + + const result = ms(exp as StringValue); + if (typeof result !== "number") { + throw new Error(`Invalid expiration format: ${exp}`); + } + + return result; +}; + +export const getMinExpiresIn = (exp1: string | number, exp2: string | number): string | number => { + const ms1 = convertToMilliseconds(exp1); + const ms2 = convertToMilliseconds(exp2); + + return ms1 <= ms2 ? exp1 : exp2; +}; diff --git a/backend/src/server/routes/v1/auth-router.ts b/backend/src/server/routes/v1/auth-router.ts index 717c6f1b6..7231ce85c 100644 --- a/backend/src/server/routes/v1/auth-router.ts +++ b/backend/src/server/routes/v1/auth-router.ts @@ -2,6 +2,7 @@ import jwt from "jsonwebtoken"; import { z } from "zod"; import { getConfig } from "@app/lib/config/env"; +import { getMinExpiresIn } from "@app/lib/fn"; import { authRateLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode, AuthTokenType } from "@app/services/auth/auth-type"; @@ -79,6 +80,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: string | number = appCfg.JWT_AUTH_LIFETIME; + if (decodedToken.organizationId) { + const org = await server.services.org.findOrganizationById( + decodedToken.userId, + decodedToken.organizationId, + decodedToken.authMethod, + decodedToken.organizationId + ); + if (org && org.userTokenExpiration) { + expiresIn = getMinExpiresIn(appCfg.JWT_AUTH_LIFETIME, org.userTokenExpiration); + } + } const token = jwt.sign( { @@ -92,7 +105,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..da1a251ff 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() + .refine((val) => new RE2(/^\d+[mhdw]$/).test(val), "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/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index 0f8ba5176..d1b0a550d 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -12,7 +12,7 @@ import { generateSrpServerKey, srpCheckClientProof } from "@app/lib/crypto"; import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { getUserPrivateKey } from "@app/lib/crypto/srp"; import { BadRequestError, DatabaseError, ForbiddenRequestError, UnauthorizedError } from "@app/lib/errors"; -import { removeTrailingSlash } from "@app/lib/fn"; +import { getMinExpiresIn, removeTrailingSlash } from "@app/lib/fn"; import { logger } from "@app/lib/logger"; import { getUserAgentType } from "@app/server/plugins/audit-log"; import { getServerCfg } from "@app/services/super-admin/super-admin-service"; @@ -143,6 +143,17 @@ export const authLoginServiceFactory = ({ ); if (!tokenSession) throw new Error("Failed to create token"); + let tokenSessionExpiresIn: string | number = cfg.JWT_AUTH_LIFETIME; + let refreshTokenExpiresIn: string | number = cfg.JWT_REFRESH_LIFETIME; + + if (organizationId) { + const org = await orgDAL.findById(organizationId); + if (org && org.userTokenExpiration) { + tokenSessionExpiresIn = getMinExpiresIn(cfg.JWT_AUTH_LIFETIME, org.userTokenExpiration); + refreshTokenExpiresIn = org.userTokenExpiration; + } + } + const accessToken = jwt.sign( { authMethod, @@ -155,7 +166,7 @@ export const authLoginServiceFactory = ({ mfaMethod }, cfg.AUTH_SECRET, - { expiresIn: cfg.JWT_AUTH_LIFETIME } + { expiresIn: tokenSessionExpiresIn } ); const refreshToken = jwt.sign( @@ -170,7 +181,7 @@ export const authLoginServiceFactory = ({ mfaMethod }, cfg.AUTH_SECRET, - { expiresIn: cfg.JWT_REFRESH_LIFETIME } + { expiresIn: refreshTokenExpiresIn } ); return { access: accessToken, refresh: refreshToken }; diff --git a/backend/src/services/auth/auth-signup-service.ts b/backend/src/services/auth/auth-signup-service.ts index a652c2a5b..58ba9186e 100644 --- a/backend/src/services/auth/auth-signup-service.ts +++ b/backend/src/services/auth/auth-signup-service.ts @@ -10,6 +10,7 @@ import { getConfig } from "@app/lib/config/env"; import { infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { generateUserSrpKeys, getUserPrivateKey } from "@app/lib/crypto/srp"; import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { getMinExpiresIn } from "@app/lib/fn"; import { isDisposableEmail } from "@app/lib/validator"; import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; @@ -46,7 +47,7 @@ type TAuthSignupDep = { projectDAL: Pick; projectBotDAL: Pick; groupProjectDAL: Pick; - orgService: Pick; + orgService: Pick; orgDAL: TOrgDALFactory; tokenService: TAuthTokenServiceFactory; smtpService: TSmtpService; @@ -320,6 +321,17 @@ export const authSignupServiceFactory = ({ projectBotDAL }); + let tokenSessionExpiresIn: string | number = appCfg.JWT_AUTH_LIFETIME; + let refreshTokenExpiresIn: string | number = appCfg.JWT_REFRESH_LIFETIME; + + if (organizationId) { + const org = await orgService.findOrganizationById(user.id, organizationId, authMethod, organizationId); + if (org && org.userTokenExpiration) { + tokenSessionExpiresIn = getMinExpiresIn(appCfg.JWT_AUTH_LIFETIME, org.userTokenExpiration); + refreshTokenExpiresIn = org.userTokenExpiration; + } + } + const tokenSession = await tokenService.getUserTokenSession({ userAgent, ip, @@ -337,7 +349,7 @@ export const authSignupServiceFactory = ({ organizationId }, appCfg.AUTH_SECRET, - { expiresIn: appCfg.JWT_AUTH_LIFETIME } + { expiresIn: tokenSessionExpiresIn } ); const refreshToken = jwt.sign( @@ -350,7 +362,7 @@ export const authSignupServiceFactory = ({ organizationId }, appCfg.AUTH_SECRET, - { expiresIn: appCfg.JWT_REFRESH_LIFETIME } + { expiresIn: refreshTokenExpiresIn } ); return { user: updateduser.info, accessToken, refreshToken, organizationId }; 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..060a01634 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -170,8 +170,12 @@ export const orgServiceFactory = ({ actorOrgId: string | undefined ) => { await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); + const appCfg = getConfig(); const org = await orgDAL.findOrgById(orgId); if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` }); + if (!org.userTokenExpiration) { + return { ...org, userTokenExpiration: appCfg.JWT_REFRESH_LIFETIME }; + } return org; }; /* @@ -350,7 +354,8 @@ export const orgServiceFactory = ({ enforceMfa, selectedMfaMethod, allowSecretSharingOutsideOrganization, - bypassOrgAuthEnabled + bypassOrgAuthEnabled, + userTokenExpiration } }: TUpdateOrgDTO) => { const appCfg = getConfig(); @@ -451,7 +456,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/docs/documentation/platform/organization.mdx b/docs/documentation/platform/organization.mdx index f1c62ff37..3a53484fb 100644 --- a/docs/documentation/platform/organization.mdx +++ b/docs/documentation/platform/organization.mdx @@ -27,6 +27,10 @@ The **Settings** page lets you manage information about your organization includ ![organization settings auth](../../images/platform/organization/organization-settings-auth.png) + + You can adjust the maximum time a user token will remain valid for your organization. After this period, users will be required to re-authenticate. This helps improve security by enforcing regular sign-ins. + + ## Access Control The **Access Control** page is where you can manage identities (both people and machines) that are part of your organization. diff --git a/docs/images/platform/organization/organization-settings-auth.png b/docs/images/platform/organization/organization-settings-auth.png index ca2340e9f..fd7a946e0 100644 Binary files a/docs/images/platform/organization/organization-settings-auth.png and b/docs/images/platform/organization/organization-settings-auth.png differ 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 bf40c7484..05d105192 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgAuthTab.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgAuthTab.tsx @@ -23,6 +23,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( @@ -167,6 +168,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..43e72bd41 --- /dev/null +++ b/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgUserAccessTokenLimitSection.tsx @@ -0,0 +1,171 @@ +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { Button, FormControl, Input, Select, SelectItem } from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects, 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. +

+ + {(isAllowed) => ( +
+
+
+ ( + + field.onChange(parseInt(e.target.value, 10))} + disabled={!isAllowed} + /> + + )} + /> +
+
+ ( + + + + )} + /> +
+
+ +
+ )} +
+
+ ); +};