From b8a9b0119faa4491acda7db25b4614d7562cbd11 Mon Sep 17 00:00:00 2001 From: Sven-Torben Janus Date: Wed, 8 Apr 2026 16:13:07 +0200 Subject: [PATCH 1/2] Make OrganizationGroupMembershipMapper claim name configurable The OrganizationGroupMembershipMapper introduced in 26.6.0 hardcoded the token claim name to "organization", unlike OrganizationMembershipMapper which already exposes the claim name as a configurable property. - Add TOKEN_CLAIM_NAME config property to OrganizationGroupMembershipMapper via OIDCAttributeMapperHelper.addTokenClaimNameConfig() - Override getEffectiveModel() to default the claim name to OAuth2Constants.ORGANIZATION when not set, preserving backward compatibility for existing mapper configurations - Set TOKEN_CLAIM_NAME default in the static create() factory method - Refactor OIDCAttributeMapperHelper.getOrInitializeOrganizationClaimAsMap() to accept a ProtocolMapperModel instead of a raw String, delegating to mapClaim() for correct claim placement (including nested path support) Closes #47851 Signed-off-by: Sven-Torben Janus --- .../oidc/OrganizationGroupMembershipMapper.java | 14 +++++++++++++- .../oidc/mappers/OIDCAttributeMapperHelper.java | 8 ++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/services/src/main/java/org/keycloak/organization/protocol/mappers/oidc/OrganizationGroupMembershipMapper.java b/services/src/main/java/org/keycloak/organization/protocol/mappers/oidc/OrganizationGroupMembershipMapper.java index 986770b1190f..5bb394c424bc 100644 --- a/services/src/main/java/org/keycloak/organization/protocol/mappers/oidc/OrganizationGroupMembershipMapper.java +++ b/services/src/main/java/org/keycloak/organization/protocol/mappers/oidc/OrganizationGroupMembershipMapper.java @@ -31,6 +31,7 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.OrganizationModel; import org.keycloak.models.ProtocolMapperModel; +import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.models.UserSessionModel; import org.keycloak.models.utils.ModelToRepresentation; @@ -46,6 +47,8 @@ import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.representations.IDToken; +import static org.keycloak.protocol.oidc.mappers.OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME; + public class OrganizationGroupMembershipMapper extends AbstractOIDCProtocolMapper implements OIDCAccessTokenMapper, OIDCIDTokenMapper, UserInfoTokenMapper, TokenIntrospectionTokenMapper, EnvironmentDependentProviderFactory { public static final String PROVIDER_ID = "oidc-organization-group-membership-mapper"; @@ -53,6 +56,7 @@ public class OrganizationGroupMembershipMapper extends AbstractOIDCProtocolMappe @Override public List getConfigProperties() { List properties = new ArrayList<>(); + OIDCAttributeMapperHelper.addTokenClaimNameConfig(properties); OIDCAttributeMapperHelper.addIncludeInTokensConfig(properties, OrganizationGroupMembershipMapper.class); return properties; } @@ -95,7 +99,7 @@ protected void setClaim(IDToken token, ProtocolMapperModel model, UserSessionMod OrganizationProvider orgProvider = session.getProvider(OrganizationProvider.class); // Get or create organization claims map for composition - final Map orgClaims = OIDCAttributeMapperHelper.getOrInitializeOrganizationClaimAsMap(token, OAuth2Constants.ORGANIZATION); + final Map orgClaims = OIDCAttributeMapperHelper.getOrInitializeOrganizationClaimAsMap(token, getEffectiveModel(session, userSession.getRealm(), model)); // Add groups to each organization organizations.forEach(org -> { @@ -138,6 +142,7 @@ public static ProtocolMapperModel create(String name, boolean accessToken, boole if (accessToken) config.put(OIDCAttributeMapperHelper.INCLUDE_IN_ACCESS_TOKEN, "true"); if (idToken) config.put(OIDCAttributeMapperHelper.INCLUDE_IN_ID_TOKEN, "true"); if (introspectionEndpoint) config.put(OIDCAttributeMapperHelper.INCLUDE_IN_INTROSPECTION, "true"); + config.put(TOKEN_CLAIM_NAME, OAuth2Constants.ORGANIZATION); mapper.setConfig(config); return mapper; @@ -153,4 +158,11 @@ public int getPriority() { // Run after OrganizationMembershipMapper (higher number = later execution) return 10; } + + @Override + public ProtocolMapperModel getEffectiveModel(KeycloakSession session, RealmModel realm, ProtocolMapperModel protocolMapperModel) { + ProtocolMapperModel effectiveModel = super.getEffectiveModel(session, realm, protocolMapperModel); + effectiveModel.getConfig().putIfAbsent(TOKEN_CLAIM_NAME, OAuth2Constants.ORGANIZATION); + return effectiveModel; + } } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/mappers/OIDCAttributeMapperHelper.java b/services/src/main/java/org/keycloak/protocol/oidc/mappers/OIDCAttributeMapperHelper.java index af0fd46c6025..a73770119010 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/mappers/OIDCAttributeMapperHelper.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/mappers/OIDCAttributeMapperHelper.java @@ -295,11 +295,11 @@ private static JsonNode getJsonNode(Object attributeValue) { * (String type), existing Map, or creates empty Map if null. * * @param token the token - * @param claimName the name of the organization claim + * @param effectiveModel the effective model of the protocol mapper to retrieve the name of the organization claim * @return a mutable Map that can be manipulated; changes will be reflected in the token */ - public static Map getOrInitializeOrganizationClaimAsMap(IDToken token, String claimName) { - Object existingClaim = token.getOtherClaims().get(claimName); + public static Map getOrInitializeOrganizationClaimAsMap(IDToken token, ProtocolMapperModel effectiveModel) { + Object existingClaim = token.getOtherClaims().get(effectiveModel.getConfig().get(TOKEN_CLAIM_NAME)); Map result; if (existingClaim instanceof ObjectNode) { @@ -317,7 +317,7 @@ public static Map getOrInitializeOrganizationClaimAsMap(IDToken result = new HashMap<>(); } - token.setOtherClaims(claimName, result); + OIDCAttributeMapperHelper.mapClaim(token, effectiveModel, result); return result; } From 2abbf8b4d6e221d45adc564aa185e9574b7f7245 Mon Sep 17 00:00:00 2001 From: Sven-Torben Janus Date: Mon, 20 Apr 2026 21:41:09 +0200 Subject: [PATCH 2/2] Fix nested claim path read and add custom claim name tests The read side of getOrInitializeOrganizationClaimAsMap was doing a flat Map.get() on the dotted claim name, while the write side (mapClaim) already creates a nested structure by splitting on dots. This caused the group mapper to find nothing when the claim name contained a dot, overwriting the membership data written by OrganizationMembershipMapper. Fix by splitting the claim path via splitClaimPath() and traversing the nested map with a new private getNestedClaimValue() helper in OIDCAttributeMapperHelper. The helper belongs there rather than in JsonUtils because it operates on Map, not JsonNode. Also add integration tests covering: - Custom flat claim name ("my_orgs") for both OrganizationMembershipMapper and OrganizationGroupMembershipMapper, verifying the claim appears at the configured name and not at "organization" - Dotted claim name ("custom.org") for OrganizationGroupMembershipMapper, verifying the token contains nested otherClaims["custom"]["org"] and that group composition is preserved Signed-off-by: Sven-Torben Janus --- .../topics/organizations/managing-groups.adoc | 1 + .../OrganizationGroupMembershipMapper.java | 28 +++-- .../mappers/OIDCAttributeMapperHelper.java | 18 ++- ...nizationGroupMembershipOIDCMapperTest.java | 106 +++++++++++++++++- .../OrganizationOIDCProtocolMapperTest.java | 21 ++++ 5 files changed, 161 insertions(+), 13 deletions(-) diff --git a/docs/documentation/server_admin/topics/organizations/managing-groups.adoc b/docs/documentation/server_admin/topics/organizations/managing-groups.adoc index 8ae84fb49b20..32f78b145ee3 100644 --- a/docs/documentation/server_admin/topics/organizations/managing-groups.adoc +++ b/docs/documentation/server_admin/topics/organizations/managing-groups.adoc @@ -160,6 +160,7 @@ Notice: - Groups appear within the organization claim, not as a separate top-level claim - Group paths are relative to the organization +- Make sure you have a single `organization-membership` mapper so that groups are included in the same claim as the organization data - Multiple organizations can be included if the user is a member of multiple organizations and uses `scope=organization:*` or multiple specific scopes like `scope=organization:org-a organization:org-b` === SAML: Organization groups in assertions diff --git a/services/src/main/java/org/keycloak/organization/protocol/mappers/oidc/OrganizationGroupMembershipMapper.java b/services/src/main/java/org/keycloak/organization/protocol/mappers/oidc/OrganizationGroupMembershipMapper.java index 5bb394c424bc..b0ddb858ca41 100644 --- a/services/src/main/java/org/keycloak/organization/protocol/mappers/oidc/OrganizationGroupMembershipMapper.java +++ b/services/src/main/java/org/keycloak/organization/protocol/mappers/oidc/OrganizationGroupMembershipMapper.java @@ -25,13 +25,11 @@ import java.util.stream.Stream; import org.keycloak.Config; -import org.keycloak.OAuth2Constants; import org.keycloak.common.Profile; import org.keycloak.models.ClientSessionContext; import org.keycloak.models.KeycloakSession; import org.keycloak.models.OrganizationModel; import org.keycloak.models.ProtocolMapperModel; -import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.models.UserSessionModel; import org.keycloak.models.utils.ModelToRepresentation; @@ -56,7 +54,6 @@ public class OrganizationGroupMembershipMapper extends AbstractOIDCProtocolMappe @Override public List getConfigProperties() { List properties = new ArrayList<>(); - OIDCAttributeMapperHelper.addTokenClaimNameConfig(properties); OIDCAttributeMapperHelper.addIncludeInTokensConfig(properties, OrganizationGroupMembershipMapper.class); return properties; } @@ -97,9 +94,19 @@ protected void setClaim(IDToken token, ProtocolMapperModel model, UserSessionMod UserModel user = userSession.getUser(); OrganizationProvider orgProvider = session.getProvider(OrganizationProvider.class); + ProtocolMapperModel organizationMapperModel = getOrganizationMapperModel(clientSessionCtx); - // Get or create organization claims map for composition - final Map orgClaims = OIDCAttributeMapperHelper.getOrInitializeOrganizationClaimAsMap(token, getEffectiveModel(session, userSession.getRealm(), model)); + if (organizationMapperModel == null) { + // this mapper requires the organization scope and its mapper set to the request + return; + } + + String orgClaimName = organizationMapperModel.getConfig().get(TOKEN_CLAIM_NAME); + + model = getEffectiveModel(session, userSession.getRealm(), model); + model.getConfig().put(TOKEN_CLAIM_NAME, orgClaimName); + + Map orgClaims = OIDCAttributeMapperHelper.getOrInitializeOrganizationClaimAsMap(token, model); // Add groups to each organization organizations.forEach(org -> { @@ -142,7 +149,6 @@ public static ProtocolMapperModel create(String name, boolean accessToken, boole if (accessToken) config.put(OIDCAttributeMapperHelper.INCLUDE_IN_ACCESS_TOKEN, "true"); if (idToken) config.put(OIDCAttributeMapperHelper.INCLUDE_IN_ID_TOKEN, "true"); if (introspectionEndpoint) config.put(OIDCAttributeMapperHelper.INCLUDE_IN_INTROSPECTION, "true"); - config.put(TOKEN_CLAIM_NAME, OAuth2Constants.ORGANIZATION); mapper.setConfig(config); return mapper; @@ -159,10 +165,10 @@ public int getPriority() { return 10; } - @Override - public ProtocolMapperModel getEffectiveModel(KeycloakSession session, RealmModel realm, ProtocolMapperModel protocolMapperModel) { - ProtocolMapperModel effectiveModel = super.getEffectiveModel(session, realm, protocolMapperModel); - effectiveModel.getConfig().putIfAbsent(TOKEN_CLAIM_NAME, OAuth2Constants.ORGANIZATION); - return effectiveModel; + private ProtocolMapperModel getOrganizationMapperModel(ClientSessionContext clientSessionContext) { + return clientSessionContext.getProtocolMappersStream() + .filter(m -> OrganizationMembershipMapper.PROVIDER_ID.equals(m.getProtocolMapper())) + .findAny() + .orElse(null); } } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/mappers/OIDCAttributeMapperHelper.java b/services/src/main/java/org/keycloak/protocol/oidc/mappers/OIDCAttributeMapperHelper.java index a73770119010..06af33df0a1a 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/mappers/OIDCAttributeMapperHelper.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/mappers/OIDCAttributeMapperHelper.java @@ -299,7 +299,8 @@ private static JsonNode getJsonNode(Object attributeValue) { * @return a mutable Map that can be manipulated; changes will be reflected in the token */ public static Map getOrInitializeOrganizationClaimAsMap(IDToken token, ProtocolMapperModel effectiveModel) { - Object existingClaim = token.getOtherClaims().get(effectiveModel.getConfig().get(TOKEN_CLAIM_NAME)); + List claimPath = splitClaimPath(effectiveModel.getConfig().get(TOKEN_CLAIM_NAME)); + Object existingClaim = getNestedClaimValue(token.getOtherClaims(), claimPath); Map result; if (existingClaim instanceof ObjectNode) { @@ -321,6 +322,21 @@ public static Map getOrInitializeOrganizationClaimAsMap(IDToken return result; } + private static Object getNestedClaimValue(Map claims, List path) { + if (path.isEmpty()) return null; + Map current = claims; + for (int i = 0; i < path.size() - 1; i++) { + Object next = current.get(path.get(i)); + if (!(next instanceof Map)) { + return null; + } + @SuppressWarnings("unchecked") + Map nested = (Map) next; + current = nested; + } + return current.get(path.get(path.size() - 1)); + } + public static void mapClaim(IDToken token, ProtocolMapperModel mappingModel, Object attributeValue) { mapClaim(token, mappingModel, attributeValue, tokenPropertySetters, token.getOtherClaims()); } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/mapper/OrganizationGroupMembershipOIDCMapperTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/mapper/OrganizationGroupMembershipOIDCMapperTest.java index e166029df6e4..e1f4c54264d1 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/mapper/OrganizationGroupMembershipOIDCMapperTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/mapper/OrganizationGroupMembershipOIDCMapperTest.java @@ -65,14 +65,23 @@ public void addGroupMapper() { // Reset to defaults setMapperConfig(ProtocolMapperUtils.MULTIVALUED, null); setMapperConfig(OIDCAttributeMapperHelper.JSON_TYPE, null); + setMapperConfig(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME, null); setMapperConfig(OrganizationMembershipMapper.ADD_ORGANIZATION_ATTRIBUTES, null); setMapperConfig(OrganizationMembershipMapper.ADD_ORGANIZATION_ID, null); - // Add the organization group membership mapper to the organization scope ClientScopeRepresentation orgScope = managedRealm.admin().clientScopes().findAll().stream() .filter(s -> OIDCLoginProtocolFactory.ORGANIZATION.equals(s.getName())) .findAny() .orElseThrow(); + List protocolMappers = orgScope.getProtocolMappers(); + + if (protocolMappers.stream().anyMatch(m -> "organization-groups".equals(m.getName()))) { + setGroupMapperConfig(OIDCAttributeMapperHelper.INCLUDE_IN_ACCESS_TOKEN, "true"); + setGroupMapperConfig(OIDCAttributeMapperHelper.INCLUDE_IN_ID_TOKEN, "true"); + setGroupMapperConfig(OIDCAttributeMapperHelper.INCLUDE_IN_INTROSPECTION, "true"); + setGroupMapperConfig(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME, OAuth2Constants.ORGANIZATION); + return; + } ClientScopeResource orgScopeResource = managedRealm.admin().clientScopes().get(orgScope.getId()); @@ -85,11 +94,34 @@ public void addGroupMapper() { config.put(OIDCAttributeMapperHelper.INCLUDE_IN_ACCESS_TOKEN, "true"); config.put(OIDCAttributeMapperHelper.INCLUDE_IN_ID_TOKEN, "true"); config.put(OIDCAttributeMapperHelper.INCLUDE_IN_INTROSPECTION, "true"); + config.put(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME, OAuth2Constants.ORGANIZATION); groupMapper.setConfig(config); orgScopeResource.getProtocolMappers().createMapper(groupMapper).close(); } + private void setGroupMapperConfig(String key, String value) { + ClientScopeRepresentation orgScope = managedRealm.admin().clientScopes().findAll().stream() + .filter(s -> OIDCLoginProtocolFactory.ORGANIZATION.equals(s.getName())) + .findAny() + .orElseThrow(); + ClientScopeResource orgScopeResource = managedRealm.admin().clientScopes().get(orgScope.getId()); + + ProtocolMapperRepresentation groupMapper = orgScopeResource.getProtocolMappers().getMappers().stream() + .filter(m -> "organization-groups".equals(m.getName())) + .findAny() + .orElseThrow(); + + Map config = groupMapper.getConfig(); + if (value == null) { + config.remove(key); + } else { + config.put(key, value); + } + + orgScopeResource.getProtocolMappers().update(groupMapper.getId(), groupMapper); + } + @Test @SuppressWarnings("unchecked") public void testNestedGroupsWithRelativePaths() throws Exception { @@ -291,4 +323,76 @@ public void testCompositionWithStringTypeOrgMapper() throws Exception { List orgCGroups = (List) orgCData.get("groups"); assertThat(orgCGroups, empty()); } + + @Test + @SuppressWarnings("unchecked") + public void testCustomClaimName() throws Exception { + OrganizationRepresentation orgRep = createOrganization("acme"); + OrganizationResource org = managedRealm.admin().organizations().get(orgRep.getId()); + MemberRepresentation member = addMember(org); + + GroupRepresentation engineering = new GroupRepresentation(); + engineering.setName("engineering"); + String engineeringId; + try (Response response = org.groups().addTopLevelGroup(engineering)) { + engineeringId = ApiUtil.getCreatedId(response); + } + org.groups().group(engineeringId).addMember(member.getId()); + + setMapperConfig(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME, "my_orgs"); + + oauth.client("direct-grant", "password"); + oauth.scope("openid organization"); + AccessTokenResponse response = oauth.doPasswordGrantRequest(memberEmail, memberPassword); + + AccessToken token = TokenVerifier.create(response.getAccessToken(), AccessToken.class).getToken(); + + assertThat(token.getOtherClaims(), not(hasKey(OAuth2Constants.ORGANIZATION))); + assertThat(token.getOtherClaims(), hasKey("my_orgs")); + + Map orgClaims = (Map) token.getOtherClaims().get("my_orgs"); + Map acmeData = (Map) orgClaims.get("acme"); + assertThat(acmeData, notNullValue()); + List groups = (List) acmeData.get("groups"); + assertThat(groups, hasSize(1)); + assertThat(groups, hasItem("/engineering")); + } + + @Test + @SuppressWarnings("unchecked") + public void testDottedClaimName() throws Exception { + OrganizationRepresentation orgRep = createOrganization("acme"); + OrganizationResource org = managedRealm.admin().organizations().get(orgRep.getId()); + MemberRepresentation member = addMember(org); + + GroupRepresentation engineering = new GroupRepresentation(); + engineering.setName("engineering"); + String engineeringId; + try (Response response = org.groups().addTopLevelGroup(engineering)) { + engineeringId = ApiUtil.getCreatedId(response); + } + org.groups().group(engineeringId).addMember(member.getId()); + + setMapperConfig(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME, "custom.org"); + + oauth.client("direct-grant", "password"); + oauth.scope("openid organization"); + AccessTokenResponse response = oauth.doPasswordGrantRequest(memberEmail, memberPassword); + + AccessToken token = TokenVerifier.create(response.getAccessToken(), AccessToken.class).getToken(); + + assertThat(token.getOtherClaims(), not(hasKey(OAuth2Constants.ORGANIZATION))); + assertThat(token.getOtherClaims(), not(hasKey("custom.org"))); + assertThat(token.getOtherClaims(), hasKey("custom")); + + Map customClaims = (Map) token.getOtherClaims().get("custom"); + assertThat(customClaims, hasKey("org")); + + Map orgClaims = (Map) customClaims.get("org"); + Map acmeData = (Map) orgClaims.get("acme"); + assertThat(acmeData, notNullValue()); + List groups = (List) acmeData.get("groups"); + assertThat(groups, hasSize(1)); + assertThat(groups, hasItem("/engineering")); + } } 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 108b3d5f5a6e..319f3230d819 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 @@ -72,6 +72,7 @@ import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.hasKey; import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.not; @@ -88,6 +89,7 @@ public class OrganizationOIDCProtocolMapperTest extends AbstractOrganizationTest public void onBefore() { setMapperConfig(ProtocolMapperUtils.MULTIVALUED, null); setMapperConfig(OIDCAttributeMapperHelper.JSON_TYPE, null); + setMapperConfig(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME, null); } @Test @@ -1691,6 +1693,25 @@ private void assertScopeAndClaims(String orgScope, OrganizationRepresentation or assertThat(refreshToken.getScope(), containsString(orgScope)); } + @Test + @SuppressWarnings("unchecked") + public void testCustomClaimName() throws Exception { + OrganizationResource org = managedRealm.admin().organizations().get(createOrganization("acme").getId()); + addMember(org); + + setMapperConfig(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME, "my_orgs"); + + oauth.client("direct-grant", "password"); + oauth.scope("openid organization"); + AccessTokenResponse response = oauth.doPasswordGrantRequest(memberEmail, memberPassword); + + AccessToken token = TokenVerifier.create(response.getAccessToken(), AccessToken.class).getToken(); + + assertThat(token.getOtherClaims(), not(hasKey(OAuth2Constants.ORGANIZATION))); + assertThat(token.getOtherClaims(), hasKey("my_orgs")); + assertThat(token.getOtherClaims().get("my_orgs"), notNullValue()); + } + private void assertResponseMissingOrganizationScopeAndClaims(AccessTokenResponse response) { assertThat(response.getScope(), not(containsString("organization"))); AccessToken accessToken = oauth.verifyToken(response.getAccessToken());