Adding SAML protocol mapper to map organization membership

Closes #28732

Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>
This commit is contained in:
Pedro Igor 2024-05-06 19:28:39 -03:00 committed by Alexander Schwartz
parent aa945d5636
commit f8bc74d64f
6 changed files with 234 additions and 10 deletions

View file

@ -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.UserModel;
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 class OrganizationMembershipMapper extends AbstractOIDCProtocolMapper imp
@Override
protected void setClaim(IDToken token, ProtocolMapperModel mappingModel, UserSessionModel userSession, KeycloakSession keycloakSession, ClientSessionContext clientSessionCtx) {
UserModel user = userSession.getUser();
OrganizationProvider organizationProvider = keycloakSession.getProvider(OrganizationProvider.class);
OrganizationModel organization = organizationProvider.getByMember(user);
OrganizationProvider provider = keycloakSession.getProvider(OrganizationProvider.class);
if (organization != null) {
Map<String, Map<String, Object>> claim = new HashMap<>();
claim.put(organization.getName(), Map.of());
token.getOtherClaims().put(OAuth2Constants.ORGANIZATION, claim);
if (!provider.isEnabled()) {
return;
}
UserModel user = userSession.getUser();
OrganizationModel organization = provider.getByMember(user);
if (organization == null || !organization.isEnabled()) {
return;
}
Map<String, Map<String, Object>> 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) {

View file

@ -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<ProviderConfigProperty> 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);
}
}

View file

@ -39,7 +39,7 @@ import org.keycloak.protocol.oidc.mappers.AddressMapper;
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;

View file

@ -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.models.utils.KeycloakModelUtils;
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 class SamlProtocolFactory extends AbstractLoginProtocolFactory {
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 @@ public class SamlProtocolFactory extends AbstractLoginProtocolFactory {
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

View file

@ -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

View file

@ -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, Stream<ASTChoiceType>>) attributeStatementType -> attributeStatementType.getAttributes().stream())
.map(ASTChoiceType::getAttribute)
.filter(attribute -> OrganizationMembershipMapper.ORGANIZATION_ATTRIBUTE_NAME.equals(attribute.getName()))
.findAny()
.orElse(null);
Assert.assertNotNull(orgAttribute);
List<Object> values = orgAttribute.getAttributeValue();
Assert.assertEquals(1, values.size());
Assert.assertEquals(organizationName, values.get(0));
}
}