const undici = require('undici'); const { get } = require('lodash'); const passport = require('passport'); const client = require('openid-client'); const jwtDecode = require('jsonwebtoken/decode'); const { hashToken, logger, tenantStorage } = require('@librechat/data-schemas'); const { Strategy: OpenIDStrategy } = require('openid-client/passport'); const { CacheKeys, ErrorTypes, SystemRoles } = require('librechat-data-provider'); const { isEnabled, logHeaders, safeStringify, findOpenIDUser, getOpenIdEmail, getOpenIdIssuer, getBalanceConfig, selectOpenIdRole, getAvatarSaveParams, isEmailDomainAllowed, getAvatarFileStrategy, resolveAppConfigForUser, getOpenIdProxyDispatcher, getOpenIdRoleSyncOptions, getOpenIdRolesForOpenIdSync, getLibreChatRolesForOpenIdSync, } = require('@librechat/api'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { resizeAvatar } = require('~/server/services/Files/images/avatar'); const { findUser, createUser, updateUser, findRolesByNames } = require('~/models'); const { getAppConfig } = require('~/server/services/Config'); const getLogStores = require('~/cache/getLogStores'); /** * @typedef {import('openid-client').ClientMetadata} ClientMetadata * @typedef {import('openid-client').Configuration} Configuration **/ /** * @param {string} url * @param {client.CustomFetchOptions} options */ 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 { /** @type {undici.RequestInit} */ let fetchOptions = options; const dispatcher = getOpenIdProxyDispatcher(); if (dispatcher) { logger.info('[openidStrategy] proxy dispatcher configured'); fetchOptions = { ...options, dispatcher, }; } 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.`); /** Cloned response without the WWW-Authenticate header */ 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; } } /** @typedef {Configuration | null} */ let openidConfig = null; const getOpenIdAuthorizationAudience = () => (process.env.OPENID_AUDIENCE ?? '') .split(',') .map((value) => value.trim()) .find(Boolean); /** * Custom OpenID Strategy * * Note: Originally overrode currentUrl() to work around Express 4's req.host not including port. * With Express 5, req.host now includes the port by default, but we continue to use DOMAIN_SERVER * for consistency and explicit configuration control. * 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}`); } authorizationRequestParams(req, options) { const params = super.authorizationRequestParams(req, options); if (options?.state && !params.has('state')) { params.set('state', options.state); } const authorizationAudience = getOpenIdAuthorizationAudience(); if (authorizationAudience) { params.set('audience', authorizationAudience); logger.debug( `[openidStrategy] Adding audience to authorization request: ${authorizationAudience}`, ); } /** Generate nonce for federated providers that require it */ 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; } } /** * 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_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; }; /** * 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.error('[openidStrategy] getUserInfo: Error fetching user info:', error); return null; } }; function getUrlOrigin(value) { try { return new URL(value).origin; } catch { return null; } } function getOpenIDAvatarAuthorizedOrigins(config) { const metadata = config?.serverMetadata?.() ?? {}; const metadataOrigins = [metadata.issuer, metadata.userinfo_endpoint] .map(getUrlOrigin) .filter(Boolean); const configuredOrigins = (process.env.OPENID_AVATAR_AUTHORIZED_ORIGINS ?? '') .split(/[\s,]+/) .map(getUrlOrigin) .filter(Boolean); return new Set([...metadataOrigins, ...configuredOrigins]); } function shouldAuthorizeOpenIDAvatar(url, config) { const origin = getUrlOrigin(url); if (!origin) { return false; } return getOpenIDAvatarAuthorizedOrigins(config).has(origin); } async function getOpenIDAvatarFetchOptions(url, config, accessToken, sub) { if (!shouldAuthorizeOpenIDAvatar(url, config)) { return undefined; } const exchangedAccessToken = await exchangeAccessTokenIfNeeded(config, accessToken, sub, true); return { headers: { Authorization: `Bearer ${exchangedAccessToken}`, }, }; } const resizeIdentityProviderAvatar = async (url, userId, config, accessToken, sub) => { if (!url) { return ''; } try { const fetchOptions = await getOpenIDAvatarFetchOptions(url, config, accessToken, sub); const avatarParams = { userId, input: url }; if (fetchOptions) { avatarParams.fetchOptions = fetchOptions; } return await resizeAvatar(avatarParams); } catch (error) { logger.error( `[openidStrategy] resizeIdentityProviderAvatar: Error processing avatar at URL "${url}": ${error}`, ); return ''; } }; /** * Determines the full name of a user based on OpenID userinfo and environment configuration. * * @param {Object} userinfo - The user information object from OpenID Connect * @param {string} [userinfo.given_name] - The user's first name * @param {string} [userinfo.family_name] - The user's last name * @param {string} [userinfo.username] - The user's username * @param {string} [userinfo.email] - The user's email address * @returns {string} The determined full name of the user */ 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; } /** * Converts an input into a string suitable for a username. * If the input is a string, it will be returned as is. * If the input is an array, elements will be joined with underscores. * In case of undefined or other falsy values, a default value will be returned. * * @param {string | string[] | undefined} input - The input value to be converted into a username. * @param {string} [defaultValue=''] - The default value to return if the input is falsy. * @returns {string} The processed input as a string suitable for a username. */ function convertToUsername(input, defaultValue = '') { if (typeof input === 'string') { return input; } else if (Array.isArray(input)) { return input.join('_'); } return defaultValue; } /** * Exchange the access token for a Graph-scoped token using the On-Behalf-Of (OBO) flow. * * The original access token has the app's own audience (api://), which Microsoft Graph * rejects. This exchange produces a token with audience https://graph.microsoft.com and the * minimum delegated scope (User.Read) required by /me/getMemberObjects. * * Uses a dedicated cache key (`${sub}:overage`) to avoid collisions with other OBO exchanges * in the codebase (userinfo, Graph principal search). * * @param {string} accessToken - The original access token from the OpenID tokenset * @param {string} sub - The subject identifier for cache keying * @returns {Promise} A Graph-scoped access token * @see https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-on-behalf-of-flow */ async function exchangeTokenForOverage(accessToken, sub) { if (!openidConfig) { throw new Error('[openidStrategy] OpenID config not initialized; cannot exchange OBO token'); } const tokensCache = getLogStores(CacheKeys.OPENID_EXCHANGED_TOKENS); const cacheKey = `${sub}:overage`; const cached = await tokensCache.get(cacheKey); if (cached?.access_token) { logger.debug('[openidStrategy] Using cached Graph token for overage resolution'); return cached.access_token; } const grantResponse = await client.genericGrantRequest( openidConfig, 'urn:ietf:params:oauth:grant-type:jwt-bearer', { scope: 'https://graph.microsoft.com/User.Read', assertion: accessToken, requested_token_use: 'on_behalf_of', }, ); if (!grantResponse.access_token) { throw new Error( '[openidStrategy] OBO exchange succeeded but returned no access_token; cannot call Graph API', ); } const ttlMs = Number.isFinite(grantResponse.expires_in) && grantResponse.expires_in > 0 ? grantResponse.expires_in * 1000 : 3600 * 1000; await tokensCache.set(cacheKey, { access_token: grantResponse.access_token }, ttlMs); return grantResponse.access_token; } /** * Resolve Azure AD groups when group overage is in effect (groups moved to _claim_names/_claim_sources). * * NOTE: Microsoft recommends treating _claim_names/_claim_sources as a signal only and using Microsoft Graph * to resolve group membership instead of calling the endpoint in _claim_sources directly. * * Before calling Graph, the access token is exchanged via the OBO flow to obtain a token with the * correct audience (https://graph.microsoft.com) and User.Read scope. * * @param {string} accessToken - Access token from the OpenID tokenset (app audience) * @param {string} sub - The subject identifier of the user (for OBO exchange and cache keying) * @returns {Promise} Resolved group IDs or null on failure * @see https://learn.microsoft.com/en-us/entra/identity-platform/access-token-claims-reference#groups-overage-claim * @see https://learn.microsoft.com/en-us/graph/api/directoryobject-getmemberobjects */ async function resolveGroupsFromOverage(accessToken, sub) { try { if (!accessToken) { logger.error('[openidStrategy] Access token missing; cannot resolve group overage'); return null; } const graphToken = await exchangeTokenForOverage(accessToken, sub); // Use /me/getMemberObjects so least-privileged delegated permission User.Read is sufficient // when resolving the signed-in user's group membership. const url = 'https://graph.microsoft.com/v1.0/me/getMemberObjects'; logger.debug( `[openidStrategy] Detected group overage, resolving groups via Microsoft Graph getMemberObjects: ${url}`, ); const fetchOptions = { method: 'POST', headers: { Authorization: `Bearer ${graphToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ securityEnabledOnly: false }), }; const dispatcher = getOpenIdProxyDispatcher(); if (dispatcher) { fetchOptions.dispatcher = dispatcher; } const response = await undici.fetch(url, fetchOptions); if (!response.ok) { logger.error( `[openidStrategy] Failed to resolve groups via Microsoft Graph getMemberObjects: HTTP ${response.status} ${response.statusText}`, ); return null; } const data = await response.json(); const values = Array.isArray(data?.value) ? data.value : null; if (!values) { logger.error( '[openidStrategy] Unexpected response format when resolving groups via Microsoft Graph getMemberObjects', ); return null; } const groupIds = values.filter((id) => typeof id === 'string'); logger.debug( `[openidStrategy] Successfully resolved ${groupIds.length} groups via Microsoft Graph getMemberObjects`, ); return groupIds; } catch (err) { logger.error( '[openidStrategy] Error resolving groups via Microsoft Graph getMemberObjects:', err, ); return null; } } /** * Resolve the source object (decoded token or userinfo) for a role check * based on the configured token kind. Throws on invalid configuration so * misconfiguration surfaces loudly instead of silently denying every login. * * @param {string} kind - One of 'access', 'id', or 'userinfo' * @param {string} label - Human-readable label for error messages (e.g. 'required role') * @param {Object} tokenset - The OpenID tokenset * @param {Object} userinfo - Merged userinfo (id-token claims + UserInfo endpoint response) */ function getRoleSource(kind, label, tokenset, userinfo) { if (kind === 'access') { return jwtDecode(tokenset.access_token); } if (kind === 'id') { return jwtDecode(tokenset.id_token); } if (kind === 'userinfo') { return userinfo; } logger.error( `[openidStrategy] Invalid ${label} token kind: ${kind}. Must be one of 'access', 'id', or 'userinfo'.`, ); throw new Error(`Invalid ${label} token kind`); } /** * Applies generic OpenID role sync to the request-local user before the existing final update. */ async function applyOpenIdRoleSync({ user, username, tokenset, claims, userinfo, resolvedOverageGroups, }) { const options = getOpenIdRoleSyncOptions(); if (!options.enabled) { return; } if (user.role === SystemRoles.ADMIN) { logger.info( `[openidStrategy] OpenID role sync skipped for ${username}; existing ADMIN role is not managed by generic role sync`, ); return; } const resolveGroupOverage = async () => resolvedOverageGroups || (await resolveGroupsFromOverage(tokenset.access_token, claims.sub)); const openIdRoleValues = await getOpenIdRolesForOpenIdSync({ options, accessToken: tokenset.access_token, idToken: tokenset.id_token, claims, userinfo, decodeToken: jwtDecode, resolveGroupOverage, }); if (openIdRoleValues === undefined) { logger.warn( `[openidStrategy] OpenID role sync skipped; claim '${options.claim}' was not found, invalid, or unresolved`, ); return; } const libreChatRoles = { getRolesByNames: findRolesByNames, rolePriority: options.rolePriority, fallbackRole: options.fallbackRole, logPrefix: '[openidStrategy]', }; /** Role definitions are tenant-scoped, so validate configured roles in the matched user's tenant. */ const { rolePriority, fallbackRole } = user?.tenantId ? await tenantStorage.run({ tenantId: user.tenantId }, async () => getLibreChatRolesForOpenIdSync(libreChatRoles), ) : await getLibreChatRolesForOpenIdSync(libreChatRoles); const result = selectOpenIdRole({ currentRole: user.role, openIdRoleValues, rolePriority, fallbackRole, }); if (!result.selectedRole || result.selectedRole === user.role) { return; } logger.info( `[openidStrategy] OpenID role sync updated role for ${username}: ${user.role || 'unset'} -> ${result.selectedRole}`, ); user.role = result.selectedRole; } /** * Process OpenID authentication tokenset and userinfo * This is the core logic extracted from the passport strategy callback * Can be reused by both the passport strategy and proxy authentication * * @param {Object} tokenset - The OpenID tokenset containing access_token, id_token, etc. * @param {boolean} existingUsersOnly - If true, only existing users will be processed * @returns {Promise} The authenticated user object with tokenset */ async function processOpenIDAuth(tokenset, existingUsersOnly = false) { const claims = tokenset.claims ? tokenset.claims() : tokenset; const userinfo = { ...claims, }; if (tokenset.access_token) { const providerUserinfo = await getUserInfo(openidConfig, tokenset.access_token, claims.sub); Object.assign(userinfo, providerUserinfo); } const email = getOpenIdEmail(userinfo); const openidIssuer = getOpenIdIssuer(claims, openidConfig); const baseConfig = await getAppConfig({ baseOnly: true }); if (!isEmailDomainAllowed(email, baseConfig?.registration?.allowedDomains)) { logger.error( `[OpenID Strategy] Authentication blocked - email domain not allowed [Identifier: ${email}]`, ); throw new Error('Email domain not allowed'); } const result = await findOpenIDUser({ findUser, email: email, openidId: claims.sub || userinfo.sub, openidIssuer, idOnTheSource: claims.oid || userinfo.oid, strategyName: 'openidStrategy', }); let user = result.user; const error = result.error; if (error) { throw new Error(ErrorTypes.AUTH_FAILED); } const appConfig = user?.tenantId ? await resolveAppConfigForUser(getAppConfig, user) : baseConfig; if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) { logger.error( `[OpenID Strategy] Authentication blocked - email domain not allowed [Identifier: ${email}]`, ); throw new Error('Email domain not allowed'); } const fullName = getFullName(userinfo); const requiredRole = process.env.OPENID_REQUIRED_ROLE; let resolvedOverageGroups = null; if (requiredRole) { const requiredRoles = requiredRole .split(',') .map((role) => role.trim()) .filter(Boolean); const requiredRoleParameterPath = process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH; const requiredRoleTokenKind = process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND; const decodedToken = getRoleSource(requiredRoleTokenKind, 'required role', tokenset, userinfo); let roles = get(decodedToken, requiredRoleParameterPath); // Handle Azure AD group overage for ID token groups: when hasgroups or _claim_* indicate overage, // resolve groups via Microsoft Graph instead of relying on token group values. const hasOverage = decodedToken?.hasgroups || (decodedToken?._claim_names?.groups && decodedToken?._claim_sources?.[decodedToken._claim_names.groups]); if ( requiredRoleTokenKind === 'id' && requiredRoleParameterPath === 'groups' && decodedToken && hasOverage ) { const overageGroups = await resolveGroupsFromOverage(tokenset.access_token, claims.sub); if (overageGroups) { roles = overageGroups; resolvedOverageGroups = overageGroups; } } if (!roles || (!Array.isArray(roles) && typeof roles !== 'string')) { logger.error( `[openidStrategy] Key '${requiredRoleParameterPath}' not found in ${requiredRoleTokenKind} token!`, ); const rolesList = requiredRoles.length === 1 ? `"${requiredRoles[0]}"` : `one of: ${requiredRoles.map((r) => `"${r}"`).join(', ')}`; throw new Error(`You must have ${rolesList} role to log in.`); } const roleValues = Array.isArray(roles) ? roles : roles.split(/[\s,]+/).filter(Boolean); if (!requiredRoles.some((role) => roleValues.includes(role))) { const rolesList = requiredRoles.length === 1 ? `"${requiredRoles[0]}"` : `one of: ${requiredRoles.map((r) => `"${r}"`).join(', ')}`; throw new Error(`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 (existingUsersOnly && !user) { throw new Error('User does not exist'); } if (!user) { user = { provider: 'openid', openidId: userinfo.sub, username, email: email || '', emailVerified: userinfo.email_verified || false, name: fullName, idOnTheSource: userinfo.oid, openidIssuer, }; const balanceConfig = getBalanceConfig(appConfig); user = await createUser(user, balanceConfig, true, true); } else { user.provider = 'openid'; user.openidId = userinfo.sub; if (openidIssuer) { user.openidIssuer = openidIssuer; } user.username = username; user.name = fullName; user.idOnTheSource = userinfo.oid; if (email && email !== user.email) { user.email = email; user.emailVerified = userinfo.email_verified || false; } } 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; let adminRoleGranted = false; if (adminRole && adminRoleParameterPath && adminRoleTokenKind) { const adminRoleObject = getRoleSource(adminRoleTokenKind, 'admin role', tokenset, userinfo); let adminRoles = get(adminRoleObject, adminRoleParameterPath); // Handle Azure AD group overage for admin role when using ID token groups if (adminRoleTokenKind === 'id' && adminRoleParameterPath === 'groups' && adminRoleObject) { const hasAdminOverage = adminRoleObject.hasgroups || (adminRoleObject._claim_names?.groups && adminRoleObject._claim_sources?.[adminRoleObject._claim_names.groups]); if (hasAdminOverage) { const overageGroups = resolvedOverageGroups || (await resolveGroupsFromOverage(tokenset.access_token, claims.sub)); if (overageGroups) { adminRoles = overageGroups; } } } let adminRoleValues = []; if (Array.isArray(adminRoles)) { adminRoleValues = adminRoles; } else if (typeof adminRoles === 'string') { adminRoleValues = adminRoles.split(/[\s,]+/).filter(Boolean); } if (adminRoles && (adminRoles === true || adminRoleValues.includes(adminRole))) { user.role = SystemRoles.ADMIN; adminRoleGranted = true; logger.info(`[openidStrategy] User ${username} is an admin based on role: ${adminRole}`); } else if (user.role === SystemRoles.ADMIN) { user.role = SystemRoles.USER; logger.info( `[openidStrategy] User ${username} demoted from admin - role no longer present in token`, ); } } if (!adminRoleGranted) { const roleBeforeSync = user.role; await applyOpenIdRoleSync({ user, username, tokenset, claims, userinfo, resolvedOverageGroups, }); /** * The earlier login-policy check ran with the pre-sync role. If role sync moved a * tenant user into a different role, re-resolve the tenant config and re-enforce * `allowedDomains` so role-scoped overrides for the new role are honored and a token * cannot complete login under the previous role's looser policy. */ if (user?.tenantId && user.role !== roleBeforeSync) { const postSyncConfig = await resolveAppConfigForUser(getAppConfig, user); if (!isEmailDomainAllowed(email, postSyncConfig?.registration?.allowedDomains)) { logger.error( `[OpenID Strategy] Authentication blocked after role sync - email domain not allowed [Identifier: ${email}]`, ); throw new Error('Email domain not allowed'); } } } if (!!userinfo && userinfo.picture && !user.avatar?.includes('manual=true')) { /** @type {string | undefined} */ const imageUrl = userinfo.picture; let fileName; if (crypto) { fileName = (await hashToken(userinfo.sub)) + '.png'; } else { fileName = userinfo.sub + '.png'; } const userId = user._id.toString(); const imageBuffer = await resizeIdentityProviderAvatar( imageUrl, userId, openidConfig, tokenset.access_token, userinfo.sub, ); if (imageBuffer) { const fileStrategy = getAvatarFileStrategy(appConfig, process.env.CDN_PROVIDER); const { saveBuffer } = getStrategyFunctions(fileStrategy); const imagePath = await saveBuffer( getAvatarSaveParams(fileStrategy, { fileName, userId, buffer: imageBuffer, tenantId: user.tenantId, }), ); 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, }, }, ); return { ...user, tokenset, federatedTokens: { access_token: tokenset.access_token, id_token: tokenset.id_token, refresh_token: tokenset.refresh_token, expires_at: tokenset.expires_at, }, }; } /** * @param {boolean | undefined} [existingUsersOnly] */ function createOpenIDCallback(existingUsersOnly) { return async (tokenset, done) => { try { const user = await processOpenIDAuth(tokenset, existingUsersOnly); done(null, user); } catch (err) { if (err.message === 'Email domain not allowed') { return done(null, false, { message: err.message }); } if (err.message === ErrorTypes.AUTH_FAILED) { return done(null, false, { message: err.message }); } if (err.message && err.message.includes('role to log in')) { return done(null, false, { message: err.message }); } logger.error('[openidStrategy] login failed', err); done(err); } }; } /** * Sets up the OpenID strategy specifically for admin authentication. * @param {Configuration} openidConfig */ const setupOpenIdAdmin = (openidConfig) => { try { if (!openidConfig) { throw new Error('OpenID configuration not initialized'); } const openidAdminLogin = new CustomOpenIDStrategy( { config: openidConfig, scope: process.env.OPENID_SCOPE, usePKCE: isEnabled(process.env.OPENID_USE_PKCE), clockTolerance: process.env.OPENID_CLOCK_TOLERANCE || 300, callbackURL: process.env.DOMAIN_SERVER + '/api/admin/oauth/openid/callback', }, createOpenIDCallback(true), ); passport.use('openidAdmin', openidAdminLogin); } catch (err) { logger.error('[openidStrategy] setupOpenIdAdmin', err); } }; /** * 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 { const usePKCE = isEnabled(process.env.OPENID_USE_PKCE); const shouldGenerateNonce = isEnabled(process.env.OPENID_GENERATE_NONCE); /** @type {ClientMetadata} */ const clientMetadata = { client_id: process.env.OPENID_CLIENT_ID, response_types: ['code'], grant_types: ['authorization_code'], }; const clientSecret = process.env.OPENID_CLIENT_SECRET?.trim(); if (clientSecret) { clientMetadata.client_secret = clientSecret; if (shouldGenerateNonce) { clientMetadata.token_endpoint_auth_method = 'client_secret_post'; } } else if (usePKCE) { clientMetadata.token_endpoint_auth_method = 'none'; } /** @type {Configuration} */ openidConfig = await client.discovery( new URL(process.env.OPENID_ISSUER), process.env.OPENID_CLIENT_ID, clientMetadata, undefined, { [client.customFetch]: customFetch, }, ); logger.info(`[openidStrategy] OpenID authentication configuration`, { usePKCE, hasClientSecret: !!clientSecret, tokenEndpointAuthMethod: clientMetadata.token_endpoint_auth_method ?? '(library default)', generateNonce: shouldGenerateNonce, }); 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, }, createOpenIDCallback(), ); passport.use('openid', openidLogin); setupOpenIdAdmin(openidConfig); 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, getOpenIdConfig, getOpenIdEmail, getRoleSource, };