🔑 feat: SAML authentication (#6169)

* feat: add SAML authentication

* refactor: change SAML icon

* refactor: resolve SAML metadata paths using paths.js

* test: add samlStrategy tests

* fix: update setupSaml import

* test: add SAML settings tests in config.spec.js

* test: add client tests

* refactor: improve SAML button label and fallback localization

* feat: allow only one authentication method OpenID or SAML at a time

* doc: add SAML configuration sample to docker-compose.override

* fix: require SAML_SESSION_SECRET to enable SAML

* feat: update samlStrategy

* test: update samle tests

* feat: add SAML login button label to translations and remove default value

* fix: update SAML cert file binding

* chore: update override example with SAML cert volume

* fix: update SAML session handling with Redis backend

---------

Co-authored-by: Ruben Talstra <RubenTalstra1211@outlook.com>
This commit is contained in:
tsutsu3 2025-05-30 00:00:58 +09:00 committed by GitHub
parent 87255dac81
commit 939b4ce659
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1134 additions and 20 deletions

View file

@ -443,7 +443,6 @@ OPENID_IMAGE_URL=
# Set to true to automatically redirect to the OpenID provider when a user visits the login page
# This will bypass the login form completely for users, only use this if OpenID is your only authentication method
OPENID_AUTO_REDIRECT=false
# Set to true to use PKCE (Proof Key for Code Exchange) for OpenID authentication
OPENID_USE_PKCE=false
#Set to true to reuse openid tokens for authentication management instead of using the mongodb session and the custom refresh token.
@ -459,6 +458,33 @@ OPENID_ON_BEHALF_FLOW_USERINFRO_SCOPE = "user.read" # example for Scope Needed f
# Set to true to use the OpenID Connect end session endpoint for logout
OPENID_USE_END_SESSION_ENDPOINT=
# SAML
# Note: If OpenID is enabled, SAML authentication will be automatically disabled.
SAML_ENTRY_POINT=
SAML_ISSUER=
SAML_CERT=
SAML_CALLBACK_URL=/oauth/saml/callback
SAML_SESSION_SECRET=
# Attribute mappings (optional)
SAML_EMAIL_CLAIM=
SAML_USERNAME_CLAIM=
SAML_GIVEN_NAME_CLAIM=
SAML_FAMILY_NAME_CLAIM=
SAML_PICTURE_CLAIM=
SAML_NAME_CLAIM=
# Logint buttion settings (optional)
SAML_BUTTON_LABEL=
SAML_IMAGE_URL=
# Whether the SAML Response should be signed.
# - If "true", the entire `SAML Response` will be signed.
# - If "false" or unset, only the `SAML Assertion` will be signed (default behavior).
# SAML_USE_AUTHN_RESPONSE_SIGNED=
# LDAP
LDAP_URL=
LDAP_BIND_DN=

2
.gitignore vendored
View file

@ -122,3 +122,5 @@ helm/**/.values.yaml
!/client/src/@types/i18next.d.ts
# SAML Idp cert
*.cert

View file

@ -50,6 +50,7 @@
"@langchain/textsplitters": "^0.1.0",
"@librechat/agents": "^2.4.37",
"@librechat/data-schemas": "*",
"@node-saml/passport-saml": "^5.0.0",
"@waylaidwanderer/fetch-event-source": "^3.0.1",
"axios": "^1.8.2",
"bcryptjs": "^2.4.3",

View file

@ -24,6 +24,12 @@ afterEach(() => {
delete process.env.GITHUB_CLIENT_SECRET;
delete process.env.DISCORD_CLIENT_ID;
delete process.env.DISCORD_CLIENT_SECRET;
delete process.env.SAML_ENTRY_POINT;
delete process.env.SAML_ISSUER;
delete process.env.SAML_CERT;
delete process.env.SAML_SESSION_SECRET;
delete process.env.SAML_BUTTON_LABEL;
delete process.env.SAML_IMAGE_URL;
delete process.env.DOMAIN_SERVER;
delete process.env.ALLOW_REGISTRATION;
delete process.env.ALLOW_SOCIAL_LOGIN;
@ -55,6 +61,12 @@ describe.skip('GET /', () => {
process.env.GITHUB_CLIENT_SECRET = 'Test Github client Secret';
process.env.DISCORD_CLIENT_ID = 'Test Discord client Id';
process.env.DISCORD_CLIENT_SECRET = 'Test Discord client Secret';
process.env.SAML_ENTRY_POINT = 'http://test-server.com';
process.env.SAML_ISSUER = 'Test SAML Issuer';
process.env.SAML_CERT = 'saml.pem';
process.env.SAML_SESSION_SECRET = 'Test Secret';
process.env.SAML_BUTTON_LABEL = 'Test SAML';
process.env.SAML_IMAGE_URL = 'http://test-server.com';
process.env.DOMAIN_SERVER = 'http://test-server.com';
process.env.ALLOW_REGISTRATION = 'true';
process.env.ALLOW_SOCIAL_LOGIN = 'true';
@ -70,7 +82,7 @@ describe.skip('GET /', () => {
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({
appTitle: 'Test Title',
socialLogins: ['google', 'facebook', 'openid', 'github', 'discord'],
socialLogins: ['google', 'facebook', 'openid', 'github', 'discord', 'saml'],
discordLoginEnabled: true,
facebookLoginEnabled: true,
githubLoginEnabled: true,
@ -78,6 +90,9 @@ describe.skip('GET /', () => {
openidLoginEnabled: true,
openidLabel: 'Test OpenID',
openidImageUrl: 'http://test-server.com',
samlLoginEnabled: true,
samlLabel: 'Test SAML',
samlImageUrl: 'http://test-server.com',
ldap: {
enabled: true,
},

View file

@ -37,6 +37,18 @@ router.get('/', async function (req, res) {
const ldap = getLdapConfig();
try {
const isOpenIdEnabled =
!!process.env.OPENID_CLIENT_ID &&
!!process.env.OPENID_CLIENT_SECRET &&
!!process.env.OPENID_ISSUER &&
!!process.env.OPENID_SESSION_SECRET;
const isSamlEnabled =
!!process.env.SAML_ENTRY_POINT &&
!!process.env.SAML_ISSUER &&
!!process.env.SAML_CERT &&
!!process.env.SAML_SESSION_SECRET;
/** @type {TStartupConfig} */
const payload = {
appTitle: process.env.APP_TITLE || 'LibreChat',
@ -51,14 +63,13 @@ router.get('/', async function (req, res) {
!!process.env.APPLE_TEAM_ID &&
!!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_SESSION_SECRET,
openidLoginEnabled: isOpenIdEnabled,
openidLabel: process.env.OPENID_BUTTON_LABEL || 'Continue with OpenID',
openidImageUrl: process.env.OPENID_IMAGE_URL,
openidAutoRedirect: isEnabled(process.env.OPENID_AUTO_REDIRECT),
samlLoginEnabled: !isOpenIdEnabled && isSamlEnabled,
samlLabel: process.env.SAML_BUTTON_LABEL,
samlImageUrl: process.env.SAML_IMAGE_URL,
serverDomain: process.env.DOMAIN_SERVER || 'http://localhost:3080',
emailLoginEnabled,
registrationEnabled: !ldap?.enabled && isEnabled(process.env.ALLOW_REGISTRATION),

View file

@ -189,4 +189,24 @@ router.post(
oauthHandler,
);
/**
* SAML Routes
*/
router.get(
'/saml',
passport.authenticate('saml', {
session: false,
}),
);
router.post(
'/saml/callback',
passport.authenticate('saml', {
failureRedirect: `${domains.client}/oauth/error`,
failureMessage: true,
session: false,
}),
oauthHandler,
);
module.exports = router;

View file

@ -10,6 +10,7 @@ const {
discordLogin,
facebookLogin,
appleLogin,
setupSaml,
openIdJwtLogin,
} = require('~/strategies');
const { isEnabled } = require('~/server/utils');
@ -70,6 +71,34 @@ const configureSocialLogins = async (app) => {
}
logger.info('OpenID Connect configured.');
}
if (
process.env.SAML_ENTRY_POINT &&
process.env.SAML_ISSUER &&
process.env.SAML_CERT &&
process.env.SAML_SESSION_SECRET
) {
logger.info('Configuring SAML Connect...');
const sessionOptions = {
secret: process.env.SAML_SESSION_SECRET,
resave: false,
saveUninitialized: false,
};
if (isEnabled(process.env.USE_REDIS)) {
logger.debug('Using Redis for session storage in SAML...');
const keyv = new Keyv({ store: keyvRedis });
const client = keyv.opts.store.client;
sessionOptions.store = new RedisStore({ client, prefix: 'saml_session' });
} else {
sessionOptions.store = new MemoryStore({
checkPeriod: 86400000, // prune expired entries every 24h
});
}
app.use(session(sessionOptions));
app.use(passport.session());
setupSaml();
logger.info('SAML Connect configured.');
}
};
module.exports = configureSocialLogins;

View file

@ -7,6 +7,7 @@ const facebookLogin = require('./facebookStrategy');
const { setupOpenId, getOpenIdConfig } = require('./openidStrategy');
const jwtLogin = require('./jwtStrategy');
const ldapLogin = require('./ldapStrategy');
const { setupSaml } = require('./samlStrategy');
const openIdJwtLogin = require('./openIdJwtStrategy');
module.exports = {
@ -20,5 +21,6 @@ module.exports = {
setupOpenId,
getOpenIdConfig,
ldapLogin,
setupSaml,
openIdJwtLogin,
};

View file

@ -0,0 +1,276 @@
const fs = require('fs');
const path = require('path');
const fetch = require('node-fetch');
const passport = require('passport');
const { Strategy: SamlStrategy } = require('@node-saml/passport-saml');
const { findUser, createUser, updateUser } = require('~/models/userMethods');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { hashToken } = require('~/server/utils/crypto');
const { logger } = require('~/config');
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);
let user = await findUser({ samlId: profile.nameID });
logger.info(
`[samlStrategy] User ${user ? 'found' : 'not found'} with SAML ID: ${profile.nameID}`,
);
if (!user) {
const email = getEmail(profile) || '';
user = await findUser({ email });
logger.info(
`[samlStrategy] User ${user ? 'found' : 'not found'} with email: ${profile.email}`,
);
}
const fullName = getFullName(profile);
const username = convertToUsername(
getUserName(profile) || getGivenName(profile) || getEmail(profile),
);
if (!user) {
user = {
provider: 'saml',
samlId: profile.nameID,
username,
email: getEmail(profile) || '',
emailVerified: true,
name: fullName,
};
user = await createUser(user, true, true);
} 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';
}
const { saveBuffer } = getStrategyFunctions(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(
`[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 };

View file

@ -0,0 +1,428 @@
const fs = require('fs');
const path = require('path');
const fetch = require('node-fetch');
const { Strategy: SamlStrategy } = require('@node-saml/passport-saml');
const { findUser, createUser, updateUser } = require('~/models/userMethods');
const { setupSaml, getCertificateContent } = require('./samlStrategy');
// --- Mocks ---
jest.mock('fs');
jest.mock('path');
jest.mock('node-fetch');
jest.mock('@node-saml/passport-saml');
jest.mock('~/models/userMethods', () => ({
findUser: jest.fn(),
createUser: jest.fn(),
updateUser: jest.fn(),
}));
jest.mock('~/server/services/Files/strategies', () => ({
getStrategyFunctions: jest.fn(() => ({
saveBuffer: jest.fn().mockResolvedValue('/fake/path/to/avatar.png'),
})),
}));
jest.mock('~/server/utils/crypto', () => ({
hashToken: jest.fn().mockResolvedValue('hashed-token'),
}));
jest.mock('~/server/utils', () => ({
isEnabled: jest.fn(() => false),
}));
jest.mock('~/config', () => ({
logger: {
info: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
},
}));
// To capture the verify callback from the strategy, we grab it from the mock constructor
let verifyCallback;
SamlStrategy.mockImplementation((options, verify) => {
verifyCallback = verify;
return { name: 'saml', options, verify };
});
describe('getCertificateContent', () => {
const certWithHeader = `-----BEGIN CERTIFICATE-----
MIIDazCCAlOgAwIBAgIUKhXaFJGJJPx466rlwYORIsqCq7MwDQYJKoZIhvcNAQEL
BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNTAzMDQwODUxNTJaFw0yNjAz
MDQwODUxNTJaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw
HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB
AQUAA4IBDwAwggEKAoIBAQCWP09NZg0xaRiLpNygCVgV3M+4RFW2S0c5X/fg/uFT
O5MfaVYzG5GxzhXzWRB8RtNPsxX/nlbPsoUroeHbz+SABkOsNEv6JuKRH4VXRH34
VzjazVkPAwj+N4WqsC/Wo4EGGpKIGeGi8Zed4yvMqoTyE3mrS19fY0nMHT62wUwS
GMm2pAQdAQePZ9WY7A5XOA1IoxW2Zh2Oxaf1p59epBkZDhoxSMu8GoSkvK27Km4A
4UXftzdg/wHNPrNirmcYouioHdmrOtYxPjrhUBQ74AmE1/QK45B6wEgirKH1A1AW
6C+ApLwpBMvy9+8Gbyvc8G18W3CjdEVKmAeWb9JUedSXAgMBAAGjUzBRMB0GA1Ud
DgQWBBRxpaqBx8VDLLc8IkHATujj8IOs6jAfBgNVHSMEGDAWgBRxpaqBx8VDLLc8
IkHATujj8IOs6jAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBc
Puk6i+yowwGccB3LhfxZ+Fz6s6/Lfx6bP/Hy4NYOxmx2/awGBgyfp1tmotjaS9Cf
FWd67LuEru4TYtz12RNMDBF5ypcEfibvb3I8O6igOSQX/Jl5D2pMChesZxhmCift
Qp09T41MA8PmHf1G9oMG0A3ZnjKDG5ebaJNRFImJhMHsgh/TP7V3uZy7YHTgopKX
Hv63V3Uo3Oihav29Q7urwmf7Ly7X7J2WE86/w3vRHi5dhaWWqEqxmnAXl+H+sG4V
meeVRI332bg1Nuy8KnnX8v3ZeJzMBkAhzvSr6Ri96R0/Un/oEFwVC5jDTq8sXVn6
u7wlOSk+oFzDIO/UILIA
-----END CERTIFICATE-----`;
const certWithoutHeader = certWithHeader
.replace(/-----BEGIN CERTIFICATE-----/g, '')
.replace(/-----END CERTIFICATE-----/g, '')
.replace(/\s+/g, '');
it('should throw an error if SAML_CERT is not set', () => {
process.env.SAML_CERT;
expect(() => getCertificateContent(process.env.SAML_CERT)).toThrow(
'Invalid input: SAML_CERT must be a string.',
);
});
it('should throw an error if SAML_CERT is empty', () => {
process.env.SAML_CERT = '';
expect(() => getCertificateContent(process.env.SAML_CERT)).toThrow(
'Invalid cert: SAML_CERT must be a valid file path or certificate string.',
);
});
it('should load cert from an environment variable if it is a single-line string(with header)', () => {
process.env.SAML_CERT = certWithHeader;
const actual = getCertificateContent(process.env.SAML_CERT);
expect(actual).toBe(certWithHeader);
});
it('should load cert from an environment variable if it is a single-line string(with no header)', () => {
process.env.SAML_CERT = certWithoutHeader;
const actual = getCertificateContent(process.env.SAML_CERT);
expect(actual).toBe(certWithoutHeader);
});
it('should throw an error if SAML_CERT is a single-line string (with header, no newline characters)', () => {
process.env.SAML_CERT = certWithHeader.replace(/\n/g, '');
expect(() => getCertificateContent(process.env.SAML_CERT)).toThrow(
'Invalid cert: SAML_CERT must be a valid file path or certificate string.',
);
});
it('should load cert from a relative file path if SAML_CERT is valid', () => {
process.env.SAML_CERT = 'test.pem';
const resolvedPath = '/absolute/path/to/test.pem';
path.isAbsolute.mockReturnValue(false);
path.join.mockReturnValue(resolvedPath);
path.normalize.mockReturnValue(resolvedPath);
fs.existsSync.mockReturnValue(true);
fs.statSync.mockReturnValue({ isFile: () => true });
fs.readFileSync.mockReturnValue(certWithHeader);
const actual = getCertificateContent(process.env.SAML_CERT);
expect(actual).toBe(certWithHeader);
});
it('should load cert from an absolute file path if SAML_CERT is valid', () => {
process.env.SAML_CERT = '/absolute/path/to/test.pem';
path.isAbsolute.mockReturnValue(true);
path.normalize.mockReturnValue(process.env.SAML_CERT);
fs.existsSync.mockReturnValue(true);
fs.statSync.mockReturnValue({ isFile: () => true });
fs.readFileSync.mockReturnValue(certWithHeader);
const actual = getCertificateContent(process.env.SAML_CERT);
expect(actual).toBe(certWithHeader);
});
it('should throw an error if the file does not exist', () => {
process.env.SAML_CERT = 'missing.pem';
const resolvedPath = '/absolute/path/to/missing.pem';
path.isAbsolute.mockReturnValue(false);
path.join.mockReturnValue(resolvedPath);
path.normalize.mockReturnValue(resolvedPath);
fs.existsSync.mockReturnValue(false);
expect(() => getCertificateContent(process.env.SAML_CERT)).toThrow(
'Invalid cert: SAML_CERT must be a valid file path or certificate string.',
);
});
it('should throw an error if the file is not readable', () => {
process.env.SAML_CERT = 'unreadable.pem';
const resolvedPath = '/absolute/path/to/unreadable.pem';
path.isAbsolute.mockReturnValue(false);
path.join.mockReturnValue(resolvedPath);
path.normalize.mockReturnValue(resolvedPath);
fs.existsSync.mockReturnValue(true);
fs.statSync.mockReturnValue({ isFile: () => true });
fs.readFileSync.mockImplementation(() => {
throw new Error('Permission denied');
});
expect(() => getCertificateContent(process.env.SAML_CERT)).toThrow(
'Error reading certificate file: Permission denied',
);
});
});
describe('setupSaml', () => {
// Helper to wrap the verify callback in a promise
const validate = (profile) =>
new Promise((resolve, reject) => {
verifyCallback(profile, (err, user, details) => {
if (err) {
reject(err);
} else {
resolve({ user, details });
}
});
});
const baseProfile = {
nameID: 'saml-1234',
email: 'test@example.com',
given_name: 'First',
family_name: 'Last',
name: 'My Full Name',
username: 'flast',
picture: 'https://example.com/avatar.png',
custom_name: 'custom',
};
beforeEach(async () => {
jest.clearAllMocks();
const cert = `
-----BEGIN CERTIFICATE-----
MIIDazCCAlOgAwIBAgIUKhXaFJGJJPx466rlwYORIsqCq7MwDQYJKoZIhvcNAQEL
BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNTAzMDQwODUxNTJaFw0yNjAz
MDQwODUxNTJaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw
HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB
AQUAA4IBDwAwggEKAoIBAQCWP09NZg0xaRiLpNygCVgV3M+4RFW2S0c5X/fg/uFT
O5MfaVYzG5GxzhXzWRB8RtNPsxX/nlbPsoUroeHbz+SABkOsNEv6JuKRH4VXRH34
VzjazVkPAwj+N4WqsC/Wo4EGGpKIGeGi8Zed4yvMqoTyE3mrS19fY0nMHT62wUwS
GMm2pAQdAQePZ9WY7A5XOA1IoxW2Zh2Oxaf1p59epBkZDhoxSMu8GoSkvK27Km4A
4UXftzdg/wHNPrNirmcYouioHdmrOtYxPjrhUBQ74AmE1/QK45B6wEgirKH1A1AW
6C+ApLwpBMvy9+8Gbyvc8G18W3CjdEVKmAeWb9JUedSXAgMBAAGjUzBRMB0GA1Ud
DgQWBBRxpaqBx8VDLLc8IkHATujj8IOs6jAfBgNVHSMEGDAWgBRxpaqBx8VDLLc8
IkHATujj8IOs6jAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBc
Puk6i+yowwGccB3LhfxZ+Fz6s6/Lfx6bP/Hy4NYOxmx2/awGBgyfp1tmotjaS9Cf
FWd67LuEru4TYtz12RNMDBF5ypcEfibvb3I8O6igOSQX/Jl5D2pMChesZxhmCift
Qp09T41MA8PmHf1G9oMG0A3ZnjKDG5ebaJNRFImJhMHsgh/TP7V3uZy7YHTgopKX
Hv63V3Uo3Oihav29Q7urwmf7Ly7X7J2WE86/w3vRHi5dhaWWqEqxmnAXl+H+sG4V
meeVRI332bg1Nuy8KnnX8v3ZeJzMBkAhzvSr6Ri96R0/Un/oEFwVC5jDTq8sXVn6
u7wlOSk+oFzDIO/UILIA
-----END CERTIFICATE-----
`;
// Reset environment variables
process.env.SAML_ENTRY_POINT = 'https://example.com/saml';
process.env.SAML_ISSUER = 'saml-issuer';
process.env.SAML_CERT = cert;
process.env.SAML_CALLBACK_URL = '/oauth/saml/callback';
delete process.env.SAML_EMAIL_CLAIM;
delete process.env.SAML_USERNAME_CLAIM;
delete process.env.SAML_GIVEN_NAME_CLAIM;
delete process.env.SAML_FAMILY_NAME_CLAIM;
delete process.env.SAML_PICTURE_CLAIM;
delete process.env.SAML_NAME_CLAIM;
findUser.mockResolvedValue(null);
createUser.mockImplementation(async (userData) => ({
_id: 'newUserId',
...userData,
}));
updateUser.mockImplementation(async (id, userData) => ({
_id: id,
...userData,
}));
// Simulate image download
const fakeBuffer = Buffer.from('fake image');
fetch.mockResolvedValue({
ok: true,
buffer: jest.fn().mockResolvedValue(fakeBuffer),
});
await setupSaml();
});
it('should create a new user with correct username when username claim exists', async () => {
const profile = { ...baseProfile };
const { user } = await validate(profile);
expect(user.username).toBe(profile.username);
expect(createUser).toHaveBeenCalledWith(
expect.objectContaining({
provider: 'saml',
samlId: profile.nameID,
username: profile.username,
email: profile.email,
name: `${profile.given_name} ${profile.family_name}`,
}),
true,
true,
);
});
it('should use given_name as username when username claim is missing', async () => {
const profile = { ...baseProfile };
delete profile.username;
const expectUsername = profile.given_name;
const { user } = await validate(profile);
expect(user.username).toBe(expectUsername);
expect(createUser).toHaveBeenCalledWith(
expect.objectContaining({ username: expectUsername }),
true,
true,
);
});
it('should use email as username when username and given_name are missing', async () => {
const profile = { ...baseProfile };
delete profile.username;
delete profile.given_name;
const expectUsername = profile.email;
const { user } = await validate(profile);
expect(user.username).toBe(expectUsername);
expect(createUser).toHaveBeenCalledWith(
expect.objectContaining({ username: expectUsername }),
true,
true,
);
});
it('should override username with SAML_USERNAME_CLAIM when set', async () => {
process.env.SAML_USERNAME_CLAIM = 'nameID';
const profile = { ...baseProfile };
const { user } = await validate(profile);
expect(user.username).toBe(profile.nameID);
expect(createUser).toHaveBeenCalledWith(
expect.objectContaining({ username: profile.nameID }),
true,
true,
);
});
it('should set the full name correctly when given_name and family_name exist', async () => {
const profile = { ...baseProfile };
const expectedFullName = `${profile.given_name} ${profile.family_name}`;
const { user } = await validate(profile);
expect(user.name).toBe(expectedFullName);
});
it('should set the full name correctly when given_name exist', async () => {
const profile = { ...baseProfile };
delete profile.family_name;
const expectedFullName = profile.given_name;
const { user } = await validate(profile);
expect(user.name).toBe(expectedFullName);
});
it('should set the full name correctly when family_name exist', async () => {
const profile = { ...baseProfile };
delete profile.given_name;
const expectedFullName = profile.family_name;
const { user } = await validate(profile);
expect(user.name).toBe(expectedFullName);
});
it('should set the full name correctly when username exist', async () => {
const profile = { ...baseProfile };
delete profile.family_name;
delete profile.given_name;
const expectedFullName = profile.username;
const { user } = await validate(profile);
expect(user.name).toBe(expectedFullName);
});
it('should set the full name correctly when email only exist', async () => {
const profile = { ...baseProfile };
delete profile.family_name;
delete profile.given_name;
delete profile.username;
const expectedFullName = profile.email;
const { user } = await validate(profile);
expect(user.name).toBe(expectedFullName);
});
it('should set the full name correctly with SAML_NAME_CLAIM when set', async () => {
process.env.SAML_NAME_CLAIM = 'custom_name';
const profile = { ...baseProfile };
const expectedFullName = profile.custom_name;
const { user } = await validate(profile);
expect(user.name).toBe(expectedFullName);
});
it('should update an existing user on login', async () => {
const existingUser = {
_id: 'existingUserId',
provider: 'local',
email: baseProfile.email,
samlId: '',
username: '',
name: '',
};
findUser.mockImplementation(async (query) => {
if (query.samlId === baseProfile.nameID || query.email === baseProfile.email) {
return existingUser;
}
return null;
});
const profile = { ...baseProfile };
await validate(profile);
expect(updateUser).toHaveBeenCalledWith(
existingUser._id,
expect.objectContaining({
provider: 'saml',
samlId: baseProfile.nameID,
username: baseProfile.username,
name: `${baseProfile.given_name} ${baseProfile.family_name}`,
}),
);
});
it('should attempt to download and save the avatar if picture is provided', async () => {
const profile = { ...baseProfile };
const { user } = await validate(profile);
expect(fetch).toHaveBeenCalled();
expect(user.avatar).toBe('/fake/path/to/avatar.png');
});
it('should not attempt to download avatar if picture is not provided', async () => {
const profile = { ...baseProfile };
delete profile.picture;
await validate(profile);
expect(fetch).not.toHaveBeenCalled();
});
});

View file

@ -1,4 +1,12 @@
import { GoogleIcon, FacebookIcon, OpenIDIcon, GithubIcon, DiscordIcon, AppleIcon } from '~/components';
import {
GoogleIcon,
FacebookIcon,
OpenIDIcon,
GithubIcon,
DiscordIcon,
AppleIcon,
SamlIcon,
} from '~/components';
import SocialButton from './SocialButton';
@ -90,6 +98,23 @@ function SocialLoginRender({
id="openid"
/>
),
saml: startupConfig.samlLoginEnabled && (
<SocialButton
key="saml"
enabled={startupConfig.samlLoginEnabled}
serverDomain={startupConfig.serverDomain}
oauthPath="saml"
Icon={() =>
startupConfig.samlImageUrl ? (
<img src={startupConfig.samlImageUrl} alt="SAML Logo" className="h-5 w-5" />
) : (
<SamlIcon />
)
}
label={startupConfig.samlLabel ? startupConfig.samlLabel : localize('com_auth_saml_login')}
id="saml"
/>
),
};
return (

View file

@ -16,7 +16,7 @@ const mockStartupConfig = {
isLoading: false,
isError: false,
data: {
socialLogins: ['google', 'facebook', 'openid', 'github', 'discord'],
socialLogins: ['google', 'facebook', 'openid', 'github', 'discord', 'saml'],
discordLoginEnabled: true,
facebookLoginEnabled: true,
githubLoginEnabled: true,
@ -24,6 +24,9 @@ const mockStartupConfig = {
openidLoginEnabled: true,
openidLabel: 'Test OpenID',
openidImageUrl: 'http://test-server.com',
samlLoginEnabled: true,
samlLabel: 'Test SAML',
samlImageUrl: 'http://test-server.com',
ldap: {
enabled: false,
},
@ -143,6 +146,11 @@ test('renders login form', () => {
'href',
'mock-server/oauth/discord',
);
expect(getByRole('link', { name: /Test SAML/i })).toBeInTheDocument();
expect(getByRole('link', { name: /Test SAML/i })).toHaveAttribute(
'href',
'mock-server/oauth/saml',
);
});
test('calls loginUser.mutate on login', async () => {

View file

@ -12,7 +12,7 @@ jest.mock('librechat-data-provider/react-query');
const mockLogin = jest.fn();
const mockStartupConfig: TStartupConfig = {
socialLogins: ['google', 'facebook', 'openid', 'github', 'discord'],
socialLogins: ['google', 'facebook', 'openid', 'github', 'discord', 'saml'],
discordLoginEnabled: true,
facebookLoginEnabled: true,
githubLoginEnabled: true,
@ -20,6 +20,9 @@ const mockStartupConfig: TStartupConfig = {
openidLoginEnabled: true,
openidLabel: 'Test OpenID',
openidImageUrl: 'http://test-server.com',
samlLoginEnabled: true,
samlLabel: 'Test SAML',
samlImageUrl: 'http://test-server.com',
registrationEnabled: true,
emailLoginEnabled: true,
socialLoginEnabled: true,

View file

@ -17,7 +17,7 @@ const mockStartupConfig = {
isLoading: false,
isError: false,
data: {
socialLogins: ['google', 'facebook', 'openid', 'github', 'discord'],
socialLogins: ['google', 'facebook', 'openid', 'github', 'discord', 'saml'],
discordLoginEnabled: true,
facebookLoginEnabled: true,
githubLoginEnabled: true,
@ -25,6 +25,9 @@ const mockStartupConfig = {
openidLoginEnabled: true,
openidLabel: 'Test OpenID',
openidImageUrl: 'http://test-server.com',
samlLoginEnabled: true,
samlLabel: 'Test SAML',
samlImageUrl: 'http://test-server.com',
registrationEnabled: true,
socialLoginEnabled: true,
serverDomain: 'mock-server',
@ -146,6 +149,11 @@ test('renders registration form', () => {
'href',
'mock-server/oauth/discord',
);
expect(getByRole('link', { name: /Test SAML/i })).toBeInTheDocument();
expect(getByRole('link', { name: /Test SAML/i })).toHaveAttribute(
'href',
'mock-server/oauth/saml',
);
});
// eslint-disable-next-line jest/no-commented-out-tests

View file

@ -0,0 +1,31 @@
/**
* SamlIcon Component
*
* Source: SVG Repo
* URL: https://www.svgrepo.com/svg/448590/saml
* - COLLECTION: Hashicorp Line Interface Icons
* - LICENSE: MLP License
* - AUTHOR: HashiCorp
*/
import React from 'react';
export default function SamlIcon() {
return (
<svg
width="800px"
height="800px"
viewBox="0 0 16 16"
xmlns="http://www.w3.org/2000/svg"
fill="none"
className="h-5 w-5"
>
<g fill="#000000">
<path d="M7.754 2l.463.41c.343.304.687.607 1.026.915C11.44 5.32 13.3 7.565 14.7 10.149c.072.132.137.268.202.403l.098.203-.108.057-.081-.115-.21-.299-.147-.214c-1.019-1.479-2.04-2.96-3.442-4.145a6.563 6.563 0 00-1.393-.904c-1.014-.485-1.916-.291-2.69.505-.736.757-1.118 1.697-1.463 2.653-.045.123-.092.245-.139.367l-.082.215-.172-.055c.1-.348.192-.698.284-1.049.21-.795.42-1.59.712-2.356.31-.816.702-1.603 1.093-2.39.169-.341.338-.682.5-1.025h.092z" />
<path d="M8.448 11.822c-1.626.77-5.56 1.564-7.426 1.36C.717 11.576 3.71 4.05 5.18 2.91l-.095.218a4.638 4.638 0 01-.138.303l-.066.129c-.76 1.462-1.519 2.926-1.908 4.53a7.482 7.482 0 00-.228 1.689c-.01 1.34.824 2.252 2.217 2.309.67.027 1.347-.043 2.023-.114.294-.03.587-.061.88-.084.108-.008.214-.021.352-.039l.231-.028z" />
<path d="M3.825 14.781c-.445.034-.89.068-1.333.108 4.097.39 8.03-.277 11.91-1.644-1.265-2.23-2.97-3.991-4.952-5.522.026.098.084.169.141.239l.048.06c.17.226.348.448.527.67.409.509.818 1.018 1.126 1.578.778 1.42.356 2.648-1.168 3.296-1.002.427-2.097.718-3.18.892-1.03.164-2.075.243-3.119.323z" />
</g>
</svg>
);
}

View file

@ -24,6 +24,7 @@ export { default as OpenIDIcon } from './OpenIDIcon';
export { default as GithubIcon } from './GithubIcon';
export { default as DiscordIcon } from './DiscordIcon';
export { default as AppleIcon } from './AppleIcon';
export { default as SamlIcon } from './SamlIcon';
export { default as AnthropicIcon } from './AnthropicIcon';
export { default as SendIcon } from './SendIcon';
export { default as LinkIcon } from './LinkIcon';

View file

@ -124,6 +124,7 @@
"com_auth_reset_password_if_email_exists": "If an account with that email exists, an email with password reset instructions has been sent. Please make sure to check your spam folder.",
"com_auth_reset_password_link_sent": "Email Sent",
"com_auth_reset_password_success": "Password Reset Success",
"com_auth_saml_login": "Continue with SAML",
"com_auth_sign_in": "Sign in",
"com_auth_sign_up": "Sign up",
"com_auth_submit_registration": "Submit registration",

View file

@ -40,7 +40,7 @@
# # BUILD FROM LATEST IMAGE
# api:
# image: ghcr.io/danny-avila/librechat-dev:latest
# # BUILD FROM LATEST IMAGE (NUMBERED RELEASE)
# api:
# image: ghcr.io/danny-avila/librechat:latest
@ -53,6 +53,13 @@
# api:
# image: ghcr.io/danny-avila/librechat-api:latest
# # ADD SAML CERT FILE
# api:
# volumes:
# - type: bind
# source: ./your_cert.pem
# target: /app/your_cert.pem
# # ADD MONGO-EXPRESS
# mongo-express:
# image: mongo-express
@ -98,7 +105,7 @@
# # USE RAG API IMAGE WITH LOCAL EMBEDDINGS SUPPORT
# rag_api:
# image: ghcr.io/danny-avila/librechat-rag-api-dev:latest
# # For Linux user:
# # For Linux user:
# extra_hosts:
# - "host.docker.internal:host-gateway"
@ -146,7 +153,7 @@
# REDIS_PASSWORD: RedisChangeMe
# volumes:
# - ./redis:/data
# # ADD LITELLM MONITORING
# langfuse-server:
# image: ghcr.io/langfuse/langfuse:latest

View file

@ -80,7 +80,7 @@ interface:
# Example Registration Object Structure (optional)
registration:
socialLogins: ['github', 'google', 'discord', 'openid', 'facebook', 'apple']
socialLogins: ['github', 'google', 'discord', 'openid', 'facebook', 'apple', 'saml']
# allowedDomains:
# - "gmail.com"

215
package-lock.json generated
View file

@ -66,6 +66,7 @@
"@langchain/textsplitters": "^0.1.0",
"@librechat/agents": "^2.4.37",
"@librechat/data-schemas": "*",
"@node-saml/passport-saml": "^5.0.0",
"@waylaidwanderer/fetch-event-source": "^3.0.1",
"axios": "^1.8.2",
"bcryptjs": "^2.4.3",
@ -19834,6 +19835,103 @@
"sparse-bitfield": "^3.0.3"
}
},
"node_modules/@node-saml/node-saml": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@node-saml/node-saml/-/node-saml-5.0.0.tgz",
"integrity": "sha512-4JGubfHgL5egpXiuo9bupSGn6mgpfOQ/brZZvv2Qiho5aJmW7O1khbjdB7tsTsCvNFtLLjQqm3BmvcRicJyA2g==",
"dependencies": {
"@types/debug": "^4.1.12",
"@types/qs": "^6.9.11",
"@types/xml-encryption": "^1.2.4",
"@types/xml2js": "^0.4.14",
"@xmldom/is-dom-node": "^1.0.1",
"@xmldom/xmldom": "^0.8.10",
"debug": "^4.3.4",
"xml-crypto": "^6.0.0",
"xml-encryption": "^3.0.2",
"xml2js": "^0.6.2",
"xmlbuilder": "^15.1.1",
"xpath": "^0.0.34"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/@node-saml/node-saml/node_modules/@xmldom/xmldom": {
"version": "0.8.10",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz",
"integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/@node-saml/node-saml/node_modules/xml-crypto": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/xml-crypto/-/xml-crypto-6.0.0.tgz",
"integrity": "sha512-L3RgnkaDrHaYcCnoENv4Idzt1ZRj5U1z1BDH98QdDTQfssScx8adgxhd9qwyYo+E3fXbQZjEQH7aiXHLVgxGvw==",
"dependencies": {
"@xmldom/is-dom-node": "^1.0.1",
"@xmldom/xmldom": "^0.8.10",
"xpath": "^0.0.33"
},
"engines": {
"node": ">=16"
}
},
"node_modules/@node-saml/node-saml/node_modules/xml-crypto/node_modules/xpath": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.33.tgz",
"integrity": "sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA==",
"engines": {
"node": ">=0.6.0"
}
},
"node_modules/@node-saml/node-saml/node_modules/xml-encryption": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/xml-encryption/-/xml-encryption-3.1.0.tgz",
"integrity": "sha512-PV7qnYpoAMXbf1kvQkqMScLeQpjCMixddAKq9PtqVrho8HnYbBOWNfG0kA4R7zxQDo7w9kiYAyzS/ullAyO55Q==",
"dependencies": {
"@xmldom/xmldom": "^0.8.5",
"escape-html": "^1.0.3",
"xpath": "0.0.32"
}
},
"node_modules/@node-saml/node-saml/node_modules/xml-encryption/node_modules/xpath": {
"version": "0.0.32",
"resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.32.tgz",
"integrity": "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw==",
"engines": {
"node": ">=0.6.0"
}
},
"node_modules/@node-saml/node-saml/node_modules/xml2js": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
"integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/@node-saml/node-saml/node_modules/xml2js/node_modules/xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
"engines": {
"node": ">=4.0"
}
},
"node_modules/@node-saml/node-saml/node_modules/xpath": {
"version": "0.0.34",
"resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.34.tgz",
"integrity": "sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA==",
"engines": {
"node": ">=0.6.0"
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
@ -19847,6 +19945,65 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@node-saml/passport-saml": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@node-saml/passport-saml/-/passport-saml-5.0.0.tgz",
"integrity": "sha512-7miY7Id6UkP39+6HO68e3/V6eJwszytEQl+oCh0R/gbzp5nHA/WI1mvrI6NNUVq5gC5GEnDS8GTw7oj+Kx499w==",
"license": "MIT",
"dependencies": {
"@node-saml/node-saml": "^5.0.0",
"@types/express": "^4.17.21",
"@types/passport": "^1.0.16",
"@types/passport-strategy": "^0.2.38",
"passport": "^0.7.0",
"passport-strategy": "^1.0.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/@node-saml/passport-saml/node_modules/@types/express": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz",
"integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==",
"license": "MIT",
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^4.17.33",
"@types/qs": "*",
"@types/serve-static": "*"
}
},
"node_modules/@node-saml/passport-saml/node_modules/@types/express-serve-static-core": {
"version": "4.19.6",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz",
"integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==",
"license": "MIT",
"dependencies": {
"@types/node": "*",
"@types/qs": "*",
"@types/range-parser": "*",
"@types/send": "*"
}
},
"node_modules/@node-saml/passport-saml/node_modules/passport": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz",
"integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==",
"license": "MIT",
"dependencies": {
"passport-strategy": "1.x.x",
"pause": "0.0.1",
"utils-merge": "^1.0.1"
},
"engines": {
"node": ">= 0.4.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/jaredhanson"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -24598,7 +24755,6 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.0.tgz",
"integrity": "sha512-DvZriSMehGHL1ZNLzi6MidnsDhUZM/x2pRdDIKdwbUNqqwHxMlRdkxtn6/EPKyqKpHqTl/4nRZsRNLpZxZRpPQ==",
"dev": true,
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0",
@ -24610,7 +24766,6 @@
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.2.tgz",
"integrity": "sha512-vluaspfvWEtE4vcSDlKRNer52DvOGrB2xv6diXy6UKyKW0lqZiWHGNApSyxOv+8DE5Z27IzVvE7hNkxg7EXIcg==",
"dev": true,
"dependencies": {
"@types/node": "*",
"@types/qs": "*",
@ -24779,6 +24934,25 @@
"integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==",
"dev": true
},
"node_modules/@types/passport": {
"version": "1.0.17",
"resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz",
"integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==",
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/passport-strategy": {
"version": "0.2.38",
"resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz",
"integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==",
"license": "MIT",
"dependencies": {
"@types/express": "*",
"@types/passport": "*"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.11",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz",
@ -24915,6 +25089,22 @@
"winston": "*"
}
},
"node_modules/@types/xml-encryption": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@types/xml-encryption/-/xml-encryption-1.2.4.tgz",
"integrity": "sha512-I69K/WW1Dv7j6O3jh13z0X8sLWJRXbu5xnHDl9yHzUNDUBtUoBY058eb5s+x/WG6yZC1h8aKdI2EoyEPjyEh+Q==",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/xml2js": {
"version": "0.4.14",
"resolved": "https://registry.npmjs.org/@types/xml2js/-/xml2js-0.4.14.tgz",
"integrity": "sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/yargs": {
"version": "17.0.32",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz",
@ -25324,6 +25514,14 @@
"@xtuc/long": "4.2.2"
}
},
"node_modules/@xmldom/is-dom-node": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@xmldom/is-dom-node/-/is-dom-node-1.0.1.tgz",
"integrity": "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q==",
"engines": {
"node": ">= 16"
}
},
"node_modules/@xtuc/ieee754": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
@ -41167,6 +41365,11 @@
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"node_modules/sax": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz",
"integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
@ -45114,6 +45317,14 @@
"node": ">=12"
}
},
"node_modules/xmlbuilder": {
"version": "15.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
"integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==",
"engines": {
"node": ">=8.0"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",

View file

@ -7,7 +7,7 @@ import { fileConfigSchema } from './file-config';
import { FileSources } from './types/files';
import { MCPServersSchema } from './mcp';
export const defaultSocialLogins = ['google', 'facebook', 'openid', 'github', 'discord'];
export const defaultSocialLogins = ['google', 'facebook', 'openid', 'github', 'discord', 'saml'];
export const defaultRetrievalModels = [
'gpt-4o',
@ -547,9 +547,12 @@ export type TStartupConfig = {
googleLoginEnabled: boolean;
openidLoginEnabled: boolean;
appleLoginEnabled: boolean;
samlLoginEnabled: boolean;
openidLabel: string;
openidImageUrl: string;
openidAutoRedirect: boolean;
samlLabel: string;
samlImageUrl: string;
/** LDAP Auth Configuration */
ldap?: {
/** LDAP enabled */

View file

@ -13,6 +13,7 @@ export interface IUser extends Document {
googleId?: string;
facebookId?: string;
openidId?: string;
samlId?: string;
ldapId?: string;
githubId?: string;
discordId?: string;
@ -67,7 +68,7 @@ const User = new Schema<IUser>(
},
email: {
type: String,
required: [true, 'can\'t be blank'],
required: [true, "can't be blank"],
lowercase: true,
unique: true,
match: [/\S+@\S+\.\S+/, 'is invalid'],
@ -112,6 +113,11 @@ const User = new Schema<IUser>(
unique: true,
sparse: true,
},
samlId: {
type: String,
unique: true,
sparse: true,
},
ldapId: {
type: String,
unique: true,
@ -160,4 +166,4 @@ const User = new Schema<IUser>(
{ timestamps: true },
);
export default User;
export default User;