From 77712c825f5c6d9303007880ad445adfafd96955 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 27 Mar 2026 16:08:43 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=8F=A2=20feat:=20Tenant-Scoped=20App=20Co?= =?UTF-8?q?nfig=20in=20Auth=20Login=20Flows=20(#12434)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add resolveAppConfigForUser utility for tenant-scoped auth config TypeScript utility in packages/api that wraps getAppConfig in tenantStorage.run() when the user has a tenantId, falling back to baseOnly for new users or non-tenant deployments. Uses DI pattern (getAppConfig passed as parameter) for testability. Auth flows apply role-level overrides only (userId not passed) because user/group principal resolution is deferred to post-auth. * feat: tenant-scoped app config in auth login flows All auth strategies (LDAP, SAML, OpenID, social login) now use a two-phase domain check consistent with requestPasswordReset: 1. Fast-fail with base config (memory-cached, zero DB queries) 2. DB user lookup 3. Tenant-scoped re-check via resolveAppConfigForUser (only when user has a tenantId; otherwise reuse base config) This preserves the original fast-fail protection against globally blocked domains while enabling tenant-specific config overrides. OpenID error ordering preserved: AUTH_FAILED checked before domain re-check so users with wrong providers get the correct error type. registerUser unchanged (baseOnly, no user identity yet). * test: add tenant-scoped config tests for auth strategies Add resolveAppConfig.spec.ts in packages/api with 8 tests: - baseOnly fallback for null/undefined/no-tenant users - tenant-scoped config with role and tenantId - ALS context propagation verified inside getAppConfig callback - undefined role with tenantId edge case Update strategy and AuthService tests to mock resolveAppConfigForUser via @librechat/api. Tests verify two-phase domain check behavior: fast-fail before DB, tenant re-check after. Non-tenant users reuse base config without calling resolveAppConfigForUser. * refactor: skip redundant domain re-check for non-tenant users Guard the second isEmailDomainAllowed call with appConfig !== baseConfig in SAML, OpenID, and social strategies. For non-tenant users the tenant config is the same base config object, so the second check is a no-op. Narrow eslint-disable in resolveAppConfig.spec.ts to the specific require line instead of blanket file-level suppression. * fix: address review findings — consistency, tests, and ordering - Consolidate duplicate require('@librechat/api') in AuthService.js - Add two-phase domain check to LDAP (base fast-fail before findUser), making all strategies consistent with PR description - Add appConfig !== baseConfig guard to requestPasswordReset second domain check, consistent with SAML/OpenID/social strategies - Move SAML provider check before tenant config resolution to avoid unnecessary resolveAppConfigForUser call for wrong-provider users - Add tenant domain rejection tests to SAML, OpenID, and social specs verifying that tenant config restrictions actually block login - Add error propagation tests to resolveAppConfig.spec.ts - Remove redundant mockTenantStorage alias in resolveAppConfig.spec.ts - Narrow eslint-disable to specific require line * test: add tenant domain rejection test for LDAP strategy Covers the appConfig !== baseConfig && !isEmailDomainAllowed path, consistent with SAML, OpenID, and social strategy specs. * refactor: rename resolveAppConfig to app/resolve per AGENTS.md Rename resolveAppConfig.ts → resolve.ts and resolveAppConfig.spec.ts → resolve.spec.ts to align with the project's concise naming convention. * fix: remove fragile reference-equality guard, add logging and docs Remove appConfig !== baseConfig guard from all strategies and requestPasswordReset. The guard relied on implicit cache-backend identity semantics (in-memory Keyv returns same object reference) that would silently break with Redis or cloned configs. The second isEmailDomainAllowed call is a cheap synchronous check — always running it is clearer and eliminates the coupling. Add audit logging to requestPasswordReset domain blocks (base and tenant), consistent with all auth strategies. Extract duplicated error construction into makeDomainDeniedError(). Wrap resolveAppConfigForUser in requestPasswordReset with try/catch to prevent DB errors from leaking to the client via the controller's generic catch handler. Document the dual tenantId propagation (ALS for DB isolation, explicit param for cache key) in resolveAppConfigForUser JSDoc. Add comment documenting the LDAP error-type ordering change (cross-provider users from blocked domains now get 'domain not allowed' instead of AUTH_FAILED). Assert resolveAppConfigForUser is not called on LDAP provider mismatch path. * fix: return generic response for tenant domain block in password reset Tenant-scoped domain rejection in requestPasswordReset now returns the same generic "If an account with that email exists..." response instead of an Error. This prevents user-enumeration: an attacker cannot distinguish between "email not found" and "tenant blocks this domain" by comparing HTTP responses. The base-config fast-fail (pre-user-lookup) still returns an Error since it fires before any user existence is revealed. * docs: document phase 1 vs phase 2 domain check behavior in JSDoc Phase 1 (base config, pre-findUser) intentionally returns Error/400 to reveal globally blocked domains without confirming user existence. Phase 2 (tenant config, post-findUser) returns generic 200 to prevent user-enumeration. This distinction is now explicit in the JSDoc. --- api/server/services/AuthService.js | 42 +- api/server/services/AuthService.spec.js | 76 +- api/strategies/ldapStrategy.js | 43 +- api/strategies/ldapStrategy.spec.js | 71 +- api/strategies/openidStrategy.js | 15 +- api/strategies/openidStrategy.spec.js | 3695 ++++++++++++----------- api/strategies/samlStrategy.js | 21 +- api/strategies/samlStrategy.spec.js | 50 + api/strategies/socialLogin.js | 21 +- api/strategies/socialLogin.test.js | 139 +- packages/api/src/app/index.ts | 1 + packages/api/src/app/resolve.spec.ts | 95 + packages/api/src/app/resolve.ts | 39 + 13 files changed, 2428 insertions(+), 1880 deletions(-) create mode 100644 packages/api/src/app/resolve.spec.ts create mode 100644 packages/api/src/app/resolve.ts diff --git a/api/server/services/AuthService.js b/api/server/services/AuthService.js index f17c5051a9..816a0eac5b 100644 --- a/api/server/services/AuthService.js +++ b/api/server/services/AuthService.js @@ -13,6 +13,7 @@ const { checkEmailConfig, isEmailDomainAllowed, shouldUseSecureCookie, + resolveAppConfigForUser, } = require('@librechat/api'); const { findUser, @@ -255,19 +256,52 @@ const registerUser = async (user, additionalData = {}) => { }; /** - * Request password reset + * Request password reset. + * + * Uses a two-phase domain check: fast-fail with the memory-cached base config + * (zero DB queries) to block globally denied domains before user lookup, then + * re-check with tenant-scoped config after user lookup so tenant-specific + * restrictions are enforced. + * + * Phase 1 (base check) returns an Error (HTTP 400) — this intentionally reveals + * that the domain is globally blocked, but fires before any DB lookup so it + * cannot confirm user existence. Phase 2 (tenant check) returns the generic + * success message (HTTP 200) to prevent user-enumeration via status codes. + * * @param {ServerRequest} req */ const requestPasswordReset = async (req) => { const { email } = req.body; - const appConfig = await getAppConfig({ baseOnly: true }); - if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) { + + const baseConfig = await getAppConfig({ baseOnly: true }); + if (!isEmailDomainAllowed(email, baseConfig?.registration?.allowedDomains)) { + logger.warn( + `[requestPasswordReset] Blocked - email domain not allowed [Email: ${email}] [IP: ${req.ip}]`, + ); const error = new Error(ErrorTypes.AUTH_FAILED); error.code = ErrorTypes.AUTH_FAILED; error.message = 'Email domain not allowed'; return error; } - const user = await findUser({ email }, 'email _id'); + + const user = await findUser({ email }, 'email _id role tenantId'); + let appConfig = baseConfig; + if (user?.tenantId) { + try { + appConfig = await resolveAppConfigForUser(getAppConfig, user); + } catch (err) { + logger.error('[requestPasswordReset] Failed to resolve tenant config, using base:', err); + } + } + + if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) { + logger.warn( + `[requestPasswordReset] Tenant config blocked domain [Email: ${email}] [IP: ${req.ip}]`, + ); + return { + message: 'If an account with that email exists, a password reset link has been sent to it.', + }; + } const emailEnabled = checkEmailConfig(); logger.warn(`[requestPasswordReset] [Password reset request initiated] [Email: ${email}]`); diff --git a/api/server/services/AuthService.spec.js b/api/server/services/AuthService.spec.js index da78f8d775..c8abafdbe5 100644 --- a/api/server/services/AuthService.spec.js +++ b/api/server/services/AuthService.spec.js @@ -14,6 +14,7 @@ jest.mock('@librechat/api', () => ({ isEmailDomainAllowed: jest.fn(), math: jest.fn((val, fallback) => (val ? Number(val) : fallback)), shouldUseSecureCookie: jest.fn(() => false), + resolveAppConfigForUser: jest.fn(async (_getAppConfig, _user) => ({})), })); jest.mock('~/models', () => ({ findUser: jest.fn(), @@ -35,8 +36,14 @@ jest.mock('~/strategies/validators', () => ({ registerSchema: { parse: jest.fn() jest.mock('~/server/services/Config', () => ({ getAppConfig: jest.fn() })); jest.mock('~/server/utils', () => ({ sendEmail: jest.fn() })); -const { shouldUseSecureCookie } = require('@librechat/api'); -const { setOpenIDAuthTokens } = require('./AuthService'); +const { + shouldUseSecureCookie, + isEmailDomainAllowed, + resolveAppConfigForUser, +} = require('@librechat/api'); +const { findUser } = require('~/models'); +const { getAppConfig } = require('~/server/services/Config'); +const { setOpenIDAuthTokens, requestPasswordReset } = require('./AuthService'); /** Helper to build a mock Express response */ function mockResponse() { @@ -267,3 +274,68 @@ describe('setOpenIDAuthTokens', () => { }); }); }); + +describe('requestPasswordReset', () => { + beforeEach(() => { + jest.clearAllMocks(); + isEmailDomainAllowed.mockReturnValue(true); + getAppConfig.mockResolvedValue({ + registration: { allowedDomains: ['example.com'] }, + }); + resolveAppConfigForUser.mockResolvedValue({ + registration: { allowedDomains: ['example.com'] }, + }); + }); + + it('should fast-fail with base config before DB lookup for blocked domains', async () => { + isEmailDomainAllowed.mockReturnValue(false); + + const req = { body: { email: 'blocked@evil.com' }, ip: '127.0.0.1' }; + const result = await requestPasswordReset(req); + + expect(getAppConfig).toHaveBeenCalledWith({ baseOnly: true }); + expect(findUser).not.toHaveBeenCalled(); + expect(result).toBeInstanceOf(Error); + }); + + it('should call resolveAppConfigForUser for tenant user', async () => { + const user = { + _id: 'user-tenant', + email: 'user@example.com', + tenantId: 'tenant-x', + role: 'USER', + }; + findUser.mockResolvedValue(user); + + const req = { body: { email: 'user@example.com' }, ip: '127.0.0.1' }; + await requestPasswordReset(req); + + expect(resolveAppConfigForUser).toHaveBeenCalledWith(getAppConfig, user); + }); + + it('should reuse baseConfig for non-tenant user without calling resolveAppConfigForUser', async () => { + findUser.mockResolvedValue({ _id: 'user-no-tenant', email: 'user@example.com' }); + + const req = { body: { email: 'user@example.com' }, ip: '127.0.0.1' }; + await requestPasswordReset(req); + + expect(resolveAppConfigForUser).not.toHaveBeenCalled(); + }); + + it('should return generic response when tenant config blocks the domain (non-enumerable)', async () => { + const user = { + _id: 'user-tenant', + email: 'user@example.com', + tenantId: 'tenant-x', + role: 'USER', + }; + findUser.mockResolvedValue(user); + isEmailDomainAllowed.mockReturnValueOnce(true).mockReturnValueOnce(false); + + const req = { body: { email: 'user@example.com' }, ip: '127.0.0.1' }; + const result = await requestPasswordReset(req); + + expect(result).not.toBeInstanceOf(Error); + expect(result.message).toContain('If an account with that email exists'); + }); +}); diff --git a/api/strategies/ldapStrategy.js b/api/strategies/ldapStrategy.js index 0c99c7b670..9253f54196 100644 --- a/api/strategies/ldapStrategy.js +++ b/api/strategies/ldapStrategy.js @@ -2,7 +2,12 @@ const fs = require('fs'); const LdapStrategy = require('passport-ldapauth'); const { logger } = require('@librechat/data-schemas'); const { SystemRoles, ErrorTypes } = require('librechat-data-provider'); -const { isEnabled, getBalanceConfig, isEmailDomainAllowed } = require('@librechat/api'); +const { + isEnabled, + getBalanceConfig, + isEmailDomainAllowed, + resolveAppConfigForUser, +} = require('@librechat/api'); const { createUser, findUser, updateUser, countUsers } = require('~/models'); const { getAppConfig } = require('~/server/services/Config'); @@ -89,16 +94,6 @@ const ldapLogin = new LdapStrategy(ldapOptions, async (userinfo, done) => { const ldapId = (LDAP_ID && userinfo[LDAP_ID]) || userinfo.uid || userinfo.sAMAccountName || userinfo.mail; - let user = await findUser({ ldapId }); - if (user && user.provider !== 'ldap') { - logger.info( - `[ldapStrategy] User ${user.email} already exists with provider ${user.provider}`, - ); - return done(null, false, { - message: ErrorTypes.AUTH_FAILED, - }); - } - const fullNameAttributes = LDAP_FULL_NAME && LDAP_FULL_NAME.split(','); const fullName = fullNameAttributes && fullNameAttributes.length > 0 @@ -122,7 +117,31 @@ const ldapLogin = new LdapStrategy(ldapOptions, async (userinfo, done) => { ); } - const appConfig = await getAppConfig({ baseOnly: true }); + // Domain check before findUser for two-phase fast-fail (consistent with SAML/OpenID/social). + // This means cross-provider users from blocked domains get 'Email domain not allowed' + // instead of AUTH_FAILED — both deny access. + const baseConfig = await getAppConfig({ baseOnly: true }); + if (!isEmailDomainAllowed(mail, baseConfig?.registration?.allowedDomains)) { + logger.error( + `[LDAP Strategy] Authentication blocked - email domain not allowed [Email: ${mail}]`, + ); + return done(null, false, { message: 'Email domain not allowed' }); + } + + let user = await findUser({ ldapId }); + if (user && user.provider !== 'ldap') { + logger.info( + `[ldapStrategy] User ${user.email} already exists with provider ${user.provider}`, + ); + return done(null, false, { + message: ErrorTypes.AUTH_FAILED, + }); + } + + const appConfig = user?.tenantId + ? await resolveAppConfigForUser(getAppConfig, user) + : baseConfig; + if (!isEmailDomainAllowed(mail, appConfig?.registration?.allowedDomains)) { logger.error( `[LDAP Strategy] Authentication blocked - email domain not allowed [Email: ${mail}]`, diff --git a/api/strategies/ldapStrategy.spec.js b/api/strategies/ldapStrategy.spec.js index a00e9b14b7..876d70f845 100644 --- a/api/strategies/ldapStrategy.spec.js +++ b/api/strategies/ldapStrategy.spec.js @@ -9,10 +9,10 @@ jest.mock('@librechat/data-schemas', () => ({ })); jest.mock('@librechat/api', () => ({ - // isEnabled used for TLS flags isEnabled: jest.fn(() => false), isEmailDomainAllowed: jest.fn(() => true), getBalanceConfig: jest.fn(() => ({ enabled: false })), + resolveAppConfigForUser: jest.fn(async (_getAppConfig, _user) => ({})), })); jest.mock('~/models', () => ({ @@ -30,14 +30,15 @@ jest.mock('~/server/services/Config', () => ({ let verifyCallback; jest.mock('passport-ldapauth', () => { return jest.fn().mockImplementation((options, verify) => { - verifyCallback = verify; // capture the strategy verify function + verifyCallback = verify; return { name: 'ldap', options, verify }; }); }); const { ErrorTypes } = require('librechat-data-provider'); -const { isEmailDomainAllowed } = require('@librechat/api'); +const { isEmailDomainAllowed, resolveAppConfigForUser } = require('@librechat/api'); const { findUser, createUser, updateUser, countUsers } = require('~/models'); +const { getAppConfig } = require('~/server/services/Config'); // Helper to call the verify callback and wrap in a Promise for convenience const callVerify = (userinfo) => @@ -117,6 +118,7 @@ describe('ldapStrategy', () => { expect(user).toBe(false); expect(info).toEqual({ message: ErrorTypes.AUTH_FAILED }); expect(createUser).not.toHaveBeenCalled(); + expect(resolveAppConfigForUser).not.toHaveBeenCalled(); }); it('updates an existing ldap user with current LDAP info', async () => { @@ -158,7 +160,6 @@ describe('ldapStrategy', () => { uid: 'uid999', givenName: 'John', cn: 'John Doe', - // no mail and no custom LDAP_EMAIL }; const { user } = await callVerify(userinfo); @@ -180,4 +181,66 @@ describe('ldapStrategy', () => { expect(user).toBe(false); expect(info).toEqual({ message: 'Email domain not allowed' }); }); + + it('passes getAppConfig and found user to resolveAppConfigForUser', async () => { + const existing = { + _id: 'u3', + provider: 'ldap', + email: 'tenant@example.com', + ldapId: 'uid-tenant', + username: 'tenantuser', + name: 'Tenant User', + tenantId: 'tenant-a', + role: 'USER', + }; + findUser.mockResolvedValue(existing); + + const userinfo = { + uid: 'uid-tenant', + mail: 'tenant@example.com', + givenName: 'Tenant', + cn: 'Tenant User', + }; + + await callVerify(userinfo); + + expect(resolveAppConfigForUser).toHaveBeenCalledWith(getAppConfig, existing); + }); + + it('uses baseConfig for new user without calling resolveAppConfigForUser', async () => { + findUser.mockResolvedValue(null); + + const userinfo = { + uid: 'uid-new', + mail: 'newuser@example.com', + givenName: 'New', + cn: 'New User', + }; + + await callVerify(userinfo); + + expect(resolveAppConfigForUser).not.toHaveBeenCalled(); + expect(getAppConfig).toHaveBeenCalledWith({ baseOnly: true }); + }); + + it('should block login when tenant config restricts the domain', async () => { + const existing = { + _id: 'u-blocked', + provider: 'ldap', + ldapId: 'uid-tenant', + tenantId: 'tenant-strict', + role: 'USER', + }; + findUser.mockResolvedValue(existing); + resolveAppConfigForUser.mockResolvedValue({ + registration: { allowedDomains: ['other.com'] }, + }); + isEmailDomainAllowed.mockReturnValueOnce(true).mockReturnValueOnce(false); + + const userinfo = { uid: 'uid-tenant', mail: 'user@example.com', givenName: 'Test', cn: 'Test' }; + const { user, info } = await callVerify(userinfo); + + expect(user).toBe(false); + expect(info).toEqual({ message: 'Email domain not allowed' }); + }); }); diff --git a/api/strategies/openidStrategy.js b/api/strategies/openidStrategy.js index ab7eb60261..7314a84e15 100644 --- a/api/strategies/openidStrategy.js +++ b/api/strategies/openidStrategy.js @@ -15,6 +15,7 @@ const { findOpenIDUser, getBalanceConfig, isEmailDomainAllowed, + resolveAppConfigForUser, } = require('@librechat/api'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { findUser, createUser, updateUser } = require('~/models'); @@ -468,9 +469,10 @@ async function processOpenIDAuth(tokenset, existingUsersOnly = false) { Object.assign(userinfo, providerUserinfo); } - const appConfig = await getAppConfig({ baseOnly: true }); const email = getOpenIdEmail(userinfo); - if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) { + + const baseConfig = await getAppConfig({ baseOnly: true }); + if (!isEmailDomainAllowed(email, baseConfig?.registration?.allowedDomains)) { logger.error( `[OpenID Strategy] Authentication blocked - email domain not allowed [Identifier: ${email}]`, ); @@ -491,6 +493,15 @@ async function processOpenIDAuth(tokenset, existingUsersOnly = false) { throw new Error(ErrorTypes.AUTH_FAILED); } + const appConfig = user?.tenantId ? await resolveAppConfigForUser(getAppConfig, user) : baseConfig; + + if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) { + logger.error( + `[OpenID Strategy] Authentication blocked - email domain not allowed [Identifier: ${email}]`, + ); + throw new Error('Email domain not allowed'); + } + const fullName = getFullName(userinfo); const requiredRole = process.env.OPENID_REQUIRED_ROLE; diff --git a/api/strategies/openidStrategy.spec.js b/api/strategies/openidStrategy.spec.js index 4436fab672..6d824176f7 100644 --- a/api/strategies/openidStrategy.spec.js +++ b/api/strategies/openidStrategy.spec.js @@ -1,1822 +1,1873 @@ -const undici = require('undici'); -const fetch = require('node-fetch'); -const jwtDecode = require('jsonwebtoken/decode'); -const { ErrorTypes } = require('librechat-data-provider'); -const { findUser, createUser, updateUser } = require('~/models'); -const { setupOpenId } = require('./openidStrategy'); - -// --- Mocks --- -jest.mock('node-fetch'); -jest.mock('jsonwebtoken/decode'); -jest.mock('undici', () => ({ - fetch: jest.fn(), - ProxyAgent: jest.fn(), -})); -jest.mock('~/server/services/Files/strategies', () => ({ - getStrategyFunctions: jest.fn(() => ({ - saveBuffer: jest.fn().mockResolvedValue('/fake/path/to/avatar.png'), - })), -})); -jest.mock('~/server/services/Config', () => ({ - getAppConfig: jest.fn().mockResolvedValue({}), -})); -jest.mock('@librechat/api', () => ({ - ...jest.requireActual('@librechat/api'), - isEnabled: jest.fn(() => false), - isEmailDomainAllowed: jest.fn(() => true), - findOpenIDUser: jest.requireActual('@librechat/api').findOpenIDUser, - getBalanceConfig: jest.fn(() => ({ - enabled: false, - })), -})); -jest.mock('~/models', () => ({ - findUser: jest.fn(), - createUser: jest.fn(), - updateUser: jest.fn(), -})); -jest.mock('@librechat/data-schemas', () => ({ - ...jest.requireActual('@librechat/api'), - logger: { - info: jest.fn(), - warn: jest.fn(), - debug: jest.fn(), - error: jest.fn(), - }, - hashToken: jest.fn().mockResolvedValue('hashed-token'), -})); -jest.mock('~/cache/getLogStores', () => - jest.fn(() => ({ - get: jest.fn(), - set: jest.fn(), - })), -); - -// Mock the openid-client module and all its dependencies -jest.mock('openid-client', () => { - return { - discovery: jest.fn().mockResolvedValue({ - clientId: 'fake_client_id', - clientSecret: 'fake_client_secret', - issuer: 'https://fake-issuer.com', - // Add any other properties needed by the implementation - }), - fetchUserInfo: jest.fn().mockImplementation(() => { - // Only return additional properties, but don't override any claims - return Promise.resolve({}); - }), - genericGrantRequest: jest.fn().mockResolvedValue({ - access_token: 'exchanged_graph_token', - expires_in: 3600, - }), - customFetch: Symbol('customFetch'), - }; -}); - -jest.mock('openid-client/passport', () => { - /** Store callbacks by strategy name - 'openid' and 'openidAdmin' */ - const verifyCallbacks = {}; - let lastVerifyCallback; - - const mockStrategy = jest.fn((options, verify) => { - lastVerifyCallback = verify; - return { name: 'openid', options, verify }; - }); - - return { - Strategy: mockStrategy, - /** Get the last registered callback (for backward compatibility) */ - __getVerifyCallback: () => lastVerifyCallback, - /** Store callback by name when passport.use is called */ - __setVerifyCallback: (name, callback) => { - verifyCallbacks[name] = callback; - }, - /** Get callback by strategy name */ - __getVerifyCallbackByName: (name) => verifyCallbacks[name], - }; -}); - -// Mock passport - capture strategy name and callback -jest.mock('passport', () => ({ - use: jest.fn((name, strategy) => { - const passportMock = require('openid-client/passport'); - if (strategy && strategy.verify) { - passportMock.__setVerifyCallback(name, strategy.verify); - } - }), -})); - -describe('setupOpenId', () => { - // Store a reference to the verify callback once it's set up - let verifyCallback; - - // Helper to wrap the verify callback in a promise - const validate = (tokenset) => - new Promise((resolve, reject) => { - verifyCallback(tokenset, (err, user, details) => { - if (err) { - reject(err); - } else { - resolve({ user, details }); - } - }); - }); - - const tokenset = { - id_token: 'fake_id_token', - access_token: 'fake_access_token', - claims: () => ({ - sub: '1234', - email: 'test@example.com', - email_verified: true, - given_name: 'First', - family_name: 'Last', - name: 'My Full', - preferred_username: 'testusername', - username: 'flast', - picture: 'https://example.com/avatar.png', - }), - }; - - beforeEach(async () => { - // Clear previous mock calls and reset implementations - jest.clearAllMocks(); - - // Reset environment variables needed by the strategy - process.env.OPENID_ISSUER = 'https://fake-issuer.com'; - process.env.OPENID_CLIENT_ID = 'fake_client_id'; - process.env.OPENID_CLIENT_SECRET = 'fake_client_secret'; - process.env.DOMAIN_SERVER = 'https://example.com'; - process.env.OPENID_CALLBACK_URL = '/callback'; - process.env.OPENID_SCOPE = 'openid profile email'; - process.env.OPENID_REQUIRED_ROLE = 'requiredRole'; - process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roles'; - process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id'; - process.env.OPENID_ADMIN_ROLE = 'admin'; - process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'permissions'; - process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'id'; - delete process.env.OPENID_USERNAME_CLAIM; - delete process.env.OPENID_NAME_CLAIM; - delete process.env.OPENID_EMAIL_CLAIM; - delete process.env.PROXY; - delete process.env.OPENID_USE_PKCE; - - // Default jwtDecode mock returns a token that includes the required role. - jwtDecode.mockReturnValue({ - roles: ['requiredRole'], - permissions: ['admin'], - }); - - // By default, assume that no user is found, so createUser will be called - findUser.mockResolvedValue(null); - createUser.mockImplementation(async (userData) => { - // simulate created user with an _id property - return { _id: 'newUserId', ...userData }; - }); - updateUser.mockImplementation(async (id, userData) => { - return { _id: id, ...userData }; - }); - - // For image download, simulate a successful response - const fakeBuffer = Buffer.from('fake image'); - const fakeResponse = { - ok: true, - buffer: jest.fn().mockResolvedValue(fakeBuffer), - }; - fetch.mockResolvedValue(fakeResponse); - - // Call the setup function and capture the verify callback for the regular 'openid' strategy - // (not 'openidAdmin' which requires existing users) - await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); - }); - - it('should create a new user with correct username when preferred_username claim exists', async () => { - // Arrange – our userinfo already has preferred_username 'testusername' - const userinfo = tokenset.claims(); - - // Act - const { user } = await validate(tokenset); - - // Assert - expect(user.username).toBe(userinfo.preferred_username); - expect(createUser).toHaveBeenCalledWith( - expect.objectContaining({ - provider: 'openid', - openidId: userinfo.sub, - username: userinfo.preferred_username, - email: userinfo.email, - name: `${userinfo.given_name} ${userinfo.family_name}`, - }), - { enabled: false }, - true, - true, - ); - }); - - it('should use username as username when preferred_username claim is missing', async () => { - // Arrange – remove preferred_username from userinfo - const userinfo = { ...tokenset.claims() }; - delete userinfo.preferred_username; - // Expect the username to be the "username" - const expectUsername = userinfo.username; - - // Act - const { user } = await validate({ ...tokenset, claims: () => userinfo }); - - // Assert - expect(user.username).toBe(expectUsername); - expect(createUser).toHaveBeenCalledWith( - expect.objectContaining({ username: expectUsername }), - { enabled: false }, - true, - true, - ); - }); - - it('should use email as username when username and preferred_username are missing', async () => { - // Arrange – remove username and preferred_username - const userinfo = { ...tokenset.claims() }; - delete userinfo.username; - delete userinfo.preferred_username; - const expectUsername = userinfo.email; - - // Act - const { user } = await validate({ ...tokenset, claims: () => userinfo }); - - // Assert - expect(user.username).toBe(expectUsername); - expect(createUser).toHaveBeenCalledWith( - expect.objectContaining({ username: expectUsername }), - { enabled: false }, - true, - true, - ); - }); - - it('should override username with OPENID_USERNAME_CLAIM when set', async () => { - // Arrange – set OPENID_USERNAME_CLAIM so that the sub claim is used - process.env.OPENID_USERNAME_CLAIM = 'sub'; - const userinfo = tokenset.claims(); - - // Act - const { user } = await validate(tokenset); - - // Assert – username should equal the sub (converted as-is) - expect(user.username).toBe(userinfo.sub); - expect(createUser).toHaveBeenCalledWith( - expect.objectContaining({ username: userinfo.sub }), - { enabled: false }, - true, - true, - ); - }); - - it('should set the full name correctly when given_name and family_name exist', async () => { - // Arrange - const userinfo = tokenset.claims(); - const expectedFullName = `${userinfo.given_name} ${userinfo.family_name}`; - - // Act - const { user } = await validate(tokenset); - - // Assert - expect(user.name).toBe(expectedFullName); - }); - - it('should override full name with OPENID_NAME_CLAIM when set', async () => { - // Arrange – use the name claim as the full name - process.env.OPENID_NAME_CLAIM = 'name'; - const userinfo = { ...tokenset.claims(), name: 'Custom Name' }; - - // Act - const { user } = await validate({ ...tokenset, claims: () => userinfo }); - - // Assert - expect(user.name).toBe('Custom Name'); - }); - - it('should update an existing user on login', async () => { - // Arrange – simulate that a user already exists with openid provider - const existingUser = { - _id: 'existingUserId', - provider: 'openid', - email: tokenset.claims().email, - openidId: '', - username: '', - name: '', - }; - findUser.mockImplementation(async (query) => { - if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) { - return existingUser; - } - return null; - }); - - const userinfo = tokenset.claims(); - - // Act - await validate(tokenset); - - // Assert – updateUser should be called and the user object updated - expect(updateUser).toHaveBeenCalledWith( - existingUser._id, - expect.objectContaining({ - provider: 'openid', - openidId: userinfo.sub, - username: userinfo.preferred_username, - name: `${userinfo.given_name} ${userinfo.family_name}`, - }), - ); - }); - - it('should block login when email exists with different provider', async () => { - // Arrange – simulate that a user exists with same email but different provider - const existingUser = { - _id: 'existingUserId', - provider: 'google', - email: tokenset.claims().email, - googleId: 'some-google-id', - username: 'existinguser', - name: 'Existing User', - }; - findUser.mockImplementation(async (query) => { - if (query.email === tokenset.claims().email && !query.provider) { - return existingUser; - } - return null; - }); - - // Act - const result = await validate(tokenset); - - // Assert – verify that the strategy rejects login - expect(result.user).toBe(false); - expect(result.details.message).toBe(ErrorTypes.AUTH_FAILED); - expect(createUser).not.toHaveBeenCalled(); - expect(updateUser).not.toHaveBeenCalled(); - }); - - it('should block login when email fallback finds user with mismatched openidId', async () => { - const existingUser = { - _id: 'existingUserId', - provider: 'openid', - openidId: 'different-sub-claim', - email: tokenset.claims().email, - username: 'existinguser', - name: 'Existing User', - }; - findUser.mockImplementation(async (query) => { - if (query.$or) { - return null; - } - if (query.email === tokenset.claims().email) { - return existingUser; - } - return null; - }); - - const result = await validate(tokenset); - - expect(result.user).toBe(false); - expect(result.details.message).toBe(ErrorTypes.AUTH_FAILED); - expect(createUser).not.toHaveBeenCalled(); - expect(updateUser).not.toHaveBeenCalled(); - }); - - it('should enforce the required role and reject login if missing', async () => { - // Arrange – simulate a token without the required role. - jwtDecode.mockReturnValue({ - roles: ['SomeOtherRole'], - }); - - // Act - const { user, details } = await validate(tokenset); - - // Assert – verify that the strategy rejects login - expect(user).toBe(false); - expect(details.message).toBe('You must have "requiredRole" role to log in.'); - }); - - it('should not treat substring matches in string roles as satisfying required role', async () => { - // Arrange – override required role to "read" then re-setup - process.env.OPENID_REQUIRED_ROLE = 'read'; - await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); - - // Token contains "bread" which *contains* "read" as a substring - jwtDecode.mockReturnValue({ - roles: 'bread', - }); - - // Act - const { user, details } = await validate(tokenset); - - // Assert – verify that substring match does not grant access - expect(user).toBe(false); - expect(details.message).toBe('You must have "read" role to log in.'); - }); - - it('should allow login when roles claim is a space-separated string containing the required role', async () => { - // Arrange – IdP returns roles as a space-delimited string - jwtDecode.mockReturnValue({ - roles: 'role1 role2 requiredRole', - }); - - // Act - const { user } = await validate(tokenset); - - // Assert – login succeeds when required role is present after splitting - expect(user).toBeTruthy(); - expect(createUser).toHaveBeenCalled(); - }); - - it('should allow login when roles claim is a comma-separated string containing the required role', async () => { - // Arrange – IdP returns roles as a comma-delimited string - jwtDecode.mockReturnValue({ - roles: 'role1,role2,requiredRole', - }); - - // Act - const { user } = await validate(tokenset); - - // Assert – login succeeds when required role is present after splitting - expect(user).toBeTruthy(); - expect(createUser).toHaveBeenCalled(); - }); - - it('should allow login when roles claim is a mixed comma-and-space-separated string containing the required role', async () => { - // Arrange – IdP returns roles with comma-and-space delimiters - jwtDecode.mockReturnValue({ - roles: 'role1, role2, requiredRole', - }); - - // Act - const { user } = await validate(tokenset); - - // Assert – login succeeds when required role is present after splitting - expect(user).toBeTruthy(); - expect(createUser).toHaveBeenCalled(); - }); - - it('should reject login when roles claim is a space-separated string that does not contain the required role', async () => { - // Arrange – IdP returns a delimited string but required role is absent - jwtDecode.mockReturnValue({ - roles: 'role1 role2 otherRole', - }); - - // Act - const { user, details } = await validate(tokenset); - - // Assert – login is rejected with the correct error message - expect(user).toBe(false); - expect(details.message).toBe('You must have "requiredRole" role to log in.'); - }); - - it('should allow login when single required role is present (backward compatibility)', async () => { - // Arrange – ensure single role configuration (as set in beforeEach) - // OPENID_REQUIRED_ROLE = 'requiredRole' - // Default jwtDecode mock in beforeEach already returns this role - jwtDecode.mockReturnValue({ - roles: ['requiredRole', 'anotherRole'], - }); - - // Act - const { user } = await validate(tokenset); - - // Assert – verify that login succeeds with single role configuration - expect(user).toBeTruthy(); - expect(user.email).toBe(tokenset.claims().email); - expect(user.username).toBe(tokenset.claims().preferred_username); - expect(createUser).toHaveBeenCalled(); - }); - - describe('group overage and groups handling', () => { - it.each([ - ['groups array contains required group', ['group-required', 'other-group'], true, undefined], - [ - 'groups array missing required group', - ['other-group'], - false, - 'You must have "group-required" role to log in.', - ], - ['groups string equals required group', 'group-required', true, undefined], - [ - 'groups string is other group', - 'other-group', - false, - 'You must have "group-required" role to log in.', - ], - ])( - 'uses groups claim directly when %s (no overage)', - async (_label, groupsClaim, expectedAllowed, expectedMessage) => { - process.env.OPENID_REQUIRED_ROLE = 'group-required'; - process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups'; - process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id'; - - jwtDecode.mockReturnValue({ - groups: groupsClaim, - permissions: ['admin'], - }); - - await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); - - const { user, details } = await validate(tokenset); - - expect(undici.fetch).not.toHaveBeenCalled(); - expect(Boolean(user)).toBe(expectedAllowed); - expect(details?.message).toBe(expectedMessage); - }, - ); - - it.each([ - ['token kind is not id', { kind: 'access', path: 'groups', decoded: { hasgroups: true } }], - ['parameter path is not groups', { kind: 'id', path: 'roles', decoded: { hasgroups: true } }], - ['decoded token is falsy', { kind: 'id', path: 'groups', decoded: null }], - [ - 'no overage indicators in decoded token', - { - kind: 'id', - path: 'groups', - decoded: { - permissions: ['admin'], - }, - }, - ], - [ - 'only _claim_names present (no _claim_sources)', - { - kind: 'id', - path: 'groups', - decoded: { - _claim_names: { groups: 'src1' }, - permissions: ['admin'], - }, - }, - ], - [ - 'only _claim_sources present (no _claim_names)', - { - kind: 'id', - path: 'groups', - decoded: { - _claim_sources: { src1: { endpoint: 'https://graph.windows.net/ignored' } }, - permissions: ['admin'], - }, - }, - ], - ])('does not attempt overage resolution when %s', async (_label, cfg) => { - process.env.OPENID_REQUIRED_ROLE = 'group-required'; - process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = cfg.path; - process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = cfg.kind; - - jwtDecode.mockReturnValue(cfg.decoded); - - await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); - - const { user, details } = await validate(tokenset); - - expect(undici.fetch).not.toHaveBeenCalled(); - expect(user).toBe(false); - expect(details.message).toBe('You must have "group-required" role to log in.'); - const { logger } = require('@librechat/data-schemas'); - const expectedTokenKind = cfg.kind === 'access' ? 'access token' : 'id token'; - expect(logger.error).toHaveBeenCalledWith( - expect.stringContaining(`Key '${cfg.path}' not found in ${expectedTokenKind}!`), - ); - }); - }); - - describe('resolving groups via Microsoft Graph', () => { - it('denies login and does not call Graph when access token is missing', async () => { - process.env.OPENID_REQUIRED_ROLE = 'group-required'; - process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups'; - process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id'; - - const { logger } = require('@librechat/data-schemas'); - - jwtDecode.mockReturnValue({ - hasgroups: true, - permissions: ['admin'], - }); - - await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); - - const tokensetWithoutAccess = { - ...tokenset, - access_token: undefined, - }; - - const { user, details } = await validate(tokensetWithoutAccess); - - expect(user).toBe(false); - expect(details.message).toBe('You must have "group-required" role to log in.'); - - expect(undici.fetch).not.toHaveBeenCalled(); - expect(logger.error).toHaveBeenCalledWith( - expect.stringContaining('Access token missing; cannot resolve group overage'), - ); - }); - - it.each([ - [ - 'Graph returns HTTP error', - async () => ({ - ok: false, - status: 403, - statusText: 'Forbidden', - json: async () => ({}), - }), - [ - '[openidStrategy] Failed to resolve groups via Microsoft Graph getMemberObjects: HTTP 403 Forbidden', - ], - ], - [ - 'Graph network error', - async () => { - throw new Error('network error'); - }, - [ - '[openidStrategy] Error resolving groups via Microsoft Graph getMemberObjects:', - expect.any(Error), - ], - ], - [ - 'Graph returns unexpected shape (no value)', - async () => ({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({}), - }), - [ - '[openidStrategy] Unexpected response format when resolving groups via Microsoft Graph getMemberObjects', - ], - ], - [ - 'Graph returns invalid value type', - async () => ({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ value: 'not-an-array' }), - }), - [ - '[openidStrategy] Unexpected response format when resolving groups via Microsoft Graph getMemberObjects', - ], - ], - ])( - 'denies login when overage resolution fails because %s', - async (_label, setupFetch, expectedErrorArgs) => { - process.env.OPENID_REQUIRED_ROLE = 'group-required'; - process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups'; - process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id'; - - const { logger } = require('@librechat/data-schemas'); - - jwtDecode.mockReturnValue({ - hasgroups: true, - permissions: ['admin'], - }); - - await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); - - undici.fetch.mockImplementation(setupFetch); - - const { user, details } = await validate(tokenset); - - expect(undici.fetch).toHaveBeenCalled(); - expect(user).toBe(false); - expect(details.message).toBe('You must have "group-required" role to log in.'); - - expect(logger.error).toHaveBeenCalledWith(...expectedErrorArgs); - }, - ); - - it.each([ - [ - 'hasgroups overage and Graph contains required group', - { - hasgroups: true, - }, - ['group-required', 'some-other-group'], - true, - ], - [ - '_claim_* overage and Graph contains required group', - { - _claim_names: { groups: 'src1' }, - _claim_sources: { src1: { endpoint: 'https://graph.windows.net/ignored' } }, - }, - ['group-required', 'some-other-group'], - true, - ], - [ - 'hasgroups overage and Graph does NOT contain required group', - { - hasgroups: true, - }, - ['some-other-group'], - false, - ], - [ - '_claim_* overage and Graph does NOT contain required group', - { - _claim_names: { groups: 'src1' }, - _claim_sources: { src1: { endpoint: 'https://graph.windows.net/ignored' } }, - }, - ['some-other-group'], - false, - ], - ])( - 'resolves groups via Microsoft Graph when %s', - async (_label, decodedTokenValue, graphGroups, expectedAllowed) => { - process.env.OPENID_REQUIRED_ROLE = 'group-required'; - process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups'; - process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id'; - - const { logger } = require('@librechat/data-schemas'); - - jwtDecode.mockReturnValue(decodedTokenValue); - - await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); - - undici.fetch.mockResolvedValue({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ - value: graphGroups, - }), - }); - - const { user } = await validate(tokenset); - - expect(undici.fetch).toHaveBeenCalledWith( - 'https://graph.microsoft.com/v1.0/me/getMemberObjects', - expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ - Authorization: 'Bearer exchanged_graph_token', - }), - }), - ); - expect(Boolean(user)).toBe(expectedAllowed); - - expect(logger.debug).toHaveBeenCalledWith( - expect.stringContaining( - `Successfully resolved ${graphGroups.length} groups via Microsoft Graph getMemberObjects`, - ), - ); - }, - ); - }); - - describe('OBO token exchange for overage', () => { - it('exchanges access token via OBO before calling Graph API', async () => { - const openidClient = require('openid-client'); - process.env.OPENID_REQUIRED_ROLE = 'group-required'; - process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups'; - process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id'; - - jwtDecode.mockReturnValue({ hasgroups: true }); - - await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); - - undici.fetch.mockResolvedValue({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ value: ['group-required'] }), - }); - - await validate(tokenset); - - expect(openidClient.genericGrantRequest).toHaveBeenCalledWith( - expect.anything(), - 'urn:ietf:params:oauth:grant-type:jwt-bearer', - expect.objectContaining({ - scope: 'https://graph.microsoft.com/User.Read', - assertion: tokenset.access_token, - requested_token_use: 'on_behalf_of', - }), - ); - - expect(undici.fetch).toHaveBeenCalledWith( - 'https://graph.microsoft.com/v1.0/me/getMemberObjects', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer exchanged_graph_token', - }), - }), - ); - }); - - it('caches the exchanged token and reuses it on subsequent calls', async () => { - const openidClient = require('openid-client'); - const getLogStores = require('~/cache/getLogStores'); - const mockSet = jest.fn(); - const mockGet = jest - .fn() - .mockResolvedValueOnce(undefined) - .mockResolvedValueOnce({ access_token: 'exchanged_graph_token' }); - getLogStores.mockReturnValue({ get: mockGet, set: mockSet }); - - process.env.OPENID_REQUIRED_ROLE = 'group-required'; - process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups'; - process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id'; - - jwtDecode.mockReturnValue({ hasgroups: true }); - - await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); - - undici.fetch.mockResolvedValue({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ value: ['group-required'] }), - }); - - // First call: cache miss → OBO exchange → cache set - await validate(tokenset); - expect(mockSet).toHaveBeenCalledWith( - '1234:overage', - { access_token: 'exchanged_graph_token' }, - 3600000, - ); - expect(openidClient.genericGrantRequest).toHaveBeenCalledTimes(1); - - // Second call: cache hit → no new OBO exchange - openidClient.genericGrantRequest.mockClear(); - await validate(tokenset); - expect(openidClient.genericGrantRequest).not.toHaveBeenCalled(); - }); - }); - - describe('admin role group overage', () => { - it('resolves admin groups via Graph when overage is detected for admin role', async () => { - process.env.OPENID_REQUIRED_ROLE = 'group-required'; - process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups'; - process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id'; - process.env.OPENID_ADMIN_ROLE = 'admin-group-id'; - process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'groups'; - process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'id'; - - jwtDecode.mockReturnValue({ hasgroups: true }); - - await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); - - undici.fetch.mockResolvedValue({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ value: ['group-required', 'admin-group-id'] }), - }); - - const { user } = await validate(tokenset); - - expect(user.role).toBe('ADMIN'); - }); - - it('does not grant admin when overage groups do not contain admin role', async () => { - process.env.OPENID_REQUIRED_ROLE = 'group-required'; - process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups'; - process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id'; - process.env.OPENID_ADMIN_ROLE = 'admin-group-id'; - process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'groups'; - process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'id'; - - jwtDecode.mockReturnValue({ hasgroups: true }); - - await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); - - undici.fetch.mockResolvedValue({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ value: ['group-required', 'other-group'] }), - }); - - const { user } = await validate(tokenset); - - expect(user).toBeTruthy(); - expect(user.role).toBeUndefined(); - }); - - it('reuses already-resolved overage groups for admin role check (no duplicate Graph call)', async () => { - process.env.OPENID_REQUIRED_ROLE = 'group-required'; - process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups'; - process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id'; - process.env.OPENID_ADMIN_ROLE = 'admin-group-id'; - process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'groups'; - process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'id'; - - jwtDecode.mockReturnValue({ hasgroups: true }); - - await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); - - undici.fetch.mockResolvedValue({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ value: ['group-required', 'admin-group-id'] }), - }); - - await validate(tokenset); - - // Graph API should be called only once (for required role), admin role reuses the result - expect(undici.fetch).toHaveBeenCalledTimes(1); - }); - - it('demotes existing admin when overage groups no longer contain admin role', async () => { - process.env.OPENID_REQUIRED_ROLE = 'group-required'; - process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups'; - process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id'; - process.env.OPENID_ADMIN_ROLE = 'admin-group-id'; - process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'groups'; - process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'id'; - - const existingAdminUser = { - _id: 'existingAdminId', - provider: 'openid', - email: tokenset.claims().email, - openidId: tokenset.claims().sub, - username: 'adminuser', - name: 'Admin User', - role: 'ADMIN', - }; - - findUser.mockImplementation(async (query) => { - if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) { - return existingAdminUser; - } - return null; - }); - - jwtDecode.mockReturnValue({ hasgroups: true }); - - await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); - - undici.fetch.mockResolvedValue({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ value: ['group-required'] }), - }); - - const { user } = await validate(tokenset); - - expect(user.role).toBe('USER'); - }); - - it('does not attempt overage for admin role when token kind is not id', async () => { - process.env.OPENID_REQUIRED_ROLE = 'requiredRole'; - process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roles'; - process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id'; - process.env.OPENID_ADMIN_ROLE = 'admin'; - process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'groups'; - process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'access'; - - jwtDecode.mockReturnValue({ - roles: ['requiredRole'], - hasgroups: true, - }); - - await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); - - const { user } = await validate(tokenset); - - // No Graph call since admin uses access token (not id) - expect(undici.fetch).not.toHaveBeenCalled(); - expect(user.role).toBeUndefined(); - }); - - it('resolves admin via Graph independently when OPENID_REQUIRED_ROLE is not configured', async () => { - delete process.env.OPENID_REQUIRED_ROLE; - process.env.OPENID_ADMIN_ROLE = 'admin-group-id'; - process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'groups'; - process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'id'; - - jwtDecode.mockReturnValue({ hasgroups: true }); - await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); - - undici.fetch.mockResolvedValue({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ value: ['admin-group-id'] }), - }); - - const { user } = await validate(tokenset); - expect(user.role).toBe('ADMIN'); - expect(undici.fetch).toHaveBeenCalledTimes(1); - }); - - it('denies admin when OPENID_REQUIRED_ROLE is absent and Graph does not contain admin group', async () => { - delete process.env.OPENID_REQUIRED_ROLE; - process.env.OPENID_ADMIN_ROLE = 'admin-group-id'; - process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'groups'; - process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'id'; - - jwtDecode.mockReturnValue({ hasgroups: true }); - await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); - - undici.fetch.mockResolvedValue({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ value: ['other-group'] }), - }); - - const { user } = await validate(tokenset); - expect(user).toBeTruthy(); - expect(user.role).toBeUndefined(); - }); - - it('denies login and logs error when OBO exchange throws', async () => { - const openidClient = require('openid-client'); - process.env.OPENID_REQUIRED_ROLE = 'group-required'; - process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups'; - process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id'; - - jwtDecode.mockReturnValue({ hasgroups: true }); - openidClient.genericGrantRequest.mockRejectedValueOnce(new Error('OBO exchange rejected')); - - await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); - - const { user, details } = await validate(tokenset); - expect(user).toBe(false); - expect(details.message).toBe('You must have "group-required" role to log in.'); - expect(undici.fetch).not.toHaveBeenCalled(); - }); - - it('denies login when OBO exchange returns no access_token', async () => { - const openidClient = require('openid-client'); - process.env.OPENID_REQUIRED_ROLE = 'group-required'; - process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups'; - process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id'; - - jwtDecode.mockReturnValue({ hasgroups: true }); - openidClient.genericGrantRequest.mockResolvedValueOnce({ expires_in: 3600 }); - - await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); - - const { user, details } = await validate(tokenset); - expect(user).toBe(false); - expect(details.message).toBe('You must have "group-required" role to log in.'); - expect(undici.fetch).not.toHaveBeenCalled(); - }); - }); - - it('should attempt to download and save the avatar if picture is provided', async () => { - // Act - const { user } = await validate(tokenset); - - // Assert – verify that download was attempted and the avatar field was set via updateUser - expect(fetch).toHaveBeenCalled(); - // Our mock getStrategyFunctions.saveBuffer returns '/fake/path/to/avatar.png' - expect(user.avatar).toBe('/fake/path/to/avatar.png'); - }); - - it('should not attempt to download avatar if picture is not provided', async () => { - // Arrange – remove picture - const userinfo = { ...tokenset.claims() }; - delete userinfo.picture; - - // Act - await validate({ ...tokenset, claims: () => userinfo }); - - // Assert – fetch should not be called and avatar should remain undefined or empty - expect(fetch).not.toHaveBeenCalled(); - // Depending on your implementation, user.avatar may be undefined or an empty string. - }); - - it('should support comma-separated multiple roles', async () => { - // Arrange - process.env.OPENID_REQUIRED_ROLE = 'someRole,anotherRole,admin'; - await setupOpenId(); // Re-initialize the strategy - verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); - jwtDecode.mockReturnValue({ - roles: ['anotherRole', 'aThirdRole'], - }); - - // Act - const { user } = await validate(tokenset); - - // Assert - expect(user).toBeTruthy(); - expect(user.email).toBe(tokenset.claims().email); - }); - - it('should reject login when user has none of the required multiple roles', async () => { - // Arrange - process.env.OPENID_REQUIRED_ROLE = 'someRole,anotherRole,admin'; - await setupOpenId(); // Re-initialize the strategy - verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); - jwtDecode.mockReturnValue({ - roles: ['aThirdRole', 'aFourthRole'], - }); - - // Act - const { user, details } = await validate(tokenset); - - // Assert - expect(user).toBe(false); - expect(details.message).toBe( - 'You must have one of: "someRole", "anotherRole", "admin" role to log in.', - ); - }); - - it('should handle spaces in comma-separated roles', async () => { - // Arrange - process.env.OPENID_REQUIRED_ROLE = ' someRole , anotherRole , admin '; - await setupOpenId(); // Re-initialize the strategy - verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); - jwtDecode.mockReturnValue({ - roles: ['someRole'], - }); - - // Act - const { user } = await validate(tokenset); - - // Assert - expect(user).toBeTruthy(); - }); - - it('should default to usePKCE false when OPENID_USE_PKCE is not defined', async () => { - const OpenIDStrategy = require('openid-client/passport').Strategy; - - delete process.env.OPENID_USE_PKCE; - await setupOpenId(); - - const callOptions = OpenIDStrategy.mock.calls[OpenIDStrategy.mock.calls.length - 1][0]; - expect(callOptions.usePKCE).toBe(false); - expect(callOptions.params?.code_challenge_method).toBeUndefined(); - }); - - it('should attach federatedTokens to user object for token propagation', async () => { - // Arrange - setup tokenset with access token, id token, refresh token, and expiration - const tokensetWithTokens = { - ...tokenset, - access_token: 'mock_access_token_abc123', - id_token: 'mock_id_token_def456', - refresh_token: 'mock_refresh_token_xyz789', - expires_at: 1234567890, - }; - - // Act - validate with the tokenset containing tokens - const { user } = await validate(tokensetWithTokens); - - // Assert - verify federatedTokens object is attached with correct values - expect(user.federatedTokens).toBeDefined(); - expect(user.federatedTokens).toEqual({ - access_token: 'mock_access_token_abc123', - id_token: 'mock_id_token_def456', - refresh_token: 'mock_refresh_token_xyz789', - expires_at: 1234567890, - }); - }); - - it('should include id_token in federatedTokens distinct from access_token', async () => { - // Arrange - use different values for access_token and id_token - const tokensetWithTokens = { - ...tokenset, - access_token: 'the_access_token', - id_token: 'the_id_token', - refresh_token: 'the_refresh_token', - expires_at: 9999999999, - }; - - // Act - const { user } = await validate(tokensetWithTokens); - - // Assert - id_token and access_token must be different values - expect(user.federatedTokens.access_token).toBe('the_access_token'); - expect(user.federatedTokens.id_token).toBe('the_id_token'); - expect(user.federatedTokens.id_token).not.toBe(user.federatedTokens.access_token); - }); - - it('should include tokenset along with federatedTokens', async () => { - // Arrange - const tokensetWithTokens = { - ...tokenset, - access_token: 'test_access_token', - id_token: 'test_id_token', - refresh_token: 'test_refresh_token', - expires_at: 9999999999, - }; - - // Act - const { user } = await validate(tokensetWithTokens); - - // Assert - both tokenset and federatedTokens should be present - expect(user.tokenset).toBeDefined(); - expect(user.federatedTokens).toBeDefined(); - expect(user.tokenset.access_token).toBe('test_access_token'); - expect(user.tokenset.id_token).toBe('test_id_token'); - expect(user.federatedTokens.access_token).toBe('test_access_token'); - expect(user.federatedTokens.id_token).toBe('test_id_token'); - }); - - it('should set role to "ADMIN" if OPENID_ADMIN_ROLE is set and user has that role', async () => { - // Act - const { user } = await validate(tokenset); - - // Assert – verify that the user role is set to "ADMIN" - expect(user.role).toBe('ADMIN'); - }); - - it('should not set user role if OPENID_ADMIN_ROLE is set but the user does not have that role', async () => { - // Arrange – simulate a token without the admin permission - jwtDecode.mockReturnValue({ - roles: ['requiredRole'], - permissions: ['not-admin'], - }); - - // Act - const { user } = await validate(tokenset); - - // Assert – verify that the user role is not defined - expect(user.role).toBeUndefined(); - }); - - it('should demote existing admin user when admin role is removed from token', async () => { - // Arrange – simulate an existing user who is currently an admin - const existingAdminUser = { - _id: 'existingAdminId', - provider: 'openid', - email: tokenset.claims().email, - openidId: tokenset.claims().sub, - username: 'adminuser', - name: 'Admin User', - role: 'ADMIN', - }; - - findUser.mockImplementation(async (query) => { - if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) { - return existingAdminUser; - } - return null; - }); - - // Token without admin permission - jwtDecode.mockReturnValue({ - roles: ['requiredRole'], - permissions: ['not-admin'], - }); - - const { logger } = require('@librechat/data-schemas'); - - // Act - const { user } = await validate(tokenset); - - // Assert – verify that the user was demoted - expect(user.role).toBe('USER'); - expect(updateUser).toHaveBeenCalledWith( - existingAdminUser._id, - expect.objectContaining({ - role: 'USER', - }), - ); - expect(logger.info).toHaveBeenCalledWith( - expect.stringContaining('demoted from admin - role no longer present in token'), - ); - }); - - it('should NOT demote admin user when admin role env vars are not configured', async () => { - // Arrange – remove admin role env vars - delete process.env.OPENID_ADMIN_ROLE; - delete process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH; - delete process.env.OPENID_ADMIN_ROLE_TOKEN_KIND; - - await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); - - // Simulate an existing admin user - const existingAdminUser = { - _id: 'existingAdminId', - provider: 'openid', - email: tokenset.claims().email, - openidId: tokenset.claims().sub, - username: 'adminuser', - name: 'Admin User', - role: 'ADMIN', - }; - - findUser.mockImplementation(async (query) => { - if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) { - return existingAdminUser; - } - return null; - }); - - jwtDecode.mockReturnValue({ - roles: ['requiredRole'], - }); - - // Act - const { user } = await validate(tokenset); - - // Assert – verify that the admin user was NOT demoted - expect(user.role).toBe('ADMIN'); - expect(updateUser).toHaveBeenCalledWith( - existingAdminUser._id, - expect.objectContaining({ - role: 'ADMIN', - }), - ); - }); - - describe('lodash get - nested path extraction', () => { - it('should extract roles from deeply nested token path', async () => { - process.env.OPENID_REQUIRED_ROLE = 'app-user'; - process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'resource_access.my-client.roles'; - - jwtDecode.mockReturnValue({ - resource_access: { - 'my-client': { - roles: ['app-user', 'viewer'], - }, - }, - }); - - await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); - - const { user } = await validate(tokenset); - - expect(user).toBeTruthy(); - expect(user.email).toBe(tokenset.claims().email); - }); - - it('should extract roles from three-level nested path', async () => { - process.env.OPENID_REQUIRED_ROLE = 'editor'; - process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'data.access.permissions.roles'; - - jwtDecode.mockReturnValue({ - data: { - access: { - permissions: { - roles: ['editor', 'reader'], - }, - }, - }, - }); - - await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); - - const { user } = await validate(tokenset); - - expect(user).toBeTruthy(); - }); - - it('should log error and reject login when required role path does not exist in token', async () => { - const { logger } = require('@librechat/data-schemas'); - process.env.OPENID_REQUIRED_ROLE = 'app-user'; - process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'resource_access.nonexistent.roles'; - - jwtDecode.mockReturnValue({ - resource_access: { - 'my-client': { - roles: ['app-user'], - }, - }, - }); - - await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); - - const { user, details } = await validate(tokenset); - - expect(logger.error).toHaveBeenCalledWith( - expect.stringContaining("Key 'resource_access.nonexistent.roles' not found in id token!"), - ); - expect(user).toBe(false); - expect(details.message).toContain('role to log in'); - }); - - it('should handle missing intermediate nested path gracefully', async () => { - const { logger } = require('@librechat/data-schemas'); - process.env.OPENID_REQUIRED_ROLE = 'user'; - process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'org.team.roles'; - - jwtDecode.mockReturnValue({ - org: { - other: 'value', - }, - }); - - await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); - - const { user } = await validate(tokenset); - - expect(logger.error).toHaveBeenCalledWith( - expect.stringContaining("Key 'org.team.roles' not found in id token!"), - ); - expect(user).toBe(false); - }); - - it('should extract admin role from nested path in access token', async () => { - process.env.OPENID_ADMIN_ROLE = 'admin'; - process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'realm_access.roles'; - process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'access'; - - jwtDecode.mockImplementation((token) => { - if (token === 'fake_access_token') { - return { - realm_access: { - roles: ['admin', 'user'], - }, - }; - } - return { - roles: ['requiredRole'], - }; - }); - - await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); - - const { user } = await validate(tokenset); - - expect(user.role).toBe('ADMIN'); - }); - - it('should extract admin role from nested path in userinfo', async () => { - process.env.OPENID_ADMIN_ROLE = 'admin'; - process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'organization.permissions'; - process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'userinfo'; - - const userinfoWithNestedGroups = { - ...tokenset.claims(), - organization: { - permissions: ['admin', 'write'], - }, - }; - - require('openid-client').fetchUserInfo.mockResolvedValue({ - organization: { - permissions: ['admin', 'write'], - }, - }); - - jwtDecode.mockReturnValue({ - roles: ['requiredRole'], - }); - - await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); - - const { user } = await validate({ - ...tokenset, - claims: () => userinfoWithNestedGroups, - }); - - expect(user.role).toBe('ADMIN'); - }); - - it('should handle boolean admin role value', async () => { - process.env.OPENID_ADMIN_ROLE = 'admin'; - process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'is_admin'; - - jwtDecode.mockReturnValue({ - roles: ['requiredRole'], - is_admin: true, - }); - - await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); - - const { user } = await validate(tokenset); - - expect(user.role).toBe('ADMIN'); - }); - - it('should handle string admin role value matching exactly', async () => { - process.env.OPENID_ADMIN_ROLE = 'super-admin'; - process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'role'; - - jwtDecode.mockReturnValue({ - roles: ['requiredRole'], - role: 'super-admin', - }); - - await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); - - const { user } = await validate(tokenset); - - expect(user.role).toBe('ADMIN'); - }); - - it('should not set admin role when string value does not match', async () => { - process.env.OPENID_ADMIN_ROLE = 'super-admin'; - process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'role'; - - jwtDecode.mockReturnValue({ - roles: ['requiredRole'], - role: 'regular-user', - }); - - await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); - - const { user } = await validate(tokenset); - - expect(user.role).toBeUndefined(); - }); - - it('should handle array admin role value', async () => { - process.env.OPENID_ADMIN_ROLE = 'site-admin'; - process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'app_roles'; - - jwtDecode.mockReturnValue({ - roles: ['requiredRole'], - app_roles: ['user', 'site-admin', 'moderator'], - }); - - await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); - - const { user } = await validate(tokenset); - - expect(user.role).toBe('ADMIN'); - }); - - it('should not set admin when role is not in array', async () => { - process.env.OPENID_ADMIN_ROLE = 'site-admin'; - process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'app_roles'; - - jwtDecode.mockReturnValue({ - roles: ['requiredRole'], - app_roles: ['user', 'moderator'], - }); - - await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); - - const { user } = await validate(tokenset); - - expect(user.role).toBeUndefined(); - }); - - it('should grant admin when admin role claim is a space-separated string containing the admin role', async () => { - // Arrange – IdP returns admin roles as a space-delimited string - process.env.OPENID_ADMIN_ROLE = 'site-admin'; - process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'app_roles'; - - jwtDecode.mockReturnValue({ - roles: ['requiredRole'], - app_roles: 'user site-admin moderator', - }); - - await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); - - // Act - const { user } = await validate(tokenset); - - // Assert – admin role is granted after splitting the delimited string - expect(user.role).toBe('ADMIN'); - }); - - it('should not grant admin when admin role claim is a space-separated string that does not contain the admin role', async () => { - // Arrange – delimited string present but admin role is absent - process.env.OPENID_ADMIN_ROLE = 'site-admin'; - process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'app_roles'; - - jwtDecode.mockReturnValue({ - roles: ['requiredRole'], - app_roles: 'user moderator', - }); - - await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); - - // Act - const { user } = await validate(tokenset); - - // Assert – admin role is not granted - expect(user.role).toBeUndefined(); - }); - - it('should handle nested path with special characters in keys', async () => { - process.env.OPENID_REQUIRED_ROLE = 'app-user'; - process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'resource_access.my-app-123.roles'; - - jwtDecode.mockReturnValue({ - resource_access: { - 'my-app-123': { - roles: ['app-user'], - }, - }, - }); - - await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); - - const { user } = await validate(tokenset); - - expect(user).toBeTruthy(); - }); - - it('should handle empty object at nested path', async () => { - const { logger } = require('@librechat/data-schemas'); - process.env.OPENID_REQUIRED_ROLE = 'user'; - process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'access.roles'; - - jwtDecode.mockReturnValue({ - access: {}, - }); - - await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); - - const { user } = await validate(tokenset); - - expect(logger.error).toHaveBeenCalledWith( - expect.stringContaining("Key 'access.roles' not found in id token!"), - ); - expect(user).toBe(false); - }); - - it('should handle null value at intermediate path', async () => { - const { logger } = require('@librechat/data-schemas'); - process.env.OPENID_REQUIRED_ROLE = 'user'; - process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'data.roles'; - - jwtDecode.mockReturnValue({ - data: null, - }); - - await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); - - const { user } = await validate(tokenset); - - expect(logger.error).toHaveBeenCalledWith( - expect.stringContaining("Key 'data.roles' not found in id token!"), - ); - expect(user).toBe(false); - }); - - it('should reject login with invalid admin role token kind', async () => { - process.env.OPENID_ADMIN_ROLE = 'admin'; - process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'roles'; - process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'invalid'; - - const { logger } = require('@librechat/data-schemas'); - - jwtDecode.mockReturnValue({ - roles: ['requiredRole', 'admin'], - }); - - await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); - - await expect(validate(tokenset)).rejects.toThrow('Invalid admin role token kind'); - - expect(logger.error).toHaveBeenCalledWith( - expect.stringContaining( - "Invalid admin role token kind: invalid. Must be one of 'access', 'id', or 'userinfo'", - ), - ); - }); - - it('should reject login when roles path returns invalid type (object)', async () => { - const { logger } = require('@librechat/data-schemas'); - process.env.OPENID_REQUIRED_ROLE = 'app-user'; - process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roles'; - - jwtDecode.mockReturnValue({ - roles: { admin: true, user: false }, - }); - - await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); - - const { user, details } = await validate(tokenset); - - expect(logger.error).toHaveBeenCalledWith( - expect.stringContaining("Key 'roles' not found in id token!"), - ); - expect(user).toBe(false); - expect(details.message).toContain('role to log in'); - }); - - it('should reject login when roles path returns invalid type (number)', async () => { - const { logger } = require('@librechat/data-schemas'); - process.env.OPENID_REQUIRED_ROLE = 'user'; - process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roleCount'; - - jwtDecode.mockReturnValue({ - roleCount: 5, - }); - - await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); - - const { user } = await validate(tokenset); - - expect(logger.error).toHaveBeenCalledWith( - expect.stringContaining("Key 'roleCount' not found in id token!"), - ); - expect(user).toBe(false); - }); - }); - - describe('OPENID_EMAIL_CLAIM', () => { - it('should use the default email when OPENID_EMAIL_CLAIM is not set', async () => { - const { user } = await validate(tokenset); - expect(user.email).toBe('test@example.com'); - }); - - it('should use the configured claim when OPENID_EMAIL_CLAIM is set', async () => { - process.env.OPENID_EMAIL_CLAIM = 'upn'; - const userinfo = { ...tokenset.claims(), upn: 'user@corp.example.com' }; - - const { user } = await validate({ ...tokenset, claims: () => userinfo }); - - expect(user.email).toBe('user@corp.example.com'); - expect(createUser).toHaveBeenCalledWith( - expect.objectContaining({ email: 'user@corp.example.com' }), - expect.anything(), - true, - true, - ); - }); - - it('should fall back to preferred_username when email is missing and OPENID_EMAIL_CLAIM is not set', async () => { - const userinfo = { ...tokenset.claims() }; - delete userinfo.email; - - const { user } = await validate({ ...tokenset, claims: () => userinfo }); - - expect(user.email).toBe('testusername'); - }); - - it('should fall back to upn when email and preferred_username are missing and OPENID_EMAIL_CLAIM is not set', async () => { - const userinfo = { ...tokenset.claims(), upn: 'user@corp.example.com' }; - delete userinfo.email; - delete userinfo.preferred_username; - - const { user } = await validate({ ...tokenset, claims: () => userinfo }); - - expect(user.email).toBe('user@corp.example.com'); - }); - - it('should ignore empty string OPENID_EMAIL_CLAIM and use default fallback', async () => { - process.env.OPENID_EMAIL_CLAIM = ''; - - const { user } = await validate(tokenset); - - expect(user.email).toBe('test@example.com'); - }); - - it('should trim whitespace from OPENID_EMAIL_CLAIM and resolve correctly', async () => { - process.env.OPENID_EMAIL_CLAIM = ' upn '; - const userinfo = { ...tokenset.claims(), upn: 'user@corp.example.com' }; - - const { user } = await validate({ ...tokenset, claims: () => userinfo }); - - expect(user.email).toBe('user@corp.example.com'); - }); - - it('should ignore whitespace-only OPENID_EMAIL_CLAIM and use default fallback', async () => { - process.env.OPENID_EMAIL_CLAIM = ' '; - - const { user } = await validate(tokenset); - - expect(user.email).toBe('test@example.com'); - }); - - it('should fall back to default chain with warning when configured claim is missing from userinfo', async () => { - const { logger } = require('@librechat/data-schemas'); - process.env.OPENID_EMAIL_CLAIM = 'nonexistent_claim'; - - const { user } = await validate(tokenset); - - expect(user.email).toBe('test@example.com'); - expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining('OPENID_EMAIL_CLAIM="nonexistent_claim" not present in userinfo'), - ); - }); - }); -}); +const undici = require('undici'); +const fetch = require('node-fetch'); +const jwtDecode = require('jsonwebtoken/decode'); +const { ErrorTypes } = require('librechat-data-provider'); +const { findUser, createUser, updateUser } = require('~/models'); +const { resolveAppConfigForUser } = require('@librechat/api'); +const { getAppConfig } = require('~/server/services/Config'); +const { setupOpenId } = require('./openidStrategy'); + +// --- Mocks --- +jest.mock('node-fetch'); +jest.mock('jsonwebtoken/decode'); +jest.mock('undici', () => ({ + fetch: jest.fn(), + ProxyAgent: jest.fn(), +})); +jest.mock('~/server/services/Files/strategies', () => ({ + getStrategyFunctions: jest.fn(() => ({ + saveBuffer: jest.fn().mockResolvedValue('/fake/path/to/avatar.png'), + })), +})); +jest.mock('~/server/services/Config', () => ({ + getAppConfig: jest.fn().mockResolvedValue({}), +})); +jest.mock('@librechat/api', () => ({ + ...jest.requireActual('@librechat/api'), + isEnabled: jest.fn(() => false), + isEmailDomainAllowed: jest.fn(() => true), + findOpenIDUser: jest.requireActual('@librechat/api').findOpenIDUser, + getBalanceConfig: jest.fn(() => ({ + enabled: false, + })), + resolveAppConfigForUser: jest.fn(async (_getAppConfig, _user) => ({})), +})); +jest.mock('~/models', () => ({ + findUser: jest.fn(), + createUser: jest.fn(), + updateUser: jest.fn(), +})); +jest.mock('@librechat/data-schemas', () => ({ + ...jest.requireActual('@librechat/api'), + logger: { + info: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + }, + hashToken: jest.fn().mockResolvedValue('hashed-token'), +})); +jest.mock('~/cache/getLogStores', () => + jest.fn(() => ({ + get: jest.fn(), + set: jest.fn(), + })), +); + +// Mock the openid-client module and all its dependencies +jest.mock('openid-client', () => { + return { + discovery: jest.fn().mockResolvedValue({ + clientId: 'fake_client_id', + clientSecret: 'fake_client_secret', + issuer: 'https://fake-issuer.com', + // Add any other properties needed by the implementation + }), + fetchUserInfo: jest.fn().mockImplementation(() => { + // Only return additional properties, but don't override any claims + return Promise.resolve({}); + }), + genericGrantRequest: jest.fn().mockResolvedValue({ + access_token: 'exchanged_graph_token', + expires_in: 3600, + }), + customFetch: Symbol('customFetch'), + }; +}); + +jest.mock('openid-client/passport', () => { + /** Store callbacks by strategy name - 'openid' and 'openidAdmin' */ + const verifyCallbacks = {}; + let lastVerifyCallback; + + const mockStrategy = jest.fn((options, verify) => { + lastVerifyCallback = verify; + return { name: 'openid', options, verify }; + }); + + return { + Strategy: mockStrategy, + /** Get the last registered callback (for backward compatibility) */ + __getVerifyCallback: () => lastVerifyCallback, + /** Store callback by name when passport.use is called */ + __setVerifyCallback: (name, callback) => { + verifyCallbacks[name] = callback; + }, + /** Get callback by strategy name */ + __getVerifyCallbackByName: (name) => verifyCallbacks[name], + }; +}); + +// Mock passport - capture strategy name and callback +jest.mock('passport', () => ({ + use: jest.fn((name, strategy) => { + const passportMock = require('openid-client/passport'); + if (strategy && strategy.verify) { + passportMock.__setVerifyCallback(name, strategy.verify); + } + }), +})); + +describe('setupOpenId', () => { + // Store a reference to the verify callback once it's set up + let verifyCallback; + + // Helper to wrap the verify callback in a promise + const validate = (tokenset) => + new Promise((resolve, reject) => { + verifyCallback(tokenset, (err, user, details) => { + if (err) { + reject(err); + } else { + resolve({ user, details }); + } + }); + }); + + const tokenset = { + id_token: 'fake_id_token', + access_token: 'fake_access_token', + claims: () => ({ + sub: '1234', + email: 'test@example.com', + email_verified: true, + given_name: 'First', + family_name: 'Last', + name: 'My Full', + preferred_username: 'testusername', + username: 'flast', + picture: 'https://example.com/avatar.png', + }), + }; + + beforeEach(async () => { + // Clear previous mock calls and reset implementations + jest.clearAllMocks(); + + // Reset environment variables needed by the strategy + process.env.OPENID_ISSUER = 'https://fake-issuer.com'; + process.env.OPENID_CLIENT_ID = 'fake_client_id'; + process.env.OPENID_CLIENT_SECRET = 'fake_client_secret'; + process.env.DOMAIN_SERVER = 'https://example.com'; + process.env.OPENID_CALLBACK_URL = '/callback'; + process.env.OPENID_SCOPE = 'openid profile email'; + process.env.OPENID_REQUIRED_ROLE = 'requiredRole'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roles'; + process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id'; + process.env.OPENID_ADMIN_ROLE = 'admin'; + process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'permissions'; + process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'id'; + delete process.env.OPENID_USERNAME_CLAIM; + delete process.env.OPENID_NAME_CLAIM; + delete process.env.OPENID_EMAIL_CLAIM; + delete process.env.PROXY; + delete process.env.OPENID_USE_PKCE; + + // Default jwtDecode mock returns a token that includes the required role. + jwtDecode.mockReturnValue({ + roles: ['requiredRole'], + permissions: ['admin'], + }); + + // By default, assume that no user is found, so createUser will be called + findUser.mockResolvedValue(null); + createUser.mockImplementation(async (userData) => { + // simulate created user with an _id property + return { _id: 'newUserId', ...userData }; + }); + updateUser.mockImplementation(async (id, userData) => { + return { _id: id, ...userData }; + }); + + // For image download, simulate a successful response + const fakeBuffer = Buffer.from('fake image'); + const fakeResponse = { + ok: true, + buffer: jest.fn().mockResolvedValue(fakeBuffer), + }; + fetch.mockResolvedValue(fakeResponse); + + // Call the setup function and capture the verify callback for the regular 'openid' strategy + // (not 'openidAdmin' which requires existing users) + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + }); + + it('should create a new user with correct username when preferred_username claim exists', async () => { + // Arrange – our userinfo already has preferred_username 'testusername' + const userinfo = tokenset.claims(); + + // Act + const { user } = await validate(tokenset); + + // Assert + expect(user.username).toBe(userinfo.preferred_username); + expect(createUser).toHaveBeenCalledWith( + expect.objectContaining({ + provider: 'openid', + openidId: userinfo.sub, + username: userinfo.preferred_username, + email: userinfo.email, + name: `${userinfo.given_name} ${userinfo.family_name}`, + }), + { enabled: false }, + true, + true, + ); + }); + + it('should use username as username when preferred_username claim is missing', async () => { + // Arrange – remove preferred_username from userinfo + const userinfo = { ...tokenset.claims() }; + delete userinfo.preferred_username; + // Expect the username to be the "username" + const expectUsername = userinfo.username; + + // Act + const { user } = await validate({ ...tokenset, claims: () => userinfo }); + + // Assert + expect(user.username).toBe(expectUsername); + expect(createUser).toHaveBeenCalledWith( + expect.objectContaining({ username: expectUsername }), + { enabled: false }, + true, + true, + ); + }); + + it('should use email as username when username and preferred_username are missing', async () => { + // Arrange – remove username and preferred_username + const userinfo = { ...tokenset.claims() }; + delete userinfo.username; + delete userinfo.preferred_username; + const expectUsername = userinfo.email; + + // Act + const { user } = await validate({ ...tokenset, claims: () => userinfo }); + + // Assert + expect(user.username).toBe(expectUsername); + expect(createUser).toHaveBeenCalledWith( + expect.objectContaining({ username: expectUsername }), + { enabled: false }, + true, + true, + ); + }); + + it('should override username with OPENID_USERNAME_CLAIM when set', async () => { + // Arrange – set OPENID_USERNAME_CLAIM so that the sub claim is used + process.env.OPENID_USERNAME_CLAIM = 'sub'; + const userinfo = tokenset.claims(); + + // Act + const { user } = await validate(tokenset); + + // Assert – username should equal the sub (converted as-is) + expect(user.username).toBe(userinfo.sub); + expect(createUser).toHaveBeenCalledWith( + expect.objectContaining({ username: userinfo.sub }), + { enabled: false }, + true, + true, + ); + }); + + it('should set the full name correctly when given_name and family_name exist', async () => { + // Arrange + const userinfo = tokenset.claims(); + const expectedFullName = `${userinfo.given_name} ${userinfo.family_name}`; + + // Act + const { user } = await validate(tokenset); + + // Assert + expect(user.name).toBe(expectedFullName); + }); + + it('should override full name with OPENID_NAME_CLAIM when set', async () => { + // Arrange – use the name claim as the full name + process.env.OPENID_NAME_CLAIM = 'name'; + const userinfo = { ...tokenset.claims(), name: 'Custom Name' }; + + // Act + const { user } = await validate({ ...tokenset, claims: () => userinfo }); + + // Assert + expect(user.name).toBe('Custom Name'); + }); + + it('should update an existing user on login', async () => { + // Arrange – simulate that a user already exists with openid provider + const existingUser = { + _id: 'existingUserId', + provider: 'openid', + email: tokenset.claims().email, + openidId: '', + username: '', + name: '', + }; + findUser.mockImplementation(async (query) => { + if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) { + return existingUser; + } + return null; + }); + + const userinfo = tokenset.claims(); + + // Act + await validate(tokenset); + + // Assert – updateUser should be called and the user object updated + expect(updateUser).toHaveBeenCalledWith( + existingUser._id, + expect.objectContaining({ + provider: 'openid', + openidId: userinfo.sub, + username: userinfo.preferred_username, + name: `${userinfo.given_name} ${userinfo.family_name}`, + }), + ); + }); + + it('should block login when email exists with different provider', async () => { + // Arrange – simulate that a user exists with same email but different provider + const existingUser = { + _id: 'existingUserId', + provider: 'google', + email: tokenset.claims().email, + googleId: 'some-google-id', + username: 'existinguser', + name: 'Existing User', + }; + findUser.mockImplementation(async (query) => { + if (query.email === tokenset.claims().email && !query.provider) { + return existingUser; + } + return null; + }); + + // Act + const result = await validate(tokenset); + + // Assert – verify that the strategy rejects login + expect(result.user).toBe(false); + expect(result.details.message).toBe(ErrorTypes.AUTH_FAILED); + expect(createUser).not.toHaveBeenCalled(); + expect(updateUser).not.toHaveBeenCalled(); + }); + + it('should block login when email fallback finds user with mismatched openidId', async () => { + const existingUser = { + _id: 'existingUserId', + provider: 'openid', + openidId: 'different-sub-claim', + email: tokenset.claims().email, + username: 'existinguser', + name: 'Existing User', + }; + findUser.mockImplementation(async (query) => { + if (query.$or) { + return null; + } + if (query.email === tokenset.claims().email) { + return existingUser; + } + return null; + }); + + const result = await validate(tokenset); + + expect(result.user).toBe(false); + expect(result.details.message).toBe(ErrorTypes.AUTH_FAILED); + expect(createUser).not.toHaveBeenCalled(); + expect(updateUser).not.toHaveBeenCalled(); + }); + + it('should enforce the required role and reject login if missing', async () => { + // Arrange – simulate a token without the required role. + jwtDecode.mockReturnValue({ + roles: ['SomeOtherRole'], + }); + + // Act + const { user, details } = await validate(tokenset); + + // Assert – verify that the strategy rejects login + expect(user).toBe(false); + expect(details.message).toBe('You must have "requiredRole" role to log in.'); + }); + + it('should not treat substring matches in string roles as satisfying required role', async () => { + // Arrange – override required role to "read" then re-setup + process.env.OPENID_REQUIRED_ROLE = 'read'; + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + + // Token contains "bread" which *contains* "read" as a substring + jwtDecode.mockReturnValue({ + roles: 'bread', + }); + + // Act + const { user, details } = await validate(tokenset); + + // Assert – verify that substring match does not grant access + expect(user).toBe(false); + expect(details.message).toBe('You must have "read" role to log in.'); + }); + + it('should allow login when roles claim is a space-separated string containing the required role', async () => { + // Arrange – IdP returns roles as a space-delimited string + jwtDecode.mockReturnValue({ + roles: 'role1 role2 requiredRole', + }); + + // Act + const { user } = await validate(tokenset); + + // Assert – login succeeds when required role is present after splitting + expect(user).toBeTruthy(); + expect(createUser).toHaveBeenCalled(); + }); + + it('should allow login when roles claim is a comma-separated string containing the required role', async () => { + // Arrange – IdP returns roles as a comma-delimited string + jwtDecode.mockReturnValue({ + roles: 'role1,role2,requiredRole', + }); + + // Act + const { user } = await validate(tokenset); + + // Assert – login succeeds when required role is present after splitting + expect(user).toBeTruthy(); + expect(createUser).toHaveBeenCalled(); + }); + + it('should allow login when roles claim is a mixed comma-and-space-separated string containing the required role', async () => { + // Arrange – IdP returns roles with comma-and-space delimiters + jwtDecode.mockReturnValue({ + roles: 'role1, role2, requiredRole', + }); + + // Act + const { user } = await validate(tokenset); + + // Assert – login succeeds when required role is present after splitting + expect(user).toBeTruthy(); + expect(createUser).toHaveBeenCalled(); + }); + + it('should reject login when roles claim is a space-separated string that does not contain the required role', async () => { + // Arrange – IdP returns a delimited string but required role is absent + jwtDecode.mockReturnValue({ + roles: 'role1 role2 otherRole', + }); + + // Act + const { user, details } = await validate(tokenset); + + // Assert – login is rejected with the correct error message + expect(user).toBe(false); + expect(details.message).toBe('You must have "requiredRole" role to log in.'); + }); + + it('should allow login when single required role is present (backward compatibility)', async () => { + // Arrange – ensure single role configuration (as set in beforeEach) + // OPENID_REQUIRED_ROLE = 'requiredRole' + // Default jwtDecode mock in beforeEach already returns this role + jwtDecode.mockReturnValue({ + roles: ['requiredRole', 'anotherRole'], + }); + + // Act + const { user } = await validate(tokenset); + + // Assert – verify that login succeeds with single role configuration + expect(user).toBeTruthy(); + expect(user.email).toBe(tokenset.claims().email); + expect(user.username).toBe(tokenset.claims().preferred_username); + expect(createUser).toHaveBeenCalled(); + }); + + describe('group overage and groups handling', () => { + it.each([ + ['groups array contains required group', ['group-required', 'other-group'], true, undefined], + [ + 'groups array missing required group', + ['other-group'], + false, + 'You must have "group-required" role to log in.', + ], + ['groups string equals required group', 'group-required', true, undefined], + [ + 'groups string is other group', + 'other-group', + false, + 'You must have "group-required" role to log in.', + ], + ])( + 'uses groups claim directly when %s (no overage)', + async (_label, groupsClaim, expectedAllowed, expectedMessage) => { + process.env.OPENID_REQUIRED_ROLE = 'group-required'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups'; + process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id'; + + jwtDecode.mockReturnValue({ + groups: groupsClaim, + permissions: ['admin'], + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + + const { user, details } = await validate(tokenset); + + expect(undici.fetch).not.toHaveBeenCalled(); + expect(Boolean(user)).toBe(expectedAllowed); + expect(details?.message).toBe(expectedMessage); + }, + ); + + it.each([ + ['token kind is not id', { kind: 'access', path: 'groups', decoded: { hasgroups: true } }], + ['parameter path is not groups', { kind: 'id', path: 'roles', decoded: { hasgroups: true } }], + ['decoded token is falsy', { kind: 'id', path: 'groups', decoded: null }], + [ + 'no overage indicators in decoded token', + { + kind: 'id', + path: 'groups', + decoded: { + permissions: ['admin'], + }, + }, + ], + [ + 'only _claim_names present (no _claim_sources)', + { + kind: 'id', + path: 'groups', + decoded: { + _claim_names: { groups: 'src1' }, + permissions: ['admin'], + }, + }, + ], + [ + 'only _claim_sources present (no _claim_names)', + { + kind: 'id', + path: 'groups', + decoded: { + _claim_sources: { src1: { endpoint: 'https://graph.windows.net/ignored' } }, + permissions: ['admin'], + }, + }, + ], + ])('does not attempt overage resolution when %s', async (_label, cfg) => { + process.env.OPENID_REQUIRED_ROLE = 'group-required'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = cfg.path; + process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = cfg.kind; + + jwtDecode.mockReturnValue(cfg.decoded); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + + const { user, details } = await validate(tokenset); + + expect(undici.fetch).not.toHaveBeenCalled(); + expect(user).toBe(false); + expect(details.message).toBe('You must have "group-required" role to log in.'); + const { logger } = require('@librechat/data-schemas'); + const expectedTokenKind = cfg.kind === 'access' ? 'access token' : 'id token'; + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining(`Key '${cfg.path}' not found in ${expectedTokenKind}!`), + ); + }); + }); + + describe('resolving groups via Microsoft Graph', () => { + it('denies login and does not call Graph when access token is missing', async () => { + process.env.OPENID_REQUIRED_ROLE = 'group-required'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups'; + process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id'; + + const { logger } = require('@librechat/data-schemas'); + + jwtDecode.mockReturnValue({ + hasgroups: true, + permissions: ['admin'], + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + + const tokensetWithoutAccess = { + ...tokenset, + access_token: undefined, + }; + + const { user, details } = await validate(tokensetWithoutAccess); + + expect(user).toBe(false); + expect(details.message).toBe('You must have "group-required" role to log in.'); + + expect(undici.fetch).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Access token missing; cannot resolve group overage'), + ); + }); + + it.each([ + [ + 'Graph returns HTTP error', + async () => ({ + ok: false, + status: 403, + statusText: 'Forbidden', + json: async () => ({}), + }), + [ + '[openidStrategy] Failed to resolve groups via Microsoft Graph getMemberObjects: HTTP 403 Forbidden', + ], + ], + [ + 'Graph network error', + async () => { + throw new Error('network error'); + }, + [ + '[openidStrategy] Error resolving groups via Microsoft Graph getMemberObjects:', + expect.any(Error), + ], + ], + [ + 'Graph returns unexpected shape (no value)', + async () => ({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({}), + }), + [ + '[openidStrategy] Unexpected response format when resolving groups via Microsoft Graph getMemberObjects', + ], + ], + [ + 'Graph returns invalid value type', + async () => ({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ value: 'not-an-array' }), + }), + [ + '[openidStrategy] Unexpected response format when resolving groups via Microsoft Graph getMemberObjects', + ], + ], + ])( + 'denies login when overage resolution fails because %s', + async (_label, setupFetch, expectedErrorArgs) => { + process.env.OPENID_REQUIRED_ROLE = 'group-required'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups'; + process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id'; + + const { logger } = require('@librechat/data-schemas'); + + jwtDecode.mockReturnValue({ + hasgroups: true, + permissions: ['admin'], + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + + undici.fetch.mockImplementation(setupFetch); + + const { user, details } = await validate(tokenset); + + expect(undici.fetch).toHaveBeenCalled(); + expect(user).toBe(false); + expect(details.message).toBe('You must have "group-required" role to log in.'); + + expect(logger.error).toHaveBeenCalledWith(...expectedErrorArgs); + }, + ); + + it.each([ + [ + 'hasgroups overage and Graph contains required group', + { + hasgroups: true, + }, + ['group-required', 'some-other-group'], + true, + ], + [ + '_claim_* overage and Graph contains required group', + { + _claim_names: { groups: 'src1' }, + _claim_sources: { src1: { endpoint: 'https://graph.windows.net/ignored' } }, + }, + ['group-required', 'some-other-group'], + true, + ], + [ + 'hasgroups overage and Graph does NOT contain required group', + { + hasgroups: true, + }, + ['some-other-group'], + false, + ], + [ + '_claim_* overage and Graph does NOT contain required group', + { + _claim_names: { groups: 'src1' }, + _claim_sources: { src1: { endpoint: 'https://graph.windows.net/ignored' } }, + }, + ['some-other-group'], + false, + ], + ])( + 'resolves groups via Microsoft Graph when %s', + async (_label, decodedTokenValue, graphGroups, expectedAllowed) => { + process.env.OPENID_REQUIRED_ROLE = 'group-required'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups'; + process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id'; + + const { logger } = require('@librechat/data-schemas'); + + jwtDecode.mockReturnValue(decodedTokenValue); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + + undici.fetch.mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ + value: graphGroups, + }), + }); + + const { user } = await validate(tokenset); + + expect(undici.fetch).toHaveBeenCalledWith( + 'https://graph.microsoft.com/v1.0/me/getMemberObjects', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer exchanged_graph_token', + }), + }), + ); + expect(Boolean(user)).toBe(expectedAllowed); + + expect(logger.debug).toHaveBeenCalledWith( + expect.stringContaining( + `Successfully resolved ${graphGroups.length} groups via Microsoft Graph getMemberObjects`, + ), + ); + }, + ); + }); + + describe('OBO token exchange for overage', () => { + it('exchanges access token via OBO before calling Graph API', async () => { + const openidClient = require('openid-client'); + process.env.OPENID_REQUIRED_ROLE = 'group-required'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups'; + process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id'; + + jwtDecode.mockReturnValue({ hasgroups: true }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + + undici.fetch.mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ value: ['group-required'] }), + }); + + await validate(tokenset); + + expect(openidClient.genericGrantRequest).toHaveBeenCalledWith( + expect.anything(), + 'urn:ietf:params:oauth:grant-type:jwt-bearer', + expect.objectContaining({ + scope: 'https://graph.microsoft.com/User.Read', + assertion: tokenset.access_token, + requested_token_use: 'on_behalf_of', + }), + ); + + expect(undici.fetch).toHaveBeenCalledWith( + 'https://graph.microsoft.com/v1.0/me/getMemberObjects', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer exchanged_graph_token', + }), + }), + ); + }); + + it('caches the exchanged token and reuses it on subsequent calls', async () => { + const openidClient = require('openid-client'); + const getLogStores = require('~/cache/getLogStores'); + const mockSet = jest.fn(); + const mockGet = jest + .fn() + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce({ access_token: 'exchanged_graph_token' }); + getLogStores.mockReturnValue({ get: mockGet, set: mockSet }); + + process.env.OPENID_REQUIRED_ROLE = 'group-required'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups'; + process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id'; + + jwtDecode.mockReturnValue({ hasgroups: true }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + + undici.fetch.mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ value: ['group-required'] }), + }); + + // First call: cache miss → OBO exchange → cache set + await validate(tokenset); + expect(mockSet).toHaveBeenCalledWith( + '1234:overage', + { access_token: 'exchanged_graph_token' }, + 3600000, + ); + expect(openidClient.genericGrantRequest).toHaveBeenCalledTimes(1); + + // Second call: cache hit → no new OBO exchange + openidClient.genericGrantRequest.mockClear(); + await validate(tokenset); + expect(openidClient.genericGrantRequest).not.toHaveBeenCalled(); + }); + }); + + describe('admin role group overage', () => { + it('resolves admin groups via Graph when overage is detected for admin role', async () => { + process.env.OPENID_REQUIRED_ROLE = 'group-required'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups'; + process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id'; + process.env.OPENID_ADMIN_ROLE = 'admin-group-id'; + process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'groups'; + process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'id'; + + jwtDecode.mockReturnValue({ hasgroups: true }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + + undici.fetch.mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ value: ['group-required', 'admin-group-id'] }), + }); + + const { user } = await validate(tokenset); + + expect(user.role).toBe('ADMIN'); + }); + + it('does not grant admin when overage groups do not contain admin role', async () => { + process.env.OPENID_REQUIRED_ROLE = 'group-required'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups'; + process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id'; + process.env.OPENID_ADMIN_ROLE = 'admin-group-id'; + process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'groups'; + process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'id'; + + jwtDecode.mockReturnValue({ hasgroups: true }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + + undici.fetch.mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ value: ['group-required', 'other-group'] }), + }); + + const { user } = await validate(tokenset); + + expect(user).toBeTruthy(); + expect(user.role).toBeUndefined(); + }); + + it('reuses already-resolved overage groups for admin role check (no duplicate Graph call)', async () => { + process.env.OPENID_REQUIRED_ROLE = 'group-required'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups'; + process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id'; + process.env.OPENID_ADMIN_ROLE = 'admin-group-id'; + process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'groups'; + process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'id'; + + jwtDecode.mockReturnValue({ hasgroups: true }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + + undici.fetch.mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ value: ['group-required', 'admin-group-id'] }), + }); + + await validate(tokenset); + + // Graph API should be called only once (for required role), admin role reuses the result + expect(undici.fetch).toHaveBeenCalledTimes(1); + }); + + it('demotes existing admin when overage groups no longer contain admin role', async () => { + process.env.OPENID_REQUIRED_ROLE = 'group-required'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups'; + process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id'; + process.env.OPENID_ADMIN_ROLE = 'admin-group-id'; + process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'groups'; + process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'id'; + + const existingAdminUser = { + _id: 'existingAdminId', + provider: 'openid', + email: tokenset.claims().email, + openidId: tokenset.claims().sub, + username: 'adminuser', + name: 'Admin User', + role: 'ADMIN', + }; + + findUser.mockImplementation(async (query) => { + if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) { + return existingAdminUser; + } + return null; + }); + + jwtDecode.mockReturnValue({ hasgroups: true }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + + undici.fetch.mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ value: ['group-required'] }), + }); + + const { user } = await validate(tokenset); + + expect(user.role).toBe('USER'); + }); + + it('does not attempt overage for admin role when token kind is not id', async () => { + process.env.OPENID_REQUIRED_ROLE = 'requiredRole'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roles'; + process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id'; + process.env.OPENID_ADMIN_ROLE = 'admin'; + process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'groups'; + process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'access'; + + jwtDecode.mockReturnValue({ + roles: ['requiredRole'], + hasgroups: true, + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + + const { user } = await validate(tokenset); + + // No Graph call since admin uses access token (not id) + expect(undici.fetch).not.toHaveBeenCalled(); + expect(user.role).toBeUndefined(); + }); + + it('resolves admin via Graph independently when OPENID_REQUIRED_ROLE is not configured', async () => { + delete process.env.OPENID_REQUIRED_ROLE; + process.env.OPENID_ADMIN_ROLE = 'admin-group-id'; + process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'groups'; + process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'id'; + + jwtDecode.mockReturnValue({ hasgroups: true }); + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + + undici.fetch.mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ value: ['admin-group-id'] }), + }); + + const { user } = await validate(tokenset); + expect(user.role).toBe('ADMIN'); + expect(undici.fetch).toHaveBeenCalledTimes(1); + }); + + it('denies admin when OPENID_REQUIRED_ROLE is absent and Graph does not contain admin group', async () => { + delete process.env.OPENID_REQUIRED_ROLE; + process.env.OPENID_ADMIN_ROLE = 'admin-group-id'; + process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'groups'; + process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'id'; + + jwtDecode.mockReturnValue({ hasgroups: true }); + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + + undici.fetch.mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ value: ['other-group'] }), + }); + + const { user } = await validate(tokenset); + expect(user).toBeTruthy(); + expect(user.role).toBeUndefined(); + }); + + it('denies login and logs error when OBO exchange throws', async () => { + const openidClient = require('openid-client'); + process.env.OPENID_REQUIRED_ROLE = 'group-required'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups'; + process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id'; + + jwtDecode.mockReturnValue({ hasgroups: true }); + openidClient.genericGrantRequest.mockRejectedValueOnce(new Error('OBO exchange rejected')); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + + const { user, details } = await validate(tokenset); + expect(user).toBe(false); + expect(details.message).toBe('You must have "group-required" role to log in.'); + expect(undici.fetch).not.toHaveBeenCalled(); + }); + + it('denies login when OBO exchange returns no access_token', async () => { + const openidClient = require('openid-client'); + process.env.OPENID_REQUIRED_ROLE = 'group-required'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups'; + process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id'; + + jwtDecode.mockReturnValue({ hasgroups: true }); + openidClient.genericGrantRequest.mockResolvedValueOnce({ expires_in: 3600 }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + + const { user, details } = await validate(tokenset); + expect(user).toBe(false); + expect(details.message).toBe('You must have "group-required" role to log in.'); + expect(undici.fetch).not.toHaveBeenCalled(); + }); + }); + + it('should attempt to download and save the avatar if picture is provided', async () => { + // Act + const { user } = await validate(tokenset); + + // Assert – verify that download was attempted and the avatar field was set via updateUser + expect(fetch).toHaveBeenCalled(); + // Our mock getStrategyFunctions.saveBuffer returns '/fake/path/to/avatar.png' + expect(user.avatar).toBe('/fake/path/to/avatar.png'); + }); + + it('should not attempt to download avatar if picture is not provided', async () => { + // Arrange – remove picture + const userinfo = { ...tokenset.claims() }; + delete userinfo.picture; + + // Act + await validate({ ...tokenset, claims: () => userinfo }); + + // Assert – fetch should not be called and avatar should remain undefined or empty + expect(fetch).not.toHaveBeenCalled(); + // Depending on your implementation, user.avatar may be undefined or an empty string. + }); + + it('should support comma-separated multiple roles', async () => { + // Arrange + process.env.OPENID_REQUIRED_ROLE = 'someRole,anotherRole,admin'; + await setupOpenId(); // Re-initialize the strategy + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + jwtDecode.mockReturnValue({ + roles: ['anotherRole', 'aThirdRole'], + }); + + // Act + const { user } = await validate(tokenset); + + // Assert + expect(user).toBeTruthy(); + expect(user.email).toBe(tokenset.claims().email); + }); + + it('should reject login when user has none of the required multiple roles', async () => { + // Arrange + process.env.OPENID_REQUIRED_ROLE = 'someRole,anotherRole,admin'; + await setupOpenId(); // Re-initialize the strategy + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + jwtDecode.mockReturnValue({ + roles: ['aThirdRole', 'aFourthRole'], + }); + + // Act + const { user, details } = await validate(tokenset); + + // Assert + expect(user).toBe(false); + expect(details.message).toBe( + 'You must have one of: "someRole", "anotherRole", "admin" role to log in.', + ); + }); + + it('should handle spaces in comma-separated roles', async () => { + // Arrange + process.env.OPENID_REQUIRED_ROLE = ' someRole , anotherRole , admin '; + await setupOpenId(); // Re-initialize the strategy + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + jwtDecode.mockReturnValue({ + roles: ['someRole'], + }); + + // Act + const { user } = await validate(tokenset); + + // Assert + expect(user).toBeTruthy(); + }); + + it('should default to usePKCE false when OPENID_USE_PKCE is not defined', async () => { + const OpenIDStrategy = require('openid-client/passport').Strategy; + + delete process.env.OPENID_USE_PKCE; + await setupOpenId(); + + const callOptions = OpenIDStrategy.mock.calls[OpenIDStrategy.mock.calls.length - 1][0]; + expect(callOptions.usePKCE).toBe(false); + expect(callOptions.params?.code_challenge_method).toBeUndefined(); + }); + + it('should attach federatedTokens to user object for token propagation', async () => { + // Arrange - setup tokenset with access token, id token, refresh token, and expiration + const tokensetWithTokens = { + ...tokenset, + access_token: 'mock_access_token_abc123', + id_token: 'mock_id_token_def456', + refresh_token: 'mock_refresh_token_xyz789', + expires_at: 1234567890, + }; + + // Act - validate with the tokenset containing tokens + const { user } = await validate(tokensetWithTokens); + + // Assert - verify federatedTokens object is attached with correct values + expect(user.federatedTokens).toBeDefined(); + expect(user.federatedTokens).toEqual({ + access_token: 'mock_access_token_abc123', + id_token: 'mock_id_token_def456', + refresh_token: 'mock_refresh_token_xyz789', + expires_at: 1234567890, + }); + }); + + it('should include id_token in federatedTokens distinct from access_token', async () => { + // Arrange - use different values for access_token and id_token + const tokensetWithTokens = { + ...tokenset, + access_token: 'the_access_token', + id_token: 'the_id_token', + refresh_token: 'the_refresh_token', + expires_at: 9999999999, + }; + + // Act + const { user } = await validate(tokensetWithTokens); + + // Assert - id_token and access_token must be different values + expect(user.federatedTokens.access_token).toBe('the_access_token'); + expect(user.federatedTokens.id_token).toBe('the_id_token'); + expect(user.federatedTokens.id_token).not.toBe(user.federatedTokens.access_token); + }); + + it('should include tokenset along with federatedTokens', async () => { + // Arrange + const tokensetWithTokens = { + ...tokenset, + access_token: 'test_access_token', + id_token: 'test_id_token', + refresh_token: 'test_refresh_token', + expires_at: 9999999999, + }; + + // Act + const { user } = await validate(tokensetWithTokens); + + // Assert - both tokenset and federatedTokens should be present + expect(user.tokenset).toBeDefined(); + expect(user.federatedTokens).toBeDefined(); + expect(user.tokenset.access_token).toBe('test_access_token'); + expect(user.tokenset.id_token).toBe('test_id_token'); + expect(user.federatedTokens.access_token).toBe('test_access_token'); + expect(user.federatedTokens.id_token).toBe('test_id_token'); + }); + + it('should set role to "ADMIN" if OPENID_ADMIN_ROLE is set and user has that role', async () => { + // Act + const { user } = await validate(tokenset); + + // Assert – verify that the user role is set to "ADMIN" + expect(user.role).toBe('ADMIN'); + }); + + it('should not set user role if OPENID_ADMIN_ROLE is set but the user does not have that role', async () => { + // Arrange – simulate a token without the admin permission + jwtDecode.mockReturnValue({ + roles: ['requiredRole'], + permissions: ['not-admin'], + }); + + // Act + const { user } = await validate(tokenset); + + // Assert – verify that the user role is not defined + expect(user.role).toBeUndefined(); + }); + + it('should demote existing admin user when admin role is removed from token', async () => { + // Arrange – simulate an existing user who is currently an admin + const existingAdminUser = { + _id: 'existingAdminId', + provider: 'openid', + email: tokenset.claims().email, + openidId: tokenset.claims().sub, + username: 'adminuser', + name: 'Admin User', + role: 'ADMIN', + }; + + findUser.mockImplementation(async (query) => { + if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) { + return existingAdminUser; + } + return null; + }); + + // Token without admin permission + jwtDecode.mockReturnValue({ + roles: ['requiredRole'], + permissions: ['not-admin'], + }); + + const { logger } = require('@librechat/data-schemas'); + + // Act + const { user } = await validate(tokenset); + + // Assert – verify that the user was demoted + expect(user.role).toBe('USER'); + expect(updateUser).toHaveBeenCalledWith( + existingAdminUser._id, + expect.objectContaining({ + role: 'USER', + }), + ); + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('demoted from admin - role no longer present in token'), + ); + }); + + it('should NOT demote admin user when admin role env vars are not configured', async () => { + // Arrange – remove admin role env vars + delete process.env.OPENID_ADMIN_ROLE; + delete process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH; + delete process.env.OPENID_ADMIN_ROLE_TOKEN_KIND; + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + + // Simulate an existing admin user + const existingAdminUser = { + _id: 'existingAdminId', + provider: 'openid', + email: tokenset.claims().email, + openidId: tokenset.claims().sub, + username: 'adminuser', + name: 'Admin User', + role: 'ADMIN', + }; + + findUser.mockImplementation(async (query) => { + if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) { + return existingAdminUser; + } + return null; + }); + + jwtDecode.mockReturnValue({ + roles: ['requiredRole'], + }); + + // Act + const { user } = await validate(tokenset); + + // Assert – verify that the admin user was NOT demoted + expect(user.role).toBe('ADMIN'); + expect(updateUser).toHaveBeenCalledWith( + existingAdminUser._id, + expect.objectContaining({ + role: 'ADMIN', + }), + ); + }); + + describe('lodash get - nested path extraction', () => { + it('should extract roles from deeply nested token path', async () => { + process.env.OPENID_REQUIRED_ROLE = 'app-user'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'resource_access.my-client.roles'; + + jwtDecode.mockReturnValue({ + resource_access: { + 'my-client': { + roles: ['app-user', 'viewer'], + }, + }, + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + + const { user } = await validate(tokenset); + + expect(user).toBeTruthy(); + expect(user.email).toBe(tokenset.claims().email); + }); + + it('should extract roles from three-level nested path', async () => { + process.env.OPENID_REQUIRED_ROLE = 'editor'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'data.access.permissions.roles'; + + jwtDecode.mockReturnValue({ + data: { + access: { + permissions: { + roles: ['editor', 'reader'], + }, + }, + }, + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + + const { user } = await validate(tokenset); + + expect(user).toBeTruthy(); + }); + + it('should log error and reject login when required role path does not exist in token', async () => { + const { logger } = require('@librechat/data-schemas'); + process.env.OPENID_REQUIRED_ROLE = 'app-user'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'resource_access.nonexistent.roles'; + + jwtDecode.mockReturnValue({ + resource_access: { + 'my-client': { + roles: ['app-user'], + }, + }, + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + + const { user, details } = await validate(tokenset); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining("Key 'resource_access.nonexistent.roles' not found in id token!"), + ); + expect(user).toBe(false); + expect(details.message).toContain('role to log in'); + }); + + it('should handle missing intermediate nested path gracefully', async () => { + const { logger } = require('@librechat/data-schemas'); + process.env.OPENID_REQUIRED_ROLE = 'user'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'org.team.roles'; + + jwtDecode.mockReturnValue({ + org: { + other: 'value', + }, + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + + const { user } = await validate(tokenset); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining("Key 'org.team.roles' not found in id token!"), + ); + expect(user).toBe(false); + }); + + it('should extract admin role from nested path in access token', async () => { + process.env.OPENID_ADMIN_ROLE = 'admin'; + process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'realm_access.roles'; + process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'access'; + + jwtDecode.mockImplementation((token) => { + if (token === 'fake_access_token') { + return { + realm_access: { + roles: ['admin', 'user'], + }, + }; + } + return { + roles: ['requiredRole'], + }; + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + + const { user } = await validate(tokenset); + + expect(user.role).toBe('ADMIN'); + }); + + it('should extract admin role from nested path in userinfo', async () => { + process.env.OPENID_ADMIN_ROLE = 'admin'; + process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'organization.permissions'; + process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'userinfo'; + + const userinfoWithNestedGroups = { + ...tokenset.claims(), + organization: { + permissions: ['admin', 'write'], + }, + }; + + require('openid-client').fetchUserInfo.mockResolvedValue({ + organization: { + permissions: ['admin', 'write'], + }, + }); + + jwtDecode.mockReturnValue({ + roles: ['requiredRole'], + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + + const { user } = await validate({ + ...tokenset, + claims: () => userinfoWithNestedGroups, + }); + + expect(user.role).toBe('ADMIN'); + }); + + it('should handle boolean admin role value', async () => { + process.env.OPENID_ADMIN_ROLE = 'admin'; + process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'is_admin'; + + jwtDecode.mockReturnValue({ + roles: ['requiredRole'], + is_admin: true, + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + + const { user } = await validate(tokenset); + + expect(user.role).toBe('ADMIN'); + }); + + it('should handle string admin role value matching exactly', async () => { + process.env.OPENID_ADMIN_ROLE = 'super-admin'; + process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'role'; + + jwtDecode.mockReturnValue({ + roles: ['requiredRole'], + role: 'super-admin', + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + + const { user } = await validate(tokenset); + + expect(user.role).toBe('ADMIN'); + }); + + it('should not set admin role when string value does not match', async () => { + process.env.OPENID_ADMIN_ROLE = 'super-admin'; + process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'role'; + + jwtDecode.mockReturnValue({ + roles: ['requiredRole'], + role: 'regular-user', + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + + const { user } = await validate(tokenset); + + expect(user.role).toBeUndefined(); + }); + + it('should handle array admin role value', async () => { + process.env.OPENID_ADMIN_ROLE = 'site-admin'; + process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'app_roles'; + + jwtDecode.mockReturnValue({ + roles: ['requiredRole'], + app_roles: ['user', 'site-admin', 'moderator'], + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + + const { user } = await validate(tokenset); + + expect(user.role).toBe('ADMIN'); + }); + + it('should not set admin when role is not in array', async () => { + process.env.OPENID_ADMIN_ROLE = 'site-admin'; + process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'app_roles'; + + jwtDecode.mockReturnValue({ + roles: ['requiredRole'], + app_roles: ['user', 'moderator'], + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + + const { user } = await validate(tokenset); + + expect(user.role).toBeUndefined(); + }); + + it('should grant admin when admin role claim is a space-separated string containing the admin role', async () => { + // Arrange – IdP returns admin roles as a space-delimited string + process.env.OPENID_ADMIN_ROLE = 'site-admin'; + process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'app_roles'; + + jwtDecode.mockReturnValue({ + roles: ['requiredRole'], + app_roles: 'user site-admin moderator', + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + + // Act + const { user } = await validate(tokenset); + + // Assert – admin role is granted after splitting the delimited string + expect(user.role).toBe('ADMIN'); + }); + + it('should not grant admin when admin role claim is a space-separated string that does not contain the admin role', async () => { + // Arrange – delimited string present but admin role is absent + process.env.OPENID_ADMIN_ROLE = 'site-admin'; + process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'app_roles'; + + jwtDecode.mockReturnValue({ + roles: ['requiredRole'], + app_roles: 'user moderator', + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + + // Act + const { user } = await validate(tokenset); + + // Assert – admin role is not granted + expect(user.role).toBeUndefined(); + }); + + it('should handle nested path with special characters in keys', async () => { + process.env.OPENID_REQUIRED_ROLE = 'app-user'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'resource_access.my-app-123.roles'; + + jwtDecode.mockReturnValue({ + resource_access: { + 'my-app-123': { + roles: ['app-user'], + }, + }, + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + + const { user } = await validate(tokenset); + + expect(user).toBeTruthy(); + }); + + it('should handle empty object at nested path', async () => { + const { logger } = require('@librechat/data-schemas'); + process.env.OPENID_REQUIRED_ROLE = 'user'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'access.roles'; + + jwtDecode.mockReturnValue({ + access: {}, + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + + const { user } = await validate(tokenset); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining("Key 'access.roles' not found in id token!"), + ); + expect(user).toBe(false); + }); + + it('should handle null value at intermediate path', async () => { + const { logger } = require('@librechat/data-schemas'); + process.env.OPENID_REQUIRED_ROLE = 'user'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'data.roles'; + + jwtDecode.mockReturnValue({ + data: null, + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + + const { user } = await validate(tokenset); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining("Key 'data.roles' not found in id token!"), + ); + expect(user).toBe(false); + }); + + it('should reject login with invalid admin role token kind', async () => { + process.env.OPENID_ADMIN_ROLE = 'admin'; + process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'roles'; + process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'invalid'; + + const { logger } = require('@librechat/data-schemas'); + + jwtDecode.mockReturnValue({ + roles: ['requiredRole', 'admin'], + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + + await expect(validate(tokenset)).rejects.toThrow('Invalid admin role token kind'); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining( + "Invalid admin role token kind: invalid. Must be one of 'access', 'id', or 'userinfo'", + ), + ); + }); + + it('should reject login when roles path returns invalid type (object)', async () => { + const { logger } = require('@librechat/data-schemas'); + process.env.OPENID_REQUIRED_ROLE = 'app-user'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roles'; + + jwtDecode.mockReturnValue({ + roles: { admin: true, user: false }, + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + + const { user, details } = await validate(tokenset); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining("Key 'roles' not found in id token!"), + ); + expect(user).toBe(false); + expect(details.message).toContain('role to log in'); + }); + + it('should reject login when roles path returns invalid type (number)', async () => { + const { logger } = require('@librechat/data-schemas'); + process.env.OPENID_REQUIRED_ROLE = 'user'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roleCount'; + + jwtDecode.mockReturnValue({ + roleCount: 5, + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + + const { user } = await validate(tokenset); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining("Key 'roleCount' not found in id token!"), + ); + expect(user).toBe(false); + }); + }); + + describe('OPENID_EMAIL_CLAIM', () => { + it('should use the default email when OPENID_EMAIL_CLAIM is not set', async () => { + const { user } = await validate(tokenset); + expect(user.email).toBe('test@example.com'); + }); + + it('should use the configured claim when OPENID_EMAIL_CLAIM is set', async () => { + process.env.OPENID_EMAIL_CLAIM = 'upn'; + const userinfo = { ...tokenset.claims(), upn: 'user@corp.example.com' }; + + const { user } = await validate({ ...tokenset, claims: () => userinfo }); + + expect(user.email).toBe('user@corp.example.com'); + expect(createUser).toHaveBeenCalledWith( + expect.objectContaining({ email: 'user@corp.example.com' }), + expect.anything(), + true, + true, + ); + }); + + it('should fall back to preferred_username when email is missing and OPENID_EMAIL_CLAIM is not set', async () => { + const userinfo = { ...tokenset.claims() }; + delete userinfo.email; + + const { user } = await validate({ ...tokenset, claims: () => userinfo }); + + expect(user.email).toBe('testusername'); + }); + + it('should fall back to upn when email and preferred_username are missing and OPENID_EMAIL_CLAIM is not set', async () => { + const userinfo = { ...tokenset.claims(), upn: 'user@corp.example.com' }; + delete userinfo.email; + delete userinfo.preferred_username; + + const { user } = await validate({ ...tokenset, claims: () => userinfo }); + + expect(user.email).toBe('user@corp.example.com'); + }); + + it('should ignore empty string OPENID_EMAIL_CLAIM and use default fallback', async () => { + process.env.OPENID_EMAIL_CLAIM = ''; + + const { user } = await validate(tokenset); + + expect(user.email).toBe('test@example.com'); + }); + + it('should trim whitespace from OPENID_EMAIL_CLAIM and resolve correctly', async () => { + process.env.OPENID_EMAIL_CLAIM = ' upn '; + const userinfo = { ...tokenset.claims(), upn: 'user@corp.example.com' }; + + const { user } = await validate({ ...tokenset, claims: () => userinfo }); + + expect(user.email).toBe('user@corp.example.com'); + }); + + it('should ignore whitespace-only OPENID_EMAIL_CLAIM and use default fallback', async () => { + process.env.OPENID_EMAIL_CLAIM = ' '; + + const { user } = await validate(tokenset); + + expect(user.email).toBe('test@example.com'); + }); + + it('should fall back to default chain with warning when configured claim is missing from userinfo', async () => { + const { logger } = require('@librechat/data-schemas'); + process.env.OPENID_EMAIL_CLAIM = 'nonexistent_claim'; + + const { user } = await validate(tokenset); + + expect(user.email).toBe('test@example.com'); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('OPENID_EMAIL_CLAIM="nonexistent_claim" not present in userinfo'), + ); + }); + }); + + describe('Tenant-scoped config', () => { + it('should call resolveAppConfigForUser for tenant user', async () => { + const existingUser = { + _id: 'openid-tenant-user', + provider: 'openid', + openidId: '1234', + email: 'test@example.com', + tenantId: 'tenant-d', + role: 'USER', + }; + findUser.mockResolvedValue(existingUser); + + await validate(tokenset); + + expect(resolveAppConfigForUser).toHaveBeenCalledWith(getAppConfig, existingUser); + }); + + it('should use baseConfig for new user without calling resolveAppConfigForUser', async () => { + findUser.mockResolvedValue(null); + + await validate(tokenset); + + expect(resolveAppConfigForUser).not.toHaveBeenCalled(); + expect(getAppConfig).toHaveBeenCalledWith({ baseOnly: true }); + }); + + it('should block login when tenant config restricts the domain', async () => { + const { isEmailDomainAllowed } = require('@librechat/api'); + const existingUser = { + _id: 'openid-tenant-blocked', + provider: 'openid', + openidId: '1234', + email: 'test@example.com', + tenantId: 'tenant-restrict', + role: 'USER', + }; + findUser.mockResolvedValue(existingUser); + resolveAppConfigForUser.mockResolvedValue({ + registration: { allowedDomains: ['other.com'] }, + }); + isEmailDomainAllowed.mockReturnValueOnce(true).mockReturnValueOnce(false); + + const { user, details } = await validate(tokenset); + expect(user).toBe(false); + expect(details).toEqual({ message: 'Email domain not allowed' }); + }); + }); +}); diff --git a/api/strategies/samlStrategy.js b/api/strategies/samlStrategy.js index abcb3de099..21e7bdd001 100644 --- a/api/strategies/samlStrategy.js +++ b/api/strategies/samlStrategy.js @@ -5,7 +5,11 @@ const passport = require('passport'); const { ErrorTypes } = require('librechat-data-provider'); const { hashToken, logger } = require('@librechat/data-schemas'); const { Strategy: SamlStrategy } = require('@node-saml/passport-saml'); -const { getBalanceConfig, isEmailDomainAllowed } = require('@librechat/api'); +const { + getBalanceConfig, + isEmailDomainAllowed, + resolveAppConfigForUser, +} = require('@librechat/api'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { findUser, createUser, updateUser } = require('~/models'); const { getAppConfig } = require('~/server/services/Config'); @@ -193,9 +197,9 @@ async function setupSaml() { logger.debug('[samlStrategy] SAML profile:', profile); const userEmail = getEmail(profile) || ''; - const appConfig = await getAppConfig({ baseOnly: true }); - if (!isEmailDomainAllowed(userEmail, appConfig?.registration?.allowedDomains)) { + const baseConfig = await getAppConfig({ baseOnly: true }); + if (!isEmailDomainAllowed(userEmail, baseConfig?.registration?.allowedDomains)) { logger.error( `[SAML Strategy] Authentication blocked - email domain not allowed [Email: ${userEmail}]`, ); @@ -223,6 +227,17 @@ async function setupSaml() { }); } + const appConfig = user?.tenantId + ? await resolveAppConfigForUser(getAppConfig, user) + : baseConfig; + + if (!isEmailDomainAllowed(userEmail, appConfig?.registration?.allowedDomains)) { + logger.error( + `[SAML Strategy] Authentication blocked - email domain not allowed [Email: ${userEmail}]`, + ); + return done(null, false, { message: 'Email domain not allowed' }); + } + const fullName = getFullName(profile); const username = convertToUsername( diff --git a/api/strategies/samlStrategy.spec.js b/api/strategies/samlStrategy.spec.js index 1d16719b87..2022d34b33 100644 --- a/api/strategies/samlStrategy.spec.js +++ b/api/strategies/samlStrategy.spec.js @@ -30,6 +30,7 @@ jest.mock('@librechat/api', () => ({ tokenCredits: 1000, startBalance: 1000, })), + resolveAppConfigForUser: jest.fn(async (_getAppConfig, _user) => ({})), })); jest.mock('~/server/services/Config/EndpointService', () => ({ config: {}, @@ -47,6 +48,9 @@ const fs = require('fs'); const path = require('path'); const fetch = require('node-fetch'); const { Strategy: SamlStrategy } = require('@node-saml/passport-saml'); +const { findUser } = require('~/models'); +const { resolveAppConfigForUser } = require('@librechat/api'); +const { getAppConfig } = require('~/server/services/Config'); const { setupSaml, getCertificateContent } = require('./samlStrategy'); // Configure fs mock @@ -440,4 +444,50 @@ u7wlOSk+oFzDIO/UILIA expect(fetch).not.toHaveBeenCalled(); }); + + it('should pass the found user to resolveAppConfigForUser', async () => { + const existingUser = { + _id: 'tenant-user-id', + provider: 'saml', + samlId: 'saml-1234', + email: 'test@example.com', + tenantId: 'tenant-c', + role: 'USER', + }; + findUser.mockResolvedValue(existingUser); + + const profile = { ...baseProfile }; + await validate(profile); + + expect(resolveAppConfigForUser).toHaveBeenCalledWith(getAppConfig, existingUser); + }); + + it('should use baseConfig for new SAML user without calling resolveAppConfigForUser', async () => { + const profile = { ...baseProfile }; + await validate(profile); + + expect(resolveAppConfigForUser).not.toHaveBeenCalled(); + expect(getAppConfig).toHaveBeenCalledWith({ baseOnly: true }); + }); + + it('should block login when tenant config restricts the domain', async () => { + const { isEmailDomainAllowed } = require('@librechat/api'); + const existingUser = { + _id: 'tenant-blocked', + provider: 'saml', + samlId: 'saml-1234', + email: 'test@example.com', + tenantId: 'tenant-restrict', + role: 'USER', + }; + findUser.mockResolvedValue(existingUser); + resolveAppConfigForUser.mockResolvedValue({ + registration: { allowedDomains: ['other.com'] }, + }); + isEmailDomainAllowed.mockReturnValueOnce(true).mockReturnValueOnce(false); + + const profile = { ...baseProfile }; + const { user } = await validate(profile); + expect(user).toBe(false); + }); }); diff --git a/api/strategies/socialLogin.js b/api/strategies/socialLogin.js index 7585e8e2fe..a5fe78e17d 100644 --- a/api/strategies/socialLogin.js +++ b/api/strategies/socialLogin.js @@ -1,6 +1,6 @@ const { logger } = require('@librechat/data-schemas'); const { ErrorTypes } = require('librechat-data-provider'); -const { isEnabled, isEmailDomainAllowed } = require('@librechat/api'); +const { isEnabled, isEmailDomainAllowed, resolveAppConfigForUser } = require('@librechat/api'); const { createSocialUser, handleExistingUser } = require('./process'); const { getAppConfig } = require('~/server/services/Config'); const { findUser } = require('~/models'); @@ -13,9 +13,8 @@ const socialLogin = profile, }); - const appConfig = await getAppConfig({ baseOnly: true }); - - if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) { + const baseConfig = await getAppConfig({ baseOnly: true }); + if (!isEmailDomainAllowed(email, baseConfig?.registration?.allowedDomains)) { logger.error( `[${provider}Login] Authentication blocked - email domain not allowed [Email: ${email}]`, ); @@ -41,6 +40,20 @@ const socialLogin = } } + const appConfig = existingUser?.tenantId + ? await resolveAppConfigForUser(getAppConfig, existingUser) + : baseConfig; + + if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) { + logger.error( + `[${provider}Login] Authentication blocked - email domain not allowed [Email: ${email}]`, + ); + const error = new Error(ErrorTypes.AUTH_FAILED); + error.code = ErrorTypes.AUTH_FAILED; + error.message = 'Email domain not allowed'; + return cb(error); + } + if (existingUser?.provider === provider) { await handleExistingUser(existingUser, avatarUrl, appConfig, email); return cb(null, existingUser); diff --git a/api/strategies/socialLogin.test.js b/api/strategies/socialLogin.test.js index ba4778c8b1..4fde397d55 100644 --- a/api/strategies/socialLogin.test.js +++ b/api/strategies/socialLogin.test.js @@ -3,6 +3,8 @@ const { ErrorTypes } = require('librechat-data-provider'); const { createSocialUser, handleExistingUser } = require('./process'); const socialLogin = require('./socialLogin'); const { findUser } = require('~/models'); +const { resolveAppConfigForUser } = require('@librechat/api'); +const { getAppConfig } = require('~/server/services/Config'); jest.mock('@librechat/data-schemas', () => { const actualModule = jest.requireActual('@librechat/data-schemas'); @@ -25,6 +27,10 @@ jest.mock('@librechat/api', () => ({ ...jest.requireActual('@librechat/api'), isEnabled: jest.fn().mockReturnValue(true), isEmailDomainAllowed: jest.fn().mockReturnValue(true), + resolveAppConfigForUser: jest.fn().mockResolvedValue({ + fileStrategy: 'local', + balance: { enabled: false }, + }), })); jest.mock('~/models', () => ({ @@ -66,10 +72,7 @@ describe('socialLogin', () => { googleId: googleId, }; - /** Mock findUser to return user on first call (by googleId), null on second call */ - findUser - .mockResolvedValueOnce(existingUser) // First call: finds by googleId - .mockResolvedValueOnce(null); // Second call would be by email, but won't be reached + findUser.mockResolvedValueOnce(existingUser).mockResolvedValueOnce(null); const mockProfile = { id: googleId, @@ -83,13 +86,9 @@ describe('socialLogin', () => { await loginFn(null, null, null, mockProfile, callback); - /** Verify it searched by googleId first */ expect(findUser).toHaveBeenNthCalledWith(1, { googleId: googleId }); - - /** Verify it did NOT search by email (because it found user by googleId) */ expect(findUser).toHaveBeenCalledTimes(1); - /** Verify handleExistingUser was called with the new email */ expect(handleExistingUser).toHaveBeenCalledWith( existingUser, 'https://example.com/avatar.png', @@ -97,7 +96,6 @@ describe('socialLogin', () => { newEmail, ); - /** Verify callback was called with success */ expect(callback).toHaveBeenCalledWith(null, existingUser); }); @@ -113,7 +111,7 @@ describe('socialLogin', () => { facebookId: facebookId, }; - findUser.mockResolvedValue(existingUser); // Always returns user + findUser.mockResolvedValue(existingUser); const mockProfile = { id: facebookId, @@ -127,7 +125,6 @@ describe('socialLogin', () => { await loginFn(null, null, null, mockProfile, callback); - /** Verify it searched by facebookId first */ expect(findUser).toHaveBeenCalledWith({ facebookId: facebookId }); expect(findUser.mock.calls[0]).toEqual([{ facebookId: facebookId }]); @@ -150,13 +147,10 @@ describe('socialLogin', () => { _id: 'user789', email: email, provider: 'google', - googleId: 'old-google-id', // Different googleId (edge case) + googleId: 'old-google-id', }; - /** First call (by googleId) returns null, second call (by email) returns user */ - findUser - .mockResolvedValueOnce(null) // By googleId - .mockResolvedValueOnce(existingUser); // By email + findUser.mockResolvedValueOnce(null).mockResolvedValueOnce(existingUser); const mockProfile = { id: googleId, @@ -170,13 +164,10 @@ describe('socialLogin', () => { await loginFn(null, null, null, mockProfile, callback); - /** Verify both searches happened */ expect(findUser).toHaveBeenNthCalledWith(1, { googleId: googleId }); - /** Email passed as-is; findUser implementation handles case normalization */ expect(findUser).toHaveBeenNthCalledWith(2, { email: email }); expect(findUser).toHaveBeenCalledTimes(2); - /** Verify warning log */ expect(logger.warn).toHaveBeenCalledWith( `[${provider}Login] User found by email: ${email} but not by ${provider}Id`, ); @@ -197,7 +188,6 @@ describe('socialLogin', () => { googleId: googleId, }; - /** Both searches return null */ findUser.mockResolvedValue(null); createSocialUser.mockResolvedValue(newUser); @@ -213,10 +203,8 @@ describe('socialLogin', () => { await loginFn(null, null, null, mockProfile, callback); - /** Verify both searches happened */ expect(findUser).toHaveBeenCalledTimes(2); - /** Verify createSocialUser was called */ expect(createSocialUser).toHaveBeenCalledWith({ email: email, avatarUrl: 'https://example.com/avatar.png', @@ -242,12 +230,10 @@ describe('socialLogin', () => { const existingUser = { _id: 'user123', email: email, - provider: 'local', // Different provider + provider: 'local', }; - findUser - .mockResolvedValueOnce(null) // By googleId - .mockResolvedValueOnce(existingUser); // By email + findUser.mockResolvedValueOnce(null).mockResolvedValueOnce(existingUser); const mockProfile = { id: googleId, @@ -261,7 +247,6 @@ describe('socialLogin', () => { await loginFn(null, null, null, mockProfile, callback); - /** Verify error callback */ expect(callback).toHaveBeenCalledWith( expect.objectContaining({ code: ErrorTypes.AUTH_FAILED, @@ -274,4 +259,104 @@ describe('socialLogin', () => { ); }); }); + + describe('Tenant-scoped config', () => { + it('should call resolveAppConfigForUser for tenant user', async () => { + const provider = 'google'; + const googleId = 'google-tenant-user'; + const email = 'tenant@example.com'; + + const existingUser = { + _id: 'userTenant', + email, + provider: 'google', + googleId, + tenantId: 'tenant-b', + role: 'USER', + }; + + findUser.mockResolvedValue(existingUser); + + const mockProfile = { + id: googleId, + emails: [{ value: email, verified: true }], + photos: [{ value: 'https://example.com/avatar.png' }], + name: { givenName: 'Tenant', familyName: 'User' }, + }; + + const loginFn = socialLogin(provider, mockGetProfileDetails); + const callback = jest.fn(); + + await loginFn(null, null, null, mockProfile, callback); + + expect(resolveAppConfigForUser).toHaveBeenCalledWith(getAppConfig, existingUser); + }); + + it('should use baseConfig for non-tenant user without calling resolveAppConfigForUser', async () => { + const provider = 'google'; + const googleId = 'google-new-tenant'; + const email = 'new@example.com'; + + findUser.mockResolvedValue(null); + createSocialUser.mockResolvedValue({ + _id: 'newUser', + email, + provider: 'google', + googleId, + }); + + const mockProfile = { + id: googleId, + emails: [{ value: email, verified: true }], + photos: [{ value: 'https://example.com/avatar.png' }], + name: { givenName: 'New', familyName: 'User' }, + }; + + const loginFn = socialLogin(provider, mockGetProfileDetails); + const callback = jest.fn(); + + await loginFn(null, null, null, mockProfile, callback); + + expect(resolveAppConfigForUser).not.toHaveBeenCalled(); + expect(getAppConfig).toHaveBeenCalledWith({ baseOnly: true }); + }); + + it('should block login when tenant config restricts the domain', async () => { + const { isEmailDomainAllowed } = require('@librechat/api'); + const provider = 'google'; + const googleId = 'google-tenant-blocked'; + const email = 'blocked@example.com'; + + const existingUser = { + _id: 'userBlocked', + email, + provider: 'google', + googleId, + tenantId: 'tenant-restrict', + role: 'USER', + }; + + findUser.mockResolvedValue(existingUser); + resolveAppConfigForUser.mockResolvedValue({ + registration: { allowedDomains: ['other.com'] }, + }); + isEmailDomainAllowed.mockReturnValueOnce(true).mockReturnValueOnce(false); + + const mockProfile = { + id: googleId, + emails: [{ value: email, verified: true }], + photos: [{ value: 'https://example.com/avatar.png' }], + name: { givenName: 'Blocked', familyName: 'User' }, + }; + + const loginFn = socialLogin(provider, mockGetProfileDetails); + const callback = jest.fn(); + + await loginFn(null, null, null, mockProfile, callback); + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Email domain not allowed' }), + ); + }); + }); }); diff --git a/packages/api/src/app/index.ts b/packages/api/src/app/index.ts index 7acb75e09d..8d8802f016 100644 --- a/packages/api/src/app/index.ts +++ b/packages/api/src/app/index.ts @@ -3,3 +3,4 @@ export * from './config'; export * from './permissions'; export * from './cdn'; export * from './checks'; +export * from './resolve'; diff --git a/packages/api/src/app/resolve.spec.ts b/packages/api/src/app/resolve.spec.ts new file mode 100644 index 0000000000..d7585198a0 --- /dev/null +++ b/packages/api/src/app/resolve.spec.ts @@ -0,0 +1,95 @@ +import type { AsyncLocalStorage } from 'async_hooks'; + +jest.mock('@librechat/data-schemas', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { AsyncLocalStorage: ALS } = require('async_hooks'); + return { tenantStorage: new ALS() }; +}); + +import { resolveAppConfigForUser } from './resolve'; + +const { tenantStorage } = jest.requireMock('@librechat/data-schemas') as { + tenantStorage: AsyncLocalStorage<{ tenantId?: string }>; +}; + +describe('resolveAppConfigForUser', () => { + const mockGetAppConfig = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockGetAppConfig.mockResolvedValue({ registration: {} }); + }); + + it('calls getAppConfig with baseOnly when user is null', async () => { + await resolveAppConfigForUser(mockGetAppConfig, null); + expect(mockGetAppConfig).toHaveBeenCalledWith({ baseOnly: true }); + }); + + it('calls getAppConfig with baseOnly when user is undefined', async () => { + await resolveAppConfigForUser(mockGetAppConfig, undefined); + expect(mockGetAppConfig).toHaveBeenCalledWith({ baseOnly: true }); + }); + + it('calls getAppConfig with baseOnly when user has no tenantId', async () => { + await resolveAppConfigForUser(mockGetAppConfig, { role: 'USER' }); + expect(mockGetAppConfig).toHaveBeenCalledWith({ baseOnly: true }); + }); + + it('calls getAppConfig with role and tenantId when user has tenantId', async () => { + await resolveAppConfigForUser(mockGetAppConfig, { tenantId: 'tenant-a', role: 'USER' }); + expect(mockGetAppConfig).toHaveBeenCalledWith({ role: 'USER', tenantId: 'tenant-a' }); + }); + + it('calls tenantStorage.run for tenant users but not for non-tenant users', async () => { + const runSpy = jest.spyOn(tenantStorage, 'run'); + + await resolveAppConfigForUser(mockGetAppConfig, { role: 'USER' }); + expect(runSpy).not.toHaveBeenCalled(); + + await resolveAppConfigForUser(mockGetAppConfig, { tenantId: 'tenant-b', role: 'ADMIN' }); + expect(runSpy).toHaveBeenCalledWith({ tenantId: 'tenant-b' }, expect.any(Function)); + + runSpy.mockRestore(); + }); + + it('makes tenantId available via ALS inside getAppConfig', async () => { + let capturedContext: { tenantId?: string } | undefined; + mockGetAppConfig.mockImplementation(async () => { + capturedContext = tenantStorage.getStore(); + return { registration: {} }; + }); + + await resolveAppConfigForUser(mockGetAppConfig, { tenantId: 'tenant-c', role: 'USER' }); + + expect(capturedContext).toEqual({ tenantId: 'tenant-c' }); + }); + + it('returns the config from getAppConfig', async () => { + const tenantConfig = { registration: { allowedDomains: ['example.com'] } }; + mockGetAppConfig.mockResolvedValue(tenantConfig); + + const result = await resolveAppConfigForUser(mockGetAppConfig, { + tenantId: 'tenant-d', + role: 'USER', + }); + + expect(result).toBe(tenantConfig); + }); + + it('calls getAppConfig with role undefined when user has tenantId but no role', async () => { + await resolveAppConfigForUser(mockGetAppConfig, { tenantId: 'tenant-e' }); + expect(mockGetAppConfig).toHaveBeenCalledWith({ role: undefined, tenantId: 'tenant-e' }); + }); + + it('propagates rejection from getAppConfig for tenant users', async () => { + mockGetAppConfig.mockRejectedValue(new Error('config unavailable')); + await expect( + resolveAppConfigForUser(mockGetAppConfig, { tenantId: 'tenant-f', role: 'USER' }), + ).rejects.toThrow('config unavailable'); + }); + + it('propagates rejection from getAppConfig for baseOnly path', async () => { + mockGetAppConfig.mockRejectedValue(new Error('cache failure')); + await expect(resolveAppConfigForUser(mockGetAppConfig, null)).rejects.toThrow('cache failure'); + }); +}); diff --git a/packages/api/src/app/resolve.ts b/packages/api/src/app/resolve.ts new file mode 100644 index 0000000000..0810400222 --- /dev/null +++ b/packages/api/src/app/resolve.ts @@ -0,0 +1,39 @@ +import { tenantStorage } from '@librechat/data-schemas'; +import type { AppConfig } from '@librechat/data-schemas'; + +interface UserForConfigResolution { + tenantId?: string; + role?: string; +} + +type GetAppConfig = (opts: { + role?: string; + tenantId?: string; + baseOnly?: boolean; +}) => Promise; + +/** + * Resolves AppConfig scoped to the given user's tenant when available, + * falling back to YAML-only base config for new users or non-tenant deployments. + * + * Auth flows only apply role-level overrides (userId is not passed) because + * user/group principal resolution requires heavier DB work that is deferred + * to post-authentication config calls. + * + * `tenantId` is propagated through two channels that serve different purposes: + * - `tenantStorage.run()` sets the ALS context so Mongoose's `applyTenantIsolation` + * plugin scopes any DB queries (e.g., `getApplicableConfigs`) to the tenant. + * - The explicit `tenantId` parameter to `getAppConfig` is used for cache-key + * computation in `overrideCacheKey()`. Both channels are required. + */ +export async function resolveAppConfigForUser( + getAppConfig: GetAppConfig, + user: UserForConfigResolution | null | undefined, +): Promise { + if (user?.tenantId) { + return tenantStorage.run({ tenantId: user.tenantId }, async () => + getAppConfig({ role: user.role, tenantId: user.tenantId }), + ); + } + return getAppConfig({ baseOnly: true }); +}