Added Prefix option to User Attribute mapper

Signed-off-by: Peter Szabo <petersz.szabo@gmail.com>
This commit is contained in:
Peter Szabo 2025-12-28 15:55:57 +01:00
parent 6519142f43
commit ff9ec6f0fe
3 changed files with 165 additions and 7 deletions

View file

@ -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 <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @version $Revision: 1 $
@ -50,6 +52,7 @@ public class UserAttributeMapper extends AbstractClaimMapper {
private static final List<ProviderConfigProperty> 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<String> values = toList(value);
List<String> 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<String> toPrefixedList(Object value, String prefix) {
List<Object> 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<String> values = toList(value);
List<String> 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.";
}
}

View file

@ -54,7 +54,7 @@ public abstract class AbstractUserAttributeMapperTest extends AbstractIdentityPr
}
}
private void assertUserAttributes(Map<String, List<String>> attrs, UserRepresentation userRep) {
protected void assertUserAttributes(Map<String, List<String>> attrs, UserRepresentation userRep) {
Set<String> mappedAttrNames = attrs.entrySet().stream()
.filter(me -> me.getValue() != null && ! me.getValue().isEmpty())
.map(me -> me.getKey())

View file

@ -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<String> PROTECTED_NAMES = ImmutableSet.<String>builder().add("email").add("lastName").add("firstName").build();
private static final Map<String, String> ATTRIBUTE_NAME_TRANSLATION = ImmutableMap.<String, String>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<IdentityProviderMapperRepresentation> createIdentityProviderMappers(IdentityProviderMapperSyncMode syncMode) {
IdentityProviderMapperRepresentation attrMapper1 = new IdentityProviderMapperRepresentation();
attrMapper1.setName("prefixed-attribute-mapper");
attrMapper1.setIdentityProviderMapper(UserAttributeMapper.PROVIDER_ID);
attrMapper1.setConfig(ImmutableMap.<String,String>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<String> toPrefixedList(Object value, String prefix) {
List<Object> 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<String, List<String>> attrs, UserRepresentation userRep) {
Set<String> 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<String, List<String>> 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()));
}
}
}
}
}