From 939b4ce659285ec5d230a1a1a7d426dddf698eae Mon Sep 17 00:00:00 2001 From: tsutsu3 Date: Fri, 30 May 2025 00:00:58 +0900 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=91=20feat:=20SAML=20authentication=20?= =?UTF-8?q?(#6169)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .env.example | 28 +- .gitignore | 2 + api/package.json | 1 + api/server/routes/__tests__/config.spec.js | 17 +- api/server/routes/config.js | 21 +- api/server/routes/oauth.js | 20 + api/server/socialLogins.js | 29 ++ api/strategies/index.js | 2 + api/strategies/samlStrategy.js | 276 +++++++++++ api/strategies/samlStrategy.spec.js | 428 ++++++++++++++++++ .../src/components/Auth/SocialLoginRender.tsx | 27 +- .../components/Auth/__tests__/Login.spec.tsx | 10 +- .../Auth/__tests__/LoginForm.spec.tsx | 5 +- .../Auth/__tests__/Registration.spec.tsx | 10 +- client/src/components/svg/SamlIcon.tsx | 31 ++ client/src/components/svg/index.ts | 1 + client/src/locales/en/translation.json | 1 + docker-compose.override.yml.example | 13 +- librechat.example.yaml | 2 +- package-lock.json | 215 ++++++++- packages/data-provider/src/config.ts | 5 +- packages/data-schemas/src/schema/user.ts | 10 +- 22 files changed, 1134 insertions(+), 20 deletions(-) create mode 100644 api/strategies/samlStrategy.js create mode 100644 api/strategies/samlStrategy.spec.js create mode 100644 client/src/components/svg/SamlIcon.tsx diff --git a/.env.example b/.env.example index fcf017c32..f79b89a15 100644 --- a/.env.example +++ b/.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= diff --git a/.gitignore b/.gitignore index c24bc76b1..f49594afd 100644 --- a/.gitignore +++ b/.gitignore @@ -122,3 +122,5 @@ helm/**/.values.yaml !/client/src/@types/i18next.d.ts +# SAML Idp cert +*.cert diff --git a/api/package.json b/api/package.json index 64903553e..3d3766bde 100644 --- a/api/package.json +++ b/api/package.json @@ -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", diff --git a/api/server/routes/__tests__/config.spec.js b/api/server/routes/__tests__/config.spec.js index 3280bc386..054e4726f 100644 --- a/api/server/routes/__tests__/config.spec.js +++ b/api/server/routes/__tests__/config.spec.js @@ -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, }, diff --git a/api/server/routes/config.js b/api/server/routes/config.js index e34497688..a53a636d0 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -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), diff --git a/api/server/routes/oauth.js b/api/server/routes/oauth.js index 9915390a5..bc8d120ef 100644 --- a/api/server/routes/oauth.js +++ b/api/server/routes/oauth.js @@ -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; diff --git a/api/server/socialLogins.js b/api/server/socialLogins.js index ba335018d..9b9541cdc 100644 --- a/api/server/socialLogins.js +++ b/api/server/socialLogins.js @@ -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; diff --git a/api/strategies/index.js b/api/strategies/index.js index dbb1bd870..725e04224 100644 --- a/api/strategies/index.js +++ b/api/strategies/index.js @@ -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, }; diff --git a/api/strategies/samlStrategy.js b/api/strategies/samlStrategy.js new file mode 100644 index 000000000..a0793f1c8 --- /dev/null +++ b/api/strategies/samlStrategy.js @@ -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} + */ +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 }; diff --git a/api/strategies/samlStrategy.spec.js b/api/strategies/samlStrategy.spec.js new file mode 100644 index 000000000..cb007c75e --- /dev/null +++ b/api/strategies/samlStrategy.spec.js @@ -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(); + }); +}); diff --git a/client/src/components/Auth/SocialLoginRender.tsx b/client/src/components/Auth/SocialLoginRender.tsx index 70c0a65b6..55a8ade6b 100644 --- a/client/src/components/Auth/SocialLoginRender.tsx +++ b/client/src/components/Auth/SocialLoginRender.tsx @@ -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 && ( + + startupConfig.samlImageUrl ? ( + SAML Logo + ) : ( + + ) + } + label={startupConfig.samlLabel ? startupConfig.samlLabel : localize('com_auth_saml_login')} + id="saml" + /> + ), }; return ( diff --git a/client/src/components/Auth/__tests__/Login.spec.tsx b/client/src/components/Auth/__tests__/Login.spec.tsx index 4395ab5bf..3937deb62 100644 --- a/client/src/components/Auth/__tests__/Login.spec.tsx +++ b/client/src/components/Auth/__tests__/Login.spec.tsx @@ -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 () => { diff --git a/client/src/components/Auth/__tests__/LoginForm.spec.tsx b/client/src/components/Auth/__tests__/LoginForm.spec.tsx index 7bcca21c7..f6376d166 100644 --- a/client/src/components/Auth/__tests__/LoginForm.spec.tsx +++ b/client/src/components/Auth/__tests__/LoginForm.spec.tsx @@ -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, diff --git a/client/src/components/Auth/__tests__/Registration.spec.tsx b/client/src/components/Auth/__tests__/Registration.spec.tsx index d48723ab8..72a21b63b 100644 --- a/client/src/components/Auth/__tests__/Registration.spec.tsx +++ b/client/src/components/Auth/__tests__/Registration.spec.tsx @@ -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 diff --git a/client/src/components/svg/SamlIcon.tsx b/client/src/components/svg/SamlIcon.tsx new file mode 100644 index 000000000..913c6a2a7 --- /dev/null +++ b/client/src/components/svg/SamlIcon.tsx @@ -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 ( + + + + + + + + + + ); +} diff --git a/client/src/components/svg/index.ts b/client/src/components/svg/index.ts index f0cf35f6c..34f62d2f7 100644 --- a/client/src/components/svg/index.ts +++ b/client/src/components/svg/index.ts @@ -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'; diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 924e7e070..1e14d7172 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -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", diff --git a/docker-compose.override.yml.example b/docker-compose.override.yml.example index 3799341ce..8c8aba9ed 100644 --- a/docker-compose.override.yml.example +++ b/docker-compose.override.yml.example @@ -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 diff --git a/librechat.example.yaml b/librechat.example.yaml index dfa8626ec..38cb26eb4 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -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" diff --git a/package-lock.json b/package-lock.json index 29ce73d75..fa49421c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 005b50add..005efa443 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -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 */ diff --git a/packages/data-schemas/src/schema/user.ts b/packages/data-schemas/src/schema/user.ts index eb25b735a..8e5fade2f 100644 --- a/packages/data-schemas/src/schema/user.ts +++ b/packages/data-schemas/src/schema/user.ts @@ -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( }, 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( unique: true, sparse: true, }, + samlId: { + type: String, + unique: true, + sparse: true, + }, ldapId: { type: String, unique: true, @@ -160,4 +166,4 @@ const User = new Schema( { timestamps: true }, ); -export default User; +export default User; \ No newline at end of file