2025-05-30 00:00:58 +09:00
|
|
|
const fs = require('fs');
|
|
|
|
|
const path = require('path');
|
|
|
|
|
const fetch = require('node-fetch');
|
|
|
|
|
const passport = require('passport');
|
2025-08-11 18:49:34 -04:00
|
|
|
const { ErrorTypes } = require('librechat-data-provider');
|
2025-05-30 22:18:13 -04:00
|
|
|
const { hashToken, logger } = require('@librechat/data-schemas');
|
2025-05-30 00:00:58 +09:00
|
|
|
const { Strategy: SamlStrategy } = require('@node-saml/passport-saml');
|
2025-09-27 21:20:19 -04:00
|
|
|
const { getBalanceConfig, isEmailDomainAllowed } = require('@librechat/api');
|
2025-05-30 00:00:58 +09:00
|
|
|
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
2025-05-30 22:18:13 -04:00
|
|
|
const { findUser, createUser, updateUser } = require('~/models');
|
2025-08-26 12:10:18 -04:00
|
|
|
const { getAppConfig } = require('~/server/services/Config');
|
2025-05-30 00:00:58 +09:00
|
|
|
const paths = require('~/config/paths');
|
|
|
|
|
|
|
|
|
|
let crypto;
|
|
|
|
|
try {
|
|
|
|
|
crypto = require('node:crypto');
|
|
|
|
|
} catch (err) {
|
|
|
|
|
logger.error('[samlStrategy] crypto support is disabled!', err);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Retrieves the certificate content from the given value.
|
|
|
|
|
*
|
|
|
|
|
* This function determines whether the provided value is a certificate string (RFC7468 format or
|
|
|
|
|
* base64-encoded without a header) or a valid file path. If the value matches one of these formats,
|
|
|
|
|
* the certificate content is returned. Otherwise, an error is thrown.
|
|
|
|
|
*
|
|
|
|
|
* @see https://github.com/node-saml/node-saml/tree/master?tab=readme-ov-file#configuration-option-idpcert
|
|
|
|
|
* @param {string} value - The certificate string or file path.
|
|
|
|
|
* @returns {string} The certificate content if valid.
|
|
|
|
|
* @throws {Error} If the value is not a valid certificate string or file path.
|
|
|
|
|
*/
|
|
|
|
|
function getCertificateContent(value) {
|
|
|
|
|
if (typeof value !== 'string') {
|
|
|
|
|
throw new Error('Invalid input: SAML_CERT must be a string.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if it's an RFC7468 formatted PEM certificate
|
|
|
|
|
const pemRegex = new RegExp(
|
|
|
|
|
'-----BEGIN (CERTIFICATE|PUBLIC KEY)-----\n' + // header
|
|
|
|
|
'([A-Za-z0-9+/=]{64}\n)+' + // base64 content (64 characters per line)
|
|
|
|
|
'[A-Za-z0-9+/=]{1,64}\n' + // base64 content (last line)
|
|
|
|
|
'-----END (CERTIFICATE|PUBLIC KEY)-----', // footer
|
|
|
|
|
);
|
|
|
|
|
if (pemRegex.test(value)) {
|
|
|
|
|
logger.info('[samlStrategy] Detected RFC7468-formatted certificate string.');
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if it's a Base64-encoded certificate (no header)
|
|
|
|
|
if (/^[A-Za-z0-9+/=]+$/.test(value) && value.length % 4 === 0) {
|
|
|
|
|
logger.info('[samlStrategy] Detected base64-encoded certificate string (no header).');
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if file exists and is readable
|
|
|
|
|
const certPath = path.normalize(path.isAbsolute(value) ? value : path.join(paths.root, value));
|
|
|
|
|
if (fs.existsSync(certPath) && fs.statSync(certPath).isFile()) {
|
|
|
|
|
try {
|
|
|
|
|
logger.info(`[samlStrategy] Loading certificate from file: ${certPath}`);
|
|
|
|
|
return fs.readFileSync(certPath, 'utf8').trim();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
throw new Error(`Error reading certificate file: ${error.message}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw new Error('Invalid cert: SAML_CERT must be a valid file path or certificate string.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Retrieves a SAML claim from a profile object based on environment configuration.
|
|
|
|
|
* @param {object} profile - Saml profile
|
|
|
|
|
* @param {string} envVar - Environment variable name (SAML_*)
|
|
|
|
|
* @param {string} defaultKey - Default key to use if the environment variable is not set
|
|
|
|
|
* @returns {string}
|
|
|
|
|
*/
|
|
|
|
|
function getSamlClaim(profile, envVar, defaultKey) {
|
|
|
|
|
const claimKey = process.env[envVar];
|
|
|
|
|
|
|
|
|
|
// Avoids accessing `profile[""]` when the environment variable is empty string.
|
|
|
|
|
if (claimKey) {
|
|
|
|
|
return profile[claimKey] ?? profile[defaultKey];
|
|
|
|
|
}
|
|
|
|
|
return profile[defaultKey];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getEmail(profile) {
|
|
|
|
|
return getSamlClaim(profile, 'SAML_EMAIL_CLAIM', 'email');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getUserName(profile) {
|
|
|
|
|
return getSamlClaim(profile, 'SAML_USERNAME_CLAIM', 'username');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getGivenName(profile) {
|
|
|
|
|
return getSamlClaim(profile, 'SAML_GIVEN_NAME_CLAIM', 'given_name');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getFamilyName(profile) {
|
|
|
|
|
return getSamlClaim(profile, 'SAML_FAMILY_NAME_CLAIM', 'family_name');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getPicture(profile) {
|
|
|
|
|
return getSamlClaim(profile, 'SAML_PICTURE_CLAIM', 'picture');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Downloads an image from a URL using an access token.
|
|
|
|
|
* @param {string} url
|
|
|
|
|
* @returns {Promise<Buffer>}
|
|
|
|
|
*/
|
|
|
|
|
const downloadImage = async (url) => {
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(url);
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
return await response.buffer();
|
|
|
|
|
} else {
|
|
|
|
|
throw new Error(`${response.statusText} (HTTP ${response.status})`);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(`[samlStrategy] Error downloading image at URL "${url}": ${error}`);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Determines the full name of a user based on SAML profile and environment configuration.
|
|
|
|
|
*
|
|
|
|
|
* @param {Object} profile - The user profile object from SAML Connect
|
|
|
|
|
* @returns {string} The determined full name of the user
|
|
|
|
|
*/
|
|
|
|
|
function getFullName(profile) {
|
|
|
|
|
if (process.env.SAML_NAME_CLAIM) {
|
|
|
|
|
logger.info(
|
|
|
|
|
`[samlStrategy] Using SAML_NAME_CLAIM: ${process.env.SAML_NAME_CLAIM}, profile: ${profile[process.env.SAML_NAME_CLAIM]}`,
|
|
|
|
|
);
|
|
|
|
|
return profile[process.env.SAML_NAME_CLAIM];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const givenName = getGivenName(profile);
|
|
|
|
|
const familyName = getFamilyName(profile);
|
|
|
|
|
|
|
|
|
|
if (givenName && familyName) {
|
|
|
|
|
return `${givenName} ${familyName}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (givenName) {
|
|
|
|
|
return givenName;
|
|
|
|
|
}
|
|
|
|
|
if (familyName) {
|
|
|
|
|
return familyName;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return getUserName(profile) || getEmail(profile);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function setupSaml() {
|
|
|
|
|
try {
|
|
|
|
|
const samlConfig = {
|
|
|
|
|
entryPoint: process.env.SAML_ENTRY_POINT,
|
|
|
|
|
issuer: process.env.SAML_ISSUER,
|
|
|
|
|
callbackUrl: process.env.SAML_CALLBACK_URL,
|
|
|
|
|
idpCert: getCertificateContent(process.env.SAML_CERT),
|
|
|
|
|
wantAssertionsSigned: process.env.SAML_USE_AUTHN_RESPONSE_SIGNED === 'true' ? false : true,
|
|
|
|
|
wantAuthnResponseSigned: process.env.SAML_USE_AUTHN_RESPONSE_SIGNED === 'true' ? true : false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
passport.use(
|
|
|
|
|
'saml',
|
|
|
|
|
new SamlStrategy(samlConfig, async (profile, done) => {
|
|
|
|
|
try {
|
|
|
|
|
logger.info(`[samlStrategy] SAML authentication received for NameID: ${profile.nameID}`);
|
|
|
|
|
logger.debug('[samlStrategy] SAML profile:', profile);
|
|
|
|
|
|
2025-09-11 01:01:58 -04:00
|
|
|
const userEmail = getEmail(profile) || '';
|
🧵 feat: ALS Context Middleware, Tenant Threading, and Config Cache Invalidation (#12407)
* feat: add tenant context middleware for ALS-based isolation
Introduces tenantContextMiddleware that propagates req.user.tenantId
into AsyncLocalStorage, activating the Mongoose applyTenantIsolation
plugin for all downstream DB queries within a request.
- Strict mode (TENANT_ISOLATION_STRICT=true) returns 403 if no tenantId
- Non-strict mode passes through for backward compatibility
- No-op for unauthenticated requests
- Includes 6 unit tests covering all paths
* feat: register tenant middleware and wrap startup/auth in runAsSystem()
- Register tenantContextMiddleware in Express app after capability middleware
- Wrap server startup initialization in runAsSystem() for strict mode compat
- Wrap auth strategy getAppConfig() calls in runAsSystem() since they run
before user context is established (LDAP, SAML, OpenID, social login, AuthService)
* feat: thread tenantId through all getAppConfig callers
Pass tenantId from req.user to getAppConfig() across all callers that
have request context, ensuring correct per-tenant cache key resolution.
Also fixes getBaseConfig admin endpoint to scope to requesting admin's
tenant instead of returning the unscoped base config.
Files updated:
- Controllers: UserController, PluginController
- Middleware: checkDomainAllowed, balance
- Routes: config
- Services: loadConfigModels, loadDefaultModels, getEndpointsConfig, MCP
- Audio services: TTSService, STTService, getVoices, getCustomConfigSpeech
- Admin: getBaseConfig endpoint
* feat: add config cache invalidation on admin mutations
- Add clearOverrideCache(tenantId?) to flush per-principal override caches
by enumerating Keyv store keys matching _OVERRIDE_: prefix
- Add invalidateConfigCaches() helper that clears base config, override
caches, tool caches, and endpoint config cache in one call
- Wire invalidation into all 5 admin config mutation handlers
(upsert, patch, delete field, delete overrides, toggle active)
- Add strict mode warning when __default__ tenant fallback is used
- Add 3 new tests for clearOverrideCache (all/scoped/base-preserving)
* chore: update getUserPrincipals comment to reflect ALS-based tenant filtering
The TODO(#12091) about missing tenantId filtering is resolved by the
tenant context middleware + applyTenantIsolation Mongoose plugin.
Group queries are now automatically scoped by tenantId via ALS.
* fix: replace runAsSystem with baseOnly for pre-tenant code paths
App configs are tenant-owned — runAsSystem() would bypass tenant
isolation and return cross-tenant DB overrides. Instead, add
baseOnly option to getAppConfig() that returns YAML-derived config
only, with zero DB queries.
All startup code, auth strategies, and MCP initialization now use
getAppConfig({ baseOnly: true }) to get the YAML config without
touching the Config collection.
* fix: address PR review findings — middleware ordering, types, cache safety
- Chain tenantContextMiddleware inside requireJwtAuth after passport auth
instead of global app.use() where req.user is always undefined (Finding 1)
- Remove global tenantContextMiddleware registration from index.js
- Update BalanceMiddlewareOptions to include tenantId, remove redundant cast (Finding 4)
- Add warning log when clearOverrideCache cannot enumerate keys on Redis (Finding 3)
- Use startsWith instead of includes for cache key filtering (Finding 12)
- Use generator loop instead of Array.from for key enumeration (Finding 3)
- Selective barrel export — exclude _resetTenantMiddlewareStrictCache (Finding 5)
- Move isMainThread check to module level, remove per-request check (Finding 9)
- Move mid-file require to top of app.js (Finding 8)
- Parallelize invalidateConfigCaches with Promise.all (Finding 10)
- Remove clearOverrideCache from public app.js exports (internal only)
- Strengthen getUserPrincipals comment re: ALS dependency (Finding 2)
* fix: restore runAsSystem for startup DB ops, consolidate require, clarify baseOnly
- Restore runAsSystem() around performStartupChecks, updateInterfacePermissions,
initializeMCPs, and initializeOAuthReconnectManager — these make Mongoose
queries that need system context in strict tenant mode (NEW-3)
- Consolidate duplicate require('@librechat/api') in requireJwtAuth.js (NEW-1)
- Document that baseOnly ignores role/userId/tenantId in JSDoc (NEW-2)
* test: add requireJwtAuth tenant chaining + invalidateConfigCaches tests
- requireJwtAuth: 5 tests verifying ALS tenant context is set after
passport auth, isolated between concurrent requests, and not set
when user has no tenantId (Finding 6)
- invalidateConfigCaches: 4 tests verifying all four caches are cleared,
tenantId is threaded through, partial failure is handled gracefully,
and operations run in parallel via Promise.all (Finding 11)
* fix: address Copilot review — passport errors, namespaced cache keys, /base scoping
- Forward passport errors in requireJwtAuth before entering tenant
middleware — prevents silent auth failures from reaching handlers (P1)
- Account for Keyv namespace prefix in clearOverrideCache — stored keys
are namespaced as "APP_CONFIG:_OVERRIDE_:..." not "_OVERRIDE_:...",
so override caches were never actually matched/cleared (P2)
- Remove role from getBaseConfig — /base should return tenant-scoped
base config, not role-merged config that drifts per admin role (P2)
- Return tenantStorage.run() for cleaner async semantics
- Update mock cache in service.spec.ts to simulate Keyv namespacing
* fix: address second review — cache safety, code quality, test reliability
- Decouple cache invalidation from mutation response: fire-and-forget
with logging so DB mutation success is not masked by cache failures
- Extract clearEndpointConfigCache helper from inline IIFE
- Move isMainThread check to lazy once-per-process guard (no import
side effect)
- Memoize process.env read in overrideCacheKey to avoid per-request
env lookups and log flooding in strict mode
- Remove flaky timer-based parallelism assertion, use structural check
- Merge orphaned double JSDoc block on getUserPrincipals
- Fix stale [getAppConfig] log prefix → [ensureBaseConfig]
- Fix import order in tenant.spec.ts (package types before local values)
- Replace "Finding 1" reference with self-contained description
- Use real tenantStorage primitives in requireJwtAuth spec mock
* fix: move JSDoc to correct function after clearEndpointConfigCache extraction
* refactor: remove Redis SCAN from clearOverrideCache, rely on TTL expiry
Redis SCAN causes 60s+ stalls under concurrent load (see #12410).
APP_CONFIG defaults to FORCED_IN_MEMORY_CACHE_NAMESPACES, so the
in-memory store.keys() path handles the standard case. When APP_CONFIG
is Redis-backed, overrides expire naturally via overrideCacheTtl (60s
default) — an acceptable window for admin config mutations.
* fix: remove return from tenantStorage.run to satisfy void middleware signature
* fix: address second review — cache safety, code quality, test reliability
- Switch invalidateConfigCaches from Promise.all to Promise.allSettled
so partial failures are logged individually instead of producing one
undifferentiated error (Finding 3)
- Gate overrideCacheKey strict-mode warning behind a once-per-process
flag to prevent log flooding under load (Finding 4)
- Add test for passport error forwarding in requireJwtAuth — the
if (err) { return next(err) } branch now has coverage (Finding 5)
- Add test for real partial failure in invalidateConfigCaches where
clearAppConfigCache rejects (not just the swallowed endpoint error)
* chore: reorder imports in index.js and app.js for consistency
- Moved logger and runAsSystem imports to maintain a consistent import order across files.
- Improved code readability by ensuring related imports are grouped together.
2026-03-26 17:35:00 -04:00
|
|
|
const appConfig = await getAppConfig({ baseOnly: true });
|
2025-09-11 01:01:58 -04:00
|
|
|
|
|
|
|
|
if (!isEmailDomainAllowed(userEmail, appConfig?.registration?.allowedDomains)) {
|
|
|
|
|
logger.error(
|
|
|
|
|
`[SAML Strategy] Authentication blocked - email domain not allowed [Email: ${userEmail}]`,
|
|
|
|
|
);
|
|
|
|
|
return done(null, false, { message: 'Email domain not allowed' });
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-30 00:00:58 +09:00
|
|
|
let user = await findUser({ samlId: profile.nameID });
|
|
|
|
|
logger.info(
|
|
|
|
|
`[samlStrategy] User ${user ? 'found' : 'not found'} with SAML ID: ${profile.nameID}`,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (!user) {
|
2025-09-11 01:01:58 -04:00
|
|
|
user = await findUser({ email: userEmail });
|
2025-05-30 00:00:58 +09:00
|
|
|
logger.info(
|
2025-09-11 01:01:58 -04:00
|
|
|
`[samlStrategy] User ${user ? 'found' : 'not found'} with email: ${userEmail}`,
|
2025-05-30 00:00:58 +09:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-11 18:49:34 -04:00
|
|
|
if (user && user.provider !== 'saml') {
|
|
|
|
|
logger.info(
|
|
|
|
|
`[samlStrategy] User ${user.email} already exists with provider ${user.provider}`,
|
|
|
|
|
);
|
|
|
|
|
return done(null, false, {
|
|
|
|
|
message: ErrorTypes.AUTH_FAILED,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-30 00:00:58 +09:00
|
|
|
const fullName = getFullName(profile);
|
|
|
|
|
|
|
|
|
|
const username = convertToUsername(
|
|
|
|
|
getUserName(profile) || getGivenName(profile) || getEmail(profile),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (!user) {
|
|
|
|
|
user = {
|
|
|
|
|
provider: 'saml',
|
|
|
|
|
samlId: profile.nameID,
|
|
|
|
|
username,
|
2025-09-10 23:13:39 -04:00
|
|
|
email: userEmail,
|
2025-05-30 00:00:58 +09:00
|
|
|
emailVerified: true,
|
|
|
|
|
name: fullName,
|
|
|
|
|
};
|
2025-08-26 12:10:18 -04:00
|
|
|
const balanceConfig = getBalanceConfig(appConfig);
|
2025-05-30 22:18:13 -04:00
|
|
|
user = await createUser(user, balanceConfig, true, true);
|
2025-05-30 00:00:58 +09:00
|
|
|
} else {
|
|
|
|
|
user.provider = 'saml';
|
|
|
|
|
user.samlId = profile.nameID;
|
|
|
|
|
user.username = username;
|
|
|
|
|
user.name = fullName;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const picture = getPicture(profile);
|
|
|
|
|
if (picture && !user.avatar?.includes('manual=true')) {
|
|
|
|
|
const imageBuffer = await downloadImage(profile.picture);
|
|
|
|
|
if (imageBuffer) {
|
|
|
|
|
let fileName;
|
|
|
|
|
if (crypto) {
|
|
|
|
|
fileName = (await hashToken(profile.nameID)) + '.png';
|
|
|
|
|
} else {
|
|
|
|
|
fileName = profile.nameID + '.png';
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-05 11:09:32 -04:00
|
|
|
const { saveBuffer } = getStrategyFunctions(
|
|
|
|
|
appConfig?.fileStrategy ?? process.env.CDN_PROVIDER,
|
|
|
|
|
);
|
2025-05-30 00:00:58 +09:00
|
|
|
const imagePath = await saveBuffer({
|
|
|
|
|
fileName,
|
|
|
|
|
userId: user._id.toString(),
|
|
|
|
|
buffer: imageBuffer,
|
|
|
|
|
});
|
|
|
|
|
user.avatar = imagePath ?? '';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
user = await updateUser(user._id, user);
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
`[samlStrategy] Login success SAML ID: ${user.samlId} | email: ${user.email} | username: ${user.username}`,
|
|
|
|
|
{
|
|
|
|
|
user: {
|
|
|
|
|
samlId: user.samlId,
|
|
|
|
|
username: user.username,
|
|
|
|
|
email: user.email,
|
|
|
|
|
name: user.name,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
done(null, user);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
logger.error('[samlStrategy] Login failed', err);
|
|
|
|
|
done(err);
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
logger.error('[samlStrategy]', err);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
module.exports = { setupSaml, getCertificateContent };
|