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 index 96994273a..9e06f18ef 100644 --- 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 @@ -1,12 +1,16 @@ 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(); if (!(await knex.schema.hasColumn(TableName.Organization, "userTokenExpiration"))) { await knex.schema.alterTable(TableName.Organization, (t) => { - t.string("userTokenExpiration").defaultTo("30d").notNullable(); + t.string("userTokenExpiration"); }); + await knex(TableName.Organization).update({ userTokenExpiration: appCfg.JWT_REFRESH_LIFETIME }); } } diff --git a/backend/src/db/schemas/organizations.ts b/backend/src/db/schemas/organizations.ts index b4331c5b1..bc6f0b7af 100644 --- a/backend/src/db/schemas/organizations.ts +++ b/backend/src/db/schemas/organizations.ts @@ -29,7 +29,7 @@ export const OrganizationsSchema = z.object({ privilegeUpgradeInitiatedByUsername: z.string().nullable().optional(), privilegeUpgradeInitiatedAt: z.date().nullable().optional(), bypassOrgAuthEnabled: z.boolean().default(false), - userTokenExpiration: z.string().default("30d") + 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..a7fca66e8 --- /dev/null +++ b/backend/src/lib/fn/time.ts @@ -0,0 +1,34 @@ +const convertToMilliseconds = (exp: string | number): number => { + if (typeof exp === "number") { + return exp * 1000; + } + + const match = exp.match(/^(\d+)\s*([a-z]*)$/i); + if (!match) { + throw new Error(`Invalid expiration format: ${exp}`); + } + + const value = parseInt(match[1], 10); + const unit = match[2].toLowerCase(); + + switch (unit) { + case "": + case "s": + return value * 1000; // seconds + case "m": + return value * 60 * 1000; // minutes + case "h": + return value * 60 * 60 * 1000; // hours + case "d": + return value * 24 * 60 * 60 * 1000; // days + default: + throw new Error(`Unsupported time unit: ${unit}`); + } +}; + +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 38934e1eb..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,7 +80,7 @@ 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; + let expiresIn: string | number = appCfg.JWT_AUTH_LIFETIME; if (decodedToken.organizationId) { const org = await server.services.org.findOrganizationById( decodedToken.userId, @@ -87,8 +88,8 @@ export const registerAuthRoutes = async (server: FastifyZodProvider) => { decodedToken.authMethod, decodedToken.organizationId ); - if (org) { - expiresIn = org.userTokenExpiration; + if (org && org.userTokenExpiration) { + expiresIn = getMinExpiresIn(appCfg.JWT_AUTH_LIFETIME, org.userTokenExpiration); } } diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index 7cde626bc..da1a251ff 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -267,7 +267,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { 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) => 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); diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index bc9c4afa3..d5b1b264b 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-service.ts b/backend/src/services/org/org-service.ts index 4b7192b99..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; }; /*