| const undici = require('undici'); |
| const { get } = require('lodash'); |
| const fetch = require('node-fetch'); |
| const passport = require('passport'); |
| const client = require('openid-client'); |
| const jwtDecode = require('jsonwebtoken/decode'); |
| const { HttpsProxyAgent } = require('https-proxy-agent'); |
| const { hashToken, logger } = require('@librechat/data-schemas'); |
| const { CacheKeys, ErrorTypes } = require('librechat-data-provider'); |
| const { Strategy: OpenIDStrategy } = require('openid-client/passport'); |
| const { |
| isEnabled, |
| logHeaders, |
| safeStringify, |
| findOpenIDUser, |
| getBalanceConfig, |
| isEmailDomainAllowed, |
| } = require('@librechat/api'); |
| const { getStrategyFunctions } = require('~/server/services/Files/strategies'); |
| const { findUser, createUser, updateUser } = require('~/models'); |
| const { getAppConfig } = require('~/server/services/Config'); |
| const getLogStores = require('~/cache/getLogStores'); |
|
|
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| async function customFetch(url, options) { |
| const urlStr = url.toString(); |
| logger.debug(`[openidStrategy] Request to: ${urlStr}`); |
| const debugOpenId = isEnabled(process.env.DEBUG_OPENID_REQUESTS); |
| if (debugOpenId) { |
| logger.debug(`[openidStrategy] Request method: ${options.method || 'GET'}`); |
| logger.debug(`[openidStrategy] Request headers: ${logHeaders(options.headers)}`); |
| if (options.body) { |
| let bodyForLogging = ''; |
| if (options.body instanceof URLSearchParams) { |
| bodyForLogging = options.body.toString(); |
| } else if (typeof options.body === 'string') { |
| bodyForLogging = options.body; |
| } else { |
| bodyForLogging = safeStringify(options.body); |
| } |
| logger.debug(`[openidStrategy] Request body: ${bodyForLogging}`); |
| } |
| } |
|
|
| try { |
| |
| let fetchOptions = options; |
| if (process.env.PROXY) { |
| logger.info(`[openidStrategy] proxy agent configured: ${process.env.PROXY}`); |
| fetchOptions = { |
| ...options, |
| dispatcher: new undici.ProxyAgent(process.env.PROXY), |
| }; |
| } |
|
|
| const response = await undici.fetch(url, fetchOptions); |
|
|
| if (debugOpenId) { |
| logger.debug(`[openidStrategy] Response status: ${response.status} ${response.statusText}`); |
| logger.debug(`[openidStrategy] Response headers: ${logHeaders(response.headers)}`); |
| } |
|
|
| if (response.status === 200 && response.headers.has('www-authenticate')) { |
| const wwwAuth = response.headers.get('www-authenticate'); |
| logger.warn(`[openidStrategy] Non-standard WWW-Authenticate header found in successful response (200 OK): ${wwwAuth}. |
| This violates RFC 7235 and may cause issues with strict OAuth clients. Removing header for compatibility.`); |
|
|
| |
| const responseBody = await response.arrayBuffer(); |
| const newHeaders = new Headers(); |
| for (const [key, value] of response.headers.entries()) { |
| if (key.toLowerCase() !== 'www-authenticate') { |
| newHeaders.append(key, value); |
| } |
| } |
|
|
| return new Response(responseBody, { |
| status: response.status, |
| statusText: response.statusText, |
| headers: newHeaders, |
| }); |
| } |
|
|
| return response; |
| } catch (error) { |
| logger.error(`[openidStrategy] Fetch error: ${error.message}`); |
| throw error; |
| } |
| } |
|
|
| |
| let openidConfig = null; |
|
|
| |
| |
|
|
| class CustomOpenIDStrategy extends OpenIDStrategy { |
| currentUrl(req) { |
| const hostAndProtocol = process.env.DOMAIN_SERVER; |
| return new URL(`${hostAndProtocol}${req.originalUrl ?? req.url}`); |
| } |
|
|
| authorizationRequestParams(req, options) { |
| const params = super.authorizationRequestParams(req, options); |
| if (options?.state && !params.has('state')) { |
| params.set('state', options.state); |
| } |
|
|
| if (process.env.OPENID_AUDIENCE) { |
| params.set('audience', process.env.OPENID_AUDIENCE); |
| logger.debug( |
| `[openidStrategy] Adding audience to authorization request: ${process.env.OPENID_AUDIENCE}`, |
| ); |
| } |
|
|
| |
| const shouldGenerateNonce = isEnabled(process.env.OPENID_GENERATE_NONCE); |
| if (shouldGenerateNonce && !params.has('nonce') && this._sessionKey) { |
| const crypto = require('crypto'); |
| const nonce = crypto.randomBytes(16).toString('hex'); |
| params.set('nonce', nonce); |
| logger.debug('[openidStrategy] Generated nonce for federated provider:', nonce); |
| } |
|
|
| return params; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| 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_USERINFO_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_USERINFO_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; |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| |
| const getUserInfo = async (config, accessToken, sub) => { |
| try { |
| const exchangedAccessToken = await exchangeAccessTokenIfNeeded(config, accessToken, sub); |
| return await client.fetchUserInfo(config, exchangedAccessToken, sub); |
| } catch (error) { |
| logger.error('[openidStrategy] getUserInfo: Error fetching user info:', error); |
| return null; |
| } |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| const downloadImage = async (url, config, accessToken, sub) => { |
| const exchangedAccessToken = await exchangeAccessTokenIfNeeded(config, accessToken, sub, true); |
| if (!url) { |
| return ''; |
| } |
|
|
| try { |
| const options = { |
| method: 'GET', |
| headers: { |
| Authorization: `Bearer ${exchangedAccessToken}`, |
| }, |
| }; |
|
|
| if (process.env.PROXY) { |
| options.agent = new HttpsProxyAgent(process.env.PROXY); |
| } |
|
|
| const response = await fetch(url, options); |
|
|
| if (response.ok) { |
| const buffer = await response.buffer(); |
| return buffer; |
| } else { |
| throw new Error(`${response.statusText} (HTTP ${response.status})`); |
| } |
| } catch (error) { |
| logger.error( |
| `[openidStrategy] downloadImage: Error downloading image at URL "${url}": ${error}`, |
| ); |
| return ''; |
| } |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function getFullName(userinfo) { |
| if (process.env.OPENID_NAME_CLAIM) { |
| return userinfo[process.env.OPENID_NAME_CLAIM]; |
| } |
|
|
| if (userinfo.given_name && userinfo.family_name) { |
| return `${userinfo.given_name} ${userinfo.family_name}`; |
| } |
|
|
| if (userinfo.given_name) { |
| return userinfo.given_name; |
| } |
|
|
| if (userinfo.family_name) { |
| return userinfo.family_name; |
| } |
|
|
| return userinfo.username || userinfo.email; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function convertToUsername(input, defaultValue = '') { |
| if (typeof input === 'string') { |
| return input; |
| } else if (Array.isArray(input)) { |
| return input.join('_'); |
| } |
|
|
| return defaultValue; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async function setupOpenId() { |
| try { |
| const shouldGenerateNonce = isEnabled(process.env.OPENID_GENERATE_NONCE); |
|
|
| |
| const clientMetadata = { |
| client_id: process.env.OPENID_CLIENT_ID, |
| client_secret: process.env.OPENID_CLIENT_SECRET, |
| }; |
|
|
| if (shouldGenerateNonce) { |
| clientMetadata.response_types = ['code']; |
| clientMetadata.grant_types = ['authorization_code']; |
| clientMetadata.token_endpoint_auth_method = 'client_secret_post'; |
| } |
|
|
| |
| openidConfig = await client.discovery( |
| new URL(process.env.OPENID_ISSUER), |
| process.env.OPENID_CLIENT_ID, |
| clientMetadata, |
| undefined, |
| { |
| [client.customFetch]: customFetch, |
| }, |
| ); |
|
|
| 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 usePKCE = isEnabled(process.env.OPENID_USE_PKCE); |
| logger.info(`[openidStrategy] OpenID authentication configuration`, { |
| generateNonce: shouldGenerateNonce, |
| reason: shouldGenerateNonce |
| ? 'OPENID_GENERATE_NONCE=true - Will generate nonce and use explicit metadata for federated providers' |
| : 'OPENID_GENERATE_NONCE=false - Standard flow without explicit nonce or metadata', |
| }); |
|
|
| |
| |
| const adminRole = process.env.OPENID_ADMIN_ROLE; |
| const adminRoleParameterPath = process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH; |
| const adminRoleTokenKind = process.env.OPENID_ADMIN_ROLE_TOKEN_KIND; |
|
|
| const openidLogin = new CustomOpenIDStrategy( |
| { |
| config: openidConfig, |
| scope: process.env.OPENID_SCOPE, |
| callbackURL: process.env.DOMAIN_SERVER + process.env.OPENID_CALLBACK_URL, |
| clockTolerance: process.env.OPENID_CLOCK_TOLERANCE || 300, |
| usePKCE, |
| }, |
| |
| |
| |
| |
| async (tokenset, done) => { |
| try { |
| const claims = tokenset.claims(); |
| const userinfo = { |
| ...claims, |
| ...(await getUserInfo(openidConfig, tokenset.access_token, claims.sub)), |
| }; |
|
|
| const appConfig = await getAppConfig(); |
| |
| const email = userinfo.email || userinfo.preferred_username || userinfo.upn; |
| if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) { |
| logger.error( |
| `[OpenID Strategy] Authentication blocked - email domain not allowed [Email: ${email}]`, |
| ); |
| return done(null, false, { message: 'Email domain not allowed' }); |
| } |
|
|
| const result = await findOpenIDUser({ |
| findUser, |
| email: email, |
| openidId: claims.sub, |
| idOnTheSource: claims.oid, |
| strategyName: 'openidStrategy', |
| }); |
| let user = result.user; |
| const error = result.error; |
|
|
| if (error) { |
| return done(null, false, { |
| message: ErrorTypes.AUTH_FAILED, |
| }); |
| } |
|
|
| const fullName = getFullName(userinfo); |
|
|
| if (requiredRole) { |
| const requiredRoles = requiredRole |
| .split(',') |
| .map((role) => role.trim()) |
| .filter(Boolean); |
| let decodedToken = ''; |
| if (requiredRoleTokenKind === 'access') { |
| decodedToken = jwtDecode(tokenset.access_token); |
| } else if (requiredRoleTokenKind === 'id') { |
| decodedToken = jwtDecode(tokenset.id_token); |
| } |
|
|
| let roles = get(decodedToken, requiredRoleParameterPath); |
| if (!roles || (!Array.isArray(roles) && typeof roles !== 'string')) { |
| logger.error( |
| `[openidStrategy] Key '${requiredRoleParameterPath}' not found or invalid type in ${requiredRoleTokenKind} token!`, |
| ); |
| const rolesList = |
| requiredRoles.length === 1 |
| ? `"${requiredRoles[0]}"` |
| : `one of: ${requiredRoles.map((r) => `"${r}"`).join(', ')}`; |
| return done(null, false, { |
| message: `You must have ${rolesList} role to log in.`, |
| }); |
| } |
|
|
| if (!requiredRoles.some((role) => roles.includes(role))) { |
| const rolesList = |
| requiredRoles.length === 1 |
| ? `"${requiredRoles[0]}"` |
| : `one of: ${requiredRoles.map((r) => `"${r}"`).join(', ')}`; |
| return done(null, false, { |
| message: `You must have ${rolesList} role to log in.`, |
| }); |
| } |
| } |
|
|
| let username = ''; |
| if (process.env.OPENID_USERNAME_CLAIM) { |
| username = userinfo[process.env.OPENID_USERNAME_CLAIM]; |
| } else { |
| username = convertToUsername( |
| userinfo.preferred_username || userinfo.username || userinfo.email, |
| ); |
| } |
|
|
| if (!user) { |
| user = { |
| provider: 'openid', |
| openidId: userinfo.sub, |
| username, |
| email: email || '', |
| emailVerified: userinfo.email_verified || false, |
| name: fullName, |
| idOnTheSource: userinfo.oid, |
| }; |
|
|
| const balanceConfig = getBalanceConfig(appConfig); |
| user = await createUser(user, balanceConfig, true, true); |
| } else { |
| user.provider = 'openid'; |
| user.openidId = userinfo.sub; |
| user.username = username; |
| user.name = fullName; |
| user.idOnTheSource = userinfo.oid; |
| if (email && email !== user.email) { |
| user.email = email; |
| user.emailVerified = userinfo.email_verified || false; |
| } |
| } |
|
|
| if (adminRole && adminRoleParameterPath && adminRoleTokenKind) { |
| let adminRoleObject; |
| switch (adminRoleTokenKind) { |
| case 'access': |
| adminRoleObject = jwtDecode(tokenset.access_token); |
| break; |
| case 'id': |
| adminRoleObject = jwtDecode(tokenset.id_token); |
| break; |
| case 'userinfo': |
| adminRoleObject = userinfo; |
| break; |
| default: |
| logger.error( |
| `[openidStrategy] Invalid admin role token kind: ${adminRoleTokenKind}. Must be one of 'access', 'id', or 'userinfo'.`, |
| ); |
| return done(new Error('Invalid admin role token kind')); |
| } |
|
|
| const adminRoles = get(adminRoleObject, adminRoleParameterPath); |
|
|
| |
| |
| |
| |
|
|
| if ( |
| adminRoles && |
| (adminRoles === true || |
| adminRoles === adminRole || |
| (Array.isArray(adminRoles) && adminRoles.includes(adminRole))) |
| ) { |
| user.role = 'ADMIN'; |
| logger.info( |
| `[openidStrategy] User ${username} is an admin based on role: ${adminRole}`, |
| ); |
| } else if (user.role === 'ADMIN') { |
| user.role = 'USER'; |
| logger.info( |
| `[openidStrategy] User ${username} demoted from admin - role no longer present in token`, |
| ); |
| } |
| } |
|
|
| if (!!userinfo && userinfo.picture && !user.avatar?.includes('manual=true')) { |
| |
| const imageUrl = userinfo.picture; |
|
|
| let fileName; |
| if (crypto) { |
| fileName = (await hashToken(userinfo.sub)) + '.png'; |
| } else { |
| fileName = userinfo.sub + '.png'; |
| } |
|
|
| const imageBuffer = await downloadImage( |
| imageUrl, |
| openidConfig, |
| tokenset.access_token, |
| userinfo.sub, |
| ); |
| if (imageBuffer) { |
| const { saveBuffer } = getStrategyFunctions( |
| appConfig?.fileStrategy ?? process.env.CDN_PROVIDER, |
| ); |
| const imagePath = await saveBuffer({ |
| fileName, |
| userId: user._id.toString(), |
| buffer: imageBuffer, |
| }); |
| user.avatar = imagePath ?? ''; |
| } |
| } |
|
|
| user = await updateUser(user._id, user); |
|
|
| logger.info( |
| `[openidStrategy] login success openidId: ${user.openidId} | email: ${user.email} | username: ${user.username} `, |
| { |
| user: { |
| openidId: user.openidId, |
| username: user.username, |
| email: user.email, |
| name: user.name, |
| }, |
| }, |
| ); |
|
|
| done(null, { |
| ...user, |
| tokenset, |
| federatedTokens: { |
| access_token: tokenset.access_token, |
| refresh_token: tokenset.refresh_token, |
| expires_at: tokenset.expires_at, |
| }, |
| }); |
| } 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() { |
| if (!openidConfig) { |
| throw new Error('OpenID client is not initialized. Please call setupOpenId first.'); |
| } |
| return openidConfig; |
| } |
|
|
| module.exports = { |
| setupOpenId, |
| getOpenIdConfig, |
| }; |
|
|