mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 06:52:47 +00:00
⏳ 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:
parent
07af6ee288
commit
98822341ed
5 changed files with 183 additions and 5 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
143
api/server/socialLogins.spec.js
Normal file
143
api/server/socialLogins.spec.js
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue