From 6ffb176056f96b6020157ca1baa053152f72a5c4 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 25 Dec 2025 12:25:41 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=AE=20refactor:=20Replace=20Eval=20wit?= =?UTF-8?q?h=20Safe=20Math=20Expression=20Parser=20(#11098)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: Add mathjs dependency * refactor: Replace eval with mathjs for safer expression evaluation and improve session expiry handling to not environment variables from data-schemas package * test: Add integration tests for math function with environment variable expressions * refactor: Update test description for clarity on expiresIn behavior * refactor: Update test cases to clarify default expiration behavior for token generation * refactor: Improve error handling in math function for clearer evaluation errors --- api/package.json | 1 + api/server/services/AuthService.js | 22 +- api/strategies/openIdJwtStrategy.js | 6 +- package-lock.json | 2 + packages/api/package.json | 1 + .../api/src/utils/math.integration.spec.ts | 196 +++++++++++ packages/api/src/utils/math.spec.ts | 326 ++++++++++++++++++ packages/api/src/utils/math.ts | 22 +- packages/data-schemas/src/index.ts | 2 +- packages/data-schemas/src/methods/index.ts | 6 +- packages/data-schemas/src/methods/session.ts | 17 +- .../data-schemas/src/methods/user.test.ts | 59 +--- packages/data-schemas/src/methods/user.ts | 20 +- packages/data-schemas/src/types/session.ts | 7 + 14 files changed, 602 insertions(+), 85 deletions(-) create mode 100644 packages/api/src/utils/math.integration.spec.ts create mode 100644 packages/api/src/utils/math.spec.ts diff --git a/api/package.json b/api/package.json index 6bf1482cb8..771b2e102b 100644 --- a/api/package.json +++ b/api/package.json @@ -79,6 +79,7 @@ "klona": "^2.0.6", "librechat-data-provider": "*", "lodash": "^4.17.21", + "mathjs": "^15.1.0", "meilisearch": "^0.38.0", "memorystore": "^1.6.7", "mime": "^3.0.0", diff --git a/api/server/services/AuthService.js b/api/server/services/AuthService.js index 72bda67322..0cb418e076 100644 --- a/api/server/services/AuthService.js +++ b/api/server/services/AuthService.js @@ -1,9 +1,13 @@ const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); const { webcrypto } = require('node:crypto'); -const { logger } = require('@librechat/data-schemas'); -const { isEnabled, checkEmailConfig, isEmailDomainAllowed } = require('@librechat/api'); +const { + logger, + DEFAULT_SESSION_EXPIRY, + DEFAULT_REFRESH_TOKEN_EXPIRY, +} = require('@librechat/data-schemas'); const { ErrorTypes, SystemRoles, errorsToString } = require('librechat-data-provider'); +const { isEnabled, checkEmailConfig, isEmailDomainAllowed, math } = require('@librechat/api'); const { findUser, findToken, @@ -369,19 +373,21 @@ const setAuthTokens = async (userId, res, _session = null) => { let session = _session; let refreshToken; let refreshTokenExpires; + const expiresIn = math(process.env.REFRESH_TOKEN_EXPIRY, DEFAULT_REFRESH_TOKEN_EXPIRY); if (session && session._id && session.expiration != null) { refreshTokenExpires = session.expiration.getTime(); refreshToken = await generateRefreshToken(session); } else { - const result = await createSession(userId); + const result = await createSession(userId, { expiresIn }); session = result.session; refreshToken = result.refreshToken; refreshTokenExpires = session.expiration.getTime(); } const user = await getUserById(userId); - const token = await generateToken(user); + const sessionExpiry = math(process.env.SESSION_EXPIRY, DEFAULT_SESSION_EXPIRY); + const token = await generateToken(user, sessionExpiry); res.cookie('refreshToken', refreshToken, { expires: new Date(refreshTokenExpires), @@ -418,10 +424,10 @@ const setOpenIDAuthTokens = (tokenset, res, userId, existingRefreshToken) => { logger.error('[setOpenIDAuthTokens] No tokenset found in request'); return; } - const { REFRESH_TOKEN_EXPIRY } = process.env ?? {}; - const expiryInMilliseconds = REFRESH_TOKEN_EXPIRY - ? eval(REFRESH_TOKEN_EXPIRY) - : 1000 * 60 * 60 * 24 * 7; // 7 days default + const expiryInMilliseconds = math( + process.env.REFRESH_TOKEN_EXPIRY, + DEFAULT_REFRESH_TOKEN_EXPIRY, + ); const expirationDate = new Date(Date.now() + expiryInMilliseconds); if (tokenset == null) { logger.error('[setOpenIDAuthTokens] No tokenset found in request'); diff --git a/api/strategies/openIdJwtStrategy.js b/api/strategies/openIdJwtStrategy.js index 998a918c30..5d9eb14085 100644 --- a/api/strategies/openIdJwtStrategy.js +++ b/api/strategies/openIdJwtStrategy.js @@ -3,8 +3,8 @@ const jwksRsa = require('jwks-rsa'); const { logger } = require('@librechat/data-schemas'); const { HttpsProxyAgent } = require('https-proxy-agent'); const { SystemRoles } = require('librechat-data-provider'); +const { isEnabled, findOpenIDUser, math } = require('@librechat/api'); const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt'); -const { isEnabled, findOpenIDUser } = require('@librechat/api'); const { updateUser, findUser } = require('~/models'); /** @@ -27,9 +27,7 @@ const { updateUser, findUser } = require('~/models'); const openIdJwtLogin = (openIdConfig) => { let jwksRsaOptions = { cache: isEnabled(process.env.OPENID_JWKS_URL_CACHE_ENABLED) || true, - cacheMaxAge: process.env.OPENID_JWKS_URL_CACHE_TIME - ? eval(process.env.OPENID_JWKS_URL_CACHE_TIME) - : 60000, + cacheMaxAge: math(process.env.OPENID_JWKS_URL_CACHE_TIME, 60000), jwksUri: openIdConfig.serverMetadata().jwks_uri, }; diff --git a/package-lock.json b/package-lock.json index 47d75fc44c..55584a7cdb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -93,6 +93,7 @@ "klona": "^2.0.6", "librechat-data-provider": "*", "lodash": "^4.17.21", + "mathjs": "^15.1.0", "meilisearch": "^0.38.0", "memorystore": "^1.6.7", "mime": "^3.0.0", @@ -48979,6 +48980,7 @@ "keyv": "^5.3.2", "keyv-file": "^5.1.2", "librechat-data-provider": "*", + "mathjs": "^15.1.0", "memorystore": "^1.6.7", "mongoose": "^8.12.1", "node-fetch": "2.7.0", diff --git a/packages/api/package.json b/packages/api/package.json index 75f18da0e3..d99f2e1ebf 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -102,6 +102,7 @@ "keyv": "^5.3.2", "keyv-file": "^5.1.2", "librechat-data-provider": "*", + "mathjs": "^15.1.0", "memorystore": "^1.6.7", "mongoose": "^8.12.1", "node-fetch": "2.7.0", diff --git a/packages/api/src/utils/math.integration.spec.ts b/packages/api/src/utils/math.integration.spec.ts new file mode 100644 index 0000000000..ec7822a164 --- /dev/null +++ b/packages/api/src/utils/math.integration.spec.ts @@ -0,0 +1,196 @@ +/** + * Integration tests for math function with actual config patterns. + * These tests verify that real environment variable expressions from .env.example + * are correctly evaluated by the math function. + */ +import { math } from './math'; + +describe('math - integration with real config patterns', () => { + describe('SESSION_EXPIRY patterns', () => { + test('should evaluate default SESSION_EXPIRY (15 minutes)', () => { + const result = math('1000 * 60 * 15'); + expect(result).toBe(900000); // 15 minutes in ms + }); + + test('should evaluate 30 minute session', () => { + const result = math('1000 * 60 * 30'); + expect(result).toBe(1800000); // 30 minutes in ms + }); + + test('should evaluate 1 hour session', () => { + const result = math('1000 * 60 * 60'); + expect(result).toBe(3600000); // 1 hour in ms + }); + }); + + describe('REFRESH_TOKEN_EXPIRY patterns', () => { + test('should evaluate default REFRESH_TOKEN_EXPIRY (7 days)', () => { + const result = math('(1000 * 60 * 60 * 24) * 7'); + expect(result).toBe(604800000); // 7 days in ms + }); + + test('should evaluate 1 day refresh token', () => { + const result = math('1000 * 60 * 60 * 24'); + expect(result).toBe(86400000); // 1 day in ms + }); + + test('should evaluate 30 day refresh token', () => { + const result = math('(1000 * 60 * 60 * 24) * 30'); + expect(result).toBe(2592000000); // 30 days in ms + }); + }); + + describe('BAN_DURATION patterns', () => { + test('should evaluate default BAN_DURATION (2 hours)', () => { + const result = math('1000 * 60 * 60 * 2'); + expect(result).toBe(7200000); // 2 hours in ms + }); + + test('should evaluate 24 hour ban', () => { + const result = math('1000 * 60 * 60 * 24'); + expect(result).toBe(86400000); // 24 hours in ms + }); + }); + + describe('Redis config patterns', () => { + test('should evaluate REDIS_RETRY_MAX_DELAY', () => { + expect(math('3000')).toBe(3000); + }); + + test('should evaluate REDIS_RETRY_MAX_ATTEMPTS', () => { + expect(math('10')).toBe(10); + }); + + test('should evaluate REDIS_CONNECT_TIMEOUT', () => { + expect(math('10000')).toBe(10000); + }); + + test('should evaluate REDIS_MAX_LISTENERS', () => { + expect(math('40')).toBe(40); + }); + + test('should evaluate REDIS_DELETE_CHUNK_SIZE', () => { + expect(math('1000')).toBe(1000); + }); + }); + + describe('MCP config patterns', () => { + test('should evaluate MCP_OAUTH_DETECTION_TIMEOUT', () => { + expect(math('5000')).toBe(5000); + }); + + test('should evaluate MCP_CONNECTION_CHECK_TTL', () => { + expect(math('60000')).toBe(60000); // 1 minute + }); + + test('should evaluate MCP_USER_CONNECTION_IDLE_TIMEOUT (15 minutes)', () => { + const result = math('15 * 60 * 1000'); + expect(result).toBe(900000); // 15 minutes in ms + }); + + test('should evaluate MCP_REGISTRY_CACHE_TTL', () => { + expect(math('5000')).toBe(5000); // 5 seconds + }); + }); + + describe('Leader election config patterns', () => { + test('should evaluate LEADER_LEASE_DURATION (25 seconds)', () => { + expect(math('25')).toBe(25); + }); + + test('should evaluate LEADER_RENEW_INTERVAL (10 seconds)', () => { + expect(math('10')).toBe(10); + }); + + test('should evaluate LEADER_RENEW_ATTEMPTS', () => { + expect(math('3')).toBe(3); + }); + + test('should evaluate LEADER_RENEW_RETRY_DELAY (0.5 seconds)', () => { + expect(math('0.5')).toBe(0.5); + }); + }); + + describe('OpenID config patterns', () => { + test('should evaluate OPENID_JWKS_URL_CACHE_TIME (10 minutes)', () => { + const result = math('600000'); + expect(result).toBe(600000); // 10 minutes in ms + }); + + test('should evaluate custom cache time expression', () => { + const result = math('1000 * 60 * 10'); + expect(result).toBe(600000); // 10 minutes in ms + }); + }); + + describe('simulated process.env usage', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + test('should work with SESSION_EXPIRY from env', () => { + process.env.SESSION_EXPIRY = '1000 * 60 * 15'; + const result = math(process.env.SESSION_EXPIRY, 900000); + expect(result).toBe(900000); + }); + + test('should work with REFRESH_TOKEN_EXPIRY from env', () => { + process.env.REFRESH_TOKEN_EXPIRY = '(1000 * 60 * 60 * 24) * 7'; + const result = math(process.env.REFRESH_TOKEN_EXPIRY, 604800000); + expect(result).toBe(604800000); + }); + + test('should work with BAN_DURATION from env', () => { + process.env.BAN_DURATION = '1000 * 60 * 60 * 2'; + const result = math(process.env.BAN_DURATION, 7200000); + expect(result).toBe(7200000); + }); + + test('should use fallback when env var is undefined', () => { + delete process.env.SESSION_EXPIRY; + const result = math(process.env.SESSION_EXPIRY, 900000); + expect(result).toBe(900000); + }); + + test('should use fallback when env var is empty string', () => { + process.env.SESSION_EXPIRY = ''; + const result = math(process.env.SESSION_EXPIRY, 900000); + expect(result).toBe(900000); + }); + + test('should use fallback when env var has invalid expression', () => { + process.env.SESSION_EXPIRY = 'invalid'; + const result = math(process.env.SESSION_EXPIRY, 900000); + expect(result).toBe(900000); + }); + }); + + describe('time calculation helpers', () => { + // Helper functions to make time calculations more readable + const seconds = (n: number) => n * 1000; + const minutes = (n: number) => seconds(n * 60); + const hours = (n: number) => minutes(n * 60); + const days = (n: number) => hours(n * 24); + + test('should match helper calculations', () => { + // Verify our math function produces same results as programmatic calculations + expect(math('1000 * 60 * 15')).toBe(minutes(15)); + expect(math('1000 * 60 * 60 * 2')).toBe(hours(2)); + expect(math('(1000 * 60 * 60 * 24) * 7')).toBe(days(7)); + }); + + test('should handle complex expressions', () => { + // 2 hours + 30 minutes + expect(math('(1000 * 60 * 60 * 2) + (1000 * 60 * 30)')).toBe(hours(2) + minutes(30)); + + // Half a day + expect(math('(1000 * 60 * 60 * 24) / 2')).toBe(days(1) / 2); + }); + }); +}); diff --git a/packages/api/src/utils/math.spec.ts b/packages/api/src/utils/math.spec.ts new file mode 100644 index 0000000000..7593098946 --- /dev/null +++ b/packages/api/src/utils/math.spec.ts @@ -0,0 +1,326 @@ +import { math } from './math'; + +describe('math', () => { + describe('number input passthrough', () => { + test('should return number as-is when input is a number', () => { + expect(math(42)).toBe(42); + }); + + test('should return zero when input is 0', () => { + expect(math(0)).toBe(0); + }); + + test('should return negative numbers as-is', () => { + expect(math(-10)).toBe(-10); + }); + + test('should return decimal numbers as-is', () => { + expect(math(0.5)).toBe(0.5); + }); + + test('should return very large numbers as-is', () => { + expect(math(Number.MAX_SAFE_INTEGER)).toBe(Number.MAX_SAFE_INTEGER); + }); + }); + + describe('simple string number parsing', () => { + test('should parse simple integer string', () => { + expect(math('42')).toBe(42); + }); + + test('should parse zero string', () => { + expect(math('0')).toBe(0); + }); + + test('should parse negative number string', () => { + expect(math('-10')).toBe(-10); + }); + + test('should parse decimal string', () => { + expect(math('0.5')).toBe(0.5); + }); + + test('should parse string with leading/trailing spaces', () => { + expect(math(' 42 ')).toBe(42); + }); + + test('should parse large number string', () => { + expect(math('9007199254740991')).toBe(Number.MAX_SAFE_INTEGER); + }); + }); + + describe('mathematical expressions - multiplication', () => { + test('should evaluate simple multiplication', () => { + expect(math('2 * 3')).toBe(6); + }); + + test('should evaluate chained multiplication (BAN_DURATION pattern: 1000 * 60 * 60 * 2)', () => { + // 2 hours in milliseconds + expect(math('1000 * 60 * 60 * 2')).toBe(7200000); + }); + + test('should evaluate SESSION_EXPIRY pattern (1000 * 60 * 15)', () => { + // 15 minutes in milliseconds + expect(math('1000 * 60 * 15')).toBe(900000); + }); + + test('should evaluate multiplication without spaces', () => { + expect(math('2*3')).toBe(6); + }); + }); + + describe('mathematical expressions - addition and subtraction', () => { + test('should evaluate simple addition', () => { + expect(math('2 + 3')).toBe(5); + }); + + test('should evaluate simple subtraction', () => { + expect(math('10 - 3')).toBe(7); + }); + + test('should evaluate mixed addition and subtraction', () => { + expect(math('10 + 5 - 3')).toBe(12); + }); + + test('should handle negative results', () => { + expect(math('3 - 10')).toBe(-7); + }); + }); + + describe('mathematical expressions - division', () => { + test('should evaluate simple division', () => { + expect(math('10 / 2')).toBe(5); + }); + + test('should evaluate division resulting in decimal', () => { + expect(math('7 / 2')).toBe(3.5); + }); + }); + + describe('mathematical expressions - parentheses', () => { + test('should evaluate expression with parentheses (REFRESH_TOKEN_EXPIRY pattern)', () => { + // 7 days in milliseconds: (1000 * 60 * 60 * 24) * 7 + expect(math('(1000 * 60 * 60 * 24) * 7')).toBe(604800000); + }); + + test('should evaluate nested parentheses', () => { + expect(math('((2 + 3) * 4)')).toBe(20); + }); + + test('should respect operator precedence with parentheses', () => { + expect(math('2 * (3 + 4)')).toBe(14); + }); + }); + + describe('mathematical expressions - modulo', () => { + test('should evaluate modulo operation', () => { + expect(math('10 % 3')).toBe(1); + }); + + test('should evaluate modulo with larger numbers', () => { + expect(math('100 % 7')).toBe(2); + }); + }); + + describe('complex real-world expressions', () => { + test('should evaluate MCP_USER_CONNECTION_IDLE_TIMEOUT pattern (15 * 60 * 1000)', () => { + // 15 minutes in milliseconds + expect(math('15 * 60 * 1000')).toBe(900000); + }); + + test('should evaluate Redis default TTL (5000)', () => { + expect(math('5000')).toBe(5000); + }); + + test('should evaluate LEADER_RENEW_RETRY_DELAY decimal (0.5)', () => { + expect(math('0.5')).toBe(0.5); + }); + + test('should evaluate BAN_DURATION default (7200000)', () => { + // 2 hours in milliseconds + expect(math('7200000')).toBe(7200000); + }); + + test('should evaluate expression with mixed operators and parentheses', () => { + // (1 hour + 30 min) in ms + expect(math('(1000 * 60 * 60) + (1000 * 60 * 30)')).toBe(5400000); + }); + }); + + describe('fallback value behavior', () => { + test('should return fallback when input is undefined', () => { + expect(math(undefined, 100)).toBe(100); + }); + + test('should return fallback when input is null', () => { + // @ts-expect-error - testing runtime behavior with invalid input + expect(math(null, 100)).toBe(100); + }); + + test('should return fallback when input contains invalid characters', () => { + expect(math('abc', 100)).toBe(100); + }); + + test('should return fallback when input has SQL injection attempt', () => { + expect(math('1; DROP TABLE users;', 100)).toBe(100); + }); + + test('should return fallback when input has function call attempt', () => { + expect(math('console.log("hacked")', 100)).toBe(100); + }); + + test('should return fallback when input is empty string', () => { + expect(math('', 100)).toBe(100); + }); + + test('should return zero fallback when specified', () => { + expect(math(undefined, 0)).toBe(0); + }); + + test('should use number input even when fallback is provided', () => { + expect(math(42, 100)).toBe(42); + }); + + test('should use valid string even when fallback is provided', () => { + expect(math('42', 100)).toBe(42); + }); + }); + + describe('error cases without fallback', () => { + test('should throw error when input is undefined without fallback', () => { + expect(() => math(undefined)).toThrow('str is undefined, but should be a string'); + }); + + test('should throw error when input is null without fallback', () => { + // @ts-expect-error - testing runtime behavior with invalid input + expect(() => math(null)).toThrow('str is object, but should be a string'); + }); + + test('should throw error when input contains invalid characters without fallback', () => { + expect(() => math('abc')).toThrow('Invalid characters in string'); + }); + + test('should throw error when input has letter characters', () => { + expect(() => math('10x')).toThrow('Invalid characters in string'); + }); + + test('should throw error when input has special characters', () => { + expect(() => math('10!')).toThrow('Invalid characters in string'); + }); + + test('should throw error for malicious code injection', () => { + expect(() => math('process.exit(1)')).toThrow('Invalid characters in string'); + }); + + test('should throw error for require injection', () => { + expect(() => math('require("fs")')).toThrow('Invalid characters in string'); + }); + }); + + describe('security - input validation', () => { + test('should reject strings with alphabetic characters', () => { + expect(() => math('Math.PI')).toThrow('Invalid characters in string'); + }); + + test('should reject strings with brackets', () => { + expect(() => math('[1,2,3]')).toThrow('Invalid characters in string'); + }); + + test('should reject strings with curly braces', () => { + expect(() => math('{}')).toThrow('Invalid characters in string'); + }); + + test('should reject strings with semicolons', () => { + expect(() => math('1;2')).toThrow('Invalid characters in string'); + }); + + test('should reject strings with quotes', () => { + expect(() => math('"test"')).toThrow('Invalid characters in string'); + }); + + test('should reject strings with backticks', () => { + expect(() => math('`test`')).toThrow('Invalid characters in string'); + }); + + test('should reject strings with equals sign', () => { + expect(() => math('x=1')).toThrow('Invalid characters in string'); + }); + + test('should reject strings with ampersand', () => { + expect(() => math('1 && 2')).toThrow('Invalid characters in string'); + }); + + test('should reject strings with pipe', () => { + expect(() => math('1 || 2')).toThrow('Invalid characters in string'); + }); + }); + + describe('edge cases', () => { + test('should handle expression resulting in Infinity with fallback', () => { + // Division by zero returns Infinity, which is technically a number + expect(math('1 / 0')).toBe(Infinity); + }); + + test('should handle very small decimals', () => { + expect(math('0.001')).toBe(0.001); + }); + + test('should handle scientific notation format', () => { + // Note: 'e' is not in the allowed character set, so this should fail + expect(() => math('1e3')).toThrow('Invalid characters in string'); + }); + + test('should handle expression with only whitespace with fallback', () => { + expect(math(' ', 100)).toBe(100); + }); + + test('should handle +number syntax', () => { + expect(math('+42')).toBe(42); + }); + + test('should handle expression starting with negative', () => { + expect(math('-5 + 10')).toBe(5); + }); + + test('should handle multiple decimal points with fallback', () => { + // Invalid syntax should return fallback value + expect(math('1.2.3', 100)).toBe(100); + }); + + test('should throw for multiple decimal points without fallback', () => { + expect(() => math('1.2.3')).toThrow(); + }); + }); + + describe('type coercion edge cases', () => { + test('should handle object input with fallback', () => { + // @ts-expect-error - testing runtime behavior with invalid input + expect(math({}, 100)).toBe(100); + }); + + test('should handle array input with fallback', () => { + // @ts-expect-error - testing runtime behavior with invalid input + expect(math([], 100)).toBe(100); + }); + + test('should handle boolean true with fallback', () => { + // @ts-expect-error - testing runtime behavior with invalid input + expect(math(true, 100)).toBe(100); + }); + + test('should handle boolean false with fallback', () => { + // @ts-expect-error - testing runtime behavior with invalid input + expect(math(false, 100)).toBe(100); + }); + + test('should throw for object input without fallback', () => { + // @ts-expect-error - testing runtime behavior with invalid input + expect(() => math({})).toThrow('str is object, but should be a string'); + }); + + test('should throw for array input without fallback', () => { + // @ts-expect-error - testing runtime behavior with invalid input + expect(() => math([])).toThrow('str is object, but should be a string'); + }); + }); +}); diff --git a/packages/api/src/utils/math.ts b/packages/api/src/utils/math.ts index 7201880ce3..b8a896f49e 100644 --- a/packages/api/src/utils/math.ts +++ b/packages/api/src/utils/math.ts @@ -1,3 +1,5 @@ +import { evaluate } from 'mathjs'; + /** * Evaluates a mathematical expression provided as a string and returns the result. * @@ -5,6 +7,8 @@ * If the input is not a string or contains invalid characters, an error is thrown. * If the evaluated result is not a number, an error is thrown. * + * Uses mathjs for safe expression evaluation instead of eval(). + * * @param str - The mathematical expression to evaluate, or a number. * @param fallbackValue - The default value to return if the input is not a string or number, or if the evaluated result is not a number. * @@ -32,14 +36,22 @@ export function math(str: string | number | undefined, fallbackValue?: number): throw new Error('Invalid characters in string'); } - const value = eval(str); + try { + const value = evaluate(str); - if (typeof value !== 'number') { + if (typeof value !== 'number') { + if (fallback) { + return fallbackValue; + } + throw new Error(`[math] str did not evaluate to a number but to a ${typeof value}`); + } + + return value; + } catch (error) { if (fallback) { return fallbackValue; } - throw new Error(`[math] str did not evaluate to a number but to a ${typeof value}`); + const originalMessage = error instanceof Error ? error.message : String(error); + throw new Error(`[math] Error while evaluating mathematical expression: ${originalMessage}`); } - - return value; } diff --git a/packages/data-schemas/src/index.ts b/packages/data-schemas/src/index.ts index 0754dfe258..a9c9a56078 100644 --- a/packages/data-schemas/src/index.ts +++ b/packages/data-schemas/src/index.ts @@ -4,7 +4,7 @@ export * from './crypto'; export * from './schema'; export * from './utils'; export { createModels } from './models'; -export { createMethods } from './methods'; +export { createMethods, DEFAULT_REFRESH_TOKEN_EXPIRY, DEFAULT_SESSION_EXPIRY } from './methods'; export type * from './types'; export type * from './methods'; export { default as logger } from './config/winston'; diff --git a/packages/data-schemas/src/methods/index.ts b/packages/data-schemas/src/methods/index.ts index 122e48419c..b6f1be64e9 100644 --- a/packages/data-schemas/src/methods/index.ts +++ b/packages/data-schemas/src/methods/index.ts @@ -1,7 +1,9 @@ -import { createSessionMethods, type SessionMethods } from './session'; +import { createSessionMethods, DEFAULT_REFRESH_TOKEN_EXPIRY, type SessionMethods } from './session'; import { createTokenMethods, type TokenMethods } from './token'; import { createRoleMethods, type RoleMethods } from './role'; -import { createUserMethods, type UserMethods } from './user'; +import { createUserMethods, DEFAULT_SESSION_EXPIRY, type UserMethods } from './user'; + +export { DEFAULT_REFRESH_TOKEN_EXPIRY, DEFAULT_SESSION_EXPIRY }; import { createKeyMethods, type KeyMethods } from './key'; import { createFileMethods, type FileMethods } from './file'; /* Memories */ diff --git a/packages/data-schemas/src/methods/session.ts b/packages/data-schemas/src/methods/session.ts index 30700bc267..68c851414a 100644 --- a/packages/data-schemas/src/methods/session.ts +++ b/packages/data-schemas/src/methods/session.ts @@ -12,8 +12,8 @@ export class SessionError extends Error { } } -const { REFRESH_TOKEN_EXPIRY } = process.env ?? {}; -const expires = REFRESH_TOKEN_EXPIRY ? eval(REFRESH_TOKEN_EXPIRY) : 1000 * 60 * 60 * 24 * 7; // 7 days default +/** Default refresh token expiry: 7 days in milliseconds */ +export const DEFAULT_REFRESH_TOKEN_EXPIRY = 1000 * 60 * 60 * 24 * 7; // Factory function that takes mongoose instance and returns the methods export function createSessionMethods(mongoose: typeof import('mongoose')) { @@ -28,11 +28,13 @@ export function createSessionMethods(mongoose: typeof import('mongoose')) { throw new SessionError('User ID is required', 'INVALID_USER_ID'); } + const expiresIn = options.expiresIn ?? DEFAULT_REFRESH_TOKEN_EXPIRY; + try { const Session = mongoose.models.Session; const currentSession = new Session({ user: userId, - expiration: options.expiration || new Date(Date.now() + expires), + expiration: options.expiration || new Date(Date.now() + expiresIn), }); const refreshToken = await generateRefreshToken(currentSession); @@ -105,7 +107,10 @@ export function createSessionMethods(mongoose: typeof import('mongoose')) { async function updateExpiration( session: t.ISession | string, newExpiration?: Date, + options: t.UpdateExpirationOptions = {}, ): Promise { + const expiresIn = options.expiresIn ?? DEFAULT_REFRESH_TOKEN_EXPIRY; + try { const Session = mongoose.models.Session; const sessionDoc = typeof session === 'string' ? await Session.findById(session) : session; @@ -114,7 +119,7 @@ export function createSessionMethods(mongoose: typeof import('mongoose')) { throw new SessionError('Session not found', 'SESSION_NOT_FOUND'); } - sessionDoc.expiration = newExpiration || new Date(Date.now() + expires); + sessionDoc.expiration = newExpiration || new Date(Date.now() + expiresIn); return await sessionDoc.save(); } catch (error) { logger.error('[updateExpiration] Error updating session:', error); @@ -208,7 +213,9 @@ export function createSessionMethods(mongoose: typeof import('mongoose')) { } try { - const expiresIn = session.expiration ? session.expiration.getTime() : Date.now() + expires; + const expiresIn = session.expiration + ? session.expiration.getTime() + : Date.now() + DEFAULT_REFRESH_TOKEN_EXPIRY; if (!session.expiration) { session.expiration = new Date(expiresIn); diff --git a/packages/data-schemas/src/methods/user.test.ts b/packages/data-schemas/src/methods/user.test.ts index 6dafd4e8fa..522e4fe158 100644 --- a/packages/data-schemas/src/methods/user.test.ts +++ b/packages/data-schemas/src/methods/user.test.ts @@ -31,11 +31,10 @@ describe('User Methods', () => { } as IUser; afterEach(() => { - delete process.env.SESSION_EXPIRY; delete process.env.JWT_SECRET; }); - it('should default to 15 minutes when SESSION_EXPIRY is not set', async () => { + it('should default to 15 minutes when expiresIn is not provided', async () => { process.env.JWT_SECRET = 'test-secret'; mockSignPayload.mockResolvedValue('mocked-token'); @@ -49,16 +48,15 @@ describe('User Methods', () => { email: mockUser.email, }, secret: 'test-secret', - expirationTime: 900, // 15 minutes in seconds + expirationTime: 900, // 15 minutes in seconds (DEFAULT_SESSION_EXPIRY / 1000) }); }); - it('should default to 15 minutes when SESSION_EXPIRY is empty string', async () => { - process.env.SESSION_EXPIRY = ''; + it('should default to 15 minutes when expiresIn is undefined', async () => { process.env.JWT_SECRET = 'test-secret'; mockSignPayload.mockResolvedValue('mocked-token'); - await userMethods.generateToken(mockUser); + await userMethods.generateToken(mockUser, undefined); expect(mockSignPayload).toHaveBeenCalledWith({ payload: { @@ -68,16 +66,15 @@ describe('User Methods', () => { email: mockUser.email, }, secret: 'test-secret', - expirationTime: 900, // 15 minutes in seconds + expirationTime: 900, // 15 minutes in seconds (DEFAULT_SESSION_EXPIRY / 1000) }); }); - it('should use custom expiry when SESSION_EXPIRY is set to a valid expression', async () => { - process.env.SESSION_EXPIRY = '1000 * 60 * 30'; // 30 minutes + it('should use custom expiry when expiresIn is provided', async () => { process.env.JWT_SECRET = 'test-secret'; mockSignPayload.mockResolvedValue('mocked-token'); - await userMethods.generateToken(mockUser); + await userMethods.generateToken(mockUser, 1000 * 60 * 30); // 30 minutes expect(mockSignPayload).toHaveBeenCalledWith({ payload: { @@ -91,12 +88,12 @@ describe('User Methods', () => { }); }); - it('should default to 15 minutes when SESSION_EXPIRY evaluates to falsy value', async () => { - process.env.SESSION_EXPIRY = '0'; // This will evaluate to 0, which is falsy + it('should use 0 when expiresIn is 0', async () => { process.env.JWT_SECRET = 'test-secret'; mockSignPayload.mockResolvedValue('mocked-token'); - await userMethods.generateToken(mockUser); + // When 0 is passed, it should use 0 (caller's responsibility to pass valid value) + await userMethods.generateToken(mockUser, 0); expect(mockSignPayload).toHaveBeenCalledWith({ payload: { @@ -106,7 +103,7 @@ describe('User Methods', () => { email: mockUser.email, }, secret: 'test-secret', - expirationTime: 900, // 15 minutes in seconds + expirationTime: 0, // 0 seconds }); }); @@ -119,45 +116,13 @@ describe('User Methods', () => { }); it('should return the token from signPayload', async () => { - process.env.SESSION_EXPIRY = '1000 * 60 * 60'; // 1 hour process.env.JWT_SECRET = 'test-secret'; const expectedToken = 'generated-jwt-token'; mockSignPayload.mockResolvedValue(expectedToken); - const token = await userMethods.generateToken(mockUser); + const token = await userMethods.generateToken(mockUser, 1000 * 60 * 60); // 1 hour expect(token).toBe(expectedToken); }); - - it('should handle invalid SESSION_EXPIRY expressions gracefully', async () => { - process.env.SESSION_EXPIRY = 'invalid expression'; - process.env.JWT_SECRET = 'test-secret'; - mockSignPayload.mockResolvedValue('mocked-token'); - - // Mock console.warn to verify it's called - const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); - - await userMethods.generateToken(mockUser); - - // Should use default value when eval fails - expect(mockSignPayload).toHaveBeenCalledWith({ - payload: { - id: mockUser._id, - username: mockUser.username, - provider: mockUser.provider, - email: mockUser.email, - }, - secret: 'test-secret', - expirationTime: 900, // 15 minutes in seconds (default) - }); - - // Verify warning was logged - expect(consoleWarnSpy).toHaveBeenCalledWith( - 'Invalid SESSION_EXPIRY expression, using default:', - expect.any(SyntaxError), - ); - - consoleWarnSpy.mockRestore(); - }); }); }); diff --git a/packages/data-schemas/src/methods/user.ts b/packages/data-schemas/src/methods/user.ts index 07b671eb67..74cb4a1e1c 100644 --- a/packages/data-schemas/src/methods/user.ts +++ b/packages/data-schemas/src/methods/user.ts @@ -2,6 +2,9 @@ import mongoose, { FilterQuery } from 'mongoose'; import type { IUser, BalanceConfig, CreateUserRequest, UserDeleteResult } from '~/types'; import { signPayload } from '~/crypto'; +/** Default JWT session expiry: 15 minutes in milliseconds */ +export const DEFAULT_SESSION_EXPIRY = 1000 * 60 * 15; + /** Factory function that takes mongoose instance and returns the methods */ export function createUserMethods(mongoose: typeof import('mongoose')) { /** @@ -161,24 +164,15 @@ export function createUserMethods(mongoose: typeof import('mongoose')) { /** * Generates a JWT token for a given user. + * @param user - The user object + * @param expiresIn - Optional expiry time in milliseconds. Default: 15 minutes */ - async function generateToken(user: IUser): Promise { + async function generateToken(user: IUser, expiresIn?: number): Promise { if (!user) { throw new Error('No user provided'); } - let expires = 1000 * 60 * 15; - - if (process.env.SESSION_EXPIRY !== undefined && process.env.SESSION_EXPIRY !== '') { - try { - const evaluated = eval(process.env.SESSION_EXPIRY); - if (evaluated) { - expires = evaluated; - } - } catch (error) { - console.warn('Invalid SESSION_EXPIRY expression, using default:', error); - } - } + const expires = expiresIn ?? DEFAULT_SESSION_EXPIRY; return await signPayload({ payload: { diff --git a/packages/data-schemas/src/types/session.ts b/packages/data-schemas/src/types/session.ts index 7df456ac4f..a7e9591e12 100644 --- a/packages/data-schemas/src/types/session.ts +++ b/packages/data-schemas/src/types/session.ts @@ -8,6 +8,13 @@ export interface ISession extends Document { export interface CreateSessionOptions { expiration?: Date; + /** Duration in milliseconds for session expiry. Default: 7 days */ + expiresIn?: number; +} + +export interface UpdateExpirationOptions { + /** Duration in milliseconds for session expiry. Default: 7 days */ + expiresIn?: number; } export interface SessionSearchParams {