mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-21 21:50:49 +02:00
🔑 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:
parent
87255dac81
commit
939b4ce659
22 changed files with 1134 additions and 20 deletions
28
.env.example
28
.env.example
|
@ -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
2
.gitignore
vendored
|
@ -122,3 +122,5 @@ helm/**/.values.yaml
|
|||
|
||||
!/client/src/@types/i18next.d.ts
|
||||
|
||||
# SAML Idp cert
|
||||
*.cert
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
276
api/strategies/samlStrategy.js
Normal file
276
api/strategies/samlStrategy.js
Normal 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 };
|
428
api/strategies/samlStrategy.spec.js
Normal file
428
api/strategies/samlStrategy.spec.js
Normal 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();
|
||||
});
|
||||
});
|
|
@ -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 (
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
31
client/src/components/svg/SamlIcon.tsx
Normal file
31
client/src/components/svg/SamlIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
215
package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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;
|
Loading…
Add table
Add a link
Reference in a new issue