diff --git a/api/strategies/openidStrategy.spec.js b/api/strategies/openidStrategy.spec.js index 916fac5d07..7322811a07 100644 --- a/api/strategies/openidStrategy.spec.js +++ b/api/strategies/openidStrategy.spec.js @@ -1670,6 +1670,23 @@ describe('setupOpenId', () => { ); }); + 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 diff --git a/packages/api/src/auth/openidRoleSync.spec.ts b/packages/api/src/auth/openidRoleSync.spec.ts index 9a3bf801c3..f80e57d965 100644 --- a/packages/api/src/auth/openidRoleSync.spec.ts +++ b/packages/api/src/auth/openidRoleSync.spec.ts @@ -94,13 +94,22 @@ describe('getOpenIdRolesForOpenIdSync', () => { ).resolves.toEqual(['STANDARD-USER']); }); - it('returns undefined for missing or invalid claim values', async () => { + 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(); }); @@ -125,10 +134,24 @@ describe('getOpenIdRolesForOpenIdSync', () => { idToken: 'id-token', decodeToken, }), - ).resolves.toBeUndefined(); + ).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'] }; @@ -193,6 +216,27 @@ describe('getLibreChatRolesForOpenIdSync', () => { }), ).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', () => { diff --git a/packages/api/src/auth/openidRoleSync.ts b/packages/api/src/auth/openidRoleSync.ts index 5c21194fdf..266a7c0cc6 100644 --- a/packages/api/src/auth/openidRoleSync.ts +++ b/packages/api/src/auth/openidRoleSync.ts @@ -131,7 +131,15 @@ export async function getOpenIdRolesForOpenIdSync({ return; } - if (options.claimSource === 'id' && options.claim === 'groups') { + /** + * 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 }; @@ -151,6 +159,13 @@ export async function getOpenIdRolesForOpenIdSync({ 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 []; } /** @@ -184,6 +199,18 @@ export async function getLibreChatRolesForOpenIdSync( .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()), );