diff --git a/docs/documentation/upgrading/topics/changes/changes-26_5_0.adoc b/docs/documentation/upgrading/topics/changes/changes-26_5_0.adoc index 52bad946f56..4f3d760c770 100644 --- a/docs/documentation/upgrading/topics/changes/changes-26_5_0.adoc +++ b/docs/documentation/upgrading/topics/changes/changes-26_5_0.adoc @@ -171,6 +171,14 @@ See the link:{upgradingguide_link}[{upgradingguide_name}] for details on how to For each expired user session there is a new user event `USER_SESSION_DELETED` fired. As part of this change, the process now deletes rows from the table in small batches, instead of issuing a delete statements that affects the whole table. This should allow for better response times when there are a lot of sessions in the table. +=== Organization custom attribute named 'id' behavior change + +Organizations can have custom attributes named `id`. When both organization attributes and organization ID are included in tokens via the organization membership mapper configuration, +the organization ID will override any custom `id` attribute value. Previously, the organization ID was added first and could be overridden by custom attributes. +This ensures that the `id` field in the organization claims always contains the actual organization ID. + +If you have a custom organization attribute named `id` and rely on its value in tokens, you should rename the attribute to avoid it being overridden by the organization ID. + // ------------------------ Deprecated features ------------------------ // == Deprecated features diff --git a/services/src/main/java/org/keycloak/organization/protocol/mappers/oidc/OrganizationMembershipMapper.java b/services/src/main/java/org/keycloak/organization/protocol/mappers/oidc/OrganizationMembershipMapper.java index dc34f0d6105..923eeb8ca3f 100644 --- a/services/src/main/java/org/keycloak/organization/protocol/mappers/oidc/OrganizationMembershipMapper.java +++ b/services/src/main/java/org/keycloak/organization/protocol/mappers/oidc/OrganizationMembershipMapper.java @@ -162,12 +162,14 @@ public class OrganizationMembershipMapper extends AbstractOIDCProtocolMapper imp Map claims = new HashMap<>(); - if (isAddOrganizationId(model)) { - claims.put(OAuth2Constants.ORGANIZATION_ID, o.getId()); - } + // Add organization attributes first if (isAddOrganizationAttributes(model)) { claims.putAll(o.getAttributes()); } + // Add organization ID last so it overrides any custom "id" attribute + if (isAddOrganizationId(model)) { + claims.put(OAuth2Constants.ORGANIZATION_ID, o.getId()); + } value.put(o.getAlias(), claims); } 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 77d8d4c0f0f..c939d990201 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 @@ -79,8 +79,8 @@ public class OrganizationOIDCProtocolMapperTest extends AbstractOrganizationTest @Before public void onBefore() { - setMapperConfig(OIDCAttributeMapperHelper.JSON_TYPE, null); setMapperConfig(ProtocolMapperUtils.MULTIVALUED, null); + setMapperConfig(OIDCAttributeMapperHelper.JSON_TYPE, null); } @Test @@ -984,6 +984,41 @@ public class OrganizationOIDCProtocolMapperTest extends AbstractOrganizationTest assertThat(organization, containsInAnyOrder("orga", "orgb")); } + @Test + @SuppressWarnings("unchecked") + public void testOrganizationAttributeNamedIdIsOverriddenByOrganizationId() throws Exception { + // When an organization has a custom attribute called "id", the organization ID should override it in tokens + OrganizationRepresentation orgRep = createOrganization(); + OrganizationResource organization = testRealm().organizations().get(orgRep.getId()); + addMember(organization); + + // Add a custom attribute named "id" to the organization + orgRep.singleAttribute("id", "custom-id-value"); + + try (Response response = organization.update(orgRep)) { + assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus()); + } + + // Verify that organization ID overrides custom "id" attribute in tokens + setMapperConfig(OrganizationMembershipMapper.ADD_ORGANIZATION_ID, Boolean.TRUE.toString()); + setMapperConfig(OrganizationMembershipMapper.ADD_ORGANIZATION_ATTRIBUTES, Boolean.TRUE.toString()); + + oauth.client("direct-grant", "password"); + oauth.scope("openid organization"); + AccessTokenResponse response = oauth.doPasswordGrantRequest(memberEmail, memberPassword); + assertThat(response.getScope(), containsString("organization")); + AccessToken accessToken = TokenVerifier.create(response.getAccessToken(), AccessToken.class).getToken(); + assertThat(accessToken.getOtherClaims().keySet(), hasItem(OAuth2Constants.ORGANIZATION)); + + Map> organizations = (Map>) accessToken.getOtherClaims().get(OAuth2Constants.ORGANIZATION); + assertThat(organizations.keySet(), hasItem(organizationName)); + Map orgClaims = organizations.get(organizationName); + + // The "id" attribute should contain the organization ID, not the custom value + assertThat(orgClaims.get("id"), equalTo(orgRep.getId())); + assertThat(orgClaims.get("id"), not(equalTo("custom-id-value"))); + } + private AccessTokenResponse assertSuccessfulCodeGrant() { String code = oauth.parseLoginResponse().getCode(); AccessTokenResponse response = oauth.doAccessTokenRequest(code);