started with Multi-Tenant OpenID.

TODO:
working code but needs some refactoring and cleaning up.
This commit is contained in:
Ruben Talstra 2025-02-08 13:12:07 +01:00
parent d786bf263c
commit 6577144554
Failed to extract signature
10 changed files with 350 additions and 58 deletions

View file

@ -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

View file

@ -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',

View file

@ -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,
);

View file

@ -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 = {

View file

@ -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>} 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<string>} - 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 };

View file

@ -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 tenants 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);
}

View file

@ -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 multitenant 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 ? (
<span role="alert" className="mt-1 text-sm text-red-500 dark:text-red-900">
{String(errorMessage)}
</span>
) : null;
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="mt-2">
<div className="mb-4">
<div className="relative">
<input
type="email"
id="email"
autoComplete="email"
aria-label={localize('com_auth_email')}
{...register('email', {
required: localize('com_auth_email_required'),
maxLength: { value: 120, message: localize('com_auth_email_max_length') },
pattern: {
value: /\S+@\S+\.\S+/,
message: localize('com_auth_email_pattern'),
},
})}
aria-invalid={!!errors.email}
className="
webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light
bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none
"
placeholder=" "
/>
<label
htmlFor="email"
className="
absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200
peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100
peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-600 dark:peer-focus:text-green-500
rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4
"
>
{localize('com_auth_email_address')}
</label>
</div>
{renderError('email')}
</div>
<button
type="submit"
className="flex w-full items-center space-x-3 rounded-2xl border border-border-light bg-surface-primary px-5 py-3 text-text-primary transition-colors duration-200 hover:bg-surface-tertiary"
data-testid="openid"
>
{openidImageUrl ? (
<img src={openidImageUrl} alt="OpenID Logo" className="h-5 w-5" />
) : (
<OpenIDIcon />
)}
<p>{openidLabel}</p>
</button>
</form>
);
}
export default MultiTenantOpenID;

View file

@ -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 && (
<SocialButton
key="openid"
enabled={startupConfig.openidLoginEnabled}
serverDomain={startupConfig.serverDomain}
oauthPath="openid"
Icon={() =>
startupConfig.openidImageUrl ? (
<img src={startupConfig.openidImageUrl} alt="OpenID Logo" className="h-5 w-5" />
) : (
<OpenIDIcon />
)
}
label={startupConfig.openidLabel}
id="openid"
/>
),
openid:
startupConfig.openidLoginEnabled &&
(startupConfig.openidMultiTenantEnabled ? (
<MultiTenantOpenID
key="openid"
openidImageUrl={startupConfig.openidImageUrl}
serverDomain={startupConfig.serverDomain}
openidLabel={startupConfig.openidLabel}
localize={localize}
/>
) : (
<SocialButton
key="openid"
enabled={startupConfig.openidLoginEnabled}
serverDomain={startupConfig.serverDomain}
oauthPath="openid"
Icon={() =>
startupConfig.openidImageUrl ? (
<img
src={startupConfig.openidImageUrl}
alt="OpenID Logo"
className="h-5 w-5"
/>
) : (
<OpenIDIcon />
)
}
label={startupConfig.openidLabel}
id="openid"
/>
)),
};
return (

View file

@ -73,6 +73,32 @@ registration:
# allowedDomains:
# - "gmail.com"
# SingleTenant 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:

View file

@ -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);