From 67f8eb681518edaef039dab19b8507323018e011 Mon Sep 17 00:00:00 2001 From: Denis Arnst Date: Thu, 12 Feb 2026 13:25:56 +0100 Subject: [PATCH] OIDC: Support object-shaped and string group claims The group claim was assumed to always be an array, which crashes with providers like Zitadel that return an object with role names as keys (e.g. { "admin": {...}, "user": {...} }). Normalize all common formats: array, single string, and object (extract keys). Fixes #4744 --- server/auth/OidcAuthStrategy.js | 17 ++++++- test/server/auth/OidcAuthStrategy.test.js | 57 +++++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/server/auth/OidcAuthStrategy.js b/server/auth/OidcAuthStrategy.js index cc099aed3..10ecff7c4 100644 --- a/server/auth/OidcAuthStrategy.js +++ b/server/auth/OidcAuthStrategy.js @@ -248,7 +248,22 @@ class OidcAuthStrategy { if (!userinfo[groupClaimName]) throw new AuthError(`Group claim ${groupClaimName} not found in userinfo`, 401) - const groupsList = userinfo[groupClaimName].map((group) => group.toLowerCase()) + const rawGroups = userinfo[groupClaimName] + // Normalize group claim formats across providers: + // - Array of strings (Keycloak, Auth0): ["admin", "user"] + // - Single string (some providers with one group): "admin" + // - Object with role keys (Zitadel): { "admin": {...}, "user": {...} } + let groups + if (Array.isArray(rawGroups)) { + groups = rawGroups + } else if (typeof rawGroups === 'string') { + groups = [rawGroups] + } else if (typeof rawGroups === 'object' && rawGroups !== null) { + groups = Object.keys(rawGroups) + } else { + throw new AuthError(`Group claim ${groupClaimName} has unsupported format: ${typeof rawGroups}`, 401) + } + const groupsList = groups.map((group) => group.toLowerCase()) const rolesInOrderOfPriority = ['admin', 'user', 'guest'] const groupMap = global.ServerSettings.authOpenIDGroupMap || {} diff --git a/test/server/auth/OidcAuthStrategy.test.js b/test/server/auth/OidcAuthStrategy.test.js index 801e1d5c6..990f444e6 100644 --- a/test/server/auth/OidcAuthStrategy.test.js +++ b/test/server/auth/OidcAuthStrategy.test.js @@ -187,6 +187,63 @@ describe('OidcAuthStrategy', function () { }) }) + describe('single string claim', function () { + it('should handle a single string group value', async function () { + DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups' + global.ServerSettings.authOpenIDGroupMap = {} + + const user = { type: 'guest', username: 'testuser', save: sinon.stub().resolves() } + await strategy.setUserGroup(user, { groups: 'admin' }) + expect(user.type).to.equal('admin') + }) + }) + + describe('object-shaped claims (e.g. Zitadel)', function () { + it('should extract group names from object keys with legacy match', async function () { + DatabaseStub.serverSettings.authOpenIDGroupClaim = 'urn:zitadel:iam:org:project:roles' + global.ServerSettings.authOpenIDGroupMap = {} + + const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() } + await strategy.setUserGroup(user, { + 'urn:zitadel:iam:org:project:roles': { + admin: { '359584706087354371': 'website.de' }, + user: { '359584706087354371': 'website.de' } + } + }) + expect(user.type).to.equal('admin') + }) + + it('should extract group names from object keys with explicit mapping', async function () { + DatabaseStub.serverSettings.authOpenIDGroupClaim = 'urn:zitadel:iam:org:project:roles' + global.ServerSettings.authOpenIDGroupMap = { 'zitadel-users': 'user', 'zitadel-admins': 'admin' } + + const user = { type: 'guest', username: 'testuser', save: sinon.stub().resolves() } + await strategy.setUserGroup(user, { + 'urn:zitadel:iam:org:project:roles': { + 'zitadel-users': { '123': 'example.com' } + } + }) + expect(user.type).to.equal('user') + }) + + it('should throw when no matching group in object keys', async function () { + DatabaseStub.serverSettings.authOpenIDGroupClaim = 'roles' + global.ServerSettings.authOpenIDGroupMap = {} + + const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() } + try { + await strategy.setUserGroup(user, { + roles: { 'some-unknown-role': { '123': 'example.com' } } + }) + expect.fail('Should have thrown') + } catch (error) { + expect(error).to.be.instanceOf(AuthError) + expect(error.statusCode).to.equal(401) + expect(error.message).to.include('No valid group found') + } + }) + }) + describe('missing group claim in userinfo', function () { it('should throw when group claim is not in userinfo', async function () { DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups'