From aaf699fdb3035d365ce334497630b1c4eaf5e066 Mon Sep 17 00:00:00 2001 From: Peter Date: Fri, 23 May 2025 21:50:13 +0200 Subject: [PATCH] Implemented OIDC Prefixed User Attribute Mapper Closes #39929 Signed-off-by: Peter --- .../mappers/PrefixedUserAttributeMapper.java | 212 ++++++++++++++++++ ...oak.broker.provider.IdentityProviderMapper | 1 + .../AbstractUserAttributeMapperTest.java | 2 +- ...cEmptyPrefixedUserAttributeMapperTest.java | 91 ++++++++ .../OidcPrefixedUserAttributeMapperTest.java | 129 +++++++++++ 5 files changed, 434 insertions(+), 1 deletion(-) create mode 100644 services/src/main/java/org/keycloak/broker/oidc/mappers/PrefixedUserAttributeMapper.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/OidcEmptyPrefixedUserAttributeMapperTest.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/OidcPrefixedUserAttributeMapperTest.java diff --git a/services/src/main/java/org/keycloak/broker/oidc/mappers/PrefixedUserAttributeMapper.java b/services/src/main/java/org/keycloak/broker/oidc/mappers/PrefixedUserAttributeMapper.java new file mode 100644 index 000000000000..05eff3273907 --- /dev/null +++ b/services/src/main/java/org/keycloak/broker/oidc/mappers/PrefixedUserAttributeMapper.java @@ -0,0 +1,212 @@ +/* + * Copyright 2016 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.broker.oidc.mappers; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +import org.jboss.logging.Logger; +import org.keycloak.broker.oidc.KeycloakOIDCIdentityProviderFactory; +import org.keycloak.broker.oidc.OIDCIdentityProviderFactory; +import org.keycloak.broker.provider.BrokeredIdentityContext; +import org.keycloak.common.util.CollectionUtil; +import org.keycloak.models.IdentityProviderMapperModel; +import org.keycloak.models.IdentityProviderSyncMode; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.utils.StringUtil; + +/** + * @author Peter Szabo + * Based on UserAttributeMapper by Bill Burke + * @version $Revision: 1 $ + */ +public class PrefixedUserAttributeMapper extends AbstractClaimMapper { + + public static final String[] COMPATIBLE_PROVIDERS = { KeycloakOIDCIdentityProviderFactory.PROVIDER_ID, + OIDCIdentityProviderFactory.PROVIDER_ID }; + + private static final List configProperties = new ArrayList<>(); + + public static final String USER_ATTRIBUTE = "user.attribute"; + public static final String ATTRIBUTE_PREFFIX = "attribute.prefix"; + public static final String EMAIL = "email"; + public static final String FIRST_NAME = "firstName"; + public static final String LAST_NAME = "lastName"; + private static final Set IDENTITY_PROVIDER_SYNC_MODES = new HashSet<>( + Arrays.asList(IdentityProviderSyncMode.values())); + + static { + ProviderConfigProperty property; + ProviderConfigProperty property1; + ProviderConfigProperty property2; + + property1 = new ProviderConfigProperty(); + property1.setName(CLAIM); + property1.setLabel("Claim"); + property1.setHelpText( + "Name of claim to search for in token. You can reference nested claims using a '.', i.e. 'address.locality'. To use dot (.) literally, escape it with backslash (\\.)"); + property1.setType(ProviderConfigProperty.STRING_TYPE); + configProperties.add(property1); + + property = new ProviderConfigProperty(); + property.setName(USER_ATTRIBUTE); + property.setLabel("User Attribute Name"); + property.setHelpText( + "User attribute name to store claim. Use email, lastName, and firstName to map to those predefined user properties."); + property.setType(ProviderConfigProperty.USER_PROFILE_ATTRIBUTE_LIST_TYPE); + configProperties.add(property); + + property2 = new ProviderConfigProperty(); + property2.setName(ATTRIBUTE_PREFFIX); + property2.setLabel("Attribute Value Prefix"); + property2.setHelpText( + "Prefix to be concatenated in front of every imported attribute value."); + property2.setType(ProviderConfigProperty.STRING_TYPE); + configProperties.add(property2); + } + + public static final String PROVIDER_ID = "oidc-prefixed-user-attribute-idp-mapper"; + + private static final Logger LOG = Logger.getLogger(PrefixedUserAttributeMapper.class); + + @Override + public boolean supportsSyncMode(IdentityProviderSyncMode syncMode) { + return IDENTITY_PROVIDER_SYNC_MODES.contains(syncMode); + } + + @Override + public List getConfigProperties() { + return configProperties; + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public String[] getCompatibleProviders() { + return COMPATIBLE_PROVIDERS; + } + + @Override + public String getDisplayCategory() { + return "Prefixed Attribute Importer"; + } + + @Override + public String getDisplayType() { + return "Prefixed Attribute Importer"; + } + + @Override + public void preprocessFederatedIdentity(KeycloakSession session, RealmModel realm, IdentityProviderMapperModel mapperModel, BrokeredIdentityContext context) { + LOG.debug("executing preprocessFederatedIdentity()"); + + String attribute = mapperModel.getConfig().get(USER_ATTRIBUTE); + String prefix = Objects.toString(mapperModel.getConfig().get(ATTRIBUTE_PREFFIX), ""); + + LOG.debug("Retrieved prefix: "+prefix); + + if(StringUtil.isNullOrEmpty(attribute)){ + return; + } + Object value = getClaimValue(mapperModel, context); + List values = toPrefixedList(value, prefix); + + if (EMAIL.equalsIgnoreCase(attribute)) { + setIfNotEmpty(context::setEmail, values); + } else if (FIRST_NAME.equalsIgnoreCase(attribute)) { + setIfNotEmpty(context::setFirstName, values); + } else if (LAST_NAME.equalsIgnoreCase(attribute)) { + setIfNotEmpty(context::setLastName, values); + } else { + List valuesToString = values.stream() + .filter(Objects::nonNull) + .map(Object::toString) + .collect(Collectors.toList()); + + context.setUserAttribute(attribute, valuesToString); + } + } + + private void setIfNotEmpty(Consumer consumer, List values) { + if (values != null && !values.isEmpty()) { + consumer.accept(values.get(0)); + } + } + + private List toPrefixedList(Object value, String prefix) { + List values = (value instanceof List) + ? (List) value + : Collections.singletonList(value); + + return values.stream() + .filter(Objects::nonNull) + .map(item -> prefix.concat(item.toString())) + .collect(Collectors.toList()); + } + + @Override + public void updateBrokeredUser(KeycloakSession session, RealmModel realm, UserModel user, + IdentityProviderMapperModel mapperModel, BrokeredIdentityContext context) { + LOG.debug("executing updateBrokeredUser()"); + + String attribute = mapperModel.getConfig().get(USER_ATTRIBUTE); + String prefix = Objects.toString(mapperModel.getConfig().get(ATTRIBUTE_PREFFIX), ""); + + LOG.debug("Retrieved prefix: "+prefix); + + if (StringUtil.isNullOrEmpty(attribute)) { + return; + } + + Object value = getClaimValue(mapperModel, context); + List values = toPrefixedList(value, prefix); + + if (EMAIL.equalsIgnoreCase(attribute)) { + setIfNotEmpty(user::setEmail, values); + } else if (FIRST_NAME.equalsIgnoreCase(attribute)) { + setIfNotEmpty(user::setFirstName, values); + } else if (LAST_NAME.equalsIgnoreCase(attribute)) { + setIfNotEmpty(user::setLastName, values); + } else { + List current = user.getAttributeStream(attribute).collect(Collectors.toList()); + if (!CollectionUtil.collectionEquals(values, current)) { + user.setAttribute(attribute, values); + } else if (values.isEmpty()) { + user.removeAttribute(attribute); + } + } + } + + @Override + public String getHelpText() { + return "Import declared claim if it exists in ID, access token or the claim set returned by the user profile endpoint into the specified user property or attribute and prefix each attribute value with the provided prefix."; + } +} \ No newline at end of file diff --git a/services/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderMapper b/services/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderMapper index 694d41455a65..909ff940a480 100755 --- a/services/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderMapper +++ b/services/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderMapper @@ -26,6 +26,7 @@ org.keycloak.broker.oidc.mappers.ClaimToUserSessionNoteMapper org.keycloak.broker.oidc.mappers.ExternalKeycloakRoleToRoleMapper org.keycloak.broker.oidc.mappers.UserAttributeMapper org.keycloak.broker.oidc.mappers.UsernameTemplateMapper +org.keycloak.broker.oidc.mappers.PrefixedUserAttributeMapper org.keycloak.broker.saml.mappers.AdvancedAttributeToRoleMapper org.keycloak.broker.saml.mappers.AdvancedAttributeToGroupMapper org.keycloak.broker.saml.mappers.AttributeToRoleMapper diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/AbstractUserAttributeMapperTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/AbstractUserAttributeMapperTest.java index 5ba26a8ccb8f..282137bf3f49 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/AbstractUserAttributeMapperTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/AbstractUserAttributeMapperTest.java @@ -53,7 +53,7 @@ public void addIdentityProviderToConsumerRealm(IdentityProviderMapperSyncMode sy } } - private void assertUserAttributes(Map> attrs, UserRepresentation userRep) { + protected void assertUserAttributes(Map> attrs, UserRepresentation userRep) { Set mappedAttrNames = attrs.entrySet().stream() .filter(me -> me.getValue() != null && ! me.getValue().isEmpty()) .map(me -> me.getKey()) diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/OidcEmptyPrefixedUserAttributeMapperTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/OidcEmptyPrefixedUserAttributeMapperTest.java new file mode 100644 index 000000000000..bfebbd518561 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/OidcEmptyPrefixedUserAttributeMapperTest.java @@ -0,0 +1,91 @@ +package org.keycloak.testsuite.broker; + +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.keycloak.testsuite.broker.KcSamlBrokerConfiguration.ATTRIBUTE_TO_MAP_FRIENDLY_NAME; + +import java.util.List; +import java.util.Arrays; +import java.util.Map; +import java.util.Set; +import java.util.Collections; +import java.util.Objects; +import java.util.stream.Collectors; + +import org.keycloak.broker.oidc.mappers.UserAttributeMapper; +import org.keycloak.broker.oidc.mappers.PrefixedUserAttributeMapper; +import org.keycloak.models.IdentityProviderMapperModel; +import org.keycloak.models.IdentityProviderMapperSyncMode; +import org.keycloak.representations.idm.IdentityProviderMapperRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.testsuite.util.AccountHelper; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Lists; + +public class OidcEmptyPrefixedUserAttributeMapperTest extends AbstractUserAttributeMapperTest { + + protected static final String MAPPED_ATTRIBUTE_NAME = "mapped-user-attribute"; + protected static final String MAPPED_ATTRIBUTE_FRIENDLY_NAME = "mapped-user-attribute-friendly"; + + private static final String PREFIX = ""; + private static final Set PROTECTED_NAMES = ImmutableSet.builder().add("email").add("lastName").add("firstName").build(); + private static final Map ATTRIBUTE_NAME_TRANSLATION = ImmutableMap.builder() + .put("dotted.email", "dotted.email") + .put("nested.email", "nested.email") + .put(ATTRIBUTE_TO_MAP_FRIENDLY_NAME, MAPPED_ATTRIBUTE_FRIENDLY_NAME) + .put(KcOidcBrokerConfiguration.ATTRIBUTE_TO_MAP_NAME, MAPPED_ATTRIBUTE_NAME) + .build(); + + @Override + protected BrokerConfiguration getBrokerConfiguration() { + return KcOidcBrokerConfiguration.INSTANCE; + } + + @Override + protected Iterable createIdentityProviderMappers(IdentityProviderMapperSyncMode syncMode) { + IdentityProviderMapperRepresentation emailAttrMapper = new IdentityProviderMapperRepresentation(); + emailAttrMapper.setName("attribute-mapper-email"); + emailAttrMapper.setIdentityProviderMapper(UserAttributeMapper.PROVIDER_ID); + emailAttrMapper.setConfig(ImmutableMap.builder() + .put(IdentityProviderMapperModel.SYNC_MODE, syncMode.toString()) + .put(UserAttributeMapper.CLAIM, "email") + .put(UserAttributeMapper.USER_ATTRIBUTE, "email") + .build()); + + IdentityProviderMapperRepresentation nestedEmailAttrMapper = new IdentityProviderMapperRepresentation(); + nestedEmailAttrMapper.setName("nested-attribute-mapper-email"); + nestedEmailAttrMapper.setIdentityProviderMapper(UserAttributeMapper.PROVIDER_ID); + nestedEmailAttrMapper.setConfig(ImmutableMap.builder() + .put(IdentityProviderMapperModel.SYNC_MODE, syncMode.toString()) + .put(UserAttributeMapper.CLAIM, "nested.email") + .put(UserAttributeMapper.USER_ATTRIBUTE, "nested.email") + .build()); + + IdentityProviderMapperRepresentation dottedEmailAttrMapper = new IdentityProviderMapperRepresentation(); + dottedEmailAttrMapper.setName("dotted-attribute-mapper-email"); + dottedEmailAttrMapper.setIdentityProviderMapper(UserAttributeMapper.PROVIDER_ID); + dottedEmailAttrMapper.setConfig(ImmutableMap.builder() + .put(IdentityProviderMapperModel.SYNC_MODE, syncMode.toString()) + .put(UserAttributeMapper.CLAIM, "dotted\\.email") + .put(UserAttributeMapper.USER_ATTRIBUTE, "dotted.email") + .build()); + + IdentityProviderMapperRepresentation emptyPrefixAttrMapper = new IdentityProviderMapperRepresentation(); + emptyPrefixAttrMapper.setName("empty-prefixed-attribute-mapper"); + emptyPrefixAttrMapper.setIdentityProviderMapper(PrefixedUserAttributeMapper.PROVIDER_ID); + emptyPrefixAttrMapper.setConfig(ImmutableMap.builder() + .put(IdentityProviderMapperModel.SYNC_MODE, syncMode.toString()) + .put(PrefixedUserAttributeMapper.CLAIM, KcOidcBrokerConfiguration.ATTRIBUTE_TO_MAP_NAME) + .put(PrefixedUserAttributeMapper.USER_ATTRIBUTE, MAPPED_ATTRIBUTE_NAME) + .put(PrefixedUserAttributeMapper.ATTRIBUTE_PREFFIX, PREFIX) + .build()); + + return Lists.newArrayList(emailAttrMapper, nestedEmailAttrMapper, dottedEmailAttrMapper, emptyPrefixAttrMapper); + } + +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/OidcPrefixedUserAttributeMapperTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/OidcPrefixedUserAttributeMapperTest.java new file mode 100644 index 000000000000..a119b3bde456 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/OidcPrefixedUserAttributeMapperTest.java @@ -0,0 +1,129 @@ +package org.keycloak.testsuite.broker; + +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.keycloak.testsuite.broker.KcSamlBrokerConfiguration.ATTRIBUTE_TO_MAP_FRIENDLY_NAME; + +import java.util.List; +import java.util.Arrays; +import java.util.Map; +import java.util.Set; +import java.util.Collections; +import java.util.Objects; +import java.util.stream.Collectors; + +import org.keycloak.broker.oidc.mappers.UserAttributeMapper; +import org.keycloak.broker.oidc.mappers.PrefixedUserAttributeMapper; +import org.keycloak.models.IdentityProviderMapperModel; +import org.keycloak.models.IdentityProviderMapperSyncMode; +import org.keycloak.representations.idm.IdentityProviderMapperRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.testsuite.util.AccountHelper; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Lists; + +public class OidcPrefixedUserAttributeMapperTest extends AbstractUserAttributeMapperTest { + + protected static final String MAPPED_ATTRIBUTE_NAME = "mapped-user-attribute"; + protected static final String MAPPED_ATTRIBUTE_FRIENDLY_NAME = "mapped-user-attribute-friendly"; + + private static final String PREFIX = "prefix_"; + private static final Set PROTECTED_NAMES = ImmutableSet.builder().add("email").add("lastName").add("firstName").build(); + private static final Map ATTRIBUTE_NAME_TRANSLATION = ImmutableMap.builder() + .put("dotted.email", "dotted.email") + .put("nested.email", "nested.email") + .put(ATTRIBUTE_TO_MAP_FRIENDLY_NAME, MAPPED_ATTRIBUTE_FRIENDLY_NAME) + .put(KcOidcBrokerConfiguration.ATTRIBUTE_TO_MAP_NAME, MAPPED_ATTRIBUTE_NAME) + .build(); + + @Override + protected BrokerConfiguration getBrokerConfiguration() { + return KcOidcBrokerConfiguration.INSTANCE; + } + + @Override + protected Iterable createIdentityProviderMappers(IdentityProviderMapperSyncMode syncMode) { + IdentityProviderMapperRepresentation emailAttrMapper = new IdentityProviderMapperRepresentation(); + emailAttrMapper.setName("attribute-mapper-email"); + emailAttrMapper.setIdentityProviderMapper(UserAttributeMapper.PROVIDER_ID); + emailAttrMapper.setConfig(ImmutableMap.builder() + .put(IdentityProviderMapperModel.SYNC_MODE, syncMode.toString()) + .put(UserAttributeMapper.CLAIM, "email") + .put(UserAttributeMapper.USER_ATTRIBUTE, "email") + .build()); + + IdentityProviderMapperRepresentation nestedEmailAttrMapper = new IdentityProviderMapperRepresentation(); + nestedEmailAttrMapper.setName("nested-attribute-mapper-email"); + nestedEmailAttrMapper.setIdentityProviderMapper(UserAttributeMapper.PROVIDER_ID); + nestedEmailAttrMapper.setConfig(ImmutableMap.builder() + .put(IdentityProviderMapperModel.SYNC_MODE, syncMode.toString()) + .put(UserAttributeMapper.CLAIM, "nested.email") + .put(UserAttributeMapper.USER_ATTRIBUTE, "nested.email") + .build()); + + IdentityProviderMapperRepresentation dottedEmailAttrMapper = new IdentityProviderMapperRepresentation(); + dottedEmailAttrMapper.setName("dotted-attribute-mapper-email"); + dottedEmailAttrMapper.setIdentityProviderMapper(UserAttributeMapper.PROVIDER_ID); + dottedEmailAttrMapper.setConfig(ImmutableMap.builder() + .put(IdentityProviderMapperModel.SYNC_MODE, syncMode.toString()) + .put(UserAttributeMapper.CLAIM, "dotted\\.email") + .put(UserAttributeMapper.USER_ATTRIBUTE, "dotted.email") + .build()); + + IdentityProviderMapperRepresentation attrMapper1 = new IdentityProviderMapperRepresentation(); + attrMapper1.setName("prefixed-attribute-mapper"); + attrMapper1.setIdentityProviderMapper(PrefixedUserAttributeMapper.PROVIDER_ID); + attrMapper1.setConfig(ImmutableMap.builder() + .put(IdentityProviderMapperModel.SYNC_MODE, syncMode.toString()) + .put(PrefixedUserAttributeMapper.CLAIM, KcOidcBrokerConfiguration.ATTRIBUTE_TO_MAP_NAME) + .put(PrefixedUserAttributeMapper.USER_ATTRIBUTE, MAPPED_ATTRIBUTE_NAME) + .put(PrefixedUserAttributeMapper.ATTRIBUTE_PREFFIX, PREFIX) + .build()); + + return Lists.newArrayList(attrMapper1, emailAttrMapper, nestedEmailAttrMapper, dottedEmailAttrMapper); + } + + private List toPrefixedList(Object value, String prefix) { + List values = (value.getClass().isArray()) + ? Arrays.asList((Object[]) value) + : Collections.singletonList(value); + + return values.stream() + .filter(Objects::nonNull) + .map(item -> prefix.concat(item.toString())) + .collect(Collectors.toList()); + } + + @Override + protected void assertUserAttributes(Map> attrs, UserRepresentation userRep) { + Set mappedAttrNames = attrs.entrySet().stream() + .filter(me -> me.getValue() != null && ! me.getValue().isEmpty()) + .map(me -> me.getKey()) + .filter(a -> ! PROTECTED_NAMES.contains(a)) + .map(ATTRIBUTE_NAME_TRANSLATION::get) + .collect(Collectors.toSet()); + + if (mappedAttrNames.isEmpty()) { + assertThat("No attributes are expected to be present", userRep.getAttributes(), nullValue()); + } else if (attrs.containsKey("email")) { + assertThat(userRep.getEmail(), equalTo(attrs.get("email").get(0))); + } else { + assertThat(userRep.getAttributes(), notNullValue()); + assertThat(userRep.getAttributes().keySet(), equalTo(mappedAttrNames)); + for (Map.Entry> me : attrs.entrySet()) { + String mappedAttrName = ATTRIBUTE_NAME_TRANSLATION.get(me.getKey()); + if (mappedAttrNames.contains(mappedAttrName)) { + log.info(userRep.getAttributes()); + assertThat(userRep.getAttributes().get(mappedAttrName), containsInAnyOrder(toPrefixedList(me.getValue().toArray(), PREFIX).toArray())); + } + } + } + + } + +}