From 83d8ac068251ffcb8069ad4160d852c59f859677 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 2 Jun 2026 14:00:56 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=AA=9C=20feat:=20Add=20OpenID=20Role=20Sy?= =?UTF-8?q?nc=20(#13415)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Shared Role-Sync Core * Environment Configuration * Browser OpenID Wiring & improved shared component * API Auth Wiring * Improved Role Lookup * added example for sync env * small simplification * protect existing manual assigned ADMIN Roles * fix: Apply OpenID role-sync fallback for present-but-empty claims Both role-sync call sites skipped on a falsy `openIdRoleValues`, treating an empty claim string ('') the same as a missing claim and returning before `selectOpenIdRole` could apply the configured fallback role. An IdP emitting an empty roles claim for a user with no mapped groups left the stale local role in place instead of the authoritative fallback. Skip only when the helper returns `undefined` (missing/invalid), letting an empty string flow through to fallback selection — consistent with how an empty array is already handled. Adds regression coverage on both the OpenID strategy and the remote-agent API auth paths. * refactor: Address OpenID role-sync review feedback - role.ts: reuse the shared escapeRegExp util instead of a local escapeRegex duplicate, matching prompt/skill/user/userGroup methods (Copilot). - openidStrategy.js / remoteAgentAuth.ts: make the tenantStorage.run callbacks async so the documented ALS contract is satisfied and tenant context cannot be lost during Mongoose execution; the wrapped lookups/updates are already async, so behavior is unchanged (codex P2). * fix: Harden OpenID role-sync claim and fallback handling Addresses the second Codex review cycle (P2 findings): - Apply fallback when the claim is absent: getOpenIdRolesForOpenIdSync now returns an empty list (not undefined) when the token source exists but has no usable claim value, so callers still run selection and assign the configured fallback instead of leaving a stale elevated role. A truly unavailable source still returns undefined and skips sync. - Resolve group overage for access tokens too: the _claim_names/_claim_sources overage path previously only ran for claimSource 'id'; Entra also moves an oversized groups claim into access tokens, so 'access'+'groups' (the only source supported by remote-agent API sync) now resolves overage as well. - Allow system fallback roles for tenant users: getLibreChatRolesForOpenIdSync treats SystemRoles (e.g. USER) as always-available canonical names, since they are provisioned globally at startup and a tenant-scoped lookup may not return them — preventing a spurious 'configured roles do not exist: USER'. Adds unit and strategy-level coverage for all three. * fix: Tighten OpenID role-sync tenant scoping and config validation Addresses the third Codex review cycle: - Constrain base-user role lookups to base roles (P2): findRolesByNames now filters to roles with an unset tenantId when no tenant ALS context is active, so a base user cannot match — and be assigned — a role that only exists within a tenant. Tenant-scoped lookups remain controlled by the isolation plugin. - Re-enforce tenant login policy after role sync (P2): when role sync changes a tenant user's role, the OpenID strategy re-resolves the tenant appConfig and re-checks allowedDomains, so a token cannot complete login under the previous role's looser policy. - Skip role-sync-specific validation when disabled (P3): getOpenIdRoleSyncOptions returns disabled options before validating role-sync settings, so a stale or mistyped value no longer breaks OpenID login while the feature is off. Adds unit and strategy-level coverage for all three. * fix: Run base role lookups under system context for strict isolation Follow-up to the base-role scoping fix (Codex P1). With TENANT_ISOLATION_STRICT=true, the tenant-isolation pre('find') hook throws on a context-less query before the manual tenantId filter is honored, so base OpenID/remote-agent auth would 500 instead of validating base roles. findRolesByNames now runs the no-context lookup inside runAsSystem (SYSTEM_TENANT_ID), bypassing strict-mode injection while still applying an explicit base-role (tenantId unset) filter. Adds a strict-mode regression test. --------- Co-authored-by: Peter Rothlaender --- .env.example | 10 + api/server/routes/agents/middleware.js | 1 + api/strategies/openidStrategy.js | 109 ++++- api/strategies/openidStrategy.spec.js | 320 +++++++++++++- packages/api/src/auth/index.ts | 1 + packages/api/src/auth/openidRoleSync.spec.ts | 398 ++++++++++++++++++ packages/api/src/auth/openidRoleSync.ts | 314 ++++++++++++++ .../src/middleware/remoteAgentAuth.spec.ts | 200 ++++++++- .../api/src/middleware/remoteAgentAuth.ts | 119 +++++- .../src/methods/role.methods.spec.ts | 88 ++++ packages/data-schemas/src/methods/role.ts | 54 ++- 11 files changed, 1605 insertions(+), 9 deletions(-) create mode 100644 packages/api/src/auth/openidRoleSync.spec.ts create mode 100644 packages/api/src/auth/openidRoleSync.ts 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,