From fa1571f231590d508d51f696210c853adeaa733b Mon Sep 17 00:00:00 2001 From: vramik Date: Tue, 26 Mar 2024 16:30:54 +0100 Subject: [PATCH] Map organization metadata when issuing tokens for OIDC clients acting on behalf of an organization member Closes #27993 Signed-off-by: vramik --- .../java/org/keycloak/OAuth2Constants.java | 2 + .../jpa/entities/OrganizationEntity.java | 2 +- .../jpa/JpaOrganizationProvider.java | 12 +- .../organization/jpa/OrganizationAdapter.java | 16 ++- .../keycloak/models/OrganizationModel.java | 2 + .../oidc/OIDCLoginProtocolFactory.java | 14 +++ .../mappers/OrganizationMembershipMapper.java | 105 ++++++++++++++++++ .../org.keycloak.protocol.ProtocolMapper | 1 + .../admin/AbstractOrganizationTest.java | 11 +- .../OrganizationOIDCProtocolMapperTest.java | 57 ++++++++++ .../organization/admin/OrganizationTest.java | 2 +- .../account/messages/messages_en.properties | 1 + .../login/messages/messages_en.properties | 1 + 13 files changed, 209 insertions(+), 17 deletions(-) create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/mappers/OrganizationMembershipMapper.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationOIDCProtocolMapperTest.java diff --git a/core/src/main/java/org/keycloak/OAuth2Constants.java b/core/src/main/java/org/keycloak/OAuth2Constants.java index df073bc6b5..5d469e2959 100755 --- a/core/src/main/java/org/keycloak/OAuth2Constants.java +++ b/core/src/main/java/org/keycloak/OAuth2Constants.java @@ -92,6 +92,8 @@ public interface OAuth2Constants { String SCOPE_ADDRESS = "address"; String SCOPE_PHONE = "phone"; + String ORGANIZATION = "organization"; + String UI_LOCALES_PARAM = "ui_locales"; String PROMPT = "prompt"; diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/OrganizationEntity.java b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/OrganizationEntity.java index b315ed6cd5..f7870aac6b 100644 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/OrganizationEntity.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/OrganizationEntity.java @@ -29,7 +29,7 @@ import jakarta.persistence.Table; @Table(name="ORGANIZATION") @Entity @NamedQueries({ - @NamedQuery(name="getByRealm", query="select o.id from OrganizationEntity o where o.realmId = :realmId") + @NamedQuery(name="getByRealm", query="select o from OrganizationEntity o where o.realmId = :realmId") }) public class OrganizationEntity { diff --git a/model/jpa/src/main/java/org/keycloak/organization/jpa/JpaOrganizationProvider.java b/model/jpa/src/main/java/org/keycloak/organization/jpa/JpaOrganizationProvider.java index aaecf537ff..426c464ef8 100644 --- a/model/jpa/src/main/java/org/keycloak/organization/jpa/JpaOrganizationProvider.java +++ b/model/jpa/src/main/java/org/keycloak/organization/jpa/JpaOrganizationProvider.java @@ -41,11 +41,9 @@ public class JpaOrganizationProvider implements OrganizationProvider { private final EntityManager em; private final GroupProvider groupProvider; - private final KeycloakSession session; private final UserProvider userProvider; public JpaOrganizationProvider(KeycloakSession session) { - this.session = session; em = session.getProvider(JpaConnectionProvider.class).getEntityManager(); groupProvider = session.groups(); userProvider = session.users(); @@ -63,7 +61,7 @@ public class JpaOrganizationProvider implements OrganizationProvider { em.persist(entity); - return new OrganizationAdapter(entity, session); + return new OrganizationAdapter(realm, entity); } @Override @@ -118,11 +116,11 @@ public class JpaOrganizationProvider implements OrganizationProvider { @Override public Stream getOrganizationsStream(RealmModel realm) { throwExceptionIfRealmIsNull(realm); - TypedQuery query = em.createNamedQuery("getByRealm", String.class); + TypedQuery query = em.createNamedQuery("getByRealm", OrganizationEntity.class); query.setParameter("realmId", realm.getId()); - return closing(query.getResultStream().map(id -> getAdapter(realm, id))); + return closing(query.getResultStream().map(entity -> new OrganizationAdapter(realm, entity))); } @Override @@ -193,7 +191,7 @@ public class JpaOrganizationProvider implements OrganizationProvider { throw new ModelException("Organization [" + entity.getId() + " does not belong to realm [" + realm.getId() + "]"); } - return new OrganizationAdapter(entity, session); + return new OrganizationAdapter(realm, entity); } private GroupModel createOrganizationGroup(RealmModel realm, String name) { @@ -209,7 +207,7 @@ public class JpaOrganizationProvider implements OrganizationProvider { throw new ModelException("A group with the same name already exist and it is bound to different organization"); } - return groupProvider.createGroup(realm, KeycloakModelUtils.generateId(), groupName); + return groupProvider.createGroup(realm, groupName); } private String getCanonicalGroupName(String name) { diff --git a/model/jpa/src/main/java/org/keycloak/organization/jpa/OrganizationAdapter.java b/model/jpa/src/main/java/org/keycloak/organization/jpa/OrganizationAdapter.java index 372a5cee8d..b761212965 100644 --- a/model/jpa/src/main/java/org/keycloak/organization/jpa/OrganizationAdapter.java +++ b/model/jpa/src/main/java/org/keycloak/organization/jpa/OrganizationAdapter.java @@ -17,19 +17,19 @@ package org.keycloak.organization.jpa; -import org.keycloak.models.KeycloakSession; import org.keycloak.models.OrganizationModel; +import org.keycloak.models.RealmModel; import org.keycloak.models.jpa.JpaModel; import org.keycloak.models.jpa.entities.OrganizationEntity; public final class OrganizationAdapter implements OrganizationModel, JpaModel { + private final RealmModel realm; private final OrganizationEntity entity; - private final KeycloakSession session; - public OrganizationAdapter(OrganizationEntity entity, KeycloakSession session) { + public OrganizationAdapter(RealmModel realm, OrganizationEntity entity) { + this.realm = realm; this.entity = entity; - this.session = session; } @Override @@ -37,8 +37,9 @@ public final class OrganizationAdapter implements OrganizationModel, JpaModel getConfigProperties() { + List properties = new ArrayList<>(); + OIDCAttributeMapperHelper.addIncludeInTokensConfig(properties, OrganizationMembershipMapper.class); + return properties; + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public String getDisplayType() { + return "Organization Membership"; + } + + @Override + public String getDisplayCategory() { + return TOKEN_MAPPER_CATEGORY; + } + + @Override + public String getHelpText() { + return "Map user Organization membership"; + } + + @Override + protected void setClaim(IDToken token, ProtocolMapperModel mappingModel, UserSessionModel userSession, KeycloakSession keycloakSession, ClientSessionContext clientSessionCtx) { + + RealmModel realm = keycloakSession.getContext().getRealm(); + UserModel user = userSession.getUser(); + OrganizationProvider organizationProvider = keycloakSession.getProvider(OrganizationProvider.class); + OrganizationModel organization = organizationProvider.getOrganizationByMember(realm, user); + + if (organization != null) { + Map> claim = new HashMap<>(); + claim.put(organization.getName(), Map.of()); + token.getOtherClaims().put(OAuth2Constants.ORGANIZATION, claim); + } + } + + public static ProtocolMapperModel create(String name, boolean accessToken, boolean idToken, boolean introspectionEndpoint) { + ProtocolMapperModel mapper = new ProtocolMapperModel(); + mapper.setName(name); + mapper.setProtocolMapper(PROVIDER_ID); + mapper.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + Map config = new HashMap<>(); + 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"); + mapper.setConfig(config); + + return mapper; + } + + @Override + public boolean isSupported(Config.Scope config) { + return Profile.isFeatureEnabled(Profile.Feature.ORGANIZATION); + } + +} diff --git a/services/src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper b/services/src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper index 1ae40e8b16..8f45caae7e 100755 --- a/services/src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper +++ b/services/src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper @@ -25,6 +25,7 @@ org.keycloak.protocol.oidc.mappers.RoleNameMapper org.keycloak.protocol.oidc.mappers.UserSessionNoteMapper org.keycloak.protocol.oidc.mappers.GroupMembershipMapper org.keycloak.protocol.oidc.mappers.AudienceProtocolMapper +org.keycloak.protocol.oidc.mappers.OrganizationMembershipMapper org.keycloak.protocol.oidc.mappers.AudienceResolveProtocolMapper org.keycloak.protocol.oidc.mappers.AllowedWebOriginsProtocolMapper org.keycloak.protocol.oidc.mappers.AcrProtocolMapper diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/AbstractOrganizationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/AbstractOrganizationTest.java index d24f47b330..7e4a1a1e7a 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/AbstractOrganizationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/AbstractOrganizationTest.java @@ -27,14 +27,19 @@ import org.keycloak.representations.idm.OrganizationRepresentation; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.testsuite.admin.AbstractAdminTest; import org.keycloak.testsuite.admin.ApiUtil; +import org.keycloak.testsuite.admin.Users; /** * @author Pedro Igor */ public abstract class AbstractOrganizationTest extends AbstractAdminTest { + protected String organizationName = "neworg"; + protected String memberEmail = "jdoe@neworg.org"; + protected String memberPassword = "password"; + protected OrganizationRepresentation createOrganization() { - return createOrganization("neworg"); + return createOrganization(organizationName); } protected OrganizationRepresentation createOrganization(String name) { @@ -56,7 +61,7 @@ public abstract class AbstractOrganizationTest extends AbstractAdminTest { } protected UserRepresentation addMember(OrganizationResource organization) { - return addMember(organization, "jdoe@neworg.org"); + return addMember(organization, memberEmail); } protected UserRepresentation addMember(OrganizationResource organization, String email) { @@ -64,6 +69,8 @@ public abstract class AbstractOrganizationTest extends AbstractAdminTest { expected.setEmail(email); expected.setUsername(expected.getEmail()); + expected.setEnabled(true); + Users.setPasswordFor(expected, memberPassword); try (Response response = organization.members().addMember(expected)) { assertEquals(Status.CREATED.getStatusCode(), response.getStatus()); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationOIDCProtocolMapperTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationOIDCProtocolMapperTest.java new file mode 100644 index 0000000000..75ef1c4f84 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationOIDCProtocolMapperTest.java @@ -0,0 +1,57 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.testsuite.organization.admin; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.notNullValue; + +import java.util.Map; +import org.junit.Test; +import org.keycloak.OAuth2Constants; +import org.keycloak.TokenVerifier; +import org.keycloak.admin.client.resource.OrganizationResource; +import org.keycloak.common.Profile.Feature; +import org.keycloak.representations.AccessToken; +import org.keycloak.testsuite.arquillian.annotation.EnableFeature; +import org.keycloak.testsuite.util.OAuthClient.AccessTokenResponse; + +@EnableFeature(Feature.ORGANIZATION) +public class OrganizationOIDCProtocolMapperTest extends AbstractOrganizationTest { + + @Test + public void testClaim() throws Exception { + OrganizationResource organization = testRealm().organizations().get(createOrganization().getId()); + addMember(organization); + + oauth.clientId("direct-grant"); + oauth.scope("openid organization"); + AccessTokenResponse response = oauth.doGrantAccessTokenRequest("password", memberEmail, memberPassword); + assertThat(response.getScope(), containsString("organization")); + + AccessToken accessToken = TokenVerifier.create(response.getAccessToken(), AccessToken.class).getToken(); + + assertThat(accessToken.getOtherClaims().keySet(), hasItem(OAuth2Constants.ORGANIZATION)); + + @SuppressWarnings("unchecked") + Map claim = (Map) accessToken.getOtherClaims().get(OAuth2Constants.ORGANIZATION); + assertThat(claim, notNullValue()); + assertThat(claim.get(organizationName), notNullValue()); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationTest.java index 54578c6f09..ce350a5edc 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationTest.java @@ -43,7 +43,7 @@ public class OrganizationTest extends AbstractOrganizationTest { public void testUpdate() { OrganizationRepresentation expected = createOrganization(); - assertEquals("neworg", expected.getName()); + assertEquals(organizationName, expected.getName()); expected.setName("acme"); OrganizationResource organization = testRealm().organizations().get(expected.getId()); diff --git a/themes/src/main/resources/theme/base/account/messages/messages_en.properties b/themes/src/main/resources/theme/base/account/messages/messages_en.properties index 1f09cb5576..0b39d2dbcf 100755 --- a/themes/src/main/resources/theme/base/account/messages/messages_en.properties +++ b/themes/src/main/resources/theme/base/account/messages/messages_en.properties @@ -76,6 +76,7 @@ phoneScopeConsentText=Phone number offlineAccessScopeConsentText=Offline Access samlRoleListScopeConsentText=My Roles rolesScopeConsentText=User roles +organizationScopeConsentText=Organization role_admin=Admin role_realm-admin=Realm Admin diff --git a/themes/src/main/resources/theme/base/login/messages/messages_en.properties b/themes/src/main/resources/theme/base/login/messages/messages_en.properties index 1a26bc66ea..f04ff18e3d 100755 --- a/themes/src/main/resources/theme/base/login/messages/messages_en.properties +++ b/themes/src/main/resources/theme/base/login/messages/messages_en.properties @@ -116,6 +116,7 @@ phoneScopeConsentText=Phone number offlineAccessScopeConsentText=Offline Access samlRoleListScopeConsentText=My Roles rolesScopeConsentText=User roles +organizationScopeConsentText=Organization restartLoginTooltip=Restart login