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'