diff --git a/services/src/main/java/org/keycloak/organization/protocol/mappers/oidc/OrganizationScope.java b/services/src/main/java/org/keycloak/organization/protocol/mappers/oidc/OrganizationScope.java index 5ff73ac059a..599731ac4fd 100644 --- a/services/src/main/java/org/keycloak/organization/protocol/mappers/oidc/OrganizationScope.java +++ b/services/src/main/java/org/keycloak/organization/protocol/mappers/oidc/OrganizationScope.java @@ -75,22 +75,14 @@ public enum OrganizationScope { */ SINGLE(StringUtil::isNotBlank, (user, scopes, session) -> { - OrganizationModel organization = parseScopeParameter(session, scopes) + List organizations = parseScopeParameter(session, scopes) .map((String scope) -> parseScopeValue(session, scope)) .map(alias -> getProvider(session).getByAlias(alias)) .filter(Objects::nonNull) - .findAny() - .orElse(null); + .filter(org -> user == null || org.isMember(user)) + .toList(); - if (organization == null) { - return Stream.empty(); - } - - if (user == null || organization.isMember(user)) { - return Stream.of(organization); - } - - return Stream.empty(); + return organizations.stream(); }, (organizations) -> organizations.findAny().isPresent(), (session, current, previous) -> { diff --git a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java index ab76a4e647c..114c95b63f8 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java @@ -723,10 +723,21 @@ public class TokenManager { Collection rawScopes = TokenManager.parseScopeParameter(scopes).collect(Collectors.toSet()); - // detect multiple organization scopes + // validate organization scopes - allow multiple specific organization scopes, but reject mixed types if (Profile.isFeatureEnabled(Feature.ORGANIZATION)) { - if (rawScopes.stream().filter(scope -> scope.startsWith(ORGANIZATION)).count() > 1) { - return false; + List orgScopes = rawScopes.stream() + .filter(scope -> scope.equals(ORGANIZATION) || scope.startsWith(ORGANIZATION + ":")) + .toList(); + + if (orgScopes.size() > 1) { + // multiple organization scopes - only allow if all are specific organizations (not ANY or ALL) + boolean hasAnyScope = orgScopes.stream().anyMatch(ORGANIZATION::equals); + boolean hasAllScope = orgScopes.stream().anyMatch(s -> s.equals(ORGANIZATION + ":*")); + + if (hasAnyScope || hasAllScope) { + // mixing ANY (organization) or ALL (organization:*) with other organization scopes is not allowed + return false; + } } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/mapper/OrganizationOIDCProtocolMapperTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/mapper/OrganizationOIDCProtocolMapperTest.java index 54c4da5c312..c1a6d95f7b6 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/mapper/OrganizationOIDCProtocolMapperTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/mapper/OrganizationOIDCProtocolMapperTest.java @@ -115,6 +115,7 @@ public class OrganizationOIDCProtocolMapperTest extends AbstractOrganizationTest } @Test + @SuppressWarnings("unchecked") public void testMultipleOrganizationScopes() throws Exception { OrganizationResource orga = testRealm().organizations().get(createOrganization("org-a").getId()); OrganizationResource orgb = testRealm().organizations().get(createOrganization("org-b").getId()); @@ -129,14 +130,28 @@ public class OrganizationOIDCProtocolMapperTest extends AbstractOrganizationTest Assert.assertTrue(orgb.members().list(-1, -1).stream().map(UserRepresentation::getId).anyMatch(member.getId()::equals)); oauth.clientId("test-app"); - oauth.scope("openid organization organization:org-a"); + + // Test multiple specific organization scopes - should return both organizations + oauth.scope("openid organization:org-a organization:org-b"); AccessTokenResponse response = oauth.doPasswordGrantRequest(memberEmail, memberPassword); + Assert.assertEquals(Response.Status.OK.getStatusCode(), response.getStatusCode()); + AccessToken accessToken = TokenVerifier.create(response.getAccessToken(), AccessToken.class).getToken(); + List organizations = (List) accessToken.getOtherClaims().get(OAuth2Constants.ORGANIZATION); + Assert.assertNotNull(organizations); + Assert.assertTrue(organizations.contains("org-a")); + Assert.assertTrue(organizations.contains("org-b")); + + // Test organization + specific organization scope - should still fail (mixing ANY with SINGLE) + oauth.scope("openid organization organization:org-a"); + response = oauth.doPasswordGrantRequest(memberEmail, memberPassword); Assert.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode()); + // Test organization + wildcard scope - should still fail (mixing ANY with ALL) oauth.scope("openid organization organization:*"); response = oauth.doPasswordGrantRequest(memberEmail, memberPassword); Assert.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode()); + // Test specific organization + wildcard scope - should still fail (mixing SINGLE with ALL) oauth.scope("openid organization:org-a organization:*"); response = oauth.doPasswordGrantRequest(memberEmail, memberPassword); Assert.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());