Added User-Session Note Idp mapper. (#19062)

Closes #17659


Co-authored-by: bal1imb <Artur.Baltabayev@bosch.com>
Co-authored-by: Daniel Fesenmeyer <daniel.fesenmeyer@bosch.io>
Co-authored-by: Sebastian Schuster <sebastian.schuster@bosch.io>
This commit is contained in:
Artur Baltabayev 2023-05-18 13:47:10 +02:00 committed by GitHub
parent 256bb84cc4
commit 33215ab6f4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 350 additions and 3 deletions

View file

@ -82,7 +82,7 @@ public abstract class AbstractClaimMapper extends AbstractIdentityProviderMapper
}
{ // search ID Token
Object rawIdToken = context.getContextData().get(KeycloakOIDCIdentityProvider.VALIDATED_ID_TOKEN);
Object rawIdToken = context.getContextData().get(OIDCIdentityProvider.VALIDATED_ID_TOKEN);
JsonWebToken idToken;
if (rawIdToken instanceof String) {

View file

@ -0,0 +1,136 @@
package org.keycloak.broker.oidc.mappers;
import static org.keycloak.utils.RegexUtils.valueMatchesRegex;
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.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 java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
public class ClaimToUserSessionNoteMapper extends AbstractClaimMapper {
private static final Logger LOG = Logger.getLogger(ClaimToUserSessionNoteMapper.class);
private static final String CLAIMS_PROPERTY_NAME = "claims";
private static final String ARE_CLAIM_VALUES_REGEX_PROPERTY_NAME = "are.claim.values.regex";
private static final String[] COMPATIBLE_PROVIDERS = {KeycloakOIDCIdentityProviderFactory.PROVIDER_ID,
OIDCIdentityProviderFactory.PROVIDER_ID};
private static final List<ProviderConfigProperty> CONFIG_PROPERTIES = new ArrayList<>();
private static final Set<IdentityProviderSyncMode> IDENTITY_PROVIDER_SYNC_MODES =
new HashSet<>(Arrays.asList(IdentityProviderSyncMode.values()));
static {
ProviderConfigProperty claimsProperty = new ProviderConfigProperty();
claimsProperty.setName(CLAIMS_PROPERTY_NAME);
claimsProperty.setLabel("Claims");
claimsProperty.setHelpText(
"Names and values of the claims to search for in the token. " +
"You can reference nested claims using a '.', i.e. 'address.locality'. " +
"To use dot (.) literally, escape it with backslash (\\.)");
claimsProperty.setType(ProviderConfigProperty.MAP_TYPE);
CONFIG_PROPERTIES.add(claimsProperty);
ProviderConfigProperty isClaimValueRegexProperty = new ProviderConfigProperty();
isClaimValueRegexProperty.setName(ARE_CLAIM_VALUES_REGEX_PROPERTY_NAME);
isClaimValueRegexProperty.setLabel("Regex Claim Values");
isClaimValueRegexProperty.setHelpText("If enabled, claim values are interpreted as regular expressions.");
isClaimValueRegexProperty.setType(ProviderConfigProperty.BOOLEAN_TYPE);
CONFIG_PROPERTIES.add(isClaimValueRegexProperty);
}
public static final String PROVIDER_ID = "oidc-user-session-note-idp-mapper";
@Override
public String[] getCompatibleProviders() {
return COMPATIBLE_PROVIDERS;
}
@Override
public String getDisplayCategory() {
return "User Session";
}
@Override
public String getDisplayType() {
return "User Session Note Mapper";
}
@Override
public String getHelpText() {
return "Add every matching claim to the user session note. " +
"This can be used together for instance with the 'User Session Note' protocol mapper configured for your " +
"client scope or client, so that claims for 3rd party IDPs would be available in the access token sent to your client application.";
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return CONFIG_PROPERTIES;
}
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public boolean supportsSyncMode(IdentityProviderSyncMode syncMode) {
return IDENTITY_PROVIDER_SYNC_MODES.contains(syncMode);
}
@Override
public void importNewUser(KeycloakSession session, RealmModel realm, UserModel user,
IdentityProviderMapperModel mapperModel, BrokeredIdentityContext context) {
addClaimsToSessionNote(mapperModel, context);
}
@Override
public void updateBrokeredUser(KeycloakSession session, RealmModel realm, UserModel user,
IdentityProviderMapperModel mapperModel, BrokeredIdentityContext context) {
addClaimsToSessionNote(mapperModel, context);
}
private void addClaimsToSessionNote(IdentityProviderMapperModel mapperModel, BrokeredIdentityContext context) {
Map<String, String> claims = mapperModel.getConfigMap(CLAIMS_PROPERTY_NAME);
boolean areClaimValuesRegex =
Boolean.parseBoolean(mapperModel.getConfig().get(ARE_CLAIM_VALUES_REGEX_PROPERTY_NAME));
for (Map.Entry<String, String> claim : claims.entrySet()) {
Object valueObj = getClaimValue(context, claim.getKey());
if (valueObj != null) {
if (!(valueObj instanceof String)) {
LOG.warnf(
"Claim '%s' does not contain a string value for user with brokerUserId '%s'. " +
"Actual value is of type '%s': %s",
claim.getKey(),
context.getBrokerUserId(), valueObj.getClass(), valueObj);
continue;
}
String value = (String) valueObj;
boolean claimValuesMatch = areClaimValuesRegex ? valueMatchesRegex(claim.getValue(), value)
: valueEquals(claim.getValue(), value);
if (claimValuesMatch) {
context.getAuthenticationSession().setUserSessionNote(claim.getKey(), value);
}
}
}
}
}

View file

@ -21,6 +21,7 @@ org.keycloak.broker.provider.HardcodedUserSessionAttributeMapper
org.keycloak.broker.oidc.mappers.ClaimToRoleMapper
org.keycloak.broker.oidc.mappers.AdvancedClaimToRoleMapper
org.keycloak.broker.oidc.mappers.AdvancedClaimToGroupMapper
org.keycloak.broker.oidc.mappers.ClaimToUserSessionNoteMapper
org.keycloak.broker.oidc.mappers.ExternalKeycloakRoleToRoleMapper
org.keycloak.broker.oidc.mappers.UserAttributeMapper
org.keycloak.broker.oidc.mappers.UsernameTemplateMapper

View file

@ -568,12 +568,12 @@ public class IdentityProviderTest extends AbstractAdminTest {
create(createRep("keycloak-oidc", "keycloak-oidc"));
provider = realm.identityProviders().get("keycloak-oidc");
mapperTypes = provider.getMapperTypes();
assertMapperTypes(mapperTypes, "keycloak-oidc-role-to-role-idp-mapper", "oidc-user-attribute-idp-mapper", "oidc-role-idp-mapper", "oidc-username-idp-mapper", "oidc-advanced-group-idp-mapper", "oidc-advanced-role-idp-mapper");
assertMapperTypes(mapperTypes, "keycloak-oidc-role-to-role-idp-mapper", "oidc-user-attribute-idp-mapper", "oidc-role-idp-mapper", "oidc-username-idp-mapper", "oidc-advanced-group-idp-mapper", "oidc-advanced-role-idp-mapper", "oidc-user-session-note-idp-mapper");
create(createRep("oidc", "oidc"));
provider = realm.identityProviders().get("oidc");
mapperTypes = provider.getMapperTypes();
assertMapperTypes(mapperTypes, "oidc-user-attribute-idp-mapper", "oidc-role-idp-mapper", "oidc-username-idp-mapper", "oidc-advanced-group-idp-mapper", "oidc-advanced-role-idp-mapper");
assertMapperTypes(mapperTypes, "oidc-user-attribute-idp-mapper", "oidc-role-idp-mapper", "oidc-username-idp-mapper", "oidc-advanced-group-idp-mapper", "oidc-advanced-role-idp-mapper", "oidc-user-session-note-idp-mapper");
create(createRep("saml", "saml"));
provider = realm.identityProviders().get("saml");

View file

@ -0,0 +1,210 @@
package org.keycloak.testsuite.broker;
import static org.hamcrest.CoreMatchers.equalTo;
import static org.hamcrest.CoreMatchers.nullValue;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.keycloak.testsuite.broker.BrokerTestTools.getConsumerRoot;
import org.junit.Before;
import org.junit.Test;
import org.keycloak.admin.client.CreatedResponseUtil;
import org.keycloak.admin.client.resource.IdentityProviderResource;
import org.keycloak.admin.client.resource.ProtocolMappersResource;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.broker.oidc.mappers.ClaimToUserSessionNoteMapper;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.jose.jws.JWSInputException;
import org.keycloak.models.IdentityProviderMapperModel;
import org.keycloak.models.IdentityProviderMapperSyncMode;
import org.keycloak.models.UserModel;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.mappers.HardcodedClaim;
import org.keycloak.protocol.oidc.mappers.UserSessionNoteMapper;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.IdentityProviderMapperRepresentation;
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
import org.keycloak.testsuite.util.OAuthClient;
import com.google.common.collect.ImmutableMap;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
public class OidcClaimToUserSessionNoteMapperTest extends AbstractIdentityProviderMapperTest {
private static final String CLAIM_NAME = "sessionNoteTest";
private static final String CLAIM_VALUE = "foo";
private static final String CONFIG_PROPERTY_CLAIMS = "claims";
private static final String HARD_CODED_CLAIM_CONFIG_PROPERTY_CLAIM_VALUE = "claim.value";
private ClientRepresentation consumerClientRep;
private String providerClientUuid;
private String providerHardcodedClaimMapperId;
@Override
protected BrokerConfiguration getBrokerConfiguration() {
return new KcOidcBrokerConfiguration();
}
@Before
public void setup() {
RealmResource consumerRealm = adminClient.realm(bc.consumerRealmName());
consumerClientRep = consumerRealm.clients().findByClientId("broker-app").get(0);
setupIdentityProvider();
// initialize the user with firstName and lastName, to avoid having to complete account data after login
createUserInProviderRealm(Map.of(
UserModel.FIRST_NAME, Collections.singletonList("FIRST NAME"),
UserModel.LAST_NAME, Collections.singletonList("LAST NAME")));
ProtocolMapperRepresentation consumerSessionNoteToClaimMapper = new ProtocolMapperRepresentation();
consumerSessionNoteToClaimMapper.setName("Session Note To Claim");
consumerSessionNoteToClaimMapper.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
consumerSessionNoteToClaimMapper.setProtocolMapper(UserSessionNoteMapper.PROVIDER_ID);
consumerSessionNoteToClaimMapper.setConfig(Map.of("user.session.note", CLAIM_NAME, "claim.name", CLAIM_NAME,
"access.token.claim", "true"));
CreatedResponseUtil.getCreatedId(consumerRealm.clients().get(consumerClientRep.getId()).getProtocolMappers()
.createMapper(consumerSessionNoteToClaimMapper));
RealmResource providerRealm = adminClient.realm(bc.providerRealmName());
ClientRepresentation providerClientRep = providerRealm.clients().findByClientId("brokerapp").get(0);
providerClientUuid = providerClientRep.getId();
ProtocolMapperRepresentation providerHardcodedClaimMapper = new ProtocolMapperRepresentation();
providerHardcodedClaimMapper.setName("Hardcoded Claim");
providerHardcodedClaimMapper.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
providerHardcodedClaimMapper.setProtocolMapper(HardcodedClaim.PROVIDER_ID);
providerHardcodedClaimMapper.setConfig(Map.of("claim.name", CLAIM_NAME,
HARD_CODED_CLAIM_CONFIG_PROPERTY_CLAIM_VALUE, CLAIM_VALUE, "access.token.claim", "true"));
providerHardcodedClaimMapperId = CreatedResponseUtil
.getCreatedId(providerRealm.clients().get(providerClientRep.getId()).getProtocolMappers()
.createMapper(providerHardcodedClaimMapper));
}
@Test
public void claimIsPropagatedOnFirstLoginOnlyWhenNameMatchesAndSyncModeIsImport() {
createUserSessionNoteIdpMapper(IdentityProviderMapperSyncMode.IMPORT, CLAIM_VALUE);
AccessToken accessToken = login();
assertThat(accessToken.getOtherClaims().get(CLAIM_NAME), equalTo(CLAIM_VALUE));
logout();
AccessToken accessTokenSecondLogin = login();
// claim should still have a value, because mapping is only applied on import
assertThat(accessTokenSecondLogin.getOtherClaims().get(CLAIM_NAME), nullValue());
}
@Test
public void claimIsPropagatedOnAllLoginsWhenNameMatchesAndSyncModeIsForce() {
IdentityProviderMapperRepresentation userSessionNoteIdpMapper =
createUserSessionNoteIdpMapper(IdentityProviderMapperSyncMode.FORCE, CLAIM_VALUE);
AccessToken accessTokenFirstLogin = login();
assertThat(accessTokenFirstLogin.getOtherClaims().get(CLAIM_NAME), equalTo(CLAIM_VALUE));
logout();
String updatedClaimValue = "updated-claim-value";
updateProviderHardcodedClaimMapper(updatedClaimValue);
updateUserSessionNoteIdpMapper(userSessionNoteIdpMapper, updatedClaimValue);
AccessToken accessTokenSecondLogin = login();
assertThat(accessTokenSecondLogin.getOtherClaims().get(CLAIM_NAME), equalTo(updatedClaimValue));
}
@Test
public void claimIsNotPropagatedWhenNameDoesNotMatch() {
createUserSessionNoteIdpMapper(IdentityProviderMapperSyncMode.IMPORT, "something-unexpected");
AccessToken accessToken = login();
assertThat(accessToken.getOtherClaims().get(CLAIM_NAME), nullValue());
}
private void logout() {
logoutFromRealm(getConsumerRoot(), bc.consumerRealmName());
}
private AccessToken login() {
OAuthClient.AuthorizationEndpointResponse authzResponse = oauth.realm(bc.consumerRealmName())
.clientId("broker-app")
.redirectUri(getAuthServerRoot() + "realms/" + bc.consumerRealmName() + "/account")
.doLoginSocial(bc.getIDPAlias(), bc.getUserLogin(), bc.getUserPassword());
String code = authzResponse.getCode();
OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, consumerClientRep.getSecret());
return toAccessToken(response.getAccessToken());
}
private AccessToken toAccessToken(String encoded) {
AccessToken accessToken;
try {
accessToken = new JWSInput(encoded).readJsonContent(AccessToken.class);
} catch (JWSInputException cause) {
throw new RuntimeException("Failed to deserialize token", cause);
}
return accessToken;
}
private IdentityProviderMapperRepresentation createUserSessionNoteIdpMapper(IdentityProviderMapperSyncMode syncMode,
String matchingValue) {
IdentityProviderMapperRepresentation mapper = new IdentityProviderMapperRepresentation();
mapper.setName("User Session Note Idp Mapper");
mapper.setIdentityProviderMapper(ClaimToUserSessionNoteMapper.PROVIDER_ID);
mapper.setConfig(ImmutableMap.<String, String> builder()
.put(IdentityProviderMapperModel.SYNC_MODE, syncMode.toString())
.put(CONFIG_PROPERTY_CLAIMS, createClaimsConfig(matchingValue))
.build());
return persistMapper(mapper);
}
private String createClaimsConfig(String matchingValue) {
return "[{\"key\":\"" + CLAIM_NAME + "\",\"value\":\"" + matchingValue + "\"}]";
}
private void updateProviderHardcodedClaimMapper(String value) {
RealmResource providerRealm = adminClient.realm(bc.providerRealmName());
ProtocolMappersResource clientProtocolMappersResource =
providerRealm.clients().get(providerClientUuid).getProtocolMappers();
ProtocolMapperRepresentation mapper =
clientProtocolMappersResource.getMapperById(providerHardcodedClaimMapperId);
Map<String, String> existingConfig = mapper.getConfig();
Map<String, String> newConfig = existingConfig == null ? new HashMap<>() : existingConfig;
newConfig.put(HARD_CODED_CLAIM_CONFIG_PROPERTY_CLAIM_VALUE, value);
mapper.setConfig(newConfig);
clientProtocolMappersResource.update(mapper.getId(), mapper);
}
private void updateUserSessionNoteIdpMapper(IdentityProviderMapperRepresentation mapper, String matchingValue) {
Map<String, String> existingConfig = mapper.getConfig();
Map<String, String> newConfig = existingConfig == null ? new HashMap<>() : existingConfig;
newConfig.put(CONFIG_PROPERTY_CLAIMS, createClaimsConfig(matchingValue));
mapper.setConfig(newConfig);
IdentityProviderResource idpResource = realm.identityProviders().get(bc.getIDPAlias());
idpResource.update(mapper.getId(), mapper);
}
private IdentityProviderMapperRepresentation persistMapper(IdentityProviderMapperRepresentation idpMapper) {
String idpAlias = bc.getIDPAlias();
IdentityProviderResource idpResource = realm.identityProviders().get(idpAlias);
idpMapper.setIdentityProviderAlias(idpAlias);
String createdId = CreatedResponseUtil.getCreatedId(idpResource.addMapper(idpMapper));
return idpResource.getMapperById(createdId);
}
}