🧮 refactor: Replace Eval with Safe Math Expression Parser (#11098)

* 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
This commit is contained in:
Danny Avila 2025-12-25 12:25:41 -05:00 committed by GitHub
parent d0863de8d4
commit 6ffb176056
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 602 additions and 85 deletions

View file

@ -79,6 +79,7 @@
"klona": "^2.0.6", "klona": "^2.0.6",
"librechat-data-provider": "*", "librechat-data-provider": "*",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"mathjs": "^15.1.0",
"meilisearch": "^0.38.0", "meilisearch": "^0.38.0",
"memorystore": "^1.6.7", "memorystore": "^1.6.7",
"mime": "^3.0.0", "mime": "^3.0.0",

View file

@ -1,9 +1,13 @@
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const { webcrypto } = require('node:crypto'); const { webcrypto } = require('node:crypto');
const { logger } = require('@librechat/data-schemas'); const {
const { isEnabled, checkEmailConfig, isEmailDomainAllowed } = require('@librechat/api'); logger,
DEFAULT_SESSION_EXPIRY,
DEFAULT_REFRESH_TOKEN_EXPIRY,
} = require('@librechat/data-schemas');
const { ErrorTypes, SystemRoles, errorsToString } = require('librechat-data-provider'); const { ErrorTypes, SystemRoles, errorsToString } = require('librechat-data-provider');
const { isEnabled, checkEmailConfig, isEmailDomainAllowed, math } = require('@librechat/api');
const { const {
findUser, findUser,
findToken, findToken,
@ -369,19 +373,21 @@ const setAuthTokens = async (userId, res, _session = null) => {
let session = _session; let session = _session;
let refreshToken; let refreshToken;
let refreshTokenExpires; let refreshTokenExpires;
const expiresIn = math(process.env.REFRESH_TOKEN_EXPIRY, DEFAULT_REFRESH_TOKEN_EXPIRY);
if (session && session._id && session.expiration != null) { if (session && session._id && session.expiration != null) {
refreshTokenExpires = session.expiration.getTime(); refreshTokenExpires = session.expiration.getTime();
refreshToken = await generateRefreshToken(session); refreshToken = await generateRefreshToken(session);
} else { } else {
const result = await createSession(userId); const result = await createSession(userId, { expiresIn });
session = result.session; session = result.session;
refreshToken = result.refreshToken; refreshToken = result.refreshToken;
refreshTokenExpires = session.expiration.getTime(); refreshTokenExpires = session.expiration.getTime();
} }
const user = await getUserById(userId); 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, { res.cookie('refreshToken', refreshToken, {
expires: new Date(refreshTokenExpires), expires: new Date(refreshTokenExpires),
@ -418,10 +424,10 @@ const setOpenIDAuthTokens = (tokenset, res, userId, existingRefreshToken) => {
logger.error('[setOpenIDAuthTokens] No tokenset found in request'); logger.error('[setOpenIDAuthTokens] No tokenset found in request');
return; return;
} }
const { REFRESH_TOKEN_EXPIRY } = process.env ?? {}; const expiryInMilliseconds = math(
const expiryInMilliseconds = REFRESH_TOKEN_EXPIRY process.env.REFRESH_TOKEN_EXPIRY,
? eval(REFRESH_TOKEN_EXPIRY) DEFAULT_REFRESH_TOKEN_EXPIRY,
: 1000 * 60 * 60 * 24 * 7; // 7 days default );
const expirationDate = new Date(Date.now() + expiryInMilliseconds); const expirationDate = new Date(Date.now() + expiryInMilliseconds);
if (tokenset == null) { if (tokenset == null) {
logger.error('[setOpenIDAuthTokens] No tokenset found in request'); logger.error('[setOpenIDAuthTokens] No tokenset found in request');

View file

@ -3,8 +3,8 @@ const jwksRsa = require('jwks-rsa');
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { HttpsProxyAgent } = require('https-proxy-agent'); const { HttpsProxyAgent } = require('https-proxy-agent');
const { SystemRoles } = require('librechat-data-provider'); const { SystemRoles } = require('librechat-data-provider');
const { isEnabled, findOpenIDUser, math } = require('@librechat/api');
const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt'); const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt');
const { isEnabled, findOpenIDUser } = require('@librechat/api');
const { updateUser, findUser } = require('~/models'); const { updateUser, findUser } = require('~/models');
/** /**
@ -27,9 +27,7 @@ const { updateUser, findUser } = require('~/models');
const openIdJwtLogin = (openIdConfig) => { const openIdJwtLogin = (openIdConfig) => {
let jwksRsaOptions = { let jwksRsaOptions = {
cache: isEnabled(process.env.OPENID_JWKS_URL_CACHE_ENABLED) || true, cache: isEnabled(process.env.OPENID_JWKS_URL_CACHE_ENABLED) || true,
cacheMaxAge: process.env.OPENID_JWKS_URL_CACHE_TIME cacheMaxAge: math(process.env.OPENID_JWKS_URL_CACHE_TIME, 60000),
? eval(process.env.OPENID_JWKS_URL_CACHE_TIME)
: 60000,
jwksUri: openIdConfig.serverMetadata().jwks_uri, jwksUri: openIdConfig.serverMetadata().jwks_uri,
}; };

2
package-lock.json generated
View file

@ -93,6 +93,7 @@
"klona": "^2.0.6", "klona": "^2.0.6",
"librechat-data-provider": "*", "librechat-data-provider": "*",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"mathjs": "^15.1.0",
"meilisearch": "^0.38.0", "meilisearch": "^0.38.0",
"memorystore": "^1.6.7", "memorystore": "^1.6.7",
"mime": "^3.0.0", "mime": "^3.0.0",
@ -48979,6 +48980,7 @@
"keyv": "^5.3.2", "keyv": "^5.3.2",
"keyv-file": "^5.1.2", "keyv-file": "^5.1.2",
"librechat-data-provider": "*", "librechat-data-provider": "*",
"mathjs": "^15.1.0",
"memorystore": "^1.6.7", "memorystore": "^1.6.7",
"mongoose": "^8.12.1", "mongoose": "^8.12.1",
"node-fetch": "2.7.0", "node-fetch": "2.7.0",

View file

@ -102,6 +102,7 @@
"keyv": "^5.3.2", "keyv": "^5.3.2",
"keyv-file": "^5.1.2", "keyv-file": "^5.1.2",
"librechat-data-provider": "*", "librechat-data-provider": "*",
"mathjs": "^15.1.0",
"memorystore": "^1.6.7", "memorystore": "^1.6.7",
"mongoose": "^8.12.1", "mongoose": "^8.12.1",
"node-fetch": "2.7.0", "node-fetch": "2.7.0",

View file

@ -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);
});
});
});

View file

@ -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');
});
});
});

View file

@ -1,3 +1,5 @@
import { evaluate } from 'mathjs';
/** /**
* Evaluates a mathematical expression provided as a string and returns the result. * 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 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. * 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 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. * @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'); 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) { if (fallback) {
return fallbackValue; 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;
} }

View file

@ -4,7 +4,7 @@ export * from './crypto';
export * from './schema'; export * from './schema';
export * from './utils'; export * from './utils';
export { createModels } from './models'; 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 './types';
export type * from './methods'; export type * from './methods';
export { default as logger } from './config/winston'; export { default as logger } from './config/winston';

View file

@ -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 { createTokenMethods, type TokenMethods } from './token';
import { createRoleMethods, type RoleMethods } from './role'; 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 { createKeyMethods, type KeyMethods } from './key';
import { createFileMethods, type FileMethods } from './file'; import { createFileMethods, type FileMethods } from './file';
/* Memories */ /* Memories */

View file

@ -12,8 +12,8 @@ export class SessionError extends Error {
} }
} }
const { REFRESH_TOKEN_EXPIRY } = process.env ?? {}; /** Default refresh token expiry: 7 days in milliseconds */
const expires = REFRESH_TOKEN_EXPIRY ? eval(REFRESH_TOKEN_EXPIRY) : 1000 * 60 * 60 * 24 * 7; // 7 days default export const DEFAULT_REFRESH_TOKEN_EXPIRY = 1000 * 60 * 60 * 24 * 7;
// Factory function that takes mongoose instance and returns the methods // Factory function that takes mongoose instance and returns the methods
export function createSessionMethods(mongoose: typeof import('mongoose')) { 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'); throw new SessionError('User ID is required', 'INVALID_USER_ID');
} }
const expiresIn = options.expiresIn ?? DEFAULT_REFRESH_TOKEN_EXPIRY;
try { try {
const Session = mongoose.models.Session; const Session = mongoose.models.Session;
const currentSession = new Session({ const currentSession = new Session({
user: userId, user: userId,
expiration: options.expiration || new Date(Date.now() + expires), expiration: options.expiration || new Date(Date.now() + expiresIn),
}); });
const refreshToken = await generateRefreshToken(currentSession); const refreshToken = await generateRefreshToken(currentSession);
@ -105,7 +107,10 @@ export function createSessionMethods(mongoose: typeof import('mongoose')) {
async function updateExpiration( async function updateExpiration(
session: t.ISession | string, session: t.ISession | string,
newExpiration?: Date, newExpiration?: Date,
options: t.UpdateExpirationOptions = {},
): Promise<t.ISession> { ): Promise<t.ISession> {
const expiresIn = options.expiresIn ?? DEFAULT_REFRESH_TOKEN_EXPIRY;
try { try {
const Session = mongoose.models.Session; const Session = mongoose.models.Session;
const sessionDoc = typeof session === 'string' ? await Session.findById(session) : 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'); 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(); return await sessionDoc.save();
} catch (error) { } catch (error) {
logger.error('[updateExpiration] Error updating session:', error); logger.error('[updateExpiration] Error updating session:', error);
@ -208,7 +213,9 @@ export function createSessionMethods(mongoose: typeof import('mongoose')) {
} }
try { 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) { if (!session.expiration) {
session.expiration = new Date(expiresIn); session.expiration = new Date(expiresIn);

View file

@ -31,11 +31,10 @@ describe('User Methods', () => {
} as IUser; } as IUser;
afterEach(() => { afterEach(() => {
delete process.env.SESSION_EXPIRY;
delete process.env.JWT_SECRET; 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'; process.env.JWT_SECRET = 'test-secret';
mockSignPayload.mockResolvedValue('mocked-token'); mockSignPayload.mockResolvedValue('mocked-token');
@ -49,16 +48,15 @@ describe('User Methods', () => {
email: mockUser.email, email: mockUser.email,
}, },
secret: 'test-secret', 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 () => { it('should default to 15 minutes when expiresIn is undefined', async () => {
process.env.SESSION_EXPIRY = '';
process.env.JWT_SECRET = 'test-secret'; process.env.JWT_SECRET = 'test-secret';
mockSignPayload.mockResolvedValue('mocked-token'); mockSignPayload.mockResolvedValue('mocked-token');
await userMethods.generateToken(mockUser); await userMethods.generateToken(mockUser, undefined);
expect(mockSignPayload).toHaveBeenCalledWith({ expect(mockSignPayload).toHaveBeenCalledWith({
payload: { payload: {
@ -68,16 +66,15 @@ describe('User Methods', () => {
email: mockUser.email, email: mockUser.email,
}, },
secret: 'test-secret', 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 () => { it('should use custom expiry when expiresIn is provided', async () => {
process.env.SESSION_EXPIRY = '1000 * 60 * 30'; // 30 minutes
process.env.JWT_SECRET = 'test-secret'; process.env.JWT_SECRET = 'test-secret';
mockSignPayload.mockResolvedValue('mocked-token'); mockSignPayload.mockResolvedValue('mocked-token');
await userMethods.generateToken(mockUser); await userMethods.generateToken(mockUser, 1000 * 60 * 30); // 30 minutes
expect(mockSignPayload).toHaveBeenCalledWith({ expect(mockSignPayload).toHaveBeenCalledWith({
payload: { payload: {
@ -91,12 +88,12 @@ describe('User Methods', () => {
}); });
}); });
it('should default to 15 minutes when SESSION_EXPIRY evaluates to falsy value', async () => { it('should use 0 when expiresIn is 0', async () => {
process.env.SESSION_EXPIRY = '0'; // This will evaluate to 0, which is falsy
process.env.JWT_SECRET = 'test-secret'; process.env.JWT_SECRET = 'test-secret';
mockSignPayload.mockResolvedValue('mocked-token'); 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({ expect(mockSignPayload).toHaveBeenCalledWith({
payload: { payload: {
@ -106,7 +103,7 @@ describe('User Methods', () => {
email: mockUser.email, email: mockUser.email,
}, },
secret: 'test-secret', 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 () => { it('should return the token from signPayload', async () => {
process.env.SESSION_EXPIRY = '1000 * 60 * 60'; // 1 hour
process.env.JWT_SECRET = 'test-secret'; process.env.JWT_SECRET = 'test-secret';
const expectedToken = 'generated-jwt-token'; const expectedToken = 'generated-jwt-token';
mockSignPayload.mockResolvedValue(expectedToken); mockSignPayload.mockResolvedValue(expectedToken);
const token = await userMethods.generateToken(mockUser); const token = await userMethods.generateToken(mockUser, 1000 * 60 * 60); // 1 hour
expect(token).toBe(expectedToken); 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();
});
}); });
}); });

View file

@ -2,6 +2,9 @@ import mongoose, { FilterQuery } from 'mongoose';
import type { IUser, BalanceConfig, CreateUserRequest, UserDeleteResult } from '~/types'; import type { IUser, BalanceConfig, CreateUserRequest, UserDeleteResult } from '~/types';
import { signPayload } from '~/crypto'; 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 */ /** Factory function that takes mongoose instance and returns the methods */
export function createUserMethods(mongoose: typeof import('mongoose')) { 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. * 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<string> { async function generateToken(user: IUser, expiresIn?: number): Promise<string> {
if (!user) { if (!user) {
throw new Error('No user provided'); throw new Error('No user provided');
} }
let expires = 1000 * 60 * 15; const expires = expiresIn ?? DEFAULT_SESSION_EXPIRY;
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);
}
}
return await signPayload({ return await signPayload({
payload: { payload: {

View file

@ -8,6 +8,13 @@ export interface ISession extends Document {
export interface CreateSessionOptions { export interface CreateSessionOptions {
expiration?: Date; 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 { export interface SessionSearchParams {