*️⃣ feat: Reuse OpenID Auth Tokens (#7397)

* feat: integrate OpenID Connect support with token reuse

- Added `jwks-rsa` and `new-openid-client` dependencies for OpenID Connect functionality.
- Implemented OpenID token refresh logic in `AuthController`.
- Enhanced `LogoutController` to handle OpenID logout and session termination.
- Updated JWT authentication middleware to support OpenID token provider.
- Modified OAuth routes to accommodate OpenID authentication and token management.
- Created `setOpenIDAuthTokens` function to manage OpenID tokens in cookies.
- Upgraded OpenID strategy with user info fetching and token exchange protocol.
- Introduced `openIdJwtLogin` strategy for handling OpenID JWT tokens.
- Added caching mechanism for exchanged OpenID tokens.
- Updated configuration to include OpenID exchanged tokens cache key.
- updated .env.example to include the new env variables needed for the feature.

* fix: update return type in downloadImage documentation for clarity and fixed openIdJwtLogin env variables

* fix: update Jest configuration and tests for OpenID strategy integration

* fix: update OpenID strategy to include callback URL in setup

* fix: fix optionalJwtAuth middleware to support OpenID token reuse and improve currentUrl method in CustomOpenIDStrategy to override the dynamic host issue related to proxy (e.g. cloudfront)

* fix: fixed code formatting

* Fix: Add mocks for openid-client and passport strategy in Jest configuration to fix unit tests

* fix eslint errors: Format mock file openid-client.

*  feat: Add PKCE support for OpenID and default handling in strategy setup

---------

Co-authored-by: Atef Bellaaj <slalom.bellaaj@external.daimlertruck.com>
Co-authored-by: Ruben Talstra <RubenTalstra1211@outlook.com>
This commit is contained in:
Peter 2025-05-22 14:19:24 +02:00 committed by Danny Avila
parent d47d827ed9
commit bf80cf30b3
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
24 changed files with 690 additions and 191 deletions

View file

@ -20,8 +20,8 @@ DOMAIN_CLIENT=http://localhost:3080
DOMAIN_SERVER=http://localhost:3080 DOMAIN_SERVER=http://localhost:3080
NO_INDEX=true NO_INDEX=true
# Use the address that is at most n number of hops away from the Express application. # Use the address that is at most n number of hops away from the Express application.
# req.socket.remoteAddress is the first hop, and the rest are looked for in the X-Forwarded-For header from right to left. # req.socket.remoteAddress is the first hop, and the rest are looked for in the X-Forwarded-For header from right to left.
# A value of 0 means that the first untrusted address would be req.socket.remoteAddress, i.e. there is no reverse proxy. # A value of 0 means that the first untrusted address would be req.socket.remoteAddress, i.e. there is no reverse proxy.
# Defaulted to 1. # Defaulted to 1.
TRUST_PROXY=1 TRUST_PROXY=1
@ -444,6 +444,21 @@ OPENID_IMAGE_URL=
# This will bypass the login form completely for users, only use this if OpenID is your only authentication method # This will bypass the login form completely for users, only use this if OpenID is your only authentication method
OPENID_AUTO_REDIRECT=false OPENID_AUTO_REDIRECT=false
# Set to true to use PKCE (Proof Key for Code Exchange) for OpenID authentication
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=
#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.
OPENID_JWKS_URL_CACHE_ENABLED=
OPENID_JWKS_URL_CACHE_TIME= # 600000 ms eq to 10 minutes leave empty to disable caching
#Set to true to trigger token exchange flow to acquire access token for the userinfo endpoint.
OPENID_ON_BEHALF_FLOW_FOR_USERINFRO_REQUIRED=
OPENID_ON_BEHALF_FLOW_USERINFRO_SCOPE = "user.read" # example for Scope Needed for Microsoft Graph API
# Set to true to use the OpenID Connect end session endpoint for logout
OPENID_USE_END_SESSION_ENDPOINT=
# LDAP # LDAP
LDAP_URL= LDAP_URL=
LDAP_BIND_DN= LDAP_BIND_DN=

View file

@ -61,6 +61,10 @@ const abortKeys = isRedisEnabled
? new Keyv({ store: keyvRedis }) ? new Keyv({ store: keyvRedis })
: new Keyv({ namespace: CacheKeys.ABORT_KEYS, ttl: Time.TEN_MINUTES }); : new Keyv({ namespace: CacheKeys.ABORT_KEYS, ttl: Time.TEN_MINUTES });
const openIdExchangedTokensCache = isRedisEnabled
? new Keyv({ store: keyvRedis, ttl: Time.TEN_MINUTES })
: new Keyv({ namespace: CacheKeys.OPENID_EXCHANGED_TOKENS, ttl: Time.TEN_MINUTES });
const namespaces = { const namespaces = {
[CacheKeys.ROLES]: roles, [CacheKeys.ROLES]: roles,
[CacheKeys.CONFIG_STORE]: config, [CacheKeys.CONFIG_STORE]: config,
@ -98,6 +102,7 @@ const namespaces = {
[CacheKeys.AUDIO_RUNS]: audioRuns, [CacheKeys.AUDIO_RUNS]: audioRuns,
[CacheKeys.MESSAGES]: messages, [CacheKeys.MESSAGES]: messages,
[CacheKeys.FLOWS]: flows, [CacheKeys.FLOWS]: flows,
[CacheKeys.OPENID_EXCHANGED_TOKENS]: openIdExchangedTokensCache,
}; };
/** /**

View file

@ -76,10 +76,13 @@ if (REDIS_URI && isEnabled(USE_REDIS)) {
keyvRedis = new KeyvRedis(REDIS_URI, keyvOpts); keyvRedis = new KeyvRedis(REDIS_URI, keyvOpts);
} }
const pingInterval = setInterval(() => { const pingInterval = setInterval(
logger.debug('KeyvRedis ping'); () => {
keyvRedis.client.ping().catch(err => logger.error('Redis keep-alive ping failed:', err)); logger.debug('KeyvRedis ping');
}, 5 * 60 * 1000); keyvRedis.client.ping().catch((err) => logger.error('Redis keep-alive ping failed:', err));
},
5 * 60 * 1000,
);
keyvRedis.on('ready', () => { keyvRedis.on('ready', () => {
logger.info('KeyvRedis connection ready'); logger.info('KeyvRedis connection ready');

View file

@ -11,5 +11,8 @@ module.exports = {
moduleNameMapper: { moduleNameMapper: {
'~/(.*)': '<rootDir>/$1', '~/(.*)': '<rootDir>/$1',
'~/data/auth.json': '<rootDir>/__mocks__/auth.mock.json', '~/data/auth.json': '<rootDir>/__mocks__/auth.mock.json',
'^openid-client/passport$': '<rootDir>/test/__mocks__/openid-client-passport.js', // Mock for the passport strategy part
'^openid-client$': '<rootDir>/test/__mocks__/openid-client.js',
}, },
transformIgnorePatterns: ['/node_modules/(?!(openid-client|oauth4webapi|jose)/).*/'],
}; };

View file

@ -75,6 +75,7 @@
"ioredis": "^5.3.2", "ioredis": "^5.3.2",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.0", "jsonwebtoken": "^9.0.0",
"jwks-rsa": "^3.2.0",
"keyv": "^5.3.2", "keyv": "^5.3.2",
"keyv-file": "^5.1.2", "keyv-file": "^5.1.2",
"klona": "^2.0.6", "klona": "^2.0.6",
@ -92,7 +93,7 @@
"ollama": "^0.5.0", "ollama": "^0.5.0",
"openai": "^4.96.2", "openai": "^4.96.2",
"openai-chat-tokens": "^0.2.8", "openai-chat-tokens": "^0.2.8",
"openid-client": "^5.4.2", "openid-client": "^6.5.0",
"passport": "^0.6.0", "passport": "^0.6.0",
"passport-apple": "^2.0.2", "passport-apple": "^2.0.2",
"passport-discord": "^0.1.4", "passport-discord": "^0.1.4",

View file

@ -1,3 +1,4 @@
const openIdClient = require('openid-client');
const cookies = require('cookie'); const cookies = require('cookie');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const { const {
@ -5,9 +6,12 @@ const {
resetPassword, resetPassword,
setAuthTokens, setAuthTokens,
requestPasswordReset, requestPasswordReset,
setOpenIDAuthTokens,
} = require('~/server/services/AuthService'); } = require('~/server/services/AuthService');
const { findSession, getUserById, deleteAllUserSessions } = require('~/models'); const { findSession, getUserById, deleteAllUserSessions, findUser } = require('~/models');
const { getOpenIdConfig } = require('~/strategies');
const { logger } = require('~/config'); const { logger } = require('~/config');
const { isEnabled } = require('~/server/utils');
const registrationController = async (req, res) => { const registrationController = async (req, res) => {
try { try {
@ -55,10 +59,28 @@ const resetPasswordController = async (req, res) => {
const refreshController = async (req, res) => { const refreshController = async (req, res) => {
const refreshToken = req.headers.cookie ? cookies.parse(req.headers.cookie).refreshToken : null; const refreshToken = req.headers.cookie ? cookies.parse(req.headers.cookie).refreshToken : null;
const token_provider = req.headers.cookie
? cookies.parse(req.headers.cookie).token_provider
: null;
if (!refreshToken) { if (!refreshToken) {
return res.status(200).send('Refresh token not provided'); return res.status(200).send('Refresh token not provided');
} }
if (token_provider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS) === true) {
try {
const openIdConfig = getOpenIdConfig();
const tokenset = await openIdClient.refreshTokenGrant(openIdConfig, refreshToken);
const claims = tokenset.claims();
const user = await findUser({ email: claims.email });
if (!user) {
return res.status(401).redirect('/login');
}
const token = setOpenIDAuthTokens(tokenset, res);
return res.status(200).send({ token, user });
} catch (error) {
logger.error('[refreshController] OpenID token refresh error', error);
return res.status(403).send('Invalid OpenID refresh token');
}
}
try { try {
const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET); const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
const user = await getUserById(payload.id, '-password -__v -totpSecret'); const user = await getUserById(payload.id, '-password -__v -totpSecret');

View file

@ -1,5 +1,5 @@
const cookies = require('cookie'); const cookies = require('cookie');
const { Issuer } = require('openid-client'); const { getOpenIdConfig } = require('~/strategies');
const { logoutUser } = require('~/server/services/AuthService'); const { logoutUser } = require('~/server/services/AuthService');
const { isEnabled } = require('~/server/utils'); const { isEnabled } = require('~/server/utils');
const { logger } = require('~/config'); const { logger } = require('~/config');
@ -10,20 +10,29 @@ const logoutController = async (req, res) => {
const logout = await logoutUser(req, refreshToken); const logout = await logoutUser(req, refreshToken);
const { status, message } = logout; const { status, message } = logout;
res.clearCookie('refreshToken'); res.clearCookie('refreshToken');
res.clearCookie('token_provider');
const response = { message }; const response = { message };
if ( if (
req.user.openidId != null && req.user.openidId != null &&
isEnabled(process.env.OPENID_USE_END_SESSION_ENDPOINT) && isEnabled(process.env.OPENID_USE_END_SESSION_ENDPOINT) &&
process.env.OPENID_ISSUER process.env.OPENID_ISSUER
) { ) {
const issuer = await Issuer.discover(process.env.OPENID_ISSUER); const openIdConfig = getOpenIdConfig();
const redirect = issuer.metadata.end_session_endpoint; if (!openIdConfig) {
if (!redirect) {
logger.warn( logger.warn(
'[logoutController] end_session_endpoint not found in OpenID issuer metadata. Please verify that the issuer is correct.', '[logoutController] OpenID config not found. Please verify that the open id configuration and initialization are correct.',
); );
} else { } else {
response.redirect = redirect; const endSessionEndpoint = openIdConfig
? openIdConfig.serverMetadata().end_session_endpoint
: null;
if (endSessionEndpoint) {
response.redirect = endSessionEndpoint;
} else {
logger.warn(
'[logoutController] end_session_endpoint not found in OpenID issuer metadata. Please verify that the issuer is correct.',
);
}
} }
} }
return res.status(status).send(response); return res.status(status).send(response);

View file

@ -75,7 +75,7 @@ const startServer = async () => {
/* OAUTH */ /* OAUTH */
app.use(passport.initialize()); app.use(passport.initialize());
passport.use(await jwtLogin()); passport.use(jwtLogin());
passport.use(passportLogin()); passport.use(passportLogin());
/* LDAP Auth */ /* LDAP Auth */
@ -84,7 +84,7 @@ const startServer = async () => {
} }
if (isEnabled(ALLOW_SOCIAL_LOGIN)) { if (isEnabled(ALLOW_SOCIAL_LOGIN)) {
configureSocialLogins(app); await configureSocialLogins(app);
} }
app.use('/oauth', routes.oauth); app.use('/oauth', routes.oauth);

View file

@ -1,9 +1,13 @@
const cookies = require('cookie');
const { isEnabled } = require('~/server/utils');
const passport = require('passport'); const passport = require('passport');
// This middleware does not require authentication, // This middleware does not require authentication,
// but if the user is authenticated, it will set the user object. // but if the user is authenticated, it will set the user object.
const optionalJwtAuth = (req, res, next) => { const optionalJwtAuth = (req, res, next) => {
passport.authenticate('jwt', { session: false }, (err, user) => { const cookieHeader = req.headers.cookie;
const tokenProvider = cookieHeader ? cookies.parse(cookieHeader).token_provider : null;
const callback = (err, user) => {
if (err) { if (err) {
return next(err); return next(err);
} }
@ -11,7 +15,11 @@ const optionalJwtAuth = (req, res, next) => {
req.user = user; req.user = user;
} }
next(); next();
})(req, res, next); };
if (tokenProvider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS)) {
return passport.authenticate('openidJwt', { session: false }, callback)(req, res, next);
}
passport.authenticate('jwt', { session: false }, callback)(req, res, next);
}; };
module.exports = optionalJwtAuth; module.exports = optionalJwtAuth;

View file

@ -1,5 +1,23 @@
const passport = require('passport'); const passport = require('passport');
const cookies = require('cookie');
const { isEnabled } = require('~/server/utils');
const requireJwtAuth = passport.authenticate('jwt', { session: false }); /**
* Custom Middleware to handle JWT authentication, with support for OpenID token reuse
* Switches between JWT and OpenID authentication based on cookies and environment settings
*/
const requireJwtAuth = (req, res, next) => {
// Check if token provider is specified in cookies
const cookieHeader = req.headers.cookie;
const tokenProvider = cookieHeader ? cookies.parse(cookieHeader).token_provider : null;
// Use OpenID authentication if token provider is OpenID and OPENID_REUSE_TOKENS is enabled
if (tokenProvider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS)) {
return passport.authenticate('openidJwt', { session: false })(req, res, next);
}
// Default to standard JWT authentication
return passport.authenticate('jwt', { session: false })(req, res, next);
};
module.exports = requireJwtAuth; module.exports = requireJwtAuth;

View file

@ -1,11 +1,11 @@
jest.mock('~/cache/getLogStores'); jest.mock('~/cache/getLogStores');
const request = require('supertest'); const request = require('supertest');
const express = require('express'); const express = require('express');
const routes = require('../'); const configRoute = require('../config');
// file deepcode ignore UseCsurfForExpress/test: test // file deepcode ignore UseCsurfForExpress/test: test
const app = express(); const app = express();
app.disable('x-powered-by'); app.disable('x-powered-by');
app.use('/api/config', routes.config); app.use('/api/config', configRoute);
afterEach(() => { afterEach(() => {
delete process.env.APP_TITLE; delete process.env.APP_TITLE;

View file

@ -8,8 +8,9 @@ const {
setBalanceConfig, setBalanceConfig,
checkDomainAllowed, checkDomainAllowed,
} = require('~/server/middleware'); } = require('~/server/middleware');
const { setAuthTokens } = require('~/server/services/AuthService'); const { setAuthTokens, setOpenIDAuthTokens } = require('~/server/services/AuthService');
const { logger } = require('~/config'); const { logger } = require('~/config');
const { isEnabled } = require('~/server/utils');
const router = express.Router(); const router = express.Router();
@ -28,7 +29,15 @@ const oauthHandler = async (req, res) => {
if (req.banned) { if (req.banned) {
return; return;
} }
await setAuthTokens(req.user._id, res); if (
req.user &&
req.user.provider == 'openid' &&
isEnabled(process.env.OPENID_REUSE_TOKENS) === true
) {
setOpenIDAuthTokens(req.user.tokenset, res);
} else {
await setAuthTokens(req.user._id, res);
}
res.redirect(domains.client); res.redirect(domains.client);
} catch (err) { } catch (err) {
logger.error('Error in setting authentication tokens:', err); logger.error('Error in setting authentication tokens:', err);

View file

@ -377,13 +377,62 @@ const setAuthTokens = async (userId, res, sessionId = null) => {
secure: isProduction, secure: isProduction,
sameSite: 'strict', sameSite: 'strict',
}); });
res.cookie('token_provider', 'librechat', {
expires: new Date(refreshTokenExpires),
httpOnly: true,
secure: isProduction,
sameSite: 'strict',
});
return token; return token;
} catch (error) { } catch (error) {
logger.error('[setAuthTokens] Error in setting authentication tokens:', error); logger.error('[setAuthTokens] Error in setting authentication tokens:', error);
throw error; throw error;
} }
}; };
/**
* @function setOpenIDAuthTokens
* Set OpenID Authentication Tokens
* //type tokenset from openid-client
* @param {import('openid-client').TokenEndpointResponse & import('openid-client').TokenEndpointResponseHelpers} tokenset
* - The tokenset object containing access and refresh tokens
* @param {Object} res - response object
* @returns {String} - access token
*/
const setOpenIDAuthTokens = (tokenset, res) => {
try {
if (!tokenset) {
logger.error('[setOpenIDAuthTokens] No tokenset found in request');
return;
}
const { REFRESH_TOKEN_EXPIRY } = process.env ?? {};
const expiryInMilliseconds = eval(REFRESH_TOKEN_EXPIRY) ?? 1000 * 60 * 60 * 24 * 7; // 7 days default
const expirationDate = new Date(Date.now() + expiryInMilliseconds);
if (tokenset == null) {
logger.error('[setOpenIDAuthTokens] No tokenset found in request');
return;
}
if (!tokenset.access_token || !tokenset.refresh_token) {
logger.error('[setOpenIDAuthTokens] No access or refresh token found in tokenset');
return;
}
res.cookie('refreshToken', tokenset.refresh_token, {
expires: expirationDate,
httpOnly: true,
secure: isProduction,
sameSite: 'strict',
});
res.cookie('token_provider', 'openid', {
expires: expirationDate,
httpOnly: true,
secure: isProduction,
sameSite: 'strict',
});
return tokenset.access_token;
} catch (error) {
logger.error('[setOpenIDAuthTokens] Error in setting authentication tokens:', error);
throw error;
}
};
/** /**
* Resend Verification Email * Resend Verification Email
@ -452,4 +501,5 @@ module.exports = {
resetPassword, resetPassword,
requestPasswordReset, requestPasswordReset,
resendVerificationEmail, resendVerificationEmail,
setOpenIDAuthTokens,
}; };

View file

@ -10,6 +10,7 @@ const {
discordLogin, discordLogin,
facebookLogin, facebookLogin,
appleLogin, appleLogin,
openIdJwtLogin,
} = require('~/strategies'); } = require('~/strategies');
const { isEnabled } = require('~/server/utils'); const { isEnabled } = require('~/server/utils');
const keyvRedis = require('~/cache/keyvRedis'); const keyvRedis = require('~/cache/keyvRedis');
@ -19,7 +20,7 @@ const { logger } = require('~/config');
* *
* @param {Express.Application} app * @param {Express.Application} app
*/ */
const configureSocialLogins = (app) => { const configureSocialLogins = async (app) => {
logger.info('Configuring social logins...'); logger.info('Configuring social logins...');
if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) { if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
@ -62,8 +63,11 @@ const configureSocialLogins = (app) => {
} }
app.use(session(sessionOptions)); app.use(session(sessionOptions));
app.use(passport.session()); app.use(passport.session());
setupOpenId(); const config = await setupOpenId();
if (isEnabled(process.env.OPENID_REUSE_TOKENS)) {
logger.info('OpenID token reuse is enabled.');
passport.use('openidJwt', openIdJwtLogin(config));
}
logger.info('OpenID Connect configured.'); logger.info('OpenID Connect configured.');
} }
}; };

View file

@ -4,9 +4,10 @@ const googleLogin = require('./googleStrategy');
const githubLogin = require('./githubStrategy'); const githubLogin = require('./githubStrategy');
const discordLogin = require('./discordStrategy'); const discordLogin = require('./discordStrategy');
const facebookLogin = require('./facebookStrategy'); const facebookLogin = require('./facebookStrategy');
const setupOpenId = require('./openidStrategy'); const { setupOpenId, getOpenIdConfig } = require('./openidStrategy');
const jwtLogin = require('./jwtStrategy'); const jwtLogin = require('./jwtStrategy');
const ldapLogin = require('./ldapStrategy'); const ldapLogin = require('./ldapStrategy');
const openIdJwtLogin = require('./openIdJwtStrategy');
module.exports = { module.exports = {
appleLogin, appleLogin,
@ -17,5 +18,7 @@ module.exports = {
jwtLogin, jwtLogin,
facebookLogin, facebookLogin,
setupOpenId, setupOpenId,
getOpenIdConfig,
ldapLogin, ldapLogin,
}; openIdJwtLogin,
};

View file

@ -4,7 +4,7 @@ const { getUserById, updateUser } = require('~/models');
const { logger } = require('~/config'); const { logger } = require('~/config');
// JWT strategy // JWT strategy
const jwtLogin = async () => const jwtLogin = () =>
new JwtStrategy( new JwtStrategy(
{ {
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),

View file

@ -0,0 +1,52 @@
const { SystemRoles } = require('librechat-data-provider');
const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt');
const { updateUser, findUser } = require('~/models');
const { logger } = require('~/config');
const jwksRsa = require('jwks-rsa');
const { isEnabled } = require('~/server/utils');
/**
* @function openIdJwtLogin
* @param {import('openid-client').Configuration} openIdConfig - Configuration object for the JWT strategy.
* @returns {JwtStrategy}
* @description This function creates a JWT strategy for OpenID authentication.
* It uses the jwks-rsa library to retrieve the signing key from a JWKS endpoint.
* The strategy extracts the JWT from the Authorization header as a Bearer token.
* The JWT is then verified using the signing key, and the user is retrieved from the database.
*/
const openIdJwtLogin = (openIdConfig) =>
new JwtStrategy(
{
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKeyProvider: jwksRsa.passportJwtSecret({
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,
jwksUri: openIdConfig.serverMetadata().jwks_uri,
}),
},
async (payload, done) => {
try {
const user = await findUser({ openidId: payload?.sub });
if (user) {
user.id = user._id.toString();
if (!user.role) {
user.role = SystemRoles.USER;
await updateUser(user.id, { role: user.role });
}
done(null, user);
} else {
logger.warn(
'[openIdJwtLogin] openId JwtStrategy => no user found with the sub claims: ' +
payload?.sub,
);
done(null, false);
}
} catch (err) {
done(err, false);
}
},
);
module.exports = openIdJwtLogin;

View file

@ -1,28 +1,101 @@
const { CacheKeys } = require('librechat-data-provider');
const fetch = require('node-fetch'); const fetch = require('node-fetch');
const passport = require('passport'); const passport = require('passport');
const jwtDecode = require('jsonwebtoken/decode'); const jwtDecode = require('jsonwebtoken/decode');
const { HttpsProxyAgent } = require('https-proxy-agent'); const { HttpsProxyAgent } = require('https-proxy-agent');
const { Issuer, Strategy: OpenIDStrategy, custom } = require('openid-client'); const client = require('openid-client');
const { Strategy: OpenIDStrategy } = require('openid-client/passport');
const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { findUser, createUser, updateUser } = require('~/models/userMethods'); const { findUser, createUser, updateUser } = require('~/models/userMethods');
const { hashToken } = require('~/server/utils/crypto'); const { hashToken } = require('~/server/utils/crypto');
const { isEnabled } = require('~/server/utils'); const { isEnabled } = require('~/server/utils');
const { logger } = require('~/config'); const { logger } = require('~/config');
const getLogStores = require('~/cache/getLogStores');
let crypto; /**
try { * @typedef {import('openid-client').ClientMetadata} ClientMetadata
crypto = require('node:crypto'); * @typedef {import('openid-client').Configuration} Configuration
} catch (err) { **/
logger.error('[openidStrategy] crypto support is disabled!', err);
/** @typedef {Configuration | null} */
let openidConfig = null;
//overload currenturl function because of express version 4 buggy req.host doesn't include port
//More info https://github.com/panva/openid-client/pull/713
class CustomOpenIDStrategy extends OpenIDStrategy {
currentUrl(req) {
const hostAndProtocol = process.env.DOMAIN_SERVER;
return new URL(`${hostAndProtocol}${req.originalUrl ?? req.url}`);
}
} }
/**
* Exchange the access token for a new access token using the on-behalf-of flow if required.
* @param {Configuration} config
* @param {string} accessToken access token to be exchanged if necessary
* @param {string} sub - The subject identifier of the user. usually found as "sub" in the claims of the token
* @param {boolean} fromCache - Indicates whether to use cached tokens.
* @returns {Promise<string>} The new access token if exchanged, otherwise the original access token.
*/
const exchangeAccessTokenIfNeeded = async (config, accessToken, sub, fromCache = false) => {
const tokensCache = getLogStores(CacheKeys.OPENID_EXCHANGED_TOKENS);
const onBehalfFlowRequired = isEnabled(process.env.OPENID_ON_BEHALF_FLOW_FOR_USERINFRO_REQUIRED);
if (onBehalfFlowRequired) {
if (fromCache) {
const cachedToken = await tokensCache.get(sub);
if (cachedToken) {
return cachedToken.access_token;
}
}
const grantResponse = await client.genericGrantRequest(
config,
'urn:ietf:params:oauth:grant-type:jwt-bearer',
{
scope: process.env.OPENID_ON_BEHALF_FLOW_USERINFRO_SCOPE || 'user.read',
assertion: accessToken,
requested_token_use: 'on_behalf_of',
},
);
await tokensCache.set(
sub,
{
access_token: grantResponse.access_token,
},
grantResponse.expires_in * 1000,
);
return grantResponse.access_token;
}
return accessToken;
};
/**
* get user info from openid provider
* @param {Configuration} config
* @param {string} accessToken access token
* @param {string} sub - The subject identifier of the user. usually found as "sub" in the claims of the token
* @returns {Promise<Object|null>}
*/
const getUserInfo = async (config, accessToken, sub) => {
try {
const exchangedAccessToken = await exchangeAccessTokenIfNeeded(config, accessToken, sub);
return await client.fetchUserInfo(config, exchangedAccessToken, sub);
} catch (error) {
logger.warn(`[openidStrategy] getUserInfo: Error fetching user info: ${error}`);
return null;
}
};
/** /**
* Downloads an image from a URL using an access token. * Downloads an image from a URL using an access token.
* @param {string} url * @param {string} url
* @param {string} accessToken * @param {Configuration} config
* @returns {Promise<Buffer>} * @param {string} accessToken access token
* @param {string} sub - The subject identifier of the user. usually found as "sub" in the claims of the token
* @returns {Promise<Buffer | string>} The image buffer or an empty string if the download fails.
*/ */
const downloadImage = async (url, accessToken) => { const downloadImage = async (url, config, accessToken, sub) => {
const exchangedAccessToken = await exchangeAccessTokenIfNeeded(config, accessToken, sub, true);
if (!url) { if (!url) {
return ''; return '';
} }
@ -31,7 +104,7 @@ const downloadImage = async (url, accessToken) => {
const options = { const options = {
method: 'GET', method: 'GET',
headers: { headers: {
Authorization: `Bearer ${accessToken}`, Authorization: `Bearer ${exchangedAccessToken}`,
}, },
}; };
@ -105,63 +178,68 @@ function convertToUsername(input, defaultValue = '') {
return defaultValue; return defaultValue;
} }
/**
* Sets up the OpenID strategy for authentication.
* This function configures the OpenID client, handles proxy settings,
* and defines the OpenID strategy for Passport.js.
*
* @async
* @function setupOpenId
* @returns {Promise<Configuration | null>} A promise that resolves when the OpenID strategy is set up and returns the openid client config object.
* @throws {Error} If an error occurs during the setup process.
*/
async function setupOpenId() { async function setupOpenId() {
try { try {
if (process.env.PROXY) { /** @type {ClientMetadata} */
const proxyAgent = new HttpsProxyAgent(process.env.PROXY);
custom.setHttpOptionsDefaults({
agent: proxyAgent,
});
logger.info(`[openidStrategy] proxy agent added: ${process.env.PROXY}`);
}
const issuer = await Issuer.discover(process.env.OPENID_ISSUER);
/* Supported Algorithms, openid-client v5 doesn't set it automatically as discovered from server.
- id_token_signed_response_alg // defaults to 'RS256'
- request_object_signing_alg // defaults to 'RS256'
- userinfo_signed_response_alg // not in v5
- introspection_signed_response_alg // not in v5
- authorization_signed_response_alg // not in v5
*/
/** @type {import('openid-client').ClientMetadata} */
const clientMetadata = { const clientMetadata = {
client_id: process.env.OPENID_CLIENT_ID, client_id: process.env.OPENID_CLIENT_ID,
client_secret: process.env.OPENID_CLIENT_SECRET, client_secret: process.env.OPENID_CLIENT_SECRET,
redirect_uris: [process.env.DOMAIN_SERVER + process.env.OPENID_CALLBACK_URL],
}; };
if (isEnabled(process.env.OPENID_SET_FIRST_SUPPORTED_ALGORITHM)) {
clientMetadata.id_token_signed_response_alg = /** @type {Configuration} */
issuer.id_token_signing_alg_values_supported?.[0] || 'RS256'; openidConfig = await client.discovery(
new URL(process.env.OPENID_ISSUER),
process.env.OPENID_CLIENT_ID,
clientMetadata,
);
if (process.env.PROXY) {
const proxyAgent = new HttpsProxyAgent(process.env.PROXY);
openidConfig[client.customFetch] = (...args) => {
return fetch(args[0], { ...args[1], agent: proxyAgent });
};
logger.info(`[openidStrategy] proxy agent added: ${process.env.PROXY}`);
} }
const client = new issuer.Client(clientMetadata);
const requiredRole = process.env.OPENID_REQUIRED_ROLE; const requiredRole = process.env.OPENID_REQUIRED_ROLE;
const requiredRoleParameterPath = process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH; const requiredRoleParameterPath = process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH;
const requiredRoleTokenKind = process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND; const requiredRoleTokenKind = process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND;
const openidLogin = new OpenIDStrategy( const usePKCE = isEnabled(process.env.OPENID_USE_PKCE);
const openidLogin = new CustomOpenIDStrategy(
{ {
client, config: openidConfig,
params: { scope: process.env.OPENID_SCOPE,
scope: process.env.OPENID_SCOPE, callbackURL: process.env.DOMAIN_SERVER + process.env.OPENID_CALLBACK_URL,
}, usePKCE,
}, },
async (tokenset, userinfo, done) => { async (tokenset, done) => {
try { try {
logger.info(`[openidStrategy] verify login openidId: ${userinfo.sub}`); const claims = tokenset.claims();
logger.debug('[openidStrategy] very login tokenset and userinfo', { tokenset, userinfo }); let user = await findUser({ openidId: claims.sub });
let user = await findUser({ openidId: userinfo.sub });
logger.info( logger.info(
`[openidStrategy] user ${user ? 'found' : 'not found'} with openidId: ${userinfo.sub}`, `[openidStrategy] user ${user ? 'found' : 'not found'} with openidId: ${claims.sub}`,
); );
if (!user) { if (!user) {
user = await findUser({ email: userinfo.email }); user = await findUser({ email: claims.email });
logger.info( logger.info(
`[openidStrategy] user ${user ? 'found' : 'not found'} with email: ${ `[openidStrategy] user ${user ? 'found' : 'not found'} with email: ${
userinfo.email claims.email
} for openidId: ${userinfo.sub}`, } for openidId: ${claims.sub}`,
); );
} }
const userinfo = {
...claims,
...(await getUserInfo(openidConfig, tokenset.access_token, claims.sub)),
};
const fullName = getFullName(userinfo); const fullName = getFullName(userinfo);
if (requiredRole) { if (requiredRole) {
@ -220,7 +298,7 @@ async function setupOpenId() {
user.name = fullName; user.name = fullName;
} }
if (userinfo.picture && !user.avatar?.includes('manual=true')) { if (!!userinfo && userinfo.picture && !user.avatar?.includes('manual=true')) {
/** @type {string | undefined} */ /** @type {string | undefined} */
const imageUrl = userinfo.picture; const imageUrl = userinfo.picture;
@ -231,7 +309,12 @@ async function setupOpenId() {
fileName = userinfo.sub + '.png'; fileName = userinfo.sub + '.png';
} }
const imageBuffer = await downloadImage(imageUrl, tokenset.access_token); const imageBuffer = await downloadImage(
imageUrl,
openidConfig,
tokenset.access_token,
userinfo.sub,
);
if (imageBuffer) { if (imageBuffer) {
const { saveBuffer } = getStrategyFunctions(process.env.CDN_PROVIDER); const { saveBuffer } = getStrategyFunctions(process.env.CDN_PROVIDER);
const imagePath = await saveBuffer({ const imagePath = await saveBuffer({
@ -257,18 +340,34 @@ async function setupOpenId() {
}, },
); );
done(null, user); done(null, { ...user, tokenset });
} catch (err) { } catch (err) {
logger.error('[openidStrategy] login failed', err); logger.error('[openidStrategy] login failed', err);
done(err); done(err);
} }
}, },
); );
passport.use('openid', openidLogin); passport.use('openid', openidLogin);
return openidConfig;
} catch (err) { } catch (err) {
logger.error('[openidStrategy]', err); logger.error('[openidStrategy]', err);
return null;
} }
} }
/**
* @function getOpenIdConfig
* @description Returns the OpenID client instance.
* @throws {Error} If the OpenID client is not initialized.
* @returns {Configuration}
*/
function getOpenIdConfig() {
if (!openidConfig) {
throw new Error('OpenID client is not initialized. Please call setupOpenId first.');
}
return openidConfig;
}
module.exports = setupOpenId; module.exports = {
setupOpenId,
getOpenIdConfig,
};

View file

@ -1,16 +1,13 @@
const fetch = require('node-fetch'); const fetch = require('node-fetch');
const jwtDecode = require('jsonwebtoken/decode'); const jwtDecode = require('jsonwebtoken/decode');
const { Issuer, Strategy: OpenIDStrategy } = require('openid-client');
const { findUser, createUser, updateUser } = require('~/models/userMethods'); const { findUser, createUser, updateUser } = require('~/models/userMethods');
const setupOpenId = require('./openidStrategy'); const { setupOpenId } = require('./openidStrategy');
// --- Mocks --- // --- Mocks ---
jest.mock('node-fetch'); jest.mock('node-fetch');
jest.mock('openid-client');
jest.mock('jsonwebtoken/decode'); jest.mock('jsonwebtoken/decode');
jest.mock('~/server/services/Files/strategies', () => ({ jest.mock('~/server/services/Files/strategies', () => ({
getStrategyFunctions: jest.fn(() => ({ getStrategyFunctions: jest.fn(() => ({
// You can modify this mock as needed (here returning a dummy function)
saveBuffer: jest.fn().mockResolvedValue('/fake/path/to/avatar.png'), saveBuffer: jest.fn().mockResolvedValue('/fake/path/to/avatar.png'),
})), })),
})); }));
@ -23,38 +20,73 @@ jest.mock('~/server/utils/crypto', () => ({
hashToken: jest.fn().mockResolvedValue('hashed-token'), hashToken: jest.fn().mockResolvedValue('hashed-token'),
})); }));
jest.mock('~/server/utils', () => ({ jest.mock('~/server/utils', () => ({
isEnabled: jest.fn(() => false), // default to false, override per test if needed isEnabled: jest.fn(() => false),
})); }));
jest.mock('~/config', () => ({ jest.mock('~/config', () => ({
logger: { logger: {
info: jest.fn(), info: jest.fn(),
debug: jest.fn(), debug: jest.fn(),
error: jest.fn(), error: jest.fn(),
warn: jest.fn(),
},
}));
jest.mock('~/cache/getLogStores', () =>
jest.fn(() => ({
get: jest.fn(),
set: jest.fn(),
})),
);
jest.mock('librechat-data-provider', () => ({
CacheKeys: {
OPENID_EXCHANGED_TOKENS: 'openid-exchanged-tokens',
}, },
})); }));
// Mock Issuer.discover so that setupOpenId gets a fake issuer and client // Mock the openid-client module and all its dependencies
Issuer.discover = jest.fn().mockResolvedValue({ jest.mock('openid-client', () => {
id_token_signing_alg_values_supported: ['RS256'], return {
Client: jest.fn().mockImplementation((clientMetadata) => { discovery: jest.fn().mockResolvedValue({
return { clientId: 'fake_client_id',
metadata: clientMetadata, clientSecret: 'fake_client_secret',
}; issuer: 'https://fake-issuer.com',
}), // Add any other properties needed by the implementation
}),
fetchUserInfo: jest.fn().mockImplementation((config, accessToken, sub) => {
// Only return additional properties, but don't override any claims
return Promise.resolve({
preferred_username: 'preferred_username',
});
}),
customFetch: Symbol('customFetch'),
};
}); });
// To capture the verify callback from the strategy, we grab it from the mock constructor jest.mock('openid-client/passport', () => {
let verifyCallback; let verifyCallback;
OpenIDStrategy.mockImplementation((options, verify) => { const mockStrategy = jest.fn((options, verify) => {
verifyCallback = verify; verifyCallback = verify;
return { name: 'openid', options, verify }; return { name: 'openid', options, verify };
});
return {
Strategy: mockStrategy,
__getVerifyCallback: () => verifyCallback,
};
}); });
// Mock passport
jest.mock('passport', () => ({
use: jest.fn(),
}));
describe('setupOpenId', () => { describe('setupOpenId', () => {
// Store a reference to the verify callback once it's set up
let verifyCallback;
// Helper to wrap the verify callback in a promise // Helper to wrap the verify callback in a promise
const validate = (tokenset, userinfo) => const validate = (tokenset) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
verifyCallback(tokenset, userinfo, (err, user, details) => { verifyCallback(tokenset, (err, user, details) => {
if (err) { if (err) {
reject(err); reject(err);
} else { } else {
@ -66,17 +98,16 @@ describe('setupOpenId', () => {
const tokenset = { const tokenset = {
id_token: 'fake_id_token', id_token: 'fake_id_token',
access_token: 'fake_access_token', access_token: 'fake_access_token',
}; claims: () => ({
sub: '1234',
const baseUserinfo = { email: 'test@example.com',
sub: '1234', email_verified: true,
email: 'test@example.com', given_name: 'First',
email_verified: true, family_name: 'Last',
given_name: 'First', name: 'My Full',
family_name: 'Last', username: 'flast',
name: 'My Full', picture: 'https://example.com/avatar.png',
username: 'flast', }),
picture: 'https://example.com/avatar.png',
}; };
beforeEach(async () => { beforeEach(async () => {
@ -96,6 +127,7 @@ describe('setupOpenId', () => {
delete process.env.OPENID_USERNAME_CLAIM; delete process.env.OPENID_USERNAME_CLAIM;
delete process.env.OPENID_NAME_CLAIM; delete process.env.OPENID_NAME_CLAIM;
delete process.env.PROXY; delete process.env.PROXY;
delete process.env.OPENID_USE_PKCE;
// Default jwtDecode mock returns a token that includes the required role. // Default jwtDecode mock returns a token that includes the required role.
jwtDecode.mockReturnValue({ jwtDecode.mockReturnValue({
@ -120,16 +152,17 @@ describe('setupOpenId', () => {
}; };
fetch.mockResolvedValue(fakeResponse); fetch.mockResolvedValue(fakeResponse);
// Finally, call the setup function so that passport.use gets called // Call the setup function and capture the verify callback
await setupOpenId(); await setupOpenId();
verifyCallback = require('openid-client/passport').__getVerifyCallback();
}); });
it('should create a new user with correct username when username claim exists', async () => { it('should create a new user with correct username when username claim exists', async () => {
// Arrange our userinfo already has username 'flast' // Arrange our userinfo already has username 'flast'
const userinfo = { ...baseUserinfo }; const userinfo = tokenset.claims();
// Act // Act
const { user } = await validate(tokenset, userinfo); const { user } = await validate(tokenset);
// Assert // Assert
expect(user.username).toBe(userinfo.username); expect(user.username).toBe(userinfo.username);
@ -148,13 +181,13 @@ describe('setupOpenId', () => {
it('should use given_name as username when username claim is missing', async () => { it('should use given_name as username when username claim is missing', async () => {
// Arrange remove username from userinfo // Arrange remove username from userinfo
const userinfo = { ...baseUserinfo }; const userinfo = { ...tokenset.claims() };
delete userinfo.username; delete userinfo.username;
// Expect the username to be the given name (unchanged case) // Expect the username to be the given name (unchanged case)
const expectUsername = userinfo.given_name; const expectUsername = userinfo.given_name;
// Act // Act
const { user } = await validate(tokenset, userinfo); const { user } = await validate({ ...tokenset, claims: () => userinfo });
// Assert // Assert
expect(user.username).toBe(expectUsername); expect(user.username).toBe(expectUsername);
@ -167,13 +200,13 @@ describe('setupOpenId', () => {
it('should use email as username when username and given_name are missing', async () => { it('should use email as username when username and given_name are missing', async () => {
// Arrange remove username and given_name // Arrange remove username and given_name
const userinfo = { ...baseUserinfo }; const userinfo = { ...tokenset.claims() };
delete userinfo.username; delete userinfo.username;
delete userinfo.given_name; delete userinfo.given_name;
const expectUsername = userinfo.email; const expectUsername = userinfo.email;
// Act // Act
const { user } = await validate(tokenset, userinfo); const { user } = await validate({ ...tokenset, claims: () => userinfo });
// Assert // Assert
expect(user.username).toBe(expectUsername); expect(user.username).toBe(expectUsername);
@ -187,10 +220,10 @@ describe('setupOpenId', () => {
it('should override username with OPENID_USERNAME_CLAIM when set', async () => { it('should override username with OPENID_USERNAME_CLAIM when set', async () => {
// Arrange set OPENID_USERNAME_CLAIM so that the sub claim is used // Arrange set OPENID_USERNAME_CLAIM so that the sub claim is used
process.env.OPENID_USERNAME_CLAIM = 'sub'; process.env.OPENID_USERNAME_CLAIM = 'sub';
const userinfo = { ...baseUserinfo }; const userinfo = tokenset.claims();
// Act // Act
const { user } = await validate(tokenset, userinfo); const { user } = await validate(tokenset);
// Assert username should equal the sub (converted as-is) // Assert username should equal the sub (converted as-is)
expect(user.username).toBe(userinfo.sub); expect(user.username).toBe(userinfo.sub);
@ -203,11 +236,11 @@ describe('setupOpenId', () => {
it('should set the full name correctly when given_name and family_name exist', async () => { it('should set the full name correctly when given_name and family_name exist', async () => {
// Arrange // Arrange
const userinfo = { ...baseUserinfo }; const userinfo = tokenset.claims();
const expectedFullName = `${userinfo.given_name} ${userinfo.family_name}`; const expectedFullName = `${userinfo.given_name} ${userinfo.family_name}`;
// Act // Act
const { user } = await validate(tokenset, userinfo); const { user } = await validate(tokenset);
// Assert // Assert
expect(user.name).toBe(expectedFullName); expect(user.name).toBe(expectedFullName);
@ -216,10 +249,10 @@ describe('setupOpenId', () => {
it('should override full name with OPENID_NAME_CLAIM when set', async () => { it('should override full name with OPENID_NAME_CLAIM when set', async () => {
// Arrange use the name claim as the full name // Arrange use the name claim as the full name
process.env.OPENID_NAME_CLAIM = 'name'; process.env.OPENID_NAME_CLAIM = 'name';
const userinfo = { ...baseUserinfo, name: 'Custom Name' }; const userinfo = { ...tokenset.claims(), name: 'Custom Name' };
// Act // Act
const { user } = await validate(tokenset, userinfo); const { user } = await validate({ ...tokenset, claims: () => userinfo });
// Assert // Assert
expect(user.name).toBe('Custom Name'); expect(user.name).toBe('Custom Name');
@ -230,31 +263,31 @@ describe('setupOpenId', () => {
const existingUser = { const existingUser = {
_id: 'existingUserId', _id: 'existingUserId',
provider: 'local', provider: 'local',
email: baseUserinfo.email, email: tokenset.claims().email,
openidId: '', openidId: '',
username: '', username: '',
name: '', name: '',
}; };
findUser.mockImplementation(async (query) => { findUser.mockImplementation(async (query) => {
if (query.openidId === baseUserinfo.sub || query.email === baseUserinfo.email) { if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) {
return existingUser; return existingUser;
} }
return null; return null;
}); });
const userinfo = { ...baseUserinfo }; const userinfo = tokenset.claims();
// Act // Act
await validate(tokenset, userinfo); await validate(tokenset);
// Assert updateUser should be called and the user object updated // Assert updateUser should be called and the user object updated
expect(updateUser).toHaveBeenCalledWith( expect(updateUser).toHaveBeenCalledWith(
existingUser._id, existingUser._id,
expect.objectContaining({ expect.objectContaining({
provider: 'openid', provider: 'openid',
openidId: baseUserinfo.sub, openidId: userinfo.sub,
username: baseUserinfo.username, username: userinfo.username,
name: `${baseUserinfo.given_name} ${baseUserinfo.family_name}`, name: `${userinfo.given_name} ${userinfo.family_name}`,
}), }),
); );
}); });
@ -264,10 +297,10 @@ describe('setupOpenId', () => {
jwtDecode.mockReturnValue({ jwtDecode.mockReturnValue({
roles: ['SomeOtherRole'], roles: ['SomeOtherRole'],
}); });
const userinfo = { ...baseUserinfo }; const userinfo = tokenset.claims();
// Act // Act
const { user, details } = await validate(tokenset, userinfo); const { user, details } = await validate(tokenset);
// Assert verify that the strategy rejects login // Assert verify that the strategy rejects login
expect(user).toBe(false); expect(user).toBe(false);
@ -276,10 +309,10 @@ describe('setupOpenId', () => {
it('should attempt to download and save the avatar if picture is provided', async () => { it('should attempt to download and save the avatar if picture is provided', async () => {
// Arrange ensure userinfo contains a picture URL // Arrange ensure userinfo contains a picture URL
const userinfo = { ...baseUserinfo }; const userinfo = tokenset.claims();
// Act // Act
const { user } = await validate(tokenset, userinfo); const { user } = await validate(tokenset);
// Assert verify that download was attempted and the avatar field was set via updateUser // Assert verify that download was attempted and the avatar field was set via updateUser
expect(fetch).toHaveBeenCalled(); expect(fetch).toHaveBeenCalled();
@ -289,14 +322,25 @@ describe('setupOpenId', () => {
it('should not attempt to download avatar if picture is not provided', async () => { it('should not attempt to download avatar if picture is not provided', async () => {
// Arrange remove picture // Arrange remove picture
const userinfo = { ...baseUserinfo }; const userinfo = { ...tokenset.claims() };
delete userinfo.picture; delete userinfo.picture;
// Act // Act
await validate(tokenset, userinfo); await validate({ ...tokenset, claims: () => userinfo });
// Assert fetch should not be called and avatar should remain undefined or empty // Assert fetch should not be called and avatar should remain undefined or empty
expect(fetch).not.toHaveBeenCalled(); expect(fetch).not.toHaveBeenCalled();
// Depending on your implementation, user.avatar may be undefined or an empty string. // Depending on your implementation, user.avatar may be undefined or an empty string.
}); });
it('should default to usePKCE false when OPENID_USE_PKCE is not defined', async () => {
const OpenIDStrategy = require('openid-client/passport').Strategy;
delete process.env.OPENID_USE_PKCE;
await setupOpenId();
const callOptions = OpenIDStrategy.mock.calls[OpenIDStrategy.mock.calls.length - 1][0];
expect(callOptions.usePKCE).toBe(false);
expect(callOptions.params?.code_challenge_method).toBeUndefined();
});
}); });

View file

@ -7,7 +7,8 @@ const socialLogin =
(provider, getProfileDetails) => async (accessToken, refreshToken, idToken, profile, cb) => { (provider, getProfileDetails) => async (accessToken, refreshToken, idToken, profile, cb) => {
try { try {
const { email, id, avatarUrl, username, name, emailVerified } = getProfileDetails({ const { email, id, avatarUrl, username, name, emailVerified } = getProfileDetails({
idToken, profile, idToken,
profile,
}); });
const oldUser = await findUser({ email: email.trim() }); const oldUser = await findUser({ email: email.trim() });

View file

@ -0,0 +1,6 @@
// api/test/__mocks__/openid-client-passport.js
const Strategy = jest.fn().mockImplementation((options, verify) => {
return { name: 'mocked-openid-passport-strategy', options, verify };
});
module.exports = { Strategy };

View file

@ -0,0 +1,67 @@
// api/test/__mocks__/openid-client.js
module.exports = {
Issuer: {
discover: jest.fn().mockResolvedValue({
Client: jest.fn().mockImplementation(() => ({
authorizationUrl: jest.fn().mockReturnValue('mock_auth_url'),
callback: jest.fn().mockResolvedValue({
access_token: 'mock_access_token',
id_token: 'mock_id_token',
claims: () => ({
sub: 'mock_sub',
email: 'mock@example.com',
}),
}),
userinfo: jest.fn().mockResolvedValue({
sub: 'mock_sub',
email: 'mock@example.com',
}),
})),
}),
},
Strategy: jest.fn().mockImplementation((options, verify) => {
// Store verify to call it if needed, or just mock the strategy behavior
return { name: 'openid-mock-strategy' };
}),
custom: {
setHttpOptionsDefaults: jest.fn(),
},
// Add any other exports from openid-client that are used directly
// For example, if your code uses `client.Issuer.discover`, then mock `Issuer`
// If it uses `new Strategy()`, then mock `Strategy`
// Based on openidStrategy.js, it uses:
// const client = require('openid-client'); -> client.discovery, client.fetchUserInfo, client.genericGrantRequest
// const { Strategy: OpenIDStrategy } = require('openid-client/passport');
// So the mock needs to cover these.
// The provided mock in openidStrategy.spec.js is a good reference.
// Simpler mock based on the spec file:
discovery: jest.fn().mockResolvedValue({
clientId: 'fake_client_id',
clientSecret: 'fake_client_secret',
issuer: 'https://fake-issuer.com',
Client: jest.fn().mockImplementation(() => ({
authorizationUrl: jest.fn().mockReturnValue('mock_auth_url'),
callback: jest.fn().mockResolvedValue({
access_token: 'mock_access_token',
id_token: 'mock_id_token',
claims: () => ({
sub: 'mock_sub',
email: 'mock@example.com',
}),
}),
userinfo: jest.fn().mockResolvedValue({
sub: 'mock_sub',
email: 'mock@example.com',
}),
grant: jest.fn().mockResolvedValue({ access_token: 'mock_grant_token' }), // For genericGrantRequest
})),
}),
fetchUserInfo: jest.fn().mockResolvedValue({
preferred_username: 'preferred_username',
}),
genericGrantRequest: jest
.fn()
.mockResolvedValue({ access_token: 'mock_grant_access_token', expires_in: 3600 }),
customFetch: Symbol('customFetch'),
};

178
package-lock.json generated
View file

@ -91,6 +91,7 @@
"ioredis": "^5.3.2", "ioredis": "^5.3.2",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.0", "jsonwebtoken": "^9.0.0",
"jwks-rsa": "^3.2.0",
"keyv": "^5.3.2", "keyv": "^5.3.2",
"keyv-file": "^5.1.2", "keyv-file": "^5.1.2",
"klona": "^2.0.6", "klona": "^2.0.6",
@ -108,7 +109,7 @@
"ollama": "^0.5.0", "ollama": "^0.5.0",
"openai": "^4.96.2", "openai": "^4.96.2",
"openai-chat-tokens": "^0.2.8", "openai-chat-tokens": "^0.2.8",
"openid-client": "^5.4.2", "openid-client": "^6.5.0",
"passport": "^0.6.0", "passport": "^0.6.0",
"passport-apple": "^2.0.2", "passport-apple": "^2.0.2",
"passport-discord": "^0.1.4", "passport-discord": "^0.1.4",
@ -814,6 +815,15 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"api/node_modules/jose": {
"version": "6.0.11",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.0.11.tgz",
"integrity": "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"api/node_modules/keyv-file": { "api/node_modules/keyv-file": {
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/keyv-file/-/keyv-file-5.1.2.tgz", "resolved": "https://registry.npmjs.org/keyv-file/-/keyv-file-5.1.2.tgz",
@ -1023,6 +1033,19 @@
} }
} }
}, },
"api/node_modules/openid-client": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.5.0.tgz",
"integrity": "sha512-fAfYaTnOYE2kQCqEJGX9KDObW2aw7IQy4jWpU/+3D3WoCFLbix5Hg6qIPQ6Js9r7f8jDUmsnnguRNCSw4wU/IQ==",
"license": "MIT",
"dependencies": {
"jose": "^6.0.10",
"oauth4webapi": "^3.5.1"
},
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"api/node_modules/sharp": { "api/node_modules/sharp": {
"version": "0.33.5", "version": "0.33.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
@ -25002,7 +25025,6 @@
"version": "1.19.5", "version": "1.19.5",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz",
"integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==",
"dev": true,
"dependencies": { "dependencies": {
"@types/connect": "*", "@types/connect": "*",
"@types/node": "*" "@types/node": "*"
@ -25012,7 +25034,6 @@
"version": "3.4.38", "version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"dependencies": { "dependencies": {
"@types/node": "*" "@types/node": "*"
} }
@ -25101,8 +25122,7 @@
"node_modules/@types/http-errors": { "node_modules/@types/http-errors": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz",
"integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA=="
"dev": true
}, },
"node_modules/@types/istanbul-lib-coverage": { "node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6", "version": "2.0.6",
@ -25172,6 +25192,16 @@
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
"dev": true "dev": true
}, },
"node_modules/@types/jsonwebtoken": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.9.tgz",
"integrity": "sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==",
"license": "MIT",
"dependencies": {
"@types/ms": "*",
"@types/node": "*"
}
},
"node_modules/@types/katex": { "node_modules/@types/katex": {
"version": "0.16.7", "version": "0.16.7",
"resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.7.tgz", "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.7.tgz",
@ -25203,8 +25233,7 @@
"node_modules/@types/mime": { "node_modules/@types/mime": {
"version": "1.3.5", "version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="
"dev": true
}, },
"node_modules/@types/ms": { "node_modules/@types/ms": {
"version": "0.7.34", "version": "0.7.34",
@ -25242,14 +25271,12 @@
"node_modules/@types/qs": { "node_modules/@types/qs": {
"version": "6.9.17", "version": "6.9.17",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.17.tgz", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.17.tgz",
"integrity": "sha512-rX4/bPcfmvxHDv0XjfJELTTr+iB+tn032nPILqHm5wbthUUUuVtNGGqzhya9XUxjTP8Fpr0qYgSZZKxGY++svQ==", "integrity": "sha512-rX4/bPcfmvxHDv0XjfJELTTr+iB+tn032nPILqHm5wbthUUUuVtNGGqzhya9XUxjTP8Fpr0qYgSZZKxGY++svQ=="
"dev": true
}, },
"node_modules/@types/range-parser": { "node_modules/@types/range-parser": {
"version": "1.2.7", "version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="
"dev": true
}, },
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "18.2.53", "version": "18.2.53",
@ -25300,7 +25327,6 @@
"version": "0.17.4", "version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz",
"integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==",
"dev": true,
"dependencies": { "dependencies": {
"@types/mime": "^1", "@types/mime": "^1",
"@types/node": "*" "@types/node": "*"
@ -25310,7 +25336,6 @@
"version": "1.15.7", "version": "1.15.7",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz",
"integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==",
"dev": true,
"dependencies": { "dependencies": {
"@types/http-errors": "*", "@types/http-errors": "*",
"@types/node": "*", "@types/node": "*",
@ -33822,6 +33847,47 @@
"safe-buffer": "^5.0.1" "safe-buffer": "^5.0.1"
} }
}, },
"node_modules/jwks-rsa": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.0.tgz",
"integrity": "sha512-PwchfHcQK/5PSydeKCs1ylNym0w/SSv8a62DgHJ//7x2ZclCoinlsjAfDxAAbpoTPybOum/Jgy+vkvMmKz89Ww==",
"license": "MIT",
"dependencies": {
"@types/express": "^4.17.20",
"@types/jsonwebtoken": "^9.0.4",
"debug": "^4.3.4",
"jose": "^4.15.4",
"limiter": "^1.1.5",
"lru-memoizer": "^2.2.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/jwks-rsa/node_modules/@types/express": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz",
"integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==",
"license": "MIT",
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^4.17.33",
"@types/qs": "*",
"@types/serve-static": "*"
}
},
"node_modules/jwks-rsa/node_modules/@types/express-serve-static-core": {
"version": "4.19.6",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz",
"integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==",
"license": "MIT",
"dependencies": {
"@types/node": "*",
"@types/qs": "*",
"@types/range-parser": "*",
"@types/send": "*"
}
},
"node_modules/jws": { "node_modules/jws": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz",
@ -34142,6 +34208,11 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/limiter": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz",
"integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA=="
},
"node_modules/lines-and-columns": { "node_modules/lines-and-columns": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@ -34483,6 +34554,12 @@
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==" "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="
}, },
"node_modules/lodash.clonedeep": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
"license": "MIT"
},
"node_modules/lodash.debounce": { "node_modules/lodash.debounce": {
"version": "4.0.8", "version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
@ -34860,6 +34937,34 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/lru-memoizer": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz",
"integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==",
"license": "MIT",
"dependencies": {
"lodash.clonedeep": "^4.5.0",
"lru-cache": "6.0.0"
}
},
"node_modules/lru-memoizer/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/lru-memoizer/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
"node_modules/lucide-react": { "node_modules/lucide-react": {
"version": "0.394.0", "version": "0.394.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.394.0.tgz", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.394.0.tgz",
@ -36779,6 +36884,15 @@
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.0.tgz", "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.0.tgz",
"integrity": "sha512-1orQ9MT1vHFGQxhuy7E/0gECD3fd2fCC+PIX+/jgmU/gI3EpRocXtmtvxCO5x3WZ443FLTLFWNDjl5MPJf9u+Q==" "integrity": "sha512-1orQ9MT1vHFGQxhuy7E/0gECD3fd2fCC+PIX+/jgmU/gI3EpRocXtmtvxCO5x3WZ443FLTLFWNDjl5MPJf9u+Q=="
}, },
"node_modules/oauth4webapi": {
"version": "3.5.1",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.5.1.tgz",
"integrity": "sha512-txg/jZQwcbaF7PMJgY7aoxc9QuCxHVFMiEkDIJ60DwDz3PbtXPQnrzo+3X4IRYGChIwWLabRBRpf1k9hO9+xrQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -36920,14 +37034,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/oidc-token-hash": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz",
"integrity": "sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==",
"engines": {
"node": "^10.13.0 || >=12.0.0"
}
},
"node_modules/ollama": { "node_modules/ollama": {
"version": "0.5.14", "version": "0.5.14",
"resolved": "https://registry.npmjs.org/ollama/-/ollama-0.5.14.tgz", "resolved": "https://registry.npmjs.org/ollama/-/ollama-0.5.14.tgz",
@ -37055,36 +37161,6 @@
"resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
"integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==" "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="
}, },
"node_modules/openid-client": {
"version": "5.6.4",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.6.4.tgz",
"integrity": "sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA==",
"dependencies": {
"jose": "^4.15.4",
"lru-cache": "^6.0.0",
"object-hash": "^2.2.0",
"oidc-token-hash": "^5.0.3"
},
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/openid-client/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/openid-client/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
},
"node_modules/optionator": { "node_modules/optionator": {
"version": "0.9.3", "version": "0.9.3",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz",

View file

@ -1056,6 +1056,10 @@ export enum CacheKeys {
* Key for s3 check intervals per user * Key for s3 check intervals per user
*/ */
S3_EXPIRY_INTERVAL = 'S3_EXPIRY_INTERVAL', S3_EXPIRY_INTERVAL = 'S3_EXPIRY_INTERVAL',
/**
* key for open id exchanged tokens
*/
OPENID_EXCHANGED_TOKENS = 'OPENID_EXCHANGED_TOKENS',
} }
/** /**