diff --git a/.env.example b/.env.example index 1326c4d9e5..b58207657b 100644 --- a/.env.example +++ b/.env.example @@ -653,6 +653,12 @@ OPENID_AUTO_REDIRECT=false OPENID_USE_PKCE=false #Set to true to reuse openid tokens for authentication management instead of using the mongodb session and the custom refresh token. OPENID_REUSE_TOKENS= +#Max age a reused OpenID session token is served before LibreChat forces an IdP refresh. Default 900000 ms (15 min). +#Accepts an arithmetic expression like SESSION_EXPIRY (e.g. 60 * 60 * 24 * 1000 for 24h). +#Raise toward the IdP access-token lifetime when the IdP revokes the previous access token on refresh, so a still-valid token +#is not rotated/revoked out from under downstream consumers (e.g. MCP servers that introspect the bearer). +#When OPENID_REUSE_TOKENS=true, the OpenID session cookie maxAge is extended to at least this value. +OPENID_REUSE_MAX_SESSION_AGE_MS= #By default, signing key verification results are cached in order to prevent excessive HTTP requests to the JWKS endpoint. #If a signing key matching the kid is found, this will be cached and the next time this kid is requested the signing key will be served from the cache. #Default is true. diff --git a/api/server/controllers/AuthController.js b/api/server/controllers/AuthController.js index 527728e98c..b3743df828 100644 --- a/api/server/controllers/AuthController.js +++ b/api/server/controllers/AuthController.js @@ -3,6 +3,7 @@ const jwt = require('jsonwebtoken'); const openIdClient = require('openid-client'); const { logger } = require('@librechat/data-schemas'); const { + math, isEnabled, findOpenIDUser, getOpenIdIssuer, @@ -28,8 +29,18 @@ const { getOpenIdConfig, getOpenIdEmail } = require('~/strategies'); const AUTH_REFRESH_USER_PROJECTION = '-password -__v -totpSecret -backupCodes -federatedTokens'; const OPENID_REUSE_EXPIRY_BUFFER_SECONDS = 30; -/** Mirrors the default SESSION_EXPIRY to bound IdP revocation lag for session-token reuse. */ -const OPENID_REUSE_MAX_SESSION_AGE_MS = 15 * 60 * 1000; +/** + * Max age (ms) LibreChat reuses a cached OpenID session token before forcing an IdP refresh. + * Env-overridable (accepts an arithmetic expression, e.g. `60 * 60 * 24 * 1000`, like + * `SESSION_EXPIRY`): deployments whose IdP revokes the previous access token on refresh can + * widen this to the access-token lifetime so a still-valid token is not rotated/revoked out + * from under downstream consumers (e.g. MCP servers that introspect the bearer). Defaults to + * 15 minutes. + */ +const OPENID_REUSE_MAX_SESSION_AGE_MS = math( + process.env.OPENID_REUSE_MAX_SESSION_AGE_MS, + 15 * 60 * 1000, +); const registrationController = async (req, res) => { try { diff --git a/api/server/controllers/AuthController.spec.js b/api/server/controllers/AuthController.spec.js index 7bed1da33b..40c20bbbe1 100644 --- a/api/server/controllers/AuthController.spec.js +++ b/api/server/controllers/AuthController.spec.js @@ -22,6 +22,7 @@ jest.mock('~/models', () => ({ findUser: jest.fn(), })); jest.mock('@librechat/api', () => ({ + math: jest.fn((value, fallback) => fallback), isEnabled: jest.fn(), findOpenIDUser: jest.fn(), getOpenIdIssuer: jest.fn(() => 'https://issuer.example.com'), diff --git a/api/server/socialLogins.js b/api/server/socialLogins.js index 78f0e82a32..f4d088e6d0 100644 --- a/api/server/socialLogins.js +++ b/api/server/socialLogins.js @@ -1,7 +1,7 @@ const passport = require('passport'); const session = require('express-session'); const { CacheKeys } = require('librechat-data-provider'); -const { isEnabled, shouldUseSecureCookie } = require('@librechat/api'); +const { math, isEnabled, shouldUseSecureCookie } = require('@librechat/api'); const { logger, DEFAULT_SESSION_EXPIRY } = require('@librechat/data-schemas'); const { openIdJwtLogin, @@ -20,6 +20,23 @@ const { } = require('~/strategies'); const { getLogStores } = require('~/cache'); +const DEFAULT_OPENID_REUSE_MAX_SESSION_AGE_MS = 15 * 60 * 1000; + +const getSessionExpiry = () => math(process.env.SESSION_EXPIRY, DEFAULT_SESSION_EXPIRY); + +const getOpenIdSessionExpiry = () => { + const sessionExpiry = getSessionExpiry(); + if (!isEnabled(process.env.OPENID_REUSE_TOKENS)) { + return sessionExpiry; + } + + const reuseMaxSessionAge = math( + process.env.OPENID_REUSE_MAX_SESSION_AGE_MS, + DEFAULT_OPENID_REUSE_MAX_SESSION_AGE_MS, + ); + return Math.max(sessionExpiry, reuseMaxSessionAge); +}; + /** * Configures OpenID Connect for the application. * @param {Express.Application} app - The Express application instance. @@ -27,7 +44,7 @@ const { getLogStores } = require('~/cache'); */ async function configureOpenId(app) { logger.info('Configuring OpenID Connect...'); - const sessionExpiry = Number(process.env.SESSION_EXPIRY) || DEFAULT_SESSION_EXPIRY; + const sessionExpiry = getOpenIdSessionExpiry(); const sessionOptions = { secret: process.env.OPENID_SESSION_SECRET, resave: false, @@ -97,7 +114,7 @@ const configureSocialLogins = async (app) => { process.env.SAML_SESSION_SECRET ) { logger.info('Configuring SAML Connect...'); - const sessionExpiry = Number(process.env.SESSION_EXPIRY) || DEFAULT_SESSION_EXPIRY; + const sessionExpiry = getSessionExpiry(); const sessionOptions = { secret: process.env.SAML_SESSION_SECRET, resave: false, diff --git a/api/server/socialLogins.spec.js b/api/server/socialLogins.spec.js new file mode 100644 index 0000000000..bf016a43eb --- /dev/null +++ b/api/server/socialLogins.spec.js @@ -0,0 +1,143 @@ +const mockSessionMiddleware = jest.fn((req, res, next) => next()); +const mockPassportSessionMiddleware = jest.fn((req, res, next) => next()); +const mockSession = jest.fn(() => mockSessionMiddleware); +const mockPassportUse = jest.fn(); +const mockPassportSession = jest.fn(() => mockPassportSessionMiddleware); +const mockGetLogStores = jest.fn(() => 'openid-session-store'); +const mockOpenIdJwtLogin = jest.fn(() => 'openid-jwt-strategy'); +const mockSetupOpenId = jest.fn(); +const mockSetupSaml = jest.fn(); +const mockIsEnabled = jest.fn(); +const mockShouldUseSecureCookie = jest.fn(() => true); +const mockMath = jest.fn((value, fallback) => { + if (value == null || value === '') { + return fallback; + } + if (typeof value === 'number') { + return value; + } + return value + .split('*') + .map((part) => Number(part.trim())) + .reduce((result, part) => result * part, 1); +}); + +jest.mock( + 'express-session', + () => + (...args) => + mockSession(...args), +); +jest.mock('passport', () => ({ + use: (...args) => mockPassportUse(...args), + session: (...args) => mockPassportSession(...args), +})); +jest.mock('librechat-data-provider', () => ({ + CacheKeys: { + OPENID_SESSION: 'openid-session', + SAML_SESSION: 'saml-session', + }, +})); +jest.mock('@librechat/api', () => ({ + math: (...args) => mockMath(...args), + isEnabled: (...args) => mockIsEnabled(...args), + shouldUseSecureCookie: (...args) => mockShouldUseSecureCookie(...args), +})); +jest.mock('@librechat/data-schemas', () => ({ + DEFAULT_SESSION_EXPIRY: 900000, + logger: { error: jest.fn(), info: jest.fn() }, +})); +jest.mock('~/cache', () => ({ getLogStores: (...args) => mockGetLogStores(...args) })); +jest.mock('~/strategies', () => ({ + openIdJwtLogin: (...args) => mockOpenIdJwtLogin(...args), + facebookLogin: jest.fn(), + facebookAdminLogin: jest.fn(), + discordLogin: jest.fn(), + discordAdminLogin: jest.fn(), + setupOpenId: (...args) => mockSetupOpenId(...args), + googleLogin: jest.fn(), + googleAdminLogin: jest.fn(), + githubLogin: jest.fn(), + githubAdminLogin: jest.fn(), + appleLogin: jest.fn(), + appleAdminLogin: jest.fn(), + setupSaml: (...args) => mockSetupSaml(...args), +})); + +const configureSocialLogins = require('./socialLogins'); + +describe('configureSocialLogins OpenID session expiry', () => { + const ORIGINAL_ENV = process.env; + + const setupOpenIdEnv = () => { + process.env.OPENID_CLIENT_ID = 'client-id'; + process.env.OPENID_CLIENT_SECRET = 'client-secret'; + process.env.OPENID_ISSUER = 'https://issuer.example.com'; + process.env.OPENID_SCOPE = 'openid profile email'; + process.env.OPENID_SESSION_SECRET = 'openid-session-secret'; + process.env.OPENID_USE_PKCE = 'false'; + }; + + beforeEach(() => { + jest.clearAllMocks(); + process.env = {}; + setupOpenIdEnv(); + mockSetupOpenId.mockResolvedValue({ issuer: 'https://issuer.example.com' }); + mockIsEnabled.mockImplementation((value) => value === 'true'); + }); + + afterAll(() => { + process.env = ORIGINAL_ENV; + }); + + it('extends the OpenID session cookie to the reuse window when token reuse is enabled', async () => { + process.env.SESSION_EXPIRY = '1000 * 60 * 15'; + process.env.OPENID_REUSE_TOKENS = 'true'; + process.env.OPENID_REUSE_MAX_SESSION_AGE_MS = '1000 * 60 * 60'; + const app = { use: jest.fn() }; + + await configureSocialLogins(app); + + expect(mockSession).toHaveBeenCalledWith( + expect.objectContaining({ + cookie: { + maxAge: 3600000, + secure: true, + }, + }), + ); + expect(mockOpenIdJwtLogin).toHaveBeenCalledWith({ issuer: 'https://issuer.example.com' }); + expect(mockPassportUse).toHaveBeenCalledWith('openidJwt', 'openid-jwt-strategy'); + }); + + it('keeps a longer SESSION_EXPIRY when the reuse window is shorter', async () => { + process.env.SESSION_EXPIRY = '1000 * 60 * 60 * 2'; + process.env.OPENID_REUSE_TOKENS = 'true'; + process.env.OPENID_REUSE_MAX_SESSION_AGE_MS = '1000 * 60 * 60'; + const app = { use: jest.fn() }; + + await configureSocialLogins(app); + + expect(mockSession).toHaveBeenCalledWith( + expect.objectContaining({ + cookie: expect.objectContaining({ maxAge: 7200000 }), + }), + ); + }); + + it('uses SESSION_EXPIRY when OpenID token reuse is disabled', async () => { + process.env.SESSION_EXPIRY = '1000 * 60 * 15'; + process.env.OPENID_REUSE_TOKENS = ''; + process.env.OPENID_REUSE_MAX_SESSION_AGE_MS = '1000 * 60 * 60'; + const app = { use: jest.fn() }; + + await configureSocialLogins(app); + + expect(mockSession).toHaveBeenCalledWith( + expect.objectContaining({ + cookie: expect.objectContaining({ maxAge: 900000 }), + }), + ); + expect(mockPassportUse).not.toHaveBeenCalled(); + }); +});