diff --git a/.env.example b/.env.example index 71173747c3..348348f99a 100644 --- a/.env.example +++ b/.env.example @@ -615,6 +615,16 @@ OPENID_REQUIRED_ROLE_PARAMETER_PATH= OPENID_ADMIN_ROLE= OPENID_ADMIN_ROLE_PARAMETER_PATH= OPENID_ADMIN_ROLE_TOKEN_KIND= +# Generic OpenID role sync maps non-admin IdP roles/groups to one LibreChat role. +# ADMIN cannot be assigned by generic role sync; use OPENID_ADMIN_ROLE for admin elevation. +# Role priority is ordered from most important to least important. +OPENID_ROLE_SYNC_ENABLED=false +OPENID_ROLE_SYNC_API_ENABLED=false +OPENID_ROLE_SYNC_SOURCE=id +OPENID_ROLE_SYNC_CLAIM= +OPENID_ROLE_SYNC_ROLE_PRIORITY= +# Fallback is authoritative when configured: if no priority role matches, this role is assigned. +OPENID_ROLE_SYNC_FALLBACK_ROLE= # Set to determine which user info property returned from OpenID Provider to store as the User's username OPENID_USERNAME_CLAIM= # Set to determine which user info property returned from OpenID Provider to store as the User's name diff --git a/api/server/routes/agents/middleware.js b/api/server/routes/agents/middleware.js index f71c25c6f8..efb4a8c0b0 100644 --- a/api/server/routes/agents/middleware.js +++ b/api/server/routes/agents/middleware.js @@ -18,6 +18,7 @@ const apiKeyMiddleware = createRequireApiKeyAuth({ const requireRemoteAgentAuth = createRemoteAgentAuth({ apiKeyMiddleware, findUser: db.findUser, + getRolesByNames: db.findRolesByNames, updateUser: db.updateUser, getAppConfig, }); diff --git a/api/strategies/openidStrategy.js b/api/strategies/openidStrategy.js index 86e09480ef..67da4bf007 100644 --- a/api/strategies/openidStrategy.js +++ b/api/strategies/openidStrategy.js @@ -3,7 +3,7 @@ const { get } = require('lodash'); const passport = require('passport'); const client = require('openid-client'); const jwtDecode = require('jsonwebtoken/decode'); -const { hashToken, logger } = require('@librechat/data-schemas'); +const { hashToken, logger, tenantStorage } = require('@librechat/data-schemas'); const { Strategy: OpenIDStrategy } = require('openid-client/passport'); const { CacheKeys, ErrorTypes, SystemRoles } = require('librechat-data-provider'); const { @@ -17,11 +17,15 @@ const { isEmailDomainAllowed, getAvatarFileStrategy, getAvatarSaveParams, + selectOpenIdRole, + getOpenIdRoleSyncOptions, + getOpenIdRolesForOpenIdSync, + getLibreChatRolesForOpenIdSync, resolveAppConfigForUser, } = require('@librechat/api'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { resizeAvatar } = require('~/server/services/Files/images/avatar'); -const { findUser, createUser, updateUser } = require('~/models'); +const { findUser, createUser, updateUser, findRolesByNames } = require('~/models'); const { getAppConfig } = require('~/server/services/Config'); const getLogStores = require('~/cache/getLogStores'); @@ -471,6 +475,78 @@ function getRoleSource(kind, label, tokenset, userinfo) { throw new Error(`Invalid ${label} token kind`); } +/** + * Applies generic OpenID role sync to the request-local user before the existing final update. + */ +async function applyOpenIdRoleSync({ + user, + username, + tokenset, + claims, + userinfo, + resolvedOverageGroups, +}) { + const options = getOpenIdRoleSyncOptions(); + if (!options.enabled) { + return; + } + + if (user.role === SystemRoles.ADMIN) { + logger.info( + `[openidStrategy] OpenID role sync skipped for ${username}; existing ADMIN role is not managed by generic role sync`, + ); + return; + } + + const resolveGroupOverage = async () => + resolvedOverageGroups || (await resolveGroupsFromOverage(tokenset.access_token, claims.sub)); + + const openIdRoleValues = await getOpenIdRolesForOpenIdSync({ + options, + accessToken: tokenset.access_token, + idToken: tokenset.id_token, + claims, + userinfo, + decodeToken: jwtDecode, + resolveGroupOverage, + }); + if (openIdRoleValues === undefined) { + logger.warn( + `[openidStrategy] OpenID role sync skipped; claim '${options.claim}' was not found, invalid, or unresolved`, + ); + return; + } + + const libreChatRoles = { + getRolesByNames: findRolesByNames, + rolePriority: options.rolePriority, + fallbackRole: options.fallbackRole, + logPrefix: '[openidStrategy]', + }; + + /** Role definitions are tenant-scoped, so validate configured roles in the matched user's tenant. */ + const { rolePriority, fallbackRole } = user?.tenantId + ? await tenantStorage.run({ tenantId: user.tenantId }, async () => + getLibreChatRolesForOpenIdSync(libreChatRoles), + ) + : await getLibreChatRolesForOpenIdSync(libreChatRoles); + const result = selectOpenIdRole({ + currentRole: user.role, + openIdRoleValues, + rolePriority, + fallbackRole, + }); + + if (!result.selectedRole || result.selectedRole === user.role) { + return; + } + + logger.info( + `[openidStrategy] OpenID role sync updated role for ${username}: ${user.role || 'unset'} -> ${result.selectedRole}`, + ); + user.role = result.selectedRole; +} + /** * Process OpenID authentication tokenset and userinfo * This is the core logic extracted from the passport strategy callback @@ -630,6 +706,7 @@ async function processOpenIDAuth(tokenset, existingUsersOnly = false) { const adminRole = process.env.OPENID_ADMIN_ROLE; const adminRoleParameterPath = process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH; const adminRoleTokenKind = process.env.OPENID_ADMIN_ROLE_TOKEN_KIND; + let adminRoleGranted = false; if (adminRole && adminRoleParameterPath && adminRoleTokenKind) { const adminRoleObject = getRoleSource(adminRoleTokenKind, 'admin role', tokenset, userinfo); @@ -662,6 +739,7 @@ async function processOpenIDAuth(tokenset, existingUsersOnly = false) { if (adminRoles && (adminRoles === true || adminRoleValues.includes(adminRole))) { user.role = SystemRoles.ADMIN; + adminRoleGranted = true; logger.info(`[openidStrategy] User ${username} is an admin based on role: ${adminRole}`); } else if (user.role === SystemRoles.ADMIN) { user.role = SystemRoles.USER; @@ -671,6 +749,33 @@ async function processOpenIDAuth(tokenset, existingUsersOnly = false) { } } + if (!adminRoleGranted) { + const roleBeforeSync = user.role; + await applyOpenIdRoleSync({ + user, + username, + tokenset, + claims, + userinfo, + resolvedOverageGroups, + }); + /** + * The earlier login-policy check ran with the pre-sync role. If role sync moved a + * tenant user into a different role, re-resolve the tenant config and re-enforce + * `allowedDomains` so role-scoped overrides for the new role are honored and a token + * cannot complete login under the previous role's looser policy. + */ + if (user?.tenantId && user.role !== roleBeforeSync) { + const postSyncConfig = await resolveAppConfigForUser(getAppConfig, user); + if (!isEmailDomainAllowed(email, postSyncConfig?.registration?.allowedDomains)) { + logger.error( + `[OpenID Strategy] Authentication blocked after role sync - email domain not allowed [Identifier: ${email}]`, + ); + throw new Error('Email domain not allowed'); + } + } + } + if (!!userinfo && userinfo.picture && !user.avatar?.includes('manual=true')) { /** @type {string | undefined} */ const imageUrl = userinfo.picture; diff --git a/api/strategies/openidStrategy.spec.js b/api/strategies/openidStrategy.spec.js index 12056f2530..b1d6081fe8 100644 --- a/api/strategies/openidStrategy.spec.js +++ b/api/strategies/openidStrategy.spec.js @@ -2,7 +2,7 @@ const undici = require('undici'); const fetch = require('node-fetch'); const jwtDecode = require('jsonwebtoken/decode'); const { ErrorTypes, FileSources } = require('librechat-data-provider'); -const { findUser, createUser, updateUser } = require('~/models'); +const { findUser, createUser, updateUser, findRolesByNames } = require('~/models'); const { getOpenIdIssuer, resolveAppConfigForUser, isEnabled } = require('@librechat/api'); const { resizeAvatar } = require('~/server/services/Files/images/avatar'); const { getAppConfig } = require('~/server/services/Config'); @@ -94,6 +94,7 @@ jest.mock('~/models', () => ({ findUser: jest.fn(), createUser: jest.fn(), updateUser: jest.fn(), + findRolesByNames: jest.fn(), })); jest.mock('@librechat/data-schemas', () => ({ ...jest.requireActual('@librechat/api'), @@ -103,6 +104,9 @@ jest.mock('@librechat/data-schemas', () => ({ debug: jest.fn(), error: jest.fn(), }, + tenantStorage: { + run: jest.fn((_context, fn) => fn()), + }, hashToken: jest.fn().mockResolvedValue('hashed-token'), })); jest.mock('~/cache/getLogStores', () => @@ -202,6 +206,15 @@ describe('setupOpenId', () => { // Clear previous mock calls and reset implementations jest.clearAllMocks(); isEnabled.mockImplementation(jest.requireActual('@librechat/api').isEnabled); + require('~/cache/getLogStores').mockImplementation(() => ({ + get: jest.fn(), + set: jest.fn(), + })); + require('openid-client').genericGrantRequest.mockReset(); + require('openid-client').genericGrantRequest.mockResolvedValue({ + access_token: 'exchanged_graph_token', + expires_in: 3600, + }); // Reset environment variables needed by the strategy process.env.OPENID_ISSUER = 'https://fake-issuer.com'; @@ -223,6 +236,14 @@ describe('setupOpenId', () => { delete process.env.PROXY; delete process.env.OPENID_USE_PKCE; delete process.env.OPENID_GENERATE_NONCE; + delete process.env.OPENID_ROLE_SYNC_ENABLED; + delete process.env.OPENID_ROLE_SYNC_API_ENABLED; + delete process.env.OPENID_ROLE_SYNC_SOURCE; + delete process.env.OPENID_ROLE_SYNC_CLAIM; + delete process.env.OPENID_ROLE_SYNC_ROLE_PRIORITY; + delete process.env.OPENID_ROLE_SYNC_FALLBACK_ROLE; + delete process.env.OPENID_ON_BEHALF_FLOW_FOR_USERINFO_REQUIRED; + delete process.env.OPENID_ON_BEHALF_FLOW_USERINFO_SCOPE; // Default jwtDecode mock returns a token that includes the required role. jwtDecode.mockReturnValue({ @@ -239,6 +260,9 @@ describe('setupOpenId', () => { updateUser.mockImplementation(async (id, userData) => { return { _id: id, ...userData }; }); + findRolesByNames.mockImplementation(async (roleNames) => + roleNames.map((roleName) => ({ name: roleName })), + ); resizeAvatar.mockResolvedValue(Buffer.from('safe avatar')); @@ -974,6 +998,10 @@ describe('setupOpenId', () => { }); describe('OBO token exchange for overage', () => { + beforeEach(() => { + delete process.env.OPENID_ADMIN_ROLE; + }); + it('exchanges access token via OBO before calling Graph API', async () => { const openidClient = require('openid-client'); process.env.OPENID_REQUIRED_ROLE = 'group-required'; @@ -1534,6 +1562,296 @@ describe('setupOpenId', () => { expect(user.role).toBeUndefined(); }); + describe('OpenID role sync', () => { + beforeEach(() => { + process.env.OPENID_ROLE_SYNC_ENABLED = 'true'; + process.env.OPENID_ROLE_SYNC_SOURCE = 'id'; + process.env.OPENID_ROLE_SYNC_CLAIM = 'roles'; + process.env.OPENID_ROLE_SYNC_ROLE_PRIORITY = 'STANDARD-USER,BASIC-USER'; + process.env.OPENID_ROLE_SYNC_FALLBACK_ROLE = 'USER'; + }); + + it('selects the highest configured matching role from the OpenID token', async () => { + jwtDecode.mockReturnValue({ + roles: ['requiredRole', 'BASIC-USER', 'STANDARD-USER'], + permissions: ['not-admin'], + }); + + const { user } = await validate(tokenset); + + expect(user.role).toBe('STANDARD-USER'); + expect(updateUser).toHaveBeenCalledWith( + 'newUserId', + expect.objectContaining({ role: 'STANDARD-USER' }), + ); + }); + + it('does not run when disabled', async () => { + delete process.env.OPENID_ROLE_SYNC_ENABLED; + jwtDecode.mockReturnValue({ + roles: ['requiredRole', 'STANDARD-USER'], + permissions: ['not-admin'], + }); + + const { user } = await validate(tokenset); + + expect(user.role).toBeUndefined(); + expect(findRolesByNames).not.toHaveBeenCalled(); + }); + + it('leaves ADMIN authoritative when OPENID_ADMIN_ROLE grants admin', async () => { + jwtDecode.mockReturnValue({ + roles: ['requiredRole', 'STANDARD-USER'], + permissions: ['admin'], + }); + + const { user } = await validate(tokenset); + + expect(user.role).toBe('ADMIN'); + }); + + it('preserves an existing ADMIN role when admin is manually assigned', async () => { + delete process.env.OPENID_ADMIN_ROLE; + delete process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH; + delete process.env.OPENID_ADMIN_ROLE_TOKEN_KIND; + 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', 'STANDARD-USER'], + permissions: ['not-admin'], + }); + + const { user } = await validate(tokenset); + + expect(user.role).toBe('ADMIN'); + expect(findRolesByNames).not.toHaveBeenCalled(); + }); + + it('uses fallback when a valid role claim has no configured role match', async () => { + jwtDecode.mockReturnValue({ + roles: ['requiredRole', 'external-role'], + permissions: ['not-admin'], + }); + + const { user } = await validate(tokenset); + + expect(user.role).toBe('USER'); + }); + + it('uses fallback when the role claim is present but empty', async () => { + // The required-role gate reads the same `roles` claim this test empties, so + // disable it to model an IdP that authenticates the user yet emits no roles. + delete process.env.OPENID_REQUIRED_ROLE; + jwtDecode.mockReturnValue({ + roles: '', + permissions: ['not-admin'], + }); + + const { user } = await validate(tokenset); + + expect(user.role).toBe('USER'); + expect(updateUser).toHaveBeenCalledWith( + 'newUserId', + expect.objectContaining({ role: 'USER' }), + ); + }); + + it('applies fallback when the role claim is absent from the token', async () => { + // Required-role gate reads the same `roles` claim; disable it to model an IdP + // that authenticates the user but stops emitting the role claim entirely. + delete process.env.OPENID_REQUIRED_ROLE; + jwtDecode.mockReturnValue({ + permissions: ['not-admin'], + }); + + const { user } = await validate(tokenset); + + expect(user.role).toBe('USER'); + expect(updateUser).toHaveBeenCalledWith( + 'newUserId', + expect.objectContaining({ role: 'USER' }), + ); + }); + + it('rejects login when configured sync roles do not exist', async () => { + findRolesByNames.mockImplementation(async (roleNames) => + roleNames + .filter((roleName) => roleName !== 'STANDARD-USER') + .map((roleName) => ({ name: roleName })), + ); + jwtDecode.mockReturnValue({ + roles: ['requiredRole', 'STANDARD-USER'], + permissions: ['not-admin'], + }); + + await expect(validate(tokenset)).rejects.toThrow( + 'OpenID role sync configured roles do not exist: STANDARD-USER', + ); + }); + + it('can assign a non-admin role after the existing admin demotion path runs', async () => { + 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', 'STANDARD-USER'], + permissions: ['not-admin'], + }); + + const { user } = await validate(tokenset); + + expect(user.role).toBe('STANDARD-USER'); + expect(updateUser).toHaveBeenCalledWith( + existingAdminUser._id, + expect.objectContaining({ role: 'STANDARD-USER' }), + ); + }); + + it('wraps role lookup in tenant context for tenant users', async () => { + const existingUser = { + _id: 'existingTenantUserId', + provider: 'openid', + email: tokenset.claims().email, + openidId: tokenset.claims().sub, + username: 'tenantuser', + name: 'Tenant User', + tenantId: 'tenant-a', + role: 'USER', + }; + const { tenantStorage } = require('@librechat/data-schemas'); + + findUser.mockImplementation(async (query) => { + if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) { + return existingUser; + } + return null; + }); + jwtDecode.mockReturnValue({ + roles: ['requiredRole', 'BASIC-USER'], + permissions: ['not-admin'], + }); + + const { user } = await validate(tokenset); + + expect(user.role).toBe('BASIC-USER'); + expect(tenantStorage.run).toHaveBeenCalledWith( + { tenantId: 'tenant-a' }, + expect.any(Function), + ); + }); + + it('re-enforces tenant login policy after role sync changes the role', async () => { + const existingUser = { + _id: 'existingTenantUserId', + provider: 'openid', + email: tokenset.claims().email, + openidId: tokenset.claims().sub, + username: 'tenantuser', + name: 'Tenant User', + tenantId: 'tenant-a', + role: 'USER', + }; + const { isEmailDomainAllowed } = require('@librechat/api'); + + findUser.mockImplementation(async (query) => { + if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) { + return existingUser; + } + return null; + }); + jwtDecode.mockReturnValue({ + roles: ['requiredRole', 'BASIC-USER'], + permissions: ['not-admin'], + }); + // Pre-sync domain check passes; the post-sync re-resolved config rejects the domain. + isEmailDomainAllowed.mockReturnValueOnce(true).mockReturnValueOnce(false); + resolveAppConfigForUser.mockResolvedValue({ + registration: { allowedDomains: ['restricted.com'] }, + }); + + const { user, details } = await validate(tokenset); + + expect(user).toBe(false); + expect(details).toEqual({ message: 'Email domain not allowed' }); + }); + + it('reuses required-role overage groups for role sync', async () => { + process.env.OPENID_REQUIRED_ROLE = 'group-required'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups'; + process.env.OPENID_ROLE_SYNC_CLAIM = 'groups'; + + jwtDecode.mockReturnValue({ + hasgroups: true, + permissions: ['not-admin'], + }); + undici.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ value: ['group-required', 'STANDARD-USER'] }), + }); + + const { user } = await validate(tokenset); + + expect(user.role).toBe('STANDARD-USER'); + expect(undici.fetch).toHaveBeenCalledTimes(1); + }); + + it('leaves the role unchanged when role-sync group overage cannot be resolved', async () => { + process.env.OPENID_ROLE_SYNC_CLAIM = 'groups'; + const existingUser = { + _id: 'existingUserId', + provider: 'openid', + email: tokenset.claims().email, + openidId: tokenset.claims().sub, + username: 'existinguser', + name: 'Existing User', + role: 'BASIC-USER', + }; + + findUser.mockImplementation(async (query) => { + if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) { + return existingUser; + } + return null; + }); + jwtDecode.mockReturnValue({ + roles: ['requiredRole'], + hasgroups: true, + permissions: ['not-admin'], + }); + + const { user } = await validate({ ...tokenset, access_token: undefined }); + + expect(user.role).toBe('BASIC-USER'); + }); + }); + 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 = { diff --git a/packages/api/src/auth/index.ts b/packages/api/src/auth/index.ts index d3e08deb14..a2cbe4fc2f 100644 --- a/packages/api/src/auth/index.ts +++ b/packages/api/src/auth/index.ts @@ -6,3 +6,4 @@ export * from './agent'; export * from './password'; export * from './invite'; export * from './codeapi'; +export * from './openidRoleSync'; diff --git a/packages/api/src/auth/openidRoleSync.spec.ts b/packages/api/src/auth/openidRoleSync.spec.ts new file mode 100644 index 0000000000..4451d31f76 --- /dev/null +++ b/packages/api/src/auth/openidRoleSync.spec.ts @@ -0,0 +1,398 @@ +import { SystemRoles } from 'librechat-data-provider'; +import { + getOpenIdRoleSyncOptions, + getOpenIdRolesForOpenIdSync, + getLibreChatRolesForOpenIdSync, + selectOpenIdRole, +} from './openidRoleSync'; + +describe('getOpenIdRoleSyncOptions', () => { + it('defaults role sync and API role sync to disabled', () => { + expect(getOpenIdRoleSyncOptions({})).toEqual({ + enabled: false, + apiEnabled: false, + claimSource: 'id', + claim: undefined, + rolePriority: [], + fallbackRole: undefined, + }); + }); + + it('parses enabled config and comma-separated priority roles', () => { + expect( + getOpenIdRoleSyncOptions({ + OPENID_ROLE_SYNC_ENABLED: 'true', + OPENID_ROLE_SYNC_API_ENABLED: 'true', + OPENID_ROLE_SYNC_SOURCE: 'access', + OPENID_ROLE_SYNC_CLAIM: 'roles', + OPENID_ROLE_SYNC_ROLE_PRIORITY: 'STANDARD-USER, BASIC-USER', + OPENID_ROLE_SYNC_FALLBACK_ROLE: SystemRoles.USER, + }), + ).toEqual({ + enabled: true, + apiEnabled: true, + claimSource: 'access', + claim: 'roles', + rolePriority: ['STANDARD-USER', 'BASIC-USER'], + fallbackRole: SystemRoles.USER, + }); + }); + + it('requires claim when role sync is enabled', () => { + expect(() => getOpenIdRoleSyncOptions({ OPENID_ROLE_SYNC_ENABLED: 'true' })).toThrow( + 'OPENID_ROLE_SYNC_CLAIM is required', + ); + }); + + it('rejects invalid claim sources when enabled', () => { + expect(() => + getOpenIdRoleSyncOptions({ + OPENID_ROLE_SYNC_ENABLED: 'true', + OPENID_ROLE_SYNC_CLAIM: 'roles', + OPENID_ROLE_SYNC_SOURCE: 'profile', + }), + ).toThrow('OPENID_ROLE_SYNC_SOURCE must be one of'); + }); + + it('ignores role-sync-specific settings when the feature is disabled', () => { + expect( + getOpenIdRoleSyncOptions({ + OPENID_ROLE_SYNC_SOURCE: 'profile', + OPENID_ROLE_SYNC_ROLE_PRIORITY: SystemRoles.ADMIN, + OPENID_ROLE_SYNC_FALLBACK_ROLE: SystemRoles.ADMIN, + }), + ).toMatchObject({ enabled: false, apiEnabled: false }); + }); + + it('rejects API role sync when global role sync is disabled', () => { + expect(() => + getOpenIdRoleSyncOptions({ + OPENID_ROLE_SYNC_API_ENABLED: 'true', + }), + ).toThrow('OPENID_ROLE_SYNC_API_ENABLED requires OPENID_ROLE_SYNC_ENABLED=true'); + }); + + it('rejects ADMIN in role priority and fallback role when enabled', () => { + expect(() => + getOpenIdRoleSyncOptions({ + OPENID_ROLE_SYNC_ENABLED: 'true', + OPENID_ROLE_SYNC_CLAIM: 'roles', + OPENID_ROLE_SYNC_ROLE_PRIORITY: `STANDARD-USER,${SystemRoles.ADMIN}`, + }), + ).toThrow('OPENID_ROLE_SYNC_ROLE_PRIORITY cannot include ADMIN'); + + expect(() => + getOpenIdRoleSyncOptions({ + OPENID_ROLE_SYNC_ENABLED: 'true', + OPENID_ROLE_SYNC_CLAIM: 'roles', + OPENID_ROLE_SYNC_FALLBACK_ROLE: SystemRoles.ADMIN, + }), + ).toThrow('OPENID_ROLE_SYNC_FALLBACK_ROLE cannot be ADMIN'); + }); +}); + +describe('getOpenIdRolesForOpenIdSync', () => { + const options = { + enabled: true, + apiEnabled: false, + claimSource: 'id' as const, + claim: 'roles', + rolePriority: ['STANDARD-USER'], + }; + + it('extracts a configured claim from the selected source', async () => { + await expect( + getOpenIdRolesForOpenIdSync({ + options, + idToken: 'id-token', + decodeToken: () => ({ roles: ['STANDARD-USER'] }), + }), + ).resolves.toEqual(['STANDARD-USER']); + }); + + it('returns an empty list when the source lacks a usable claim value (applies fallback)', async () => { + await expect( + getOpenIdRolesForOpenIdSync({ + options, + idToken: 'id-token', + decodeToken: () => ({ roles: { STANDARD_USER: true } }), + }), + ).resolves.toEqual([]); + }); + + it('returns undefined when no token source is available (skips sync)', async () => { + await expect( + getOpenIdRolesForOpenIdSync({ + options, + decodeToken: () => ({ roles: ['STANDARD-USER'] }), + }), + ).resolves.toBeUndefined(); + }); + + it('uses overage resolution for id token groups', async () => { + await expect( + getOpenIdRolesForOpenIdSync({ + options: { ...options, claim: 'groups' }, + idToken: 'id-token', + decodeToken: () => ({ hasgroups: true }), + resolveGroupOverage: async () => ['group-a'], + }), + ).resolves.toEqual(['group-a']); + }); + + it('decodes only the configured access token source', async () => { + const decodeToken = jest.fn((token: string) => ({ token })); + + await expect( + getOpenIdRolesForOpenIdSync({ + options: { ...options, claimSource: 'access' }, + accessToken: 'access-token', + idToken: 'id-token', + decodeToken, + }), + ).resolves.toEqual([]); + expect(decodeToken).toHaveBeenCalledTimes(1); + }); + + it('resolves group overage from an access token source (not just id)', async () => { + await expect( + getOpenIdRolesForOpenIdSync({ + options: { ...options, claimSource: 'access', claim: 'groups' }, + accessClaims: { + _claim_names: { groups: 'src1' }, + _claim_sources: { src1: { endpoint: 'https://graph' } }, + }, + decodeToken: () => ({}), + resolveGroupOverage: async () => ['group-a'], + }), + ).resolves.toEqual(['group-a']); + }); + + it('uses claims for the id source when no id token is available', async () => { + const decodeToken = jest.fn(); + const claims = { roles: ['STANDARD-USER'] }; + + await expect( + getOpenIdRolesForOpenIdSync({ + options, + claims, + decodeToken, + }), + ).resolves.toEqual(['STANDARD-USER']); + expect(decodeToken).not.toHaveBeenCalled(); + }); + + it('uses userinfo directly for the userinfo source', async () => { + const userinfo = { roles: ['STANDARD-USER'] }; + + await expect( + getOpenIdRolesForOpenIdSync({ + options: { ...options, claimSource: 'userinfo' }, + userinfo, + decodeToken: jest.fn(), + }), + ).resolves.toEqual(['STANDARD-USER']); + }); +}); + +describe('getLibreChatRolesForOpenIdSync', () => { + it('deduplicates configured roles and returns canonical role names', async () => { + const getRolesByNames = jest.fn(async (roleNames: string[]) => + roleNames.map((roleName) => ({ + name: roleName.toLowerCase() === 'standard-user' ? 'STANDARD-USER' : roleName, + })), + ); + + await expect( + getLibreChatRolesForOpenIdSync({ + getRolesByNames, + rolePriority: [' standard-user ', 'STANDARD-USER'], + fallbackRole: SystemRoles.USER, + }), + ).resolves.toEqual({ + rolePriority: ['STANDARD-USER', 'STANDARD-USER'], + fallbackRole: SystemRoles.USER, + }); + expect(getRolesByNames).toHaveBeenCalledTimes(1); + expect(getRolesByNames).toHaveBeenCalledWith(['standard-user', SystemRoles.USER], 'name'); + }); + + it('rejects configured roles that do not exist', async () => { + const getRolesByNames = jest.fn(async (roleNames: string[]) => + roleNames + .filter((roleName) => roleName !== 'MISSING') + .map((roleName) => ({ name: roleName })), + ); + + await expect( + getLibreChatRolesForOpenIdSync({ + getRolesByNames, + rolePriority: ['STANDARD-USER', 'MISSING'], + logPrefix: '[openidStrategy]', + }), + ).rejects.toThrow('[openidStrategy] OpenID role sync configured roles do not exist: MISSING'); + }); + + it('accepts a system fallback role even when a tenant-scoped lookup omits it', async () => { + // Tenant-scoped getRolesByNames returns only the tenant's own roles, not the + // globally-provisioned system USER role. + const getRolesByNames = jest.fn(async (roleNames: string[]) => + roleNames + .filter((roleName) => roleName === 'STANDARD-USER') + .map((roleName) => ({ name: roleName })), + ); + + await expect( + getLibreChatRolesForOpenIdSync({ + getRolesByNames, + rolePriority: ['STANDARD-USER'], + fallbackRole: SystemRoles.USER, + }), + ).resolves.toEqual({ + rolePriority: ['STANDARD-USER'], + fallbackRole: SystemRoles.USER, + }); + }); +}); + +describe('selectOpenIdRole', () => { + it('selects the highest-priority configured role from matching OpenID values', () => { + expect( + selectOpenIdRole({ + currentRole: SystemRoles.USER, + openIdRoleValues: 'BASIC-USER STANDARD-USER', + rolePriority: ['STANDARD-USER', 'BASIC-USER'], + }), + ).toEqual({ + selectedRole: 'STANDARD-USER', + reason: 'matched_priority', + }); + }); + + it('matches roles case-insensitively and returns the configured canonical role name', () => { + expect( + selectOpenIdRole({ + openIdRoleValues: ['standard-user'], + rolePriority: ['STANDARD-USER'], + }).selectedRole, + ).toBe('STANDARD-USER'); + }); + + it('uses rolePriority as the ordered assignable role list', () => { + expect( + selectOpenIdRole({ + currentRole: 'BASIC-USER', + openIdRoleValues: ['BASIC-USER', 'STANDARD-USER'], + rolePriority: ['BASIC-USER', 'STANDARD-USER'], + }), + ).toEqual({ + selectedRole: 'BASIC-USER', + reason: 'matched_priority', + }); + }); + + it('ignores token values that are not in rolePriority or fallbackRole', () => { + expect( + selectOpenIdRole({ + currentRole: SystemRoles.USER, + openIdRoleValues: ['BASIC-USER', 'STANDARD-USER'], + rolePriority: [], + }), + ).toEqual({ + reason: 'no_matching_role', + }); + }); + + it('applies fallback only when no configured role matches', () => { + expect( + selectOpenIdRole({ + currentRole: 'BASIC-USER', + openIdRoleValues: ['UNKNOWN'], + rolePriority: ['BASIC-USER'], + fallbackRole: SystemRoles.USER, + }), + ).toEqual({ + selectedRole: SystemRoles.USER, + reason: 'fallback', + }); + }); + + it('does not apply fallback when a priority role already matches', () => { + expect( + selectOpenIdRole({ + openIdRoleValues: ['BASIC-USER'], + rolePriority: ['BASIC-USER'], + fallbackRole: SystemRoles.USER, + }), + ).toEqual({ + selectedRole: 'BASIC-USER', + reason: 'matched_priority', + }); + }); + + it('keeps the current fallback role when it is present in the OpenID values', () => { + expect( + selectOpenIdRole({ + currentRole: SystemRoles.USER, + openIdRoleValues: [SystemRoles.USER], + rolePriority: ['BASIC-USER'], + fallbackRole: SystemRoles.USER, + }), + ).toEqual({ + selectedRole: SystemRoles.USER, + reason: 'kept_current', + }); + }); + + it('does not keep a current role that is outside rolePriority and fallbackRole', () => { + expect( + selectOpenIdRole({ + currentRole: 'LOCAL-ROLE', + openIdRoleValues: ['LOCAL-ROLE'], + rolePriority: ['STANDARD-USER'], + fallbackRole: SystemRoles.USER, + }), + ).toEqual({ + selectedRole: SystemRoles.USER, + reason: 'fallback', + }); + }); + + it('selects fallback when the fallback role is present and no priority role matches', () => { + expect( + selectOpenIdRole({ + currentRole: 'BASIC-USER', + openIdRoleValues: [SystemRoles.USER], + rolePriority: ['STANDARD-USER'], + fallbackRole: SystemRoles.USER, + }), + ).toEqual({ + selectedRole: SystemRoles.USER, + reason: 'fallback', + }); + }); + + it('excludes ADMIN from generic matching and fallback assignment', () => { + expect( + selectOpenIdRole({ + openIdRoleValues: [SystemRoles.ADMIN], + rolePriority: [SystemRoles.ADMIN], + fallbackRole: SystemRoles.ADMIN, + }), + ).toEqual({ + reason: 'no_matching_role', + }); + }); + + it('ignores unknown normalized values without affecting priority selection', () => { + expect( + selectOpenIdRole({ + openIdRoleValues: ['UNKNOWN', 'unknown', 'BASIC-USER', 'ignored'], + rolePriority: ['BASIC-USER'], + }), + ).toEqual({ + selectedRole: 'BASIC-USER', + reason: 'matched_priority', + }); + }); +}); diff --git a/packages/api/src/auth/openidRoleSync.ts b/packages/api/src/auth/openidRoleSync.ts new file mode 100644 index 0000000000..3f028213ee --- /dev/null +++ b/packages/api/src/auth/openidRoleSync.ts @@ -0,0 +1,314 @@ +import { get } from 'lodash'; +import { SystemRoles } from 'librechat-data-provider'; +import { isEnabled } from '~/utils'; + +export type OpenIdRoleSyncClaimSource = 'access' | 'id' | 'userinfo'; + +export type OpenIdRoleSyncOptions = { + enabled: boolean; + apiEnabled: boolean; + claimSource: OpenIdRoleSyncClaimSource; + claim?: string; + rolePriority: string[]; + fallbackRole?: string; +}; + +type OpenIdRoleSyncSelectionInput = { + currentRole?: string; + openIdRoleValues?: string | unknown[]; + rolePriority: string[]; + fallbackRole?: string; +}; + +export type OpenIdRoleSyncSelectionResult = { + selectedRole?: string; + reason?: 'matched_priority' | 'kept_current' | 'fallback' | 'no_matching_role'; +}; + +type OpenIdRolesForOpenIdSyncInput = { + options: OpenIdRoleSyncOptions; + accessToken?: string; + accessClaims?: unknown; + idToken?: string; + claims?: unknown; + userinfo?: unknown; + decodeToken: (token: string) => unknown; + resolveGroupOverage?: () => Promise; +}; + +type OpenIdRoleSyncRolesLookup = ( + roleNames: string[], + fieldsToSelect?: string | string[] | null, +) => Promise | null | undefined>; + +type LibreChatRolesForOpenIdSyncInput = { + rolePriority: string[]; + fallbackRole?: string; + getRolesByNames: OpenIdRoleSyncRolesLookup; + logPrefix?: string; +}; + +type LibreChatRolesForOpenIdSync = { + rolePriority: string[]; + fallbackRole?: string; +}; + +/** + * Reads and validates the OPENID_ROLE_SYNC_* environment configuration. + */ +export function getOpenIdRoleSyncOptions( + env: NodeJS.ProcessEnv = process.env, +): OpenIdRoleSyncOptions { + const enabled = isEnabled(env.OPENID_ROLE_SYNC_ENABLED); + const apiEnabled = isEnabled(env.OPENID_ROLE_SYNC_API_ENABLED); + const rawSource = env.OPENID_ROLE_SYNC_SOURCE?.trim() || 'id'; + const claimSource = rawSource as OpenIdRoleSyncClaimSource; + const claim = env.OPENID_ROLE_SYNC_CLAIM?.trim() || undefined; + const rolePriority = + env.OPENID_ROLE_SYNC_ROLE_PRIORITY?.split(',') + .map((role) => role.trim()) + .filter(Boolean) ?? []; + const fallbackRole = env.OPENID_ROLE_SYNC_FALLBACK_ROLE?.trim() || undefined; + + if (apiEnabled && !enabled) { + throw new Error( + '[openidRoleSync] OPENID_ROLE_SYNC_API_ENABLED requires OPENID_ROLE_SYNC_ENABLED=true', + ); + } + + /** + * Only validate role-sync-specific settings once the feature is enabled. A + * disabled deployment must not fail OpenID login just because a stale or + * mistyped OPENID_ROLE_SYNC_* value is left in the environment. + */ + if (!enabled) { + return { enabled, apiEnabled, claimSource, claim, rolePriority, fallbackRole }; + } + + if (!['access', 'id', 'userinfo'].includes(claimSource)) { + throw new Error( + `[openidRoleSync] OPENID_ROLE_SYNC_SOURCE must be one of: access, id, userinfo`, + ); + } + + if (!claim) { + throw new Error( + '[openidRoleSync] OPENID_ROLE_SYNC_CLAIM is required when role sync is enabled', + ); + } + + if (rolePriority.some((role) => role.toLowerCase() === SystemRoles.ADMIN.toLowerCase())) { + throw new Error('[openidRoleSync] OPENID_ROLE_SYNC_ROLE_PRIORITY cannot include ADMIN'); + } + + if (fallbackRole?.toLowerCase() === SystemRoles.ADMIN.toLowerCase()) { + throw new Error('[openidRoleSync] OPENID_ROLE_SYNC_FALLBACK_ROLE cannot be ADMIN'); + } + + return { enabled, apiEnabled, claimSource, claim, rolePriority, fallbackRole }; +} + +/** + * Extracts the configured role claim from the configured OpenID source. + * Callers provide token decoding and optional group-overage resolution. + */ +export async function getOpenIdRolesForOpenIdSync({ + options, + accessToken, + accessClaims, + idToken, + claims, + userinfo, + decodeToken, + resolveGroupOverage, +}: OpenIdRolesForOpenIdSyncInput): Promise { + let source: unknown; + + switch (options.claimSource) { + case 'access': + source = accessClaims ?? (accessToken ? decodeToken(accessToken) : undefined); + break; + case 'id': + source = idToken ? decodeToken(idToken) : claims; + break; + case 'userinfo': + source = userinfo; + break; + } + + if (!source || !options.claim) { + return; + } + + /** + * Azure AD/Entra moves an oversized `groups` claim into `_claim_names`/`_claim_sources` + * for both ID and access tokens, so overage resolution must cover both sources — not + * just the ID token — or `access`-sourced syncs silently see no groups for those users. + */ + const supportsGroupOverage = + options.claim === 'groups' && + (options.claimSource === 'id' || options.claimSource === 'access'); + if (supportsGroupOverage) { + const claimsData = source as { + hasgroups?: unknown; + _claim_names?: { groups?: string }; + _claim_sources?: Record; + }; + const groupSource = claimsData._claim_names?.groups; + const hasGroupOverage = Boolean( + claimsData.hasgroups || (groupSource && claimsData._claim_sources?.[groupSource]), + ); + + if (hasGroupOverage) { + return (await resolveGroupOverage?.()) ?? undefined; + } + } + + const openIdRoleValues = get(source, options.claim); + if (Array.isArray(openIdRoleValues) || typeof openIdRoleValues === 'string') { + return openIdRoleValues; + } + /** + * The source is available but carries no usable value for the configured claim + * (absent, null, or a non-string/array type). Return an empty list rather than + * `undefined` so callers still run selection and apply the configured fallback, + * instead of leaving a stale elevated role in place. + */ + return []; +} + +/** + * Gets the configured LibreChat roles that OpenID role sync is allowed to assign. + * The names are read from config, checked against storage, and returned as canonical role names. + */ +export async function getLibreChatRolesForOpenIdSync( + input: LibreChatRolesForOpenIdSyncInput, +): Promise { + const roleNames = input.fallbackRole + ? [...input.rolePriority, input.fallbackRole] + : input.rolePriority; + const uniqueRoleNames: string[] = []; + const seenRoleKeys = new Set(); + + for (const roleName of roleNames) { + const trimmed = roleName.trim(); + const key = trimmed.toLowerCase(); + + if (!trimmed || seenRoleKeys.has(key)) { + continue; + } + + seenRoleKeys.add(key); + uniqueRoleNames.push(trimmed); + } + + const roles = (await input.getRolesByNames(uniqueRoleNames, 'name')) ?? []; + const existingRoleNames = new Map( + roles + .filter((role): role is { name: string } => typeof role?.name === 'string') + .map((role) => [role.name.trim().toLowerCase(), role.name.trim()]), + ); + /** + * System roles (e.g. USER) are provisioned globally at startup without a tenant + * context, so a tenant-scoped lookup may not return them even though they exist. + * Treat them as always-available canonical names so a documented system fallback + * role does not fail validation for tenant users. + */ + for (const systemRole of Object.values(SystemRoles)) { + const key = systemRole.toLowerCase(); + if (!existingRoleNames.has(key)) { + existingRoleNames.set(key, systemRole); + } + } + const missingRoleNames = uniqueRoleNames.filter( + (roleName) => !existingRoleNames.has(roleName.toLowerCase()), + ); + + if (missingRoleNames.length > 0) { + throw new Error( + `${input.logPrefix ?? '[openidRoleSync]'} OpenID role sync configured roles do not exist: ${missingRoleNames.join(', ')}`, + ); + } + + return { + rolePriority: input.rolePriority.map( + (roleName) => existingRoleNames.get(roleName.trim().toLowerCase()) ?? roleName.trim(), + ), + fallbackRole: input.fallbackRole + ? (existingRoleNames.get(input.fallbackRole.trim().toLowerCase()) ?? + input.fallbackRole.trim()) + : undefined, + }; +} + +/** + * Chooses the LibreChat role to assign from normalized OpenID token values and validated config. + * Priority roles win first, then the current fallback role can be preserved, then fallback applies. + */ +export function selectOpenIdRole( + input: OpenIdRoleSyncSelectionInput, +): OpenIdRoleSyncSelectionResult { + const assignableRoles = input.fallbackRole + ? [...input.rolePriority, input.fallbackRole] + : input.rolePriority; + const openIdRoleValues = Array.isArray(input.openIdRoleValues) + ? input.openIdRoleValues + : (input.openIdRoleValues?.split(/[\s,]+/) ?? []); + const assignableRoleKeys = new Set( + assignableRoles + .map((role) => role.trim().toLowerCase()) + .filter((role) => role && role !== SystemRoles.ADMIN.toLowerCase()), + ); + const openIdRoleKeys = new Set(); + + // Keep only OpenID roles that can map to configured LibreChat roles. + for (const value of openIdRoleValues) { + if (typeof value !== 'string') { + continue; + } + + const trimmed = value.trim(); + const key = trimmed.toLowerCase(); + + if ( + !trimmed || + key === SystemRoles.ADMIN.toLowerCase() || + (assignableRoleKeys.size > 0 && !assignableRoleKeys.has(key)) + ) { + continue; + } + + openIdRoleKeys.add(key); + } + + for (const role of input.rolePriority) { + const trimmed = role.trim(); + + if ( + trimmed && + trimmed.toLowerCase() !== SystemRoles.ADMIN.toLowerCase() && + openIdRoleKeys.has(trimmed.toLowerCase()) + ) { + return { selectedRole: trimmed, reason: 'matched_priority' }; + } + } + + if ( + input.currentRole && + input.fallbackRole && + input.currentRole.trim().toLowerCase() === input.fallbackRole.trim().toLowerCase() && + openIdRoleKeys.has(input.currentRole.trim().toLowerCase()) + ) { + return { selectedRole: input.currentRole, reason: 'kept_current' }; + } + + if (input.fallbackRole) { + const trimmed = input.fallbackRole.trim(); + + if (trimmed && trimmed.toLowerCase() !== SystemRoles.ADMIN.toLowerCase()) { + return { selectedRole: trimmed, reason: 'fallback' }; + } + } + + return { reason: 'no_matching_role' }; +} diff --git a/packages/api/src/middleware/remoteAgentAuth.spec.ts b/packages/api/src/middleware/remoteAgentAuth.spec.ts index 96b9f30e94..f7c7c96db0 100644 --- a/packages/api/src/middleware/remoteAgentAuth.spec.ts +++ b/packages/api/src/middleware/remoteAgentAuth.spec.ts @@ -46,15 +46,17 @@ jest.mock('../auth/openid', () => { import jwt from 'jsonwebtoken'; import jwksRsa from 'jwks-rsa'; +import { SystemRoles } from 'librechat-data-provider'; import { ProxyAgent, fetch as undiciFetch } from 'undici'; import { logger, tenantStorage } from '@librechat/data-schemas'; import { clearRemoteAgentAuthCache, createRemoteAgentAuth } from './remoteAgentAuth'; import { findOpenIDUser, getOpenIdEmail } from '../auth/openid'; -import { math } from '~/utils'; +import { isEnabled, math } from '~/utils'; const mockFetch = undiciFetch as jest.Mock; const mockProxyAgent = ProxyAgent as unknown as jest.Mock; const mockMath = math as jest.Mock; +const mockIsEnabled = isEnabled as jest.Mock; const realFindOpenIDUser = jest.requireActual('../auth/openid').findOpenIDUser; const mockFindOpenIDUser = findOpenIDUser as jest.MockedFunction; @@ -66,6 +68,12 @@ const ENV_KEYS = [ 'OPENID_JWKS_URL', 'OPENID_JWKS_URL_CACHE_ENABLED', 'OPENID_JWKS_URL_CACHE_TIME', + 'OPENID_ROLE_SYNC_ENABLED', + 'OPENID_ROLE_SYNC_API_ENABLED', + 'OPENID_ROLE_SYNC_SOURCE', + 'OPENID_ROLE_SYNC_CLAIM', + 'OPENID_ROLE_SYNC_ROLE_PRIORITY', + 'OPENID_ROLE_SYNC_FALLBACK_ROLE', 'PROXY', ] as const; @@ -92,6 +100,12 @@ const originalEnv = ENV_KEYS.reduce + roleNames.map((roleName) => ({ name: roleName })), + ), getAppConfig: jest.fn().mockResolvedValue(appConfig), apiKeyMiddleware: jest.fn((_req: unknown, _res: unknown, next: () => void) => next()), }; @@ -229,6 +246,7 @@ describe('createRemoteAgentAuth', () => { clearRemoteAgentAuthCache(); mockFetch.mockReset(); mockMath.mockReturnValue(60000); + mockIsEnabled.mockImplementation((value?: string) => value === 'true'); mockFindOpenIDUser.mockImplementation(realFindOpenIDUser); mockNext = jest.fn(); }); @@ -1416,6 +1434,186 @@ describe('createRemoteAgentAuth', () => { }); }); + describe('OpenID role sync', () => { + function enableApiRoleSync(overrides: Record = {}) { + process.env.OPENID_ROLE_SYNC_ENABLED = 'true'; + process.env.OPENID_ROLE_SYNC_API_ENABLED = 'true'; + process.env.OPENID_ROLE_SYNC_SOURCE = 'access'; + process.env.OPENID_ROLE_SYNC_CLAIM = 'roles'; + process.env.OPENID_ROLE_SYNC_ROLE_PRIORITY = 'STANDARD-USER,BASIC-USER'; + process.env.OPENID_ROLE_SYNC_FALLBACK_ROLE = 'USER'; + + for (const [key, value] of Object.entries(overrides)) { + process.env[key] = value; + } + } + + it('does not run unless API role sync is explicitly enabled', async () => { + process.env.OPENID_ROLE_SYNC_ENABLED = 'true'; + process.env.OPENID_ROLE_SYNC_SOURCE = 'access'; + process.env.OPENID_ROLE_SYNC_CLAIM = 'roles'; + process.env.OPENID_ROLE_SYNC_ROLE_PRIORITY = 'STANDARD-USER'; + setupOidcMocks({ sub: 'sub123', email: 'agent@test.com', roles: ['STANDARD-USER'] }); + + const deps = makeDeps(); + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + makeRes().res, + mockNext, + ); + + expect(deps.getRolesByNames).not.toHaveBeenCalled(); + expect(deps.updateUser).not.toHaveBeenCalled(); + expect(mockNext).toHaveBeenCalled(); + }); + + it('selects the highest configured matching role from the verified bearer payload', async () => { + enableApiRoleSync(); + setupOidcMocks({ + sub: 'sub123', + email: 'agent@test.com', + roles: ['BASIC-USER', 'STANDARD-USER'], + }); + + const deps = makeDeps(); + const req = makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }); + await createRemoteAgentAuth(deps)(req as Request, makeRes().res, mockNext); + + expect(deps.updateUser).toHaveBeenCalledWith('uid123', { role: 'STANDARD-USER' }); + expect(req.user).toMatchObject({ role: 'STANDARD-USER' }); + expect(mockNext).toHaveBeenCalled(); + }); + + it('applies fallback when the verified payload has no configured role match', async () => { + enableApiRoleSync(); + setupOidcMocks({ + sub: 'sub123', + email: 'agent@test.com', + roles: ['external-role'], + }); + + const deps = makeDeps(); + const req = makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }); + await createRemoteAgentAuth(deps)(req as Request, makeRes().res, mockNext); + + expect(deps.updateUser).toHaveBeenCalledWith('uid123', { role: 'USER' }); + expect(req.user).toMatchObject({ role: 'USER' }); + }); + + it('applies fallback when the role claim is present but empty', async () => { + enableApiRoleSync(); + setupOidcMocks({ + sub: 'sub123', + email: 'agent@test.com', + roles: '', + }); + + const deps = makeDeps(); + const req = makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }); + await createRemoteAgentAuth(deps)(req as Request, makeRes().res, mockNext); + + expect(deps.updateUser).toHaveBeenCalledWith('uid123', { role: 'USER' }); + expect(req.user).toMatchObject({ role: 'USER' }); + }); + + it('preserves an existing ADMIN role because generic role sync cannot manage admin', async () => { + enableApiRoleSync(); + setupOidcMocks({ + sub: 'sub123', + email: 'agent@test.com', + roles: ['STANDARD-USER'], + }); + + const deps = makeDeps(); + deps.findUser = makeFindUser(makeUser({ role: SystemRoles.ADMIN })); + const req = makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }); + await createRemoteAgentAuth(deps)(req as Request, makeRes().res, mockNext); + + expect(deps.getRolesByNames).not.toHaveBeenCalled(); + expect(deps.updateUser).not.toHaveBeenCalled(); + expect(req.user).toMatchObject({ role: SystemRoles.ADMIN }); + }); + + it('leaves the role unchanged when the configured source is unavailable to API auth', async () => { + enableApiRoleSync({ OPENID_ROLE_SYNC_SOURCE: 'id' }); + setupOidcMocks({ sub: 'sub123', email: 'agent@test.com', roles: ['STANDARD-USER'] }); + + const deps = makeDeps(); + const req = makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }); + await createRemoteAgentAuth(deps)(req as Request, makeRes().res, mockNext); + + expect(deps.getRolesByNames).not.toHaveBeenCalled(); + expect(deps.updateUser).not.toHaveBeenCalled(); + expect(req.user).toMatchObject({ role: 'user' }); + }); + + it('does not apply fallback when API group overage is unresolved', async () => { + enableApiRoleSync({ OPENID_ROLE_SYNC_CLAIM: 'groups' }); + setupOidcMocks({ + sub: 'sub123', + email: 'agent@test.com', + hasgroups: true, + }); + + const deps = makeDeps(); + const req = makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }); + await createRemoteAgentAuth(deps)(req as Request, makeRes().res, mockNext); + + expect(deps.updateUser).not.toHaveBeenCalled(); + expect(req.user).toMatchObject({ role: 'user' }); + }); + + it('runs role lookup and persistence in the resolved user tenant context', async () => { + enableApiRoleSync(); + setupOidcMocks({ sub: 'sub123', email: 'agent@test.com', roles: ['BASIC-USER'] }); + + const deps = makeDeps(); + deps.findUser = makeFindUser(makeUser({ tenantId: 'tenant-role-sync' })); + deps.getAppConfig.mockImplementation(async (options) => + options?.tenantId === 'tenant-role-sync' + ? makeConfig({ scope: undefined }, { enabled: false }) + : makeConfig({ scope: undefined }, { enabled: true }), + ); + const req = makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }); + + await createRemoteAgentAuth(deps)(req as Request, makeRes().res, mockNext); + + expect(deps.getRolesByNames).toHaveBeenCalledWith( + ['STANDARD-USER', 'BASIC-USER', 'USER'], + 'name', + ); + expect(deps.updateUser).toHaveBeenCalledWith('uid123', { role: 'BASIC-USER' }); + expect(req.user).toMatchObject({ tenantId: 'tenant-role-sync', role: 'BASIC-USER' }); + }); + + it('re-checks resolved user policy after role sync changes the role', async () => { + enableApiRoleSync(); + setupOidcMocks({ sub: 'sub123', email: 'agent@test.com', roles: ['STANDARD-USER'] }); + + const deps = makeDeps(); + deps.findUser = makeFindUser(makeUser({ tenantId: 'tenant-role-sync', role: 'BASIC-USER' })); + deps.getAppConfig.mockImplementation(async (options) => { + if (options?.tenantId !== 'tenant-role-sync') { + return makeConfig({ scope: undefined }, { enabled: true }); + } + + return options.role === 'STANDARD-USER' + ? makeConfig({ enabled: false }, { enabled: false }) + : makeConfig({ scope: undefined }, { enabled: false }); + }); + const req = makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }); + const { res, status, json } = makeRes(); + + await createRemoteAgentAuth(deps)(req as Request, res, mockNext); + + expect(status).toHaveBeenCalledWith(401); + expect(json).toHaveBeenCalledWith({ error: 'Unauthorized' }); + expect(deps.updateUser).not.toHaveBeenCalled(); + expect(req.user).toBeUndefined(); + expect(mockNext).not.toHaveBeenCalled(); + }); + }); + describe('scope validation', () => { it('returns 401 when required scope is missing from token', async () => { setupOidcMocks({ sub: 'sub123', email: 'agent@test.com', scope: 'openid profile' }); diff --git a/packages/api/src/middleware/remoteAgentAuth.ts b/packages/api/src/middleware/remoteAgentAuth.ts index 2ed7031fe8..49b160d4dc 100644 --- a/packages/api/src/middleware/remoteAgentAuth.ts +++ b/packages/api/src/middleware/remoteAgentAuth.ts @@ -2,20 +2,27 @@ import jwt from 'jsonwebtoken'; import jwksRsa from 'jwks-rsa'; import { HttpsProxyAgent } from 'https-proxy-agent'; import { ProxyAgent, fetch as undiciFetch } from 'undici'; -import { getTenantId, logger } from '@librechat/data-schemas'; +import { getTenantId, logger, tenantStorage } from '@librechat/data-schemas'; import { SystemRoles, isRemoteOidcUrlAllowed } from 'librechat-data-provider'; import type { RequestHandler, Request, Response, NextFunction } from 'express'; -import type { AppConfig, IUser, UserMethods } from '@librechat/data-schemas'; +import type { AppConfig, IUser, RoleMethods, UserMethods } from '@librechat/data-schemas'; import type { Algorithm, JwtPayload, VerifyOptions } from 'jsonwebtoken'; import type { TAgentsEndpoint } from 'librechat-data-provider'; import type { RequestInit } from 'undici'; import type { GetAppConfigOptions } from '../app/service'; import { findOpenIDUser, getOpenIdEmail, normalizeOpenIdIssuer } from '../auth/openid'; +import { + getLibreChatRolesForOpenIdSync, + getOpenIdRolesForOpenIdSync, + getOpenIdRoleSyncOptions, + selectOpenIdRole, +} from '../auth/openidRoleSync'; import { isEnabled, math } from '~/utils'; export interface RemoteAgentAuthDeps { apiKeyMiddleware: RequestHandler; findUser: UserMethods['findUser']; + getRolesByNames: RoleMethods['findRolesByNames']; updateUser: UserMethods['updateUser']; getAppConfig: (options?: GetAppConfigOptions) => Promise; } @@ -444,6 +451,87 @@ async function resolveUser( return { status: 'resolved', user, updateData }; } +async function selectOpenIdRoleForOpenIdSync( + payload: JwtPayload, + user: IUser, + getRolesByNames: RemoteAgentAuthDeps['getRolesByNames'], +): Promise { + const options = getOpenIdRoleSyncOptions(); + if (!options.enabled || !options.apiEnabled) { + return; + } + + if (user.role === SystemRoles.ADMIN) { + logger.info( + `[remoteAgentAuth] OpenID role sync skipped for ${user.id}; existing ADMIN role is not managed by generic role sync`, + ); + return; + } + + if (options.claimSource !== 'access') { + logger.warn( + `[remoteAgentAuth] OpenID role sync skipped; source '${options.claimSource}' is not available for API auth`, + ); + return; + } + + const openIdRoleValues = await getOpenIdRolesForOpenIdSync({ + options, + accessClaims: payload, + decodeToken: () => payload, + }); + if (openIdRoleValues === undefined) { + logger.warn( + `[remoteAgentAuth] OpenID role sync skipped; claim '${options.claim}' was not found or invalid`, + ); + return; + } + + const loadLibreChatRoles = async () => + getLibreChatRolesForOpenIdSync({ + getRolesByNames, + rolePriority: options.rolePriority, + fallbackRole: options.fallbackRole, + logPrefix: '[remoteAgentAuth]', + }); + const { rolePriority, fallbackRole } = + user.tenantId && getTenantId() !== user.tenantId + ? await tenantStorage.run({ tenantId: user.tenantId }, loadLibreChatRoles) + : await loadLibreChatRoles(); + const result = selectOpenIdRole({ + currentRole: user.role, + openIdRoleValues, + rolePriority, + fallbackRole, + }); + + if (!result.selectedRole || result.selectedRole === user.role) { + return; + } + + logger.info( + `[remoteAgentAuth] OpenID role sync selected role for ${user.id}: ${user.role || 'unset'} -> ${result.selectedRole}`, + ); + return result.selectedRole; +} + +async function updateResolvedUser( + userResolution: Extract, + updateUser: RemoteAgentAuthDeps['updateUser'], +): Promise { + if (Object.keys(userResolution.updateData).length === 0) { + return; + } + + const update = async () => updateUser(userResolution.user.id, userResolution.updateData); + if (userResolution.user.tenantId && getTenantId() !== userResolution.user.tenantId) { + await tenantStorage.run({ tenantId: userResolution.user.tenantId }, update); + return; + } + + await update(); +} + /** * Factory for Remote Agent API auth middleware. * @@ -468,6 +556,7 @@ async function resolveUser( export function createRemoteAgentAuth({ apiKeyMiddleware, findUser, + getRolesByNames, updateUser, getAppConfig, }: RemoteAgentAuthDeps): RequestHandler { @@ -569,10 +658,32 @@ export function createRemoteAgentAuth({ return; } - if (Object.keys(userResolution.updateData).length > 0) { - await updateUser(userResolution.user.id, userResolution.updateData); + const selectedRole = await selectOpenIdRoleForOpenIdSync( + payload, + userResolution.user, + getRolesByNames, + ); + const roleChanged = Boolean(selectedRole); + if (selectedRole) { + userResolution.user.role = selectedRole; + userResolution.updateData.role = selectedRole; } + if ( + roleChanged && + !(await enforceOidcTenantPolicy( + token, + userResolution.user, + initialConfigOptions, + getAppConfig, + )) + ) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + await updateResolvedUser(userResolution, updateUser); + req.user = userResolution.user; return next(); } catch (err) { diff --git a/packages/data-schemas/src/methods/role.methods.spec.ts b/packages/data-schemas/src/methods/role.methods.spec.ts index be75be7b6f..2cd9d152f8 100644 --- a/packages/data-schemas/src/methods/role.methods.spec.ts +++ b/packages/data-schemas/src/methods/role.methods.spec.ts @@ -4,6 +4,8 @@ import { SystemRoles, Permissions, roleDefaults, PermissionTypes } from 'librech import type { IRole, IUser, RolePermissions } from '..'; import { createRoleMethods } from './role'; import { createModels } from '../models'; +import { _resetStrictCache } from '../models/plugins/tenantIsolation'; +import { tenantStorage } from '~/config/tenantContext'; jest.mock('~/config/winston', () => ({ error: jest.fn(), @@ -23,6 +25,7 @@ const mockGetCache = jest.fn().mockReturnValue(mockCache); let Role: mongoose.Model; let User: mongoose.Model; let getRoleByName: ReturnType['getRoleByName']; +let findRolesByNames: ReturnType['findRolesByNames']; let updateAccessPermissions: ReturnType['updateAccessPermissions']; let initializeRoles: ReturnType['initializeRoles']; let createRoleByName: ReturnType['createRoleByName']; @@ -44,6 +47,7 @@ beforeAll(async () => { User = mongoose.models.User as mongoose.Model; const methods = createRoleMethods(mongoose, { getCache: mockGetCache }); getRoleByName = methods.getRoleByName; + findRolesByNames = methods.findRolesByNames; updateAccessPermissions = methods.updateAccessPermissions; initializeRoles = methods.initializeRoles; createRoleByName = methods.createRoleByName; @@ -70,6 +74,90 @@ beforeEach(async () => { mockCache.del.mockClear(); }); +describe('findRolesByNames', () => { + it('queries storage without reading or writing the role cache', async () => { + await Role.create([ + { name: 'STANDARD-USER', permissions: {} }, + { name: 'BASIC-USER', permissions: {} }, + ]); + + await expect(findRolesByNames(['STANDARD-USER', 'BASIC-USER'], 'name')).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'STANDARD-USER' }), + expect.objectContaining({ name: 'BASIC-USER' }), + ]), + ); + expect(mockGetCache).not.toHaveBeenCalled(); + expect(mockCache.get).not.toHaveBeenCalled(); + expect(mockCache.set).not.toHaveBeenCalled(); + }); + + it('matches role names case-insensitively without using pagination', async () => { + const roles = Array.from({ length: 75 }, (_value, index) => ({ + name: `ROLE-${index}`, + permissions: {}, + })); + await Role.create([...roles, { name: 'STANDARD-USER', permissions: {} }]); + + await expect(findRolesByNames(['standard-user'], 'name')).resolves.toEqual([ + expect.objectContaining({ name: 'STANDARD-USER' }), + ]); + }); + + it('uses the active tenant context for matching role names', async () => { + await tenantStorage.run({ tenantId: 'tenant-a' }, async () => { + await Role.create({ name: 'TENANT-ROLE', permissions: {} }); + }); + await tenantStorage.run({ tenantId: 'tenant-b' }, async () => { + await Role.create({ name: 'TENANT-ROLE', permissions: {} }); + }); + + const tenantARoles = await tenantStorage.run({ tenantId: 'tenant-a' }, async () => + findRolesByNames(['TENANT-ROLE'], 'name tenantId'), + ); + const tenantBRoles = await tenantStorage.run({ tenantId: 'tenant-b' }, async () => + findRolesByNames(['TENANT-ROLE'], 'name tenantId'), + ); + + expect(tenantARoles).toEqual([ + expect.objectContaining({ name: 'TENANT-ROLE', tenantId: 'tenant-a' }), + ]); + expect(tenantBRoles).toEqual([ + expect.objectContaining({ name: 'TENANT-ROLE', tenantId: 'tenant-b' }), + ]); + }); + + it('matches only base roles when no tenant context is active', async () => { + await Role.create({ name: 'SCOPED-ROLE', permissions: {} }); + await tenantStorage.run({ tenantId: 'tenant-a' }, async () => { + await Role.create({ name: 'TENANT-ONLY-ROLE', permissions: {} }); + }); + + const baseMatches = await findRolesByNames( + ['SCOPED-ROLE', 'TENANT-ONLY-ROLE'], + 'name tenantId', + ); + + expect(baseMatches).toEqual([expect.objectContaining({ name: 'SCOPED-ROLE' })]); + expect(baseMatches).toHaveLength(1); + }); + + it('matches base roles without a tenant context even under strict isolation', async () => { + await Role.create({ name: 'STRICT-BASE-ROLE', permissions: {} }); + process.env.TENANT_ISOLATION_STRICT = 'true'; + _resetStrictCache(); + + try { + await expect(findRolesByNames(['STRICT-BASE-ROLE'], 'name')).resolves.toEqual([ + expect.objectContaining({ name: 'STRICT-BASE-ROLE' }), + ]); + } finally { + delete process.env.TENANT_ISOLATION_STRICT; + _resetStrictCache(); + } + }); +}); + describe('updateAccessPermissions', () => { it('should update permissions when changes are needed', async () => { await new Role({ diff --git a/packages/data-schemas/src/methods/role.ts b/packages/data-schemas/src/methods/role.ts index ab3cce196f..8b22e4a5a0 100644 --- a/packages/data-schemas/src/methods/role.ts +++ b/packages/data-schemas/src/methods/role.ts @@ -7,7 +7,8 @@ import { } from 'librechat-data-provider'; import type { Model } from 'mongoose'; import type { IRole, IUser } from '~/types'; -import { scopedCacheKey } from '~/config/tenantContext'; +import { scopedCacheKey, getTenantId, runAsSystem, SYSTEM_TENANT_ID } from '~/config/tenantContext'; +import { escapeRegExp } from '~/utils/string'; import logger from '~/config/winston'; const systemRoleValues = new Set(Object.values(SystemRoles)); @@ -123,6 +124,56 @@ export function createRoleMethods(mongoose: typeof import('mongoose'), deps: Rol } } + /** + * Find roles by name without using or populating the shared role-name cache. + * Use this for tenant-scoped authorization lookups where the active ALS tenant context must control the query. + * + * When a non-system tenant context is active, the tenant-isolation plugin scopes the + * query to that tenant. When no tenant context is active (base/global users), the lookup + * runs under an explicit system context — so strict-mode isolation does not reject the + * context-less query — while an explicit base-role filter (`tenantId` unset) ensures a + * base user cannot match, and be assigned, a role that only exists within some tenant. + */ + async function findRolesByNames( + roleNames: string[], + fieldsToSelect: string | string[] | null = null, + ) { + try { + const uniqueRoleNames = [ + ...new Set(roleNames.map((roleName) => roleName.trim()).filter(Boolean)), + ]; + if (uniqueRoleNames.length === 0) { + return [] as IRole[]; + } + + const Role = mongoose.models.Role; + const nameFilter = { + $or: uniqueRoleNames.map((roleName) => ({ + name: new RegExp(`^${escapeRegExp(roleName)}$`, 'i'), + })), + }; + + const runQuery = (filter: Record) => { + let query = Role.find(filter); + if (fieldsToSelect) { + query = query.select(fieldsToSelect); + } + return query.lean().exec(); + }; + + const tenantId = getTenantId(); + if (tenantId && tenantId !== SYSTEM_TENANT_ID) { + return await runQuery(nameFilter); + } + + return await runAsSystem(() => + runQuery({ ...nameFilter, tenantId: { $in: [null, undefined] } }), + ); + } catch (error) { + throw new Error(`Failed to retrieve roles: ${(error as Error).message}`); + } + } + /** * Update role values by name. */ @@ -510,6 +561,7 @@ export function createRoleMethods(mongoose: typeof import('mongoose'), deps: Rol countRoles, initializeRoles, getRoleByName, + findRolesByNames, updateRoleByName, updateAccessPermissions, migrateRoleSchema,