From ff9ec6f0fed3642696242a53caacfc2b000c42e5 Mon Sep 17 00:00:00 2001 From: Peter Szabo Date: Sun, 28 Dec 2025 15:55:57 +0100 Subject: [PATCH] Added Prefix option to User Attribute mapper Signed-off-by: Peter Szabo --- .../oidc/mappers/UserAttributeMapper.java | 69 ++++++++++-- .../AbstractUserAttributeMapperTest.java | 2 +- .../OidcPrefixedUserAttributeMapperTest.java | 101 ++++++++++++++++++ 3 files changed, 165 insertions(+), 7 deletions(-) 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/UserAttributeMapper.java b/services/src/main/java/org/keycloak/broker/oidc/mappers/UserAttributeMapper.java index bea9c11d698..479a19cb940 100755 --- a/services/src/main/java/org/keycloak/broker/oidc/mappers/UserAttributeMapper.java +++ b/services/src/main/java/org/keycloak/broker/oidc/mappers/UserAttributeMapper.java @@ -39,6 +39,8 @@ import org.keycloak.models.UserModel; import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.saml.common.util.StringUtil; +import org.jboss.logging.Logger; + /** * @author Bill Burke * @version $Revision: 1 $ @@ -50,6 +52,7 @@ public class UserAttributeMapper extends AbstractClaimMapper { 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"; @@ -58,22 +61,35 @@ public class UserAttributeMapper extends AbstractClaimMapper { 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( + "Optional 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-user-attribute-idp-mapper"; + private static final Logger LOG = Logger.getLogger(UserAttributeMapper.class); + @Override public boolean supportsSyncMode(IdentityProviderSyncMode syncMode) { return IDENTITY_PROVIDER_SYNC_MODES.contains(syncMode); @@ -106,12 +122,27 @@ public class UserAttributeMapper extends AbstractClaimMapper { @Override public void preprocessFederatedIdentity(KeycloakSession session, RealmModel realm, IdentityProviderMapperModel mapperModel, BrokeredIdentityContext context) { + LOG.debug("executing preprocessFederatedIdentity()"); + String attribute = mapperModel.getConfig().get(USER_ATTRIBUTE); - if(StringUtil.isNullOrEmpty(attribute)){ + String prefix = Objects.toString(mapperModel.getConfig().get(ATTRIBUTE_PREFFIX), ""); + + if (StringUtil.isNullOrEmpty(attribute)) { + LOG.debug("Empty attribute, not processing."); return; } + Object value = getClaimValue(mapperModel, context); - List values = toList(value); + List values; + + if (StringUtil.isNullOrEmpty(prefix)) { + LOG.debug("No attribute prefix configured."); + values = toList(value); + } + else { + LOG.debug("Retrieved prefix: " + prefix); + values = toPrefixedList(value, prefix); + } if (EMAIL.equalsIgnoreCase(attribute)) { setIfNotEmpty(context::setEmail, values); @@ -145,14 +176,41 @@ public class UserAttributeMapper extends AbstractClaimMapper { .collect(Collectors.toList()); } + 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); - if(StringUtil.isNullOrEmpty(attribute)){ + String prefix = Objects.toString(mapperModel.getConfig().get(ATTRIBUTE_PREFFIX), ""); + + if (StringUtil.isNullOrEmpty(attribute)) { + LOG.debug("Empty attribute, not processing."); return; } + Object value = getClaimValue(mapperModel, context); - List values = toList(value); + List values; + + if (StringUtil.isNullOrEmpty(prefix)) { + LOG.debug("No attribute prefix configured."); + values = toList(value); + } + else { + LOG.debug("Retrieved prefix: " + prefix); + values = toPrefixedList(value, prefix); + } + if (EMAIL.equalsIgnoreCase(attribute)) { setIfNotEmpty(user::setEmail, values); } else if (FIRST_NAME.equalsIgnoreCase(attribute)) { @@ -171,7 +229,6 @@ public class UserAttributeMapper extends AbstractClaimMapper { @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."; + 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 optionally prefix each attribute value with the provided prefix."; } - } 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 f25fda1c2ac..600fed3bffb 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 @@ -54,7 +54,7 @@ public abstract class AbstractUserAttributeMapperTest extends AbstractIdentityPr } } - 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/OidcPrefixedUserAttributeMapperTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/OidcPrefixedUserAttributeMapperTest.java new file mode 100644 index 00000000000..51d5ad6ebd8 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/OidcPrefixedUserAttributeMapperTest.java @@ -0,0 +1,101 @@ +package org.keycloak.testsuite.broker; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +import org.keycloak.broker.oidc.mappers.UserAttributeMapper; +import org.keycloak.models.IdentityProviderMapperModel; +import org.keycloak.models.IdentityProviderMapperSyncMode; +import org.keycloak.representations.idm.IdentityProviderMapperRepresentation; +import org.keycloak.representations.idm.UserRepresentation; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Lists; + +import static org.keycloak.testsuite.broker.KcSamlBrokerConfiguration.ATTRIBUTE_TO_MAP_FRIENDLY_NAME; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; + +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 attrMapper1 = new IdentityProviderMapperRepresentation(); + attrMapper1.setName("prefixed-attribute-mapper"); + attrMapper1.setIdentityProviderMapper(UserAttributeMapper.PROVIDER_ID); + attrMapper1.setConfig(ImmutableMap.builder() + .put(IdentityProviderMapperModel.SYNC_MODE, syncMode.toString()) + .put(UserAttributeMapper.CLAIM, KcOidcBrokerConfiguration.ATTRIBUTE_TO_MAP_NAME) + .put(UserAttributeMapper.USER_ATTRIBUTE, MAPPED_ATTRIBUTE_NAME) + .put(UserAttributeMapper.ATTRIBUTE_PREFFIX, PREFIX) + .build()); + + return Lists.newArrayList(attrMapper1); + } + + 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())); + } + } + } + + } + +} \ No newline at end of file