feat: Make OpenID Token Reuse Window Configurable (#13546)

* feat: make OpenID token reuse window configurable via OPENID_REUSE_MAX_SESSION_AGE_MS

The OpenID session-token reuse window in AuthController was a hardcoded 15-minute
constant, forcing /api/auth/refresh to perform a real refreshTokenGrant against the
IdP every 15 minutes even when the current access token is still valid. IdPs that
rotate and revoke the previous access token on refresh then invalidate a token that
is still in use by downstream consumers of the reused OpenID token (e.g. MCP servers
that receive {{LIBRECHAT_OPENID_TOKEN}} and introspect the bearer), producing
~15-minute 401 cycles regardless of the access token's actual lifetime.

Read the window from process.env.OPENID_REUSE_MAX_SESSION_AGE_MS via the existing
math() helper, so it accepts an arithmetic expression like SESSION_EXPIRY (e.g.
60 * 60 * 24 * 1000), defaulting to the existing 15 minutes so behavior is unchanged
unless explicitly configured. The existing 30s-before-expiry guard still forces a
refresh before genuine expiry, so a larger window remains safe.

* fix: extend OpenID reuse session lifetime

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Peter Boers 2026-06-06 21:15:58 +02:00 committed by GitHub
parent 07af6ee288
commit 98822341ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 183 additions and 5 deletions

View file

@ -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 {

View file

@ -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'),

View file

@ -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,

View file

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