8000 Map non-string claims in the IdP user session note mapper by baptistegh · Pull Request #55075 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,15 @@ public static Object getClaimValue(IdentityProviderMapperModel mapperModel, Brok
}

public static Object getClaimValue(BrokeredIdentityContext context, String claim) {
return getClaimValue(context, claim, false);
}

/**
* @param keepUserInfoArrays if true, an array found in the UserInfo claim set is returned as the
* {@link JsonNode} itself, with all its elements, instead of a list
* holding only its text elements
*/
public static Object getClaimValue(BrokeredIdentityContext context, String claim, boolean keepUserInfoArrays) {
{ // search access token
JsonWebToken token = (JsonWebToken)context.getContextData().get(KeycloakOIDCIdentityProvider.VALIDATED_ACCESS_TOKEN);
if (token != null) {
Expand Down Expand Up @@ -105,7 +114,7 @@ public static Object getClaimValue(BrokeredIdentityContext context, String claim
{
// Search the OIDC UserInfo claim set (if any)
JsonNode profileJsonNode = (JsonNode) context.getContextData().get(OIDCIdentityProvider.USER_INFO);
Object value = AbstractJsonUserAttributeMapper.getJsonValue(profileJsonNode, claim);
Object value = AbstractJsonUserAttributeMapper.getJsonValue(profileJsonNode, claim, keepUserInfoArrays);
if (value != null) return value;
}
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,14 @@ protected static Object getJsonValue(IdentityProviderMapperModel mapperModel, Br


public static Object getJsonValue(JsonNode baseNode, String fieldPath) {
return getJsonValue(baseNode, fieldPath, false);
}

/**
* @param keepArrays if true, an array is returned as the {@link JsonNode} itself, with all its
* elements, instead of a list holding only its text elements
*/
public static Object getJsonValue(JsonNode baseNode, String fieldPath, boolean keepArrays) {
logger.debugf("Going to process JsonNode path %s on data %s", fieldPath, baseNode);
if (baseNode != null) {

Expand Down Expand Up @@ -238,7 +246,7 @@ public static Object getJsonValue(JsonNode baseNode, String fieldPath) {
}

currentNode = currentNode.get(currentNodeName);
if (arrayIndex > -1 && currentNode.isArray()) {
if (arrayIndex > -1 && currentNode != null && currentNode.isArray()) {
logger.debugf("Going to take array node at index %d", arrayIndex);
currentNode = currentNode.get(arrayIndex);
}
Expand All @@ -249,6 +257,10 @@ public static Object getJsonValue(JsonNode baseNode, String fieldPath) {
}

if (currentNode.isArray()) {
if (keepArrays) {
// keep walking the path, so that an array is only returned if it is the claim itself
continue;
Comment on lines +260 to +262
}
List<String> values = new ArrayList<>();
for (JsonNode childNode : currentNode) {
if (childNode.isTextual()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.keycloak.broker.oidc.mappers;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
Expand All @@ -16,15 +17,38 @@
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.util.JsonSerialization;
import org.keycloak.utils.StringUtil;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.jboss.logging.Logger;

import static org.keycloak.utils.RegexUtils.valueMatchesRegex;

/**
* Stores matching claims of the brokered tokens as user session notes.
* <p>
* Without regular expressions, a claim matches when its value equals the configured value. For a
* multivalued claim it is enough that one of its values is equal. Session notes hold one string
* value, so multivalued claims are stored joined with a comma and object values as their JSON
* representation.
* <p>
* With regular expressions, only string claims are matched. Multivalued, object, number and boolean
* claims are skipped.
* <p>
* A claim configured with an empty value is always stored, whatever its value and type, as the OID4VP
* SD-JWT user session attribute mapper does.
*/
public class ClaimToUserSessionNoteMapper extends AbstractClaimMapper {

private static final Logger LOG = Logger.getLogger(ClaimToUserSessionNoteMapper.class);

// The shared mapper drops null values, which would change the JSON of object claims.
private static final ObjectMapper CLAIM_MAPPER =
JsonSerialization.mapper.copy().setDefaultPropertyInclusion(JsonInclude.Include.ALWAYS);

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,
Expand All @@ -41,6 +65,7 @@ public class ClaimToUserSessionNoteMapper extends AbstractClaimMapper {
claimsProperty.setLabel("Claims");
claimsProperty.setHelpText(
"Names and values of the claims to search for in the token. " +
"Leave the value empty to store the claim whatever its value. " +
"You can reference nested claims using a '.', i.e. 'address.locality'. " +
"To use dot (.) literally, escape it with backslash (\\.)");
claimsProperty.setType(ProviderConfigProperty.MAP_TYPE);
Expand Down Expand Up @@ -73,6 +98,10 @@ public String getDisplayType() {
@Override
public String getHelpText() {
return "Add every matching claim to the user session note. " +
"Without regular expressions, a multivalued claim matches when one of its values equals the configured value, " +
"and is stored with its values joined with a comma. Object claims are stored as JSON. " +
"With regular expressions, only string claims are matched. " +
"Leave the value empty to store the claim whatever its value. " +
"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.";
}
Expand Down Expand Up @@ -110,29 +139,80 @@ private void addClaimsToSessionNote(IdentityProviderMapperModel mapperModel, Bro
Boolean.parseBoolean(mapperModel.getConfig().get(ARE_CLAIM_VALUES_REGEX_PROPERTY_NAME));

for (Map.Entry<String, List<String>> claim : claims.entrySet()) {
Object claimValueObj = getClaimValue(context, claim.getKey());
Object claimValueObj = getClaimValue(context, claim.getKey(), true);
if (claimValueObj == null) {
continue;
}
// An empty value matches any claim value, so the claim is stored as is.
if (claim.getValue().stream().anyMatch(StringUtil::isBlank)) {
List<String> claimValues = claimValues(claimValueObj);
if (!claimValues.isEmpty()) {
context.setSessionNote(claim.getKey(), String.join(",", claimValues));
}
continue;
}
if (areClaimValuesRegex) {
addStringClaimMatchingRegex(context, claim.getKey(), claim.getValue(), claimValueObj);
continue;
}
List<String> claimValues = claimValues(claimValueObj);
Comment thread
Copilot marked this conversation as resolved.
Resolved
if (claimValues.isEmpty()) {
continue;
}
for (String value : claim.getValue()) {
if (claimValueObj != null) {
if (!(claimValueObj 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(), claimValueObj.getClass(), claimValueObj);
continue;
}

String claimValue = (String) claimValueObj;

boolean claimValuesMatch = areClaimValuesRegex ? valueMatchesRegex(value, claimValue)
: valueEquals(value, claimValue);

if (claimValuesMatch) {
context.setSessionNote(claim.getKey(), claimValue);
}
if (claimValues.stream().anyMatch(claimValue -> valueEquals(value, claimValue))) {
context.setSessionNote(claim.getKey(), String.join(",", claimValues));
}
}
}
}

// Regular expressions are only applied to string claims, so a pattern never matches the
// serialized form of an array or object.
private static void addStringClaimMatchingRegex(BrokeredIdentityContext context, String claimName,
List<String> regexes, Object claimValueObj) {
if (!(claimValueObj instanceof String claimValue)) {
LOG.warnf(
"Claim '%s' does not contain a string value for user with brokerUserId '%s', which is required "
+ "when claim values are regular expressions. Actual value is of type '%s': %s",
claimName, context.getBrokerUserId(), claimValueObj.getClass(), claimValueObj);
return;
}
for (String regex : regexes) {
if (valueMatchesRegex(regex, claimValue)) {
context.setSessionNote(claimName, claimValue);
}
}
}

/**
* Flattens a claim value into the string values it holds: a top level array yields one value per
* non null element, scalars yield their text and objects or nested arrays their JSON representation.
*/
private static List<String> claimValues(Object claimValueObj) {
List<String> values = new ArrayList<>();
if (claimValueObj == null) {
return values;
}
JsonNode node = CLAIM_MAPPER.valueToTree(claimValueObj);
Iterable<JsonNode> elements = node.isArray() ? node : List.of(node);
for (JsonNode element : elements) {
if (!element.isNull()) {
values.add(value(element));
}
}
return values;
}

private static String value(JsonNode node) {
if (node.isValueNode()) {
return node.asText();
}
try {
return CLAIM_MAPPER.writeValueAsString(node);
} catch (IOException e) {
throw new IllegalStateException("Failed to serialize the claim value", e);
}
}

}
Loading
0