diff --git a/.env.example b/.env.example index d87021ea4b..e63d043660 100644 --- a/.env.example +++ b/.env.example @@ -406,9 +406,11 @@ APPLE_PRIVATE_KEY_PATH= APPLE_CALLBACK_URL=/oauth/apple/callback # OpenID -OPENID_CLIENT_ID= -OPENID_CLIENT_SECRET= -OPENID_ISSUER= +OPENID_ENABLED=true +#OPENID_MULTI_TENANT= +#OPENID_CLIENT_ID= +#OPENID_CLIENT_SECRET= +#OPENID_ISSUER= OPENID_SESSION_SECRET= OPENID_SCOPE="openid profile email" OPENID_CALLBACK_URL=/oauth/openid/callback diff --git a/api/server/routes/config.js b/api/server/routes/config.js index 705a1d3cb1..2dbcca6d3b 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -52,10 +52,9 @@ router.get('/', async function (req, res) { !!process.env.APPLE_KEY_ID && !!process.env.APPLE_PRIVATE_KEY_PATH, openidLoginEnabled: - !!process.env.OPENID_CLIENT_ID && - !!process.env.OPENID_CLIENT_SECRET && - !!process.env.OPENID_ISSUER && + !!process.env.OPENID_ENABLED && !!process.env.OPENID_SESSION_SECRET, + openidMultiTenantEnabled: !!process.env.OPENID_MULTI_TENANT, openidLabel: process.env.OPENID_BUTTON_LABEL || 'Continue with OpenID', openidImageUrl: process.env.OPENID_IMAGE_URL, serverDomain: process.env.DOMAIN_SERVER || 'http://localhost:3080', diff --git a/api/server/routes/oauth.js b/api/server/routes/oauth.js index 046370798b..1ca49e4ebc 100644 --- a/api/server/routes/oauth.js +++ b/api/server/routes/oauth.js @@ -4,6 +4,7 @@ const passport = require('passport'); const { loginLimiter, checkBan, checkDomainAllowed } = require('~/server/middleware'); const { setAuthTokens } = require('~/server/services/AuthService'); const { logger } = require('~/config'); +const { chooseOpenIdStrategy } = require('~/server/utils/openidHelper'); const router = express.Router(); @@ -30,7 +31,7 @@ const oauthHandler = async (req, res) => { router.get('/error', (req, res) => { // A single error message is pushed by passport when authentication fails. - logger.error('Error in OAuth authentication:', { message: req.session.messages.pop() }); + logger.error('Error in OAuth authentication:', { message: req.session?.messages?.pop() }); res.redirect(`${domains.client}/login`); }); @@ -83,20 +84,32 @@ router.get( /** * OpenID Routes */ -router.get( - '/openid', - passport.authenticate('openid', { - session: false, - }), -); +router.get('/openid', async (req, res, next) => { + try { + const strategy = await chooseOpenIdStrategy(req); + console.log('OpenID login using strategy:', strategy); + passport.authenticate(strategy, { + session: false, + })(req, res, next); + } catch (err) { + next(err); + } +}); router.get( '/openid/callback', - passport.authenticate('openid', { - failureRedirect: `${domains.client}/oauth/error`, - failureMessage: true, - session: false, - }), + async (req, res, next) => { + try { + const strategy = await chooseOpenIdStrategy(req); + passport.authenticate(strategy, { + failureRedirect: `${domains.client}/oauth/error`, + failureMessage: true, + session: false, + })(req, res, next); + } catch (err) { + next(err); + } + }, oauthHandler, ); diff --git a/api/server/socialLogins.js b/api/server/socialLogins.js index f39d1da596..88947c7940 100644 --- a/api/server/socialLogins.js +++ b/api/server/socialLogins.js @@ -15,7 +15,6 @@ const { isEnabled } = require('~/server/utils'); const { logger } = require('~/config'); /** - * * @param {Express.Application} app */ const configureSocialLogins = (app) => { @@ -35,10 +34,7 @@ const configureSocialLogins = (app) => { passport.use(appleLogin()); } if ( - process.env.OPENID_CLIENT_ID && - process.env.OPENID_CLIENT_SECRET && - process.env.OPENID_ISSUER && - process.env.OPENID_SCOPE && + process.env.OPENID_ENABLED && process.env.OPENID_SESSION_SECRET ) { const sessionOptions = { diff --git a/api/server/utils/openidHelper.js b/api/server/utils/openidHelper.js new file mode 100644 index 0000000000..0e5f859c21 --- /dev/null +++ b/api/server/utils/openidHelper.js @@ -0,0 +1,52 @@ +const { logger } = require('~/config'); +const { getCustomConfig } = require('~/server/services/Config'); + +/** + * Loads the tenant configurations from the custom configuration. + * @returns {Promise} Array of tenant configurations. + */ +async function getOpenIdTenants() { + try { + const customConfig = await getCustomConfig(); + if (customConfig?.openid?.tenants) { + return customConfig.openid.tenants; + } + } catch (err) { + logger.error('Failed to load custom configuration for OpenID tenants:', err); + } + return []; +} + +/** + * Chooses the OpenID strategy name based on the email domain. + * It consults the global tenant mapping (built in setupOpenId). + * @param {import('express').Request} req - The Express request object. + * @returns {Promise} - The chosen strategy name. + */ +async function chooseOpenIdStrategy(req) { + if (req.query.email) { + const email = req.query.email; + const domain = email.split('@')[1].toLowerCase(); + const tenants = await getOpenIdTenants(); + + // Iterate over the tenants and return the strategy name of the first matching tenant + for (const tenant of tenants) { + if (tenant.domains) { + const tenantDomains = tenant.domains.split(',').map(s => s.trim().toLowerCase()); + if (tenantDomains.includes(domain)) { + // Look up the registered strategy via the global mapping. + if (tenant.name && tenant.name.trim() && global.__openidTenantMapping) { + const mapped = global.__openidTenantMapping.get(tenant.name.trim().toLowerCase()); + if (mapped) { + return mapped; + } + } + return 'openid'; // Fallback if no mapping exists. + } + } + } + } + return 'openid'; +} + +module.exports = { getOpenIdTenants, chooseOpenIdStrategy }; \ No newline at end of file diff --git a/api/strategies/openidStrategy.js b/api/strategies/openidStrategy.js index b26b11efed..d05c1db3f1 100644 --- a/api/strategies/openidStrategy.js +++ b/api/strategies/openidStrategy.js @@ -1,6 +1,6 @@ const fetch = require('node-fetch'); const passport = require('passport'); -const jwtDecode = require('jsonwebtoken/decode'); +const { decode: jwtDecode } = require('jsonwebtoken'); const { HttpsProxyAgent } = require('https-proxy-agent'); const { Issuer, Strategy: OpenIDStrategy, custom } = require('openid-client'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); @@ -8,6 +8,7 @@ const { findUser, createUser, updateUser } = require('~/models/userMethods'); const { hashToken } = require('~/server/utils/crypto'); const { isEnabled } = require('~/server/utils'); const { logger } = require('~/config'); +const { getOpenIdTenants } = require('~/server/utils/openidHelper'); let crypto; try { @@ -105,16 +106,18 @@ function convertToUsername(input, defaultValue = '') { return defaultValue; } -async function setupOpenId() { +/** + * Sets up a single OpenID strategy for the given tenant configuration. + * @param {Object} tenant - The tenant’s OpenID config (issuer, clientId, etc.). + * @param {string} tenant.issuer + * @param {string} tenant.clientId + * @param {string} tenant.clientSecret + * @param {string} strategyName - Unique name for the strategy. + */ +async function setupSingleStrategy(tenant, strategyName) { 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); + // Discover the issuer (this performs the .well-known lookup). + const issuer = await Issuer.discover(tenant.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' @@ -124,8 +127,8 @@ async function setupOpenId() { */ /** @type {import('openid-client').ClientMetadata} */ const clientMetadata = { - client_id: process.env.OPENID_CLIENT_ID, - client_secret: process.env.OPENID_CLIENT_SECRET, + client_id: tenant.clientId, + client_secret: tenant.clientSecret, redirect_uris: [process.env.DOMAIN_SERVER + process.env.OPENID_CALLBACK_URL], }; if (isEnabled(process.env.OPENID_SET_FIRST_SUPPORTED_ALGORITHM)) { @@ -146,7 +149,7 @@ async function setupOpenId() { async (tokenset, userinfo, done) => { try { logger.info(`[openidStrategy] verify login openidId: ${userinfo.sub}`); - logger.debug('[openidStrategy] very login tokenset and userinfo', { tokenset, userinfo }); + logger.debug('[openidStrategy] verify login tokenset and userinfo', { tokenset, userinfo }); let user = await findUser({ openidId: userinfo.sub }); logger.info( @@ -265,7 +268,65 @@ async function setupOpenId() { }, ); - passport.use('openid', openidLogin); + passport.use(strategyName, openidLogin); + logger.info(`Configured OpenID strategy [${strategyName}] for issuer: ${tenant.issuer}`); + } catch (err) { + logger.error(`[openidStrategy] Error configuring strategy "${strategyName}":`, err); + } +} + +/** + * Reads the YAML configuration and registers strategies for multi-tenant OpenID Connect. + */ +async function setupOpenId() { + try { + // If a proxy is configured, set it for openid-client. + + // Set global HTTP options for openid-client + if (process.env.PROXY) { + const proxyAgent = new HttpsProxyAgent(process.env.PROXY); + custom.setHttpOptionsDefaults({ + agent: proxyAgent, + timeout: 10000, // 10,000ms = 10 seconds + }); + logger.info(`[openidStrategy] Proxy agent added: ${process.env.PROXY} with timeout 10000ms`); + } else { + custom.setHttpOptionsDefaults({ + timeout: 10000, // Increase the default timeout + }); + logger.info('[openidStrategy] Set default timeout to 10000ms'); + } + + const tenants = await getOpenIdTenants(); + + // Global mapping: tenant name (lowercase) -> strategy name. + const tenantMapping = new Map(); + + // If there is one tenant with no domains specified, register it as the default "openid" strategy. + if (tenants.length === 1 && (!tenants[0].domains || tenants[0].domains.trim() === '')) { + await setupSingleStrategy(tenants[0].openid, 'openid'); + tenantMapping.set(tenants[0].name?.trim().toLowerCase() || 'openid', 'openid'); + logger.info('Configured single-tenant OpenID strategy as "openid"'); + } else { + // Otherwise, iterate over each tenant. + for (const tenantCfg of tenants) { + const openidCfg = tenantCfg.openid; + let strategyName = 'openid'; + if (tenantCfg.name && tenantCfg.name.trim()) { + strategyName = `openid_${tenantCfg.name.trim()}`; + }else { + logger.warn( + `[openidStrategy] Tenant with issuer ${openidCfg.issuer} has no domains specified; defaulting strategy name to "openid".`, + ); + } + await setupSingleStrategy(openidCfg, strategyName); + if (tenantCfg.name && tenantCfg.name.trim()) { + tenantMapping.set(tenantCfg.name.trim().toLowerCase(), strategyName); + } + } + } + // Store the tenant mapping globally so that the helper can choose the correct strategy. + global.__openidTenantMapping = tenantMapping; } catch (err) { logger.error('[openidStrategy]', err); } diff --git a/client/src/components/Auth/MultiTenantOpenID.tsx b/client/src/components/Auth/MultiTenantOpenID.tsx new file mode 100644 index 0000000000..4f7ff51ecb --- /dev/null +++ b/client/src/components/Auth/MultiTenantOpenID.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { useForm } from 'react-hook-form'; +import { OpenIDIcon } from '~/components'; + +interface MultiTenantOpenIDProps { + serverDomain: string; + openidLabel: string; + openidImageUrl: string; + localize: (key: string) => string; +} + +/** + * When multi‑tenant mode is enabled (startupConfig.emailLoginEnabled === true), + * we render a form for the user to enter their email. When submitted, we perform a GET + * request (via redirect) to /oauth/openid with the email as a query parameter. + * If, for some reason, no email is provided, we simply redirect to /oauth/openid. + */ +function MultiTenantOpenID({ + serverDomain, + openidLabel, + openidImageUrl, + localize, +}: MultiTenantOpenIDProps) { + const { + register, + handleSubmit, + formState: { errors }, + } = useForm<{ email: string }>(); + + const onSubmit = (data: { email: string }) => { + // If an email is provided, include it as a query parameter. + // Otherwise, simply redirect without an email. + const emailQuery = + data.email && data.email.trim() !== '' + ? `?email=${encodeURIComponent(data.email)}` + : ''; + window.location.href = `${serverDomain}/oauth/openid${emailQuery}`; + }; + + const renderError = (fieldName: string) => { + const errorMessage = errors[fieldName]?.message; + return errorMessage ? ( + + {String(errorMessage)} + + ) : null; + }; + + return ( +
+
+
+ + +
+ {renderError('email')} +
+ + +
+ ); +} + +export default MultiTenantOpenID; \ No newline at end of file diff --git a/client/src/components/Auth/SocialLoginRender.tsx b/client/src/components/Auth/SocialLoginRender.tsx index 70c0a65b63..1254b4cc4e 100644 --- a/client/src/components/Auth/SocialLoginRender.tsx +++ b/client/src/components/Auth/SocialLoginRender.tsx @@ -1,10 +1,16 @@ -import { GoogleIcon, FacebookIcon, OpenIDIcon, GithubIcon, DiscordIcon, AppleIcon } from '~/components'; - +import React from 'react'; +import { + GoogleIcon, + FacebookIcon, + OpenIDIcon, + GithubIcon, + DiscordIcon, + AppleIcon, +} from '~/components'; import SocialButton from './SocialButton'; - import { useLocalize } from '~/hooks'; - import { TStartupConfig } from 'librechat-data-provider'; +import MultiTenantOpenID from './MultiTenantOpenID'; function SocialLoginRender({ startupConfig, @@ -73,23 +79,37 @@ function SocialLoginRender({ id="apple" /> ), - openid: startupConfig.openidLoginEnabled && ( - - startupConfig.openidImageUrl ? ( - OpenID Logo - ) : ( - - ) - } - label={startupConfig.openidLabel} - id="openid" - /> - ), + openid: + startupConfig.openidLoginEnabled && + (startupConfig.openidMultiTenantEnabled ? ( + + ) : ( + + startupConfig.openidImageUrl ? ( + OpenID Logo + ) : ( + + ) + } + label={startupConfig.openidLabel} + id="openid" + /> + )), }; return ( diff --git a/librechat.example.yaml b/librechat.example.yaml index e49f9b37b3..1d48c3cd96 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -73,6 +73,32 @@ registration: # allowedDomains: # - "gmail.com" +# Single‑Tenant YAML +#openid: +# tenants: +# - name: "default" +# domains: "" +# openid: +# clientId: "client-id-for-tenant1" +# clientSecret: "client-secret-for-tenant1" +# issuer: "https://example.com/oidc" + +# Add your multi-tenant OpenID settings: +openid: + tenants: + - name: "tenant1" + domains: "first.com,example.com" + openid: + clientId: "client-id-for-tenant1" + clientSecret: "client-secret-for-tenant1" + issuer: "https://example.com/oidc" + - name: "tenant2" + domains: "another.com,one.com" + openid: + clientId: "client-id-for-tenant2" + clientSecret: "client-secret-for-tenant2" + issuer: "https://example.com/oidc2" + # speech: # tts: # openai: diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 6a2db199b2..e1443b5f4b 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -481,6 +481,7 @@ export type TStartupConfig = { githubLoginEnabled: boolean; googleLoginEnabled: boolean; openidLoginEnabled: boolean; + openidMultiTenantEnabled: boolean; appleLoginEnabled: boolean; openidLabel: string; openidImageUrl: string; @@ -558,6 +559,24 @@ export const configSchema = z.object({ message: 'At least one `endpoints` field must be provided.', }) .optional(), + // ===== Add your OpenID configuration ===== + openid: z + .object({ + tenants: z + .array( + z.object({ + name: z.string(), + domains: z.string(), + openid: z.object({ + clientId: z.string(), + clientSecret: z.string(), + issuer: z.string(), + }), + }), + ) + .optional(), + }) + .optional(), }); export const getConfigDefaults = () => getSchemaDefaults(configSchema);