diff --git a/services/src/main/java/org/keycloak/protocol/oidc/mappers/OrganizationMembershipMapper.java b/services/src/main/java/org/keycloak/organization/protocol/mappers/oidc/OrganizationMembershipMapper.java similarity index 78% rename from services/src/main/java/org/keycloak/protocol/oidc/mappers/OrganizationMembershipMapper.java rename to services/src/main/java/org/keycloak/organization/protocol/mappers/oidc/OrganizationMembershipMapper.java index 7dd21991eced..35b2d2b8c185 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/mappers/OrganizationMembershipMapper.java +++ b/services/src/main/java/org/keycloak/organization/protocol/mappers/oidc/OrganizationMembershipMapper.java @@ -15,7 +15,7 @@ * limitations under the License. */ -package org.keycloak.protocol.oidc.mappers; +package org.keycloak.organization.protocol.mappers.oidc; import java.util.ArrayList; import java.util.HashMap; @@ -32,6 +32,12 @@ import org.keycloak.models.UserSessionModel; import org.keycloak.organization.OrganizationProvider; import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.mappers.AbstractOIDCProtocolMapper; +import org.keycloak.protocol.oidc.mappers.OIDCAccessTokenMapper; +import org.keycloak.protocol.oidc.mappers.OIDCAttributeMapperHelper; +import org.keycloak.protocol.oidc.mappers.OIDCIDTokenMapper; +import org.keycloak.protocol.oidc.mappers.TokenIntrospectionTokenMapper; +import org.keycloak.protocol.oidc.mappers.UserInfoTokenMapper; import org.keycloak.provider.EnvironmentDependentProviderFactory; import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.representations.IDToken; @@ -69,15 +75,22 @@ public String getHelpText() { @Override protected void setClaim(IDToken token, ProtocolMapperModel mappingModel, UserSessionModel userSession, KeycloakSession keycloakSession, ClientSessionContext clientSessionCtx) { + OrganizationProvider provider = keycloakSession.getProvider(OrganizationProvider.class); + + if (!provider.isEnabled()) { + return; + } + UserModel user = userSession.getUser(); - OrganizationProvider organizationProvider = keycloakSession.getProvider(OrganizationProvider.class); - OrganizationModel organization = organizationProvider.getByMember(user); + OrganizationModel organization = provider.getByMember(user); - if (organization != null) { - Map> claim = new HashMap<>(); - claim.put(organization.getName(), Map.of()); - token.getOtherClaims().put(OAuth2Constants.ORGANIZATION, claim); + if (organization == null || !organization.isEnabled()) { + return; } + + 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) { diff --git a/services/src/main/java/org/keycloak/organization/protocol/mappers/saml/OrganizationMembershipMapper.java b/services/src/main/java/org/keycloak/organization/protocol/mappers/saml/OrganizationMembershipMapper.java new file mode 100755 index 000000000000..1dab624cd23c --- /dev/null +++ b/services/src/main/java/org/keycloak/organization/protocol/mappers/saml/OrganizationMembershipMapper.java @@ -0,0 +1,109 @@ +/* + * 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.organization.protocol.mappers.saml; + +import java.util.List; + +import org.keycloak.Config.Scope; +import org.keycloak.common.Profile; +import org.keycloak.common.Profile.Feature; +import org.keycloak.dom.saml.v2.assertion.AttributeStatementType; +import org.keycloak.dom.saml.v2.assertion.AttributeType; +import org.keycloak.models.AuthenticatedClientSessionModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.OrganizationModel; +import org.keycloak.models.ProtocolMapperModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.UserSessionModel; +import org.keycloak.organization.OrganizationProvider; +import org.keycloak.protocol.saml.SamlProtocol; +import org.keycloak.protocol.saml.mappers.AbstractSAMLProtocolMapper; +import org.keycloak.protocol.saml.mappers.AttributeStatementHelper; +import org.keycloak.protocol.saml.mappers.SAMLAttributeStatementMapper; +import org.keycloak.provider.EnvironmentDependentProviderFactory; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.saml.common.constants.JBossSAMLURIConstants; + +public class OrganizationMembershipMapper extends AbstractSAMLProtocolMapper implements SAMLAttributeStatementMapper, EnvironmentDependentProviderFactory { + + public static final String ID = "saml-organization-membership-mapper"; + public static final String ORGANIZATION_ATTRIBUTE_NAME = "organization"; + + public static ProtocolMapperModel create() { + ProtocolMapperModel mapper = new ProtocolMapperModel(); + + mapper.setName("organization"); + mapper.setProtocolMapper(ID); + mapper.setProtocol(SamlProtocol.LOGIN_PROTOCOL); + + return mapper; + + } + + @Override + public void transformAttributeStatement(AttributeStatementType attributeStatement, ProtocolMapperModel mappingModel, KeycloakSession session, UserSessionModel userSession, AuthenticatedClientSessionModel clientSession) { + OrganizationProvider provider = session.getProvider(OrganizationProvider.class); + + if (!provider.isEnabled()) { + return; + } + + UserModel user = userSession.getUser(); + OrganizationModel organization = provider.getByMember(user); + + if (organization == null || !organization.isEnabled()) { + return; + } + + AttributeType attribute = new AttributeType(ORGANIZATION_ATTRIBUTE_NAME); + attribute.setFriendlyName(ORGANIZATION_ATTRIBUTE_NAME); + attribute.setNameFormat(JBossSAMLURIConstants.ATTRIBUTE_FORMAT_BASIC.get()); + attribute.addAttributeValue(organization.getName()); + attributeStatement.addAttribute(new AttributeStatementType.ASTChoiceType(attribute)); + } + + @Override + public List getConfigProperties() { + return List.of(); + } + + @Override + public String getId() { + return ID; + } + + @Override + public String getDisplayType() { + return "Organization Membership"; + } + + @Override + public String getDisplayCategory() { + return AttributeStatementHelper.ATTRIBUTE_STATEMENT_CATEGORY; + } + + @Override + public String getHelpText() { + return "Add an attribute to the assertion with information about the organization membership."; + } + + @Override + public boolean isSupported(Scope config) { + return Profile.isFeatureEnabled(Feature.ORGANIZATION); + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolFactory.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolFactory.java index d1eea24b9a95..71282c09835e 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolFactory.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolFactory.java @@ -39,7 +39,7 @@ import org.keycloak.protocol.oidc.mappers.AllowedWebOriginsProtocolMapper; import org.keycloak.protocol.oidc.mappers.AudienceResolveProtocolMapper; import org.keycloak.protocol.oidc.mappers.FullNameMapper; -import org.keycloak.protocol.oidc.mappers.OrganizationMembershipMapper; +import org.keycloak.organization.protocol.mappers.oidc.OrganizationMembershipMapper; import org.keycloak.protocol.oidc.mappers.UserAttributeMapper; import org.keycloak.protocol.oidc.mappers.UserClientRoleMappingMapper; import org.keycloak.protocol.oidc.mappers.UserPropertyMapper; diff --git a/services/src/main/java/org/keycloak/protocol/saml/SamlProtocolFactory.java b/services/src/main/java/org/keycloak/protocol/saml/SamlProtocolFactory.java index 2aef3e9cd860..7ac3d9f7fcac 100755 --- a/services/src/main/java/org/keycloak/protocol/saml/SamlProtocolFactory.java +++ b/services/src/main/java/org/keycloak/protocol/saml/SamlProtocolFactory.java @@ -18,6 +18,8 @@ package org.keycloak.protocol.saml; import org.keycloak.Config; +import org.keycloak.common.Profile; +import org.keycloak.common.Profile.Feature; import org.keycloak.events.EventBuilder; import org.keycloak.models.ClientModel; import org.keycloak.models.ClientScopeModel; @@ -28,6 +30,7 @@ import org.keycloak.protocol.AbstractLoginProtocolFactory; import org.keycloak.protocol.LoginProtocol; import org.keycloak.protocol.saml.mappers.AttributeStatementHelper; +import org.keycloak.organization.protocol.mappers.saml.OrganizationMembershipMapper; import org.keycloak.protocol.saml.mappers.RoleListMapper; import org.keycloak.protocol.saml.mappers.UserPropertyAttributeStatementMapper; import org.keycloak.representations.idm.CertificateRepresentation; @@ -93,6 +96,11 @@ public void init(Config.Scope config) { model = RoleListMapper.create("role list", "Role", AttributeStatementHelper.BASIC, null, false); builtins.put("role list", model); defaultBuiltins.add(model); + if (Profile.isFeatureEnabled(Feature.ORGANIZATION)) { + model = OrganizationMembershipMapper.create(); + builtins.put("organization", model); + defaultBuiltins.add(model); + } this.destinationValidator = DestinationValidator.forProtocolMap(config.getArray("knownProtocols")); } @@ -118,6 +126,14 @@ protected void createDefaultClientScopesImpl(RealmModel newRealm) { roleListScope.setProtocol(getId()); roleListScope.addProtocolMapper(builtins.get("role list")); newRealm.addDefaultClientScope(roleListScope, true); + if (Profile.isFeatureEnabled(Feature.ORGANIZATION)) { + ClientScopeModel organizationScope = newRealm.addClientScope("saml_organization"); + organizationScope.setDescription("Organization Membership"); + organizationScope.setDisplayOnConsentScreen(false); + organizationScope.setProtocol(getId()); + organizationScope.addProtocolMapper(builtins.get("organization")); + newRealm.addDefaultClientScope(organizationScope, true); + } } @Override 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 1adfde55d4b6..bf54b6c575de 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,7 +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.organization.protocol.mappers.oidc.OrganizationMembershipMapper org.keycloak.protocol.oidc.mappers.AudienceResolveProtocolMapper org.keycloak.protocol.oidc.mappers.AllowedWebOriginsProtocolMapper org.keycloak.protocol.oidc.mappers.AcrProtocolMapper @@ -56,3 +56,4 @@ org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCTypeMapper org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCContextMapper org.keycloak.protocol.oidc.mappers.SessionStateMapper org.keycloak.protocol.oidc.mappers.SubMapper +org.keycloak.organization.protocol.mappers.saml.OrganizationMembershipMapper diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationSAMLProtocolMapperTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationSAMLProtocolMapperTest.java new file mode 100644 index 000000000000..febad6a81a83 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationSAMLProtocolMapperTest.java @@ -0,0 +1,85 @@ +/* + * 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.keycloak.testsuite.util.SamlStreams.assertionsUnencrypted; +import static org.keycloak.testsuite.util.SamlStreams.attributeStatements; + +import java.util.List; +import java.util.function.Function; +import java.util.stream.Stream; + +import jakarta.ws.rs.core.UriBuilder; +import org.junit.Assert; +import org.junit.Test; +import org.keycloak.admin.client.resource.OrganizationResource; +import org.keycloak.common.Profile.Feature; +import org.keycloak.dom.saml.v2.assertion.AttributeStatementType; +import org.keycloak.dom.saml.v2.assertion.AttributeStatementType.ASTChoiceType; +import org.keycloak.dom.saml.v2.assertion.AttributeType; +import org.keycloak.protocol.saml.SamlConfigAttributes; +import org.keycloak.protocol.saml.SamlProtocol; +import org.keycloak.organization.protocol.mappers.saml.OrganizationMembershipMapper; +import org.keycloak.saml.common.constants.JBossSAMLURIConstants; +import org.keycloak.saml.processing.core.saml.v2.common.SAMLDocumentHolder; +import org.keycloak.services.resources.RealmsResource; +import org.keycloak.testsuite.arquillian.annotation.EnableFeature; +import org.keycloak.testsuite.saml.RoleMapperTest; +import org.keycloak.testsuite.util.ClientBuilder; +import org.keycloak.testsuite.util.Matchers; +import org.keycloak.testsuite.util.SamlClient; +import org.keycloak.testsuite.util.SamlClientBuilder; + +@EnableFeature(Feature.ORGANIZATION) +public class OrganizationSAMLProtocolMapperTest extends AbstractOrganizationTest { + + @Test + public void testAttribute() { + OrganizationResource organization = testRealm().organizations().get(createOrganization().getId()); + addMember(organization); + String clientId = "saml-client"; + testRealm().clients().create(ClientBuilder.create() + .protocol(SamlProtocol.LOGIN_PROTOCOL) + .clientId(clientId) + .redirectUris("*") + .attribute(SamlConfigAttributes.SAML_CLIENT_SIGNATURE_ATTRIBUTE, Boolean.FALSE.toString()) + .build()).close(); + + SAMLDocumentHolder samlResponse = new SamlClientBuilder() + .authnRequest(RealmsResource + .protocolUrl(UriBuilder.fromUri(getAuthServerRoot())) + .build(TEST_REALM_NAME, SamlProtocol.LOGIN_PROTOCOL), clientId, RoleMapperTest.SAML_ASSERTION_CONSUMER_URL_EMPLOYEE_2, SamlClient.Binding.POST) + .build() + .login().user(memberEmail, memberPassword).build() + .login().user(memberEmail, memberPassword).build() + .getSamlResponse(SamlClient.Binding.POST); + + assertThat(samlResponse.getSamlObject(), Matchers.isSamlResponse(JBossSAMLURIConstants.STATUS_SUCCESS)); + AttributeType orgAttribute = attributeStatements(assertionsUnencrypted(samlResponse.getSamlObject())) + .flatMap((Function>) attributeStatementType -> attributeStatementType.getAttributes().stream()) + .map(ASTChoiceType::getAttribute) + .filter(attribute -> OrganizationMembershipMapper.ORGANIZATION_ATTRIBUTE_NAME.equals(attribute.getName())) + .findAny() + .orElse(null); + Assert.assertNotNull(orgAttribute); + List values = orgAttribute.getAttributeValue(); + Assert.assertEquals(1, values.size()); + Assert.assertEquals(organizationName, values.get(0)); + } +}