diff --git a/.env.example b/.env.example index bd2873206..8eca457ae 100644 --- a/.env.example +++ b/.env.example @@ -20,8 +20,8 @@ DOMAIN_CLIENT=http://localhost:3080 DOMAIN_SERVER=http://localhost:3080 NO_INDEX=true -# 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. +# 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. # 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. 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 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_URL= LDAP_BIND_DN= diff --git a/api/cache/getLogStores.js b/api/cache/getLogStores.js index 612638b97..b0a6a822a 100644 --- a/api/cache/getLogStores.js +++ b/api/cache/getLogStores.js @@ -61,6 +61,10 @@ const abortKeys = isRedisEnabled ? new Keyv({ store: keyvRedis }) : 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 = { [CacheKeys.ROLES]: roles, [CacheKeys.CONFIG_STORE]: config, @@ -98,6 +102,7 @@ const namespaces = { [CacheKeys.AUDIO_RUNS]: audioRuns, [CacheKeys.MESSAGES]: messages, [CacheKeys.FLOWS]: flows, + [CacheKeys.OPENID_EXCHANGED_TOKENS]: openIdExchangedTokensCache, }; /** diff --git a/api/cache/keyvRedis.js b/api/cache/keyvRedis.js index cb9d837e2..0c3878b34 100644 --- a/api/cache/keyvRedis.js +++ b/api/cache/keyvRedis.js @@ -76,10 +76,13 @@ if (REDIS_URI && isEnabled(USE_REDIS)) { keyvRedis = new KeyvRedis(REDIS_URI, keyvOpts); } - const pingInterval = setInterval(() => { - logger.debug('KeyvRedis ping'); - keyvRedis.client.ping().catch(err => logger.error('Redis keep-alive ping failed:', err)); - }, 5 * 60 * 1000); + const pingInterval = setInterval( + () => { + logger.debug('KeyvRedis ping'); + keyvRedis.client.ping().catch((err) => logger.error('Redis keep-alive ping failed:', err)); + }, + 5 * 60 * 1000, + ); keyvRedis.on('ready', () => { logger.info('KeyvRedis connection ready'); diff --git a/api/jest.config.js b/api/jest.config.js index 2df7790b7..7169e8225 100644 --- a/api/jest.config.js +++ b/api/jest.config.js @@ -11,5 +11,8 @@ module.exports = { moduleNameMapper: { '~/(.*)': '/$1', '~/data/auth.json': '/__mocks__/auth.mock.json', + '^openid-client/passport$': '/test/__mocks__/openid-client-passport.js', // Mock for the passport strategy part + '^openid-client$': '/test/__mocks__/openid-client.js', }, + transformIgnorePatterns: ['/node_modules/(?!(openid-client|oauth4webapi|jose)/).*/'], }; diff --git a/api/package.json b/api/package.json index 1f2e326f7..d96e763e3 100644 --- a/api/package.json +++ b/api/package.json @@ -75,6 +75,7 @@ "ioredis": "^5.3.2", "js-yaml": "^4.1.0", "jsonwebtoken": "^9.0.0", + "jwks-rsa": "^3.2.0", "keyv": "^5.3.2", "keyv-file": "^5.1.2", "klona": "^2.0.6", @@ -92,7 +93,7 @@ "ollama": "^0.5.0", "openai": "^4.96.2", "openai-chat-tokens": "^0.2.8", - "openid-client": "^5.4.2", + "openid-client": "^6.5.0", "passport": "^0.6.0", "passport-apple": "^2.0.2", "passport-discord": "^0.1.4", diff --git a/api/server/controllers/AuthController.js b/api/server/controllers/AuthController.js index 7cdfaa9aa..a71ce7d59 100644 --- a/api/server/controllers/AuthController.js +++ b/api/server/controllers/AuthController.js @@ -1,3 +1,4 @@ +const openIdClient = require('openid-client'); const cookies = require('cookie'); const jwt = require('jsonwebtoken'); const { @@ -5,9 +6,12 @@ const { resetPassword, setAuthTokens, requestPasswordReset, + setOpenIDAuthTokens, } = 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 { isEnabled } = require('~/server/utils'); const registrationController = async (req, res) => { try { @@ -55,10 +59,28 @@ const resetPasswordController = async (req, res) => { const refreshController = async (req, res) => { 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) { 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 { const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET); const user = await getUserById(payload.id, '-password -__v -totpSecret'); diff --git a/api/server/controllers/auth/LogoutController.js b/api/server/controllers/auth/LogoutController.js index ed22d7340..1d18e4a94 100644 --- a/api/server/controllers/auth/LogoutController.js +++ b/api/server/controllers/auth/LogoutController.js @@ -1,5 +1,5 @@ const cookies = require('cookie'); -const { Issuer } = require('openid-client'); +const { getOpenIdConfig } = require('~/strategies'); const { logoutUser } = require('~/server/services/AuthService'); const { isEnabled } = require('~/server/utils'); const { logger } = require('~/config'); @@ -10,20 +10,29 @@ const logoutController = async (req, res) => { const logout = await logoutUser(req, refreshToken); const { status, message } = logout; res.clearCookie('refreshToken'); + res.clearCookie('token_provider'); const response = { message }; if ( req.user.openidId != null && isEnabled(process.env.OPENID_USE_END_SESSION_ENDPOINT) && process.env.OPENID_ISSUER ) { - const issuer = await Issuer.discover(process.env.OPENID_ISSUER); - const redirect = issuer.metadata.end_session_endpoint; - if (!redirect) { + const openIdConfig = getOpenIdConfig(); + if (!openIdConfig) { 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 { - 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); diff --git a/api/server/index.js b/api/server/index.js index f7548f840..c7525f9b9 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -75,7 +75,7 @@ const startServer = async () => { /* OAUTH */ app.use(passport.initialize()); - passport.use(await jwtLogin()); + passport.use(jwtLogin()); passport.use(passportLogin()); /* LDAP Auth */ @@ -84,7 +84,7 @@ const startServer = async () => { } if (isEnabled(ALLOW_SOCIAL_LOGIN)) { - configureSocialLogins(app); + await configureSocialLogins(app); } app.use('/oauth', routes.oauth); diff --git a/api/server/middleware/optionalJwtAuth.js b/api/server/middleware/optionalJwtAuth.js index 8aa1c27e0..953bc5402 100644 --- a/api/server/middleware/optionalJwtAuth.js +++ b/api/server/middleware/optionalJwtAuth.js @@ -1,9 +1,13 @@ +const cookies = require('cookie'); +const { isEnabled } = require('~/server/utils'); const passport = require('passport'); // This middleware does not require authentication, // but if the user is authenticated, it will set the user object. 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) { return next(err); } @@ -11,7 +15,11 @@ const optionalJwtAuth = (req, res, next) => { req.user = user; } 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; diff --git a/api/server/middleware/requireJwtAuth.js b/api/server/middleware/requireJwtAuth.js index 5c9a51f92..c00b37ee0 100644 --- a/api/server/middleware/requireJwtAuth.js +++ b/api/server/middleware/requireJwtAuth.js @@ -1,5 +1,23 @@ 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; diff --git a/api/server/routes/__tests__/config.spec.js b/api/server/routes/__tests__/config.spec.js index 0bb80bb9e..3280bc386 100644 --- a/api/server/routes/__tests__/config.spec.js +++ b/api/server/routes/__tests__/config.spec.js @@ -1,11 +1,11 @@ jest.mock('~/cache/getLogStores'); const request = require('supertest'); const express = require('express'); -const routes = require('../'); +const configRoute = require('../config'); // file deepcode ignore UseCsurfForExpress/test: test const app = express(); app.disable('x-powered-by'); -app.use('/api/config', routes.config); +app.use('/api/config', configRoute); afterEach(() => { delete process.env.APP_TITLE; diff --git a/api/server/routes/oauth.js b/api/server/routes/oauth.js index b2037683d..2336ac023 100644 --- a/api/server/routes/oauth.js +++ b/api/server/routes/oauth.js @@ -8,8 +8,9 @@ const { setBalanceConfig, checkDomainAllowed, } = require('~/server/middleware'); -const { setAuthTokens } = require('~/server/services/AuthService'); +const { setAuthTokens, setOpenIDAuthTokens } = require('~/server/services/AuthService'); const { logger } = require('~/config'); +const { isEnabled } = require('~/server/utils'); const router = express.Router(); @@ -28,7 +29,15 @@ const oauthHandler = async (req, res) => { if (req.banned) { 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); } catch (err) { logger.error('Error in setting authentication tokens:', err); diff --git a/api/server/services/AuthService.js b/api/server/services/AuthService.js index 0bb1e22cf..ac1317212 100644 --- a/api/server/services/AuthService.js +++ b/api/server/services/AuthService.js @@ -377,13 +377,62 @@ const setAuthTokens = async (userId, res, sessionId = null) => { secure: isProduction, sameSite: 'strict', }); - + res.cookie('token_provider', 'librechat', { + expires: new Date(refreshTokenExpires), + httpOnly: true, + secure: isProduction, + sameSite: 'strict', + }); return token; } catch (error) { logger.error('[setAuthTokens] Error in setting authentication tokens:', 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 @@ -452,4 +501,5 @@ module.exports = { resetPassword, requestPasswordReset, resendVerificationEmail, + setOpenIDAuthTokens, }; diff --git a/api/server/socialLogins.js b/api/server/socialLogins.js index 0eb44514d..ba335018d 100644 --- a/api/server/socialLogins.js +++ b/api/server/socialLogins.js @@ -10,6 +10,7 @@ const { discordLogin, facebookLogin, appleLogin, + openIdJwtLogin, } = require('~/strategies'); const { isEnabled } = require('~/server/utils'); const keyvRedis = require('~/cache/keyvRedis'); @@ -19,7 +20,7 @@ const { logger } = require('~/config'); * * @param {Express.Application} app */ -const configureSocialLogins = (app) => { +const configureSocialLogins = async (app) => { logger.info('Configuring social logins...'); if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) { @@ -62,8 +63,11 @@ const configureSocialLogins = (app) => { } app.use(session(sessionOptions)); 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.'); } }; diff --git a/api/strategies/index.js b/api/strategies/index.js index 242984bea..dbb1bd870 100644 --- a/api/strategies/index.js +++ b/api/strategies/index.js @@ -4,9 +4,10 @@ const googleLogin = require('./googleStrategy'); const githubLogin = require('./githubStrategy'); const discordLogin = require('./discordStrategy'); const facebookLogin = require('./facebookStrategy'); -const setupOpenId = require('./openidStrategy'); +const { setupOpenId, getOpenIdConfig } = require('./openidStrategy'); const jwtLogin = require('./jwtStrategy'); const ldapLogin = require('./ldapStrategy'); +const openIdJwtLogin = require('./openIdJwtStrategy'); module.exports = { appleLogin, @@ -17,5 +18,7 @@ module.exports = { jwtLogin, facebookLogin, setupOpenId, + getOpenIdConfig, ldapLogin, -}; \ No newline at end of file + openIdJwtLogin, +}; diff --git a/api/strategies/jwtStrategy.js b/api/strategies/jwtStrategy.js index ac19e92ac..eb4b34fd8 100644 --- a/api/strategies/jwtStrategy.js +++ b/api/strategies/jwtStrategy.js @@ -4,7 +4,7 @@ const { getUserById, updateUser } = require('~/models'); const { logger } = require('~/config'); // JWT strategy -const jwtLogin = async () => +const jwtLogin = () => new JwtStrategy( { jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), diff --git a/api/strategies/openIdJwtStrategy.js b/api/strategies/openIdJwtStrategy.js new file mode 100644 index 000000000..dae8d17bc --- /dev/null +++ b/api/strategies/openIdJwtStrategy.js @@ -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; diff --git a/api/strategies/openidStrategy.js b/api/strategies/openidStrategy.js index b26b11efe..92b225b20 100644 --- a/api/strategies/openidStrategy.js +++ b/api/strategies/openidStrategy.js @@ -1,28 +1,101 @@ +const { CacheKeys } = require('librechat-data-provider'); const fetch = require('node-fetch'); const passport = require('passport'); const jwtDecode = require('jsonwebtoken/decode'); 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 { findUser, createUser, updateUser } = require('~/models/userMethods'); const { hashToken } = require('~/server/utils/crypto'); const { isEnabled } = require('~/server/utils'); const { logger } = require('~/config'); +const getLogStores = require('~/cache/getLogStores'); -let crypto; -try { - crypto = require('node:crypto'); -} catch (err) { - logger.error('[openidStrategy] crypto support is disabled!', err); +/** + * @typedef {import('openid-client').ClientMetadata} ClientMetadata + * @typedef {import('openid-client').Configuration} Configuration + **/ + +/** @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} 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} + */ +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. * @param {string} url - * @param {string} accessToken - * @returns {Promise} + * @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} 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) { return ''; } @@ -31,7 +104,7 @@ const downloadImage = async (url, accessToken) => { const options = { method: 'GET', headers: { - Authorization: `Bearer ${accessToken}`, + Authorization: `Bearer ${exchangedAccessToken}`, }, }; @@ -105,63 +178,68 @@ function convertToUsername(input, 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} 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() { try { - if (process.env.PROXY) { - 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} */ + /** @type {ClientMetadata} */ const clientMetadata = { client_id: process.env.OPENID_CLIENT_ID, 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 = - issuer.id_token_signing_alg_values_supported?.[0] || 'RS256'; + + /** @type {Configuration} */ + 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 requiredRoleParameterPath = process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH; 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, - params: { - scope: process.env.OPENID_SCOPE, - }, + config: openidConfig, + scope: process.env.OPENID_SCOPE, + callbackURL: process.env.DOMAIN_SERVER + process.env.OPENID_CALLBACK_URL, + usePKCE, }, - async (tokenset, userinfo, done) => { + async (tokenset, done) => { try { - logger.info(`[openidStrategy] verify login openidId: ${userinfo.sub}`); - logger.debug('[openidStrategy] very login tokenset and userinfo', { tokenset, userinfo }); - - let user = await findUser({ openidId: userinfo.sub }); + const claims = tokenset.claims(); + let user = await findUser({ openidId: claims.sub }); logger.info( - `[openidStrategy] user ${user ? 'found' : 'not found'} with openidId: ${userinfo.sub}`, + `[openidStrategy] user ${user ? 'found' : 'not found'} with openidId: ${claims.sub}`, ); if (!user) { - user = await findUser({ email: userinfo.email }); + user = await findUser({ email: claims.email }); logger.info( `[openidStrategy] user ${user ? 'found' : 'not found'} with email: ${ - userinfo.email - } for openidId: ${userinfo.sub}`, + claims.email + } for openidId: ${claims.sub}`, ); } - + const userinfo = { + ...claims, + ...(await getUserInfo(openidConfig, tokenset.access_token, claims.sub)), + }; const fullName = getFullName(userinfo); if (requiredRole) { @@ -220,7 +298,7 @@ async function setupOpenId() { user.name = fullName; } - if (userinfo.picture && !user.avatar?.includes('manual=true')) { + if (!!userinfo && userinfo.picture && !user.avatar?.includes('manual=true')) { /** @type {string | undefined} */ const imageUrl = userinfo.picture; @@ -231,7 +309,12 @@ async function setupOpenId() { 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) { const { saveBuffer } = getStrategyFunctions(process.env.CDN_PROVIDER); const imagePath = await saveBuffer({ @@ -257,18 +340,34 @@ async function setupOpenId() { }, ); - done(null, user); + done(null, { ...user, tokenset }); } catch (err) { logger.error('[openidStrategy] login failed', err); done(err); } }, ); - passport.use('openid', openidLogin); + return openidConfig; } catch (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, +}; diff --git a/api/strategies/openidStrategy.spec.js b/api/strategies/openidStrategy.spec.js index cea7c5e4a..e70dfa552 100644 --- a/api/strategies/openidStrategy.spec.js +++ b/api/strategies/openidStrategy.spec.js @@ -1,16 +1,13 @@ const fetch = require('node-fetch'); const jwtDecode = require('jsonwebtoken/decode'); -const { Issuer, Strategy: OpenIDStrategy } = require('openid-client'); const { findUser, createUser, updateUser } = require('~/models/userMethods'); -const setupOpenId = require('./openidStrategy'); +const { setupOpenId } = require('./openidStrategy'); // --- Mocks --- jest.mock('node-fetch'); -jest.mock('openid-client'); jest.mock('jsonwebtoken/decode'); jest.mock('~/server/services/Files/strategies', () => ({ getStrategyFunctions: jest.fn(() => ({ - // You can modify this mock as needed (here returning a dummy function) saveBuffer: jest.fn().mockResolvedValue('/fake/path/to/avatar.png'), })), })); @@ -23,38 +20,73 @@ jest.mock('~/server/utils/crypto', () => ({ hashToken: jest.fn().mockResolvedValue('hashed-token'), })); jest.mock('~/server/utils', () => ({ - isEnabled: jest.fn(() => false), // default to false, override per test if needed + isEnabled: jest.fn(() => false), })); jest.mock('~/config', () => ({ logger: { info: jest.fn(), debug: 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 -Issuer.discover = jest.fn().mockResolvedValue({ - id_token_signing_alg_values_supported: ['RS256'], - Client: jest.fn().mockImplementation((clientMetadata) => { - return { - metadata: clientMetadata, - }; - }), +// Mock the openid-client module and all its dependencies +jest.mock('openid-client', () => { + return { + discovery: jest.fn().mockResolvedValue({ + clientId: 'fake_client_id', + 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 -let verifyCallback; -OpenIDStrategy.mockImplementation((options, verify) => { - verifyCallback = verify; - return { name: 'openid', options, verify }; +jest.mock('openid-client/passport', () => { + let verifyCallback; + const mockStrategy = jest.fn((options, verify) => { + verifyCallback = verify; + return { name: 'openid', options, verify }; + }); + + return { + Strategy: mockStrategy, + __getVerifyCallback: () => verifyCallback, + }; }); +// Mock passport +jest.mock('passport', () => ({ + use: jest.fn(), +})); + 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 - const validate = (tokenset, userinfo) => + const validate = (tokenset) => new Promise((resolve, reject) => { - verifyCallback(tokenset, userinfo, (err, user, details) => { + verifyCallback(tokenset, (err, user, details) => { if (err) { reject(err); } else { @@ -66,17 +98,16 @@ describe('setupOpenId', () => { const tokenset = { id_token: 'fake_id_token', access_token: 'fake_access_token', - }; - - const baseUserinfo = { - sub: '1234', - email: 'test@example.com', - email_verified: true, - given_name: 'First', - family_name: 'Last', - name: 'My Full', - username: 'flast', - picture: 'https://example.com/avatar.png', + claims: () => ({ + sub: '1234', + email: 'test@example.com', + email_verified: true, + given_name: 'First', + family_name: 'Last', + name: 'My Full', + username: 'flast', + picture: 'https://example.com/avatar.png', + }), }; beforeEach(async () => { @@ -96,6 +127,7 @@ describe('setupOpenId', () => { delete process.env.OPENID_USERNAME_CLAIM; delete process.env.OPENID_NAME_CLAIM; delete process.env.PROXY; + delete process.env.OPENID_USE_PKCE; // Default jwtDecode mock returns a token that includes the required role. jwtDecode.mockReturnValue({ @@ -120,16 +152,17 @@ describe('setupOpenId', () => { }; 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(); + verifyCallback = require('openid-client/passport').__getVerifyCallback(); }); it('should create a new user with correct username when username claim exists', async () => { // Arrange – our userinfo already has username 'flast' - const userinfo = { ...baseUserinfo }; + const userinfo = tokenset.claims(); // Act - const { user } = await validate(tokenset, userinfo); + const { user } = await validate(tokenset); // Assert 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 () => { // Arrange – remove username from userinfo - const userinfo = { ...baseUserinfo }; + const userinfo = { ...tokenset.claims() }; delete userinfo.username; // Expect the username to be the given name (unchanged case) const expectUsername = userinfo.given_name; // Act - const { user } = await validate(tokenset, userinfo); + const { user } = await validate({ ...tokenset, claims: () => userinfo }); // Assert 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 () => { // Arrange – remove username and given_name - const userinfo = { ...baseUserinfo }; + const userinfo = { ...tokenset.claims() }; delete userinfo.username; delete userinfo.given_name; const expectUsername = userinfo.email; // Act - const { user } = await validate(tokenset, userinfo); + const { user } = await validate({ ...tokenset, claims: () => userinfo }); // Assert expect(user.username).toBe(expectUsername); @@ -187,10 +220,10 @@ describe('setupOpenId', () => { it('should override username with OPENID_USERNAME_CLAIM when set', async () => { // Arrange – set OPENID_USERNAME_CLAIM so that the sub claim is used process.env.OPENID_USERNAME_CLAIM = 'sub'; - const userinfo = { ...baseUserinfo }; + const userinfo = tokenset.claims(); // Act - const { user } = await validate(tokenset, userinfo); + const { user } = await validate(tokenset); // Assert – username should equal the sub (converted as-is) 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 () => { // Arrange - const userinfo = { ...baseUserinfo }; + const userinfo = tokenset.claims(); const expectedFullName = `${userinfo.given_name} ${userinfo.family_name}`; // Act - const { user } = await validate(tokenset, userinfo); + const { user } = await validate(tokenset); // Assert expect(user.name).toBe(expectedFullName); @@ -216,10 +249,10 @@ describe('setupOpenId', () => { it('should override full name with OPENID_NAME_CLAIM when set', async () => { // Arrange – use the name claim as the full name process.env.OPENID_NAME_CLAIM = 'name'; - const userinfo = { ...baseUserinfo, name: 'Custom Name' }; + const userinfo = { ...tokenset.claims(), name: 'Custom Name' }; // Act - const { user } = await validate(tokenset, userinfo); + const { user } = await validate({ ...tokenset, claims: () => userinfo }); // Assert expect(user.name).toBe('Custom Name'); @@ -230,31 +263,31 @@ describe('setupOpenId', () => { const existingUser = { _id: 'existingUserId', provider: 'local', - email: baseUserinfo.email, + email: tokenset.claims().email, openidId: '', username: '', name: '', }; 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 null; }); - const userinfo = { ...baseUserinfo }; + const userinfo = tokenset.claims(); // Act - await validate(tokenset, userinfo); + await validate(tokenset); // Assert – updateUser should be called and the user object updated expect(updateUser).toHaveBeenCalledWith( existingUser._id, expect.objectContaining({ provider: 'openid', - openidId: baseUserinfo.sub, - username: baseUserinfo.username, - name: `${baseUserinfo.given_name} ${baseUserinfo.family_name}`, + openidId: userinfo.sub, + username: userinfo.username, + name: `${userinfo.given_name} ${userinfo.family_name}`, }), ); }); @@ -264,10 +297,10 @@ describe('setupOpenId', () => { jwtDecode.mockReturnValue({ roles: ['SomeOtherRole'], }); - const userinfo = { ...baseUserinfo }; + const userinfo = tokenset.claims(); // Act - const { user, details } = await validate(tokenset, userinfo); + const { user, details } = await validate(tokenset); // Assert – verify that the strategy rejects login expect(user).toBe(false); @@ -276,10 +309,10 @@ describe('setupOpenId', () => { it('should attempt to download and save the avatar if picture is provided', async () => { // Arrange – ensure userinfo contains a picture URL - const userinfo = { ...baseUserinfo }; + const userinfo = tokenset.claims(); // 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 expect(fetch).toHaveBeenCalled(); @@ -289,14 +322,25 @@ describe('setupOpenId', () => { it('should not attempt to download avatar if picture is not provided', async () => { // Arrange – remove picture - const userinfo = { ...baseUserinfo }; + const userinfo = { ...tokenset.claims() }; delete userinfo.picture; // Act - await validate(tokenset, userinfo); + await validate({ ...tokenset, claims: () => userinfo }); // Assert – fetch should not be called and avatar should remain undefined or empty expect(fetch).not.toHaveBeenCalled(); // 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(); + }); }); diff --git a/api/strategies/socialLogin.js b/api/strategies/socialLogin.js index 4b900371d..925c2de34 100644 --- a/api/strategies/socialLogin.js +++ b/api/strategies/socialLogin.js @@ -7,7 +7,8 @@ const socialLogin = (provider, getProfileDetails) => async (accessToken, refreshToken, idToken, profile, cb) => { try { const { email, id, avatarUrl, username, name, emailVerified } = getProfileDetails({ - idToken, profile, + idToken, + profile, }); const oldUser = await findUser({ email: email.trim() }); diff --git a/api/test/__mocks__/openid-client-passport.js b/api/test/__mocks__/openid-client-passport.js new file mode 100644 index 000000000..8a6f60e70 --- /dev/null +++ b/api/test/__mocks__/openid-client-passport.js @@ -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 }; diff --git a/api/test/__mocks__/openid-client.js b/api/test/__mocks__/openid-client.js new file mode 100644 index 000000000..4848a4799 --- /dev/null +++ b/api/test/__mocks__/openid-client.js @@ -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'), +}; diff --git a/package-lock.json b/package-lock.json index 014bd4fe3..25a341bd2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -91,6 +91,7 @@ "ioredis": "^5.3.2", "js-yaml": "^4.1.0", "jsonwebtoken": "^9.0.0", + "jwks-rsa": "^3.2.0", "keyv": "^5.3.2", "keyv-file": "^5.1.2", "klona": "^2.0.6", @@ -108,7 +109,7 @@ "ollama": "^0.5.0", "openai": "^4.96.2", "openai-chat-tokens": "^0.2.8", - "openid-client": "^5.4.2", + "openid-client": "^6.5.0", "passport": "^0.6.0", "passport-apple": "^2.0.2", "passport-discord": "^0.1.4", @@ -814,6 +815,15 @@ "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": { "version": "5.1.2", "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": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", @@ -25002,7 +25025,6 @@ "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", - "dev": true, "dependencies": { "@types/connect": "*", "@types/node": "*" @@ -25012,7 +25034,6 @@ "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, "dependencies": { "@types/node": "*" } @@ -25101,8 +25122,7 @@ "node_modules/@types/http-errors": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", - "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", - "dev": true + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==" }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", @@ -25172,6 +25192,16 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "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": { "version": "0.16.7", "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.7.tgz", @@ -25203,8 +25233,7 @@ "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==" }, "node_modules/@types/ms": { "version": "0.7.34", @@ -25242,14 +25271,12 @@ "node_modules/@types/qs": { "version": "6.9.17", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.17.tgz", - "integrity": "sha512-rX4/bPcfmvxHDv0XjfJELTTr+iB+tn032nPILqHm5wbthUUUuVtNGGqzhya9XUxjTP8Fpr0qYgSZZKxGY++svQ==", - "dev": true + "integrity": "sha512-rX4/bPcfmvxHDv0XjfJELTTr+iB+tn032nPILqHm5wbthUUUuVtNGGqzhya9XUxjTP8Fpr0qYgSZZKxGY++svQ==" }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" }, "node_modules/@types/react": { "version": "18.2.53", @@ -25300,7 +25327,6 @@ "version": "0.17.4", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", - "dev": true, "dependencies": { "@types/mime": "^1", "@types/node": "*" @@ -25310,7 +25336,6 @@ "version": "1.15.7", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", - "dev": true, "dependencies": { "@types/http-errors": "*", "@types/node": "*", @@ -33822,6 +33847,47 @@ "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": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", @@ -34142,6 +34208,11 @@ "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": { "version": "1.2.4", "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", "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": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -34860,6 +34937,34 @@ "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": { "version": "0.394.0", "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", "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": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -36920,14 +37034,6 @@ "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": { "version": "0.5.14", "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", "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": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 1d727470f..fa953decc 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1056,6 +1056,10 @@ export enum CacheKeys { * Key for s3 check intervals per user */ S3_EXPIRY_INTERVAL = 'S3_EXPIRY_INTERVAL', + /** + * key for open id exchanged tokens + */ + OPENID_EXCHANGED_TOKENS = 'OPENID_EXCHANGED_TOKENS', } /**