Support JSON objects when evaluating claims in regex policy

Closes #11514
This commit is contained in:
Pedro Igor 2022-06-14 10:26:32 -03:00 committed by Bruno Oliveira da Silva
parent c95ecd9e88
commit 3d2c3fbc6a
11 changed files with 301 additions and 66 deletions

View file

@ -17,6 +17,13 @@
*/
package org.keycloak.authorization.policy.provider.regex;
import static org.keycloak.utils.JsonUtils.getJsonValue;
import static org.keycloak.utils.JsonUtils.hasPath;
import static org.keycloak.utils.JsonUtils.splitClaimPath;
import java.io.IOException;
import java.util.List;
import java.util.Optional;
import java.util.function.BiFunction;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@ -27,6 +34,8 @@ import org.keycloak.authorization.model.Policy;
import org.keycloak.authorization.policy.evaluation.Evaluation;
import org.keycloak.authorization.policy.provider.PolicyProvider;
import org.keycloak.representations.idm.authorization.RegexPolicyRepresentation;
import org.keycloak.util.JsonSerialization;
import com.fasterxml.jackson.databind.JsonNode;
/**
* @author <a href="mailto:yoshiyuki.tabata.jy@hitachi.com">Yoshiyuki Tabata</a>
@ -47,17 +56,60 @@ public class RegexPolicyProvider implements PolicyProvider {
public void evaluate(Evaluation evaluation) {
AuthorizationProvider authorizationProvider = evaluation.getAuthorizationProvider();
RegexPolicyRepresentation policy = representationFunction.apply(evaluation.getPolicy(), authorizationProvider);
Attributes.Entry targetClaim = evaluation.getContext().getIdentity().getAttributes().getValue(policy.getTargetClaim());
String value = getClaimValue(evaluation, policy);
if (targetClaim == null) {
if (value == null) {
return;
}
Pattern pattern = Pattern.compile(policy.getPattern());
Matcher matcher = pattern.matcher(targetClaim.asString(0));
Matcher matcher = pattern.matcher(value);
if (matcher.matches()) {
evaluation.grant();
}
}
private String getClaimValue(Evaluation evaluation, RegexPolicyRepresentation policy) {
Attributes attributes = evaluation.getContext().getIdentity().getAttributes();
String targetClaim = policy.getTargetClaim();
try {
if (hasPath(targetClaim)) {
return resolveJsonValue(attributes, targetClaim);
}
return resolveSimpleValue(attributes, targetClaim);
} catch (IOException cause) {
throw new RuntimeException("Failed to resolve value from claim: " + targetClaim, cause);
}
}
private String resolveSimpleValue(Attributes attributes, String targetClaim) {
Attributes.Entry value = attributes.getValue(targetClaim);
if (value == null || value.isEmpty()) {
return null;
}
return value.asString(0);
}
private String resolveJsonValue(Attributes attributes, String targetClaim) throws IOException {
List<String> paths = splitClaimPath(targetClaim);
if (paths.isEmpty()) {
return null;
}
Attributes.Entry attribute = attributes.getValue(paths.get(0));
if (attribute == null || attribute.isEmpty()) {
return null;
}
JsonNode node = JsonSerialization.readValue(attribute.asString(0), JsonNode.class);
String path = String.join(".", paths.subList(1, paths.size()));
return Optional.ofNullable(getJsonValue(node, path)).map(Object::toString).orElse(null);
}
}

View file

@ -31,6 +31,8 @@
<module name="org.keycloak.keycloak-server-spi"/>
<module name="org.keycloak.keycloak-server-spi-private"/>
<module name="org.keycloak.keycloak-services"/>
<module name="com.fasterxml.jackson.core.jackson-core"/>
<module name="com.fasterxml.jackson.core.jackson-databind"/>
</dependencies>
</module>

View file

@ -31,6 +31,8 @@
<module name="org.keycloak.keycloak-server-spi"/>
<module name="org.keycloak.keycloak-server-spi-private"/>
<module name="org.keycloak.keycloak-services"/>
<module name="com.fasterxml.jackson.core.jackson-core"/>
<module name="com.fasterxml.jackson.core.jackson-databind"/>
</dependencies>
</module>

View file

@ -0,0 +1,139 @@
/*
* Copyright 2022 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.utils;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.fasterxml.jackson.databind.JsonNode;
/**
* Utility methods for manipulating JSON objects.
*/
public class JsonUtils {
// A character in a claim component is either a literal character escaped by a backslash (\., \\, \_, \q, etc.)
// or any character other than backslash (escaping) and dot (claim component separator)
private static final Pattern CLAIM_COMPONENT = Pattern.compile("^((\\\\.|[^\\\\.])+?)\\.");
private static final Pattern BACKSLASHED_CHARACTER = Pattern.compile("\\\\(.)");
/**
* Splits the given {@code claim} into separate paths if the value contains separators as per {@link #CLAIM_COMPONENT}.
*
* @param claim the claim
* @return a list with the paths
*/
public static List<String> splitClaimPath(String claim) {
final LinkedList<String> claimComponents = new LinkedList<>();
Matcher m = CLAIM_COMPONENT.matcher(claim);
int start = 0;
while (m.find()) {
claimComponents.add(BACKSLASHED_CHARACTER.matcher(m.group(1)).replaceAll("$1"));
start = m.end();
// This is necessary to match the start of region as the start of string as determined by ^
m.region(start, claim.length());
}
if (claim.length() > start) {
claimComponents.add(BACKSLASHED_CHARACTER.matcher(claim.substring(start)).replaceAll("$1"));
}
return claimComponents;
}
/**
* Determines if the given {@code claim} contains paths.
*
* @param claim the claim
* @return {@code true} if the {@code claim} contains paths. Otherwise, false.
*/
public static boolean hasPath(String claim) {
return CLAIM_COMPONENT.matcher(claim).find();
}
/**
* <p>Returns the value corresponding to the given {@code claim}.
*
* @param node the JSON node
* @param claim the claim
* @return the value
*/
public static Object getJsonValue(JsonNode node, String claim) {
if (node != null) {
List<String> fields = splitClaimPath(claim);
if (fields.isEmpty() || claim.endsWith(".")) {
return null;
}
JsonNode currentNode = node;
for (String currentFieldName : fields) {
// if array path, retrieve field name and index
String currentNodeName = currentFieldName;
int arrayIndex = -1;
if (currentFieldName.endsWith("]")) {
int bi = currentFieldName.indexOf("[");
if (bi == -1) {
return null;
}
try {
String is = currentFieldName.substring(bi + 1, currentFieldName.length() - 1).trim();
arrayIndex = Integer.parseInt(is);
if( arrayIndex < 0) throw new ArrayIndexOutOfBoundsException();
} catch (Exception e) {
return null;
}
currentNodeName = currentFieldName.substring(0, bi).trim();
}
currentNode = currentNode.get(currentNodeName);
if (arrayIndex > -1 && currentNode.isArray()) {
currentNode = currentNode.get(arrayIndex);
}
if (currentNode == null) {
return null;
}
if (currentNode.isArray()) {
List<String> values = new ArrayList<>();
for (JsonNode childNode : currentNode) {
if (childNode.isTextual()) {
values.add(childNode.textValue());
}
}
if (values.isEmpty()) {
return null;
}
return values ;
} else if (currentNode.isNull()) {
return null;
} else if (currentNode.isValueNode()) {
String ret = currentNode.asText();
if (ret != null && !ret.trim().isEmpty())
return ret.trim();
else
return null;
}
}
return currentNode;
}
return null;
}
}

View file

@ -195,6 +195,8 @@ public class KeycloakIdentity implements Identity {
while (valueIterator.hasNext()) {
values.add(valueIterator.next().asText());
}
} else if (fieldValue.isObject()) {
values.add(fieldValue.toString());
} else {
String value = fieldValue.asText();

View file

@ -17,13 +17,14 @@
package org.keycloak.broker.oidc.mappers;
import static org.keycloak.utils.JsonUtils.splitClaimPath;
import com.fasterxml.jackson.databind.JsonNode;
import org.keycloak.broker.oidc.KeycloakOIDCIdentityProvider;
import org.keycloak.broker.oidc.OIDCIdentityProvider;
import org.keycloak.broker.provider.AbstractIdentityProviderMapper;
import org.keycloak.broker.provider.BrokeredIdentityContext;
import org.keycloak.models.IdentityProviderMapperModel;
import org.keycloak.protocol.oidc.mappers.OIDCAttributeMapperHelper;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.util.JsonSerialization;
@ -47,7 +48,7 @@ public abstract class AbstractClaimMapper extends AbstractIdentityProviderMapper
// found no match, try other claims
}
List<String> split = OIDCAttributeMapperHelper.splitClaimPath(claim);
List<String> split = splitClaimPath(claim);
Map<String, Object> jsonObject = token.getOtherClaims();
final int length = split.size();
int i = 0;

View file

@ -17,6 +17,8 @@
package org.keycloak.broker.oidc.mappers;
import static org.keycloak.utils.JsonUtils.splitClaimPath;
import com.fasterxml.jackson.databind.JsonNode;
import org.jboss.logging.Logger;
import org.keycloak.broker.oidc.OIDCIdentityProvider;
@ -27,7 +29,6 @@ import org.keycloak.models.IdentityProviderSyncMode;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.protocol.oidc.mappers.OIDCAttributeMapperHelper;
import org.keycloak.provider.ProviderConfigProperty;
import java.util.ArrayList;
@ -206,7 +207,7 @@ public abstract class AbstractJsonUserAttributeMapper extends AbstractIdentityPr
logger.debug("Going to process JsonNode path " + fieldPath + " on data " + baseNode);
if (baseNode != null) {
List<String> fields = OIDCAttributeMapperHelper.splitClaimPath(fieldPath);
List<String> fields = splitClaimPath(fieldPath);
if (fields.isEmpty() || fieldPath.endsWith(".")) {
logger.debug("JSON path is invalid " + fieldPath);
return null;

View file

@ -17,6 +17,8 @@
package org.keycloak.protocol.oidc.mappers;
import static org.keycloak.utils.JsonUtils.splitClaimPath;
import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.protocol.ProtocolMapperUtils;
import org.keycloak.representations.AccessToken;
@ -100,7 +102,7 @@ abstract class AbstractUserRoleMappingMapper extends AbstractOIDCProtocolMapper
protocolClaim = CLIENT_ID_PATTERN.matcher(protocolClaim).replaceAll(clientId);
}
List<String> split = OIDCAttributeMapperHelper.splitClaimPath(protocolClaim);
List<String> split = splitClaimPath(protocolClaim);
// Special case
if (checkAccessToken(token, split, attributeValue)) {

View file

@ -17,6 +17,8 @@
package org.keycloak.protocol.oidc.mappers;
import static org.keycloak.utils.JsonUtils.splitClaimPath;
import com.fasterxml.jackson.databind.JsonNode;
import org.jboss.logging.Logger;
import org.keycloak.models.ProtocolMapperModel;
@ -31,8 +33,6 @@ import org.keycloak.util.JsonSerialization;
import java.util.*;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
@ -250,28 +250,6 @@ public class OIDCAttributeMapperHelper {
return null;
}
// A character in a claim component is either a literal character escaped by a backslash (\., \\, \_, \q, etc.)
// or any character other than backslash (escaping) and dot (claim component separator)
private static final Pattern CLAIM_COMPONENT = Pattern.compile("^((\\\\.|[^\\\\.])+?)\\.");
private static final Pattern BACKSLASHED_CHARACTER = Pattern.compile("\\\\(.)");
public static List<String> splitClaimPath(String claimPath) {
final LinkedList<String> claimComponents = new LinkedList<>();
Matcher m = CLAIM_COMPONENT.matcher(claimPath);
int start = 0;
while (m.find()) {
claimComponents.add(BACKSLASHED_CHARACTER.matcher(m.group(1)).replaceAll("$1"));
start = m.end();
// This is necessary to match the start of region as the start of string as determined by ^
m.region(start, claimPath.length());
}
if (claimPath.length() > start) {
claimComponents.add(BACKSLASHED_CHARACTER.matcher(claimPath.substring(start)).replaceAll("$1"));
}
return claimComponents;
}
public static void mapClaim(IDToken token, ProtocolMapperModel mappingModel, Object attributeValue) {
mapClaim(token, mappingModel, attributeValue, tokenPropertySetters, token.getOtherClaims());
}

View file

@ -18,6 +18,8 @@ package org.keycloak.protocol.oidc.mappers;
import org.hamcrest.Matchers;
import org.junit.Test;
import org.keycloak.utils.JsonUtils;
import static org.junit.Assert.assertThat;
/**
@ -28,17 +30,17 @@ public class OIDCAttributeMapperHelperTest {
@Test
public void testSplitClaimPath() {
assertThat(OIDCAttributeMapperHelper.splitClaimPath(""), Matchers.empty());
assertThat(OIDCAttributeMapperHelper.splitClaimPath("a"), Matchers.contains("a"));
assertThat(JsonUtils.splitClaimPath(""), Matchers.empty());
assertThat(JsonUtils.splitClaimPath("a"), Matchers.contains("a"));
assertThat(OIDCAttributeMapperHelper.splitClaimPath("a.b"), Matchers.contains("a", "b"));
assertThat(OIDCAttributeMapperHelper.splitClaimPath("a\\.b"), Matchers.contains("a.b"));
assertThat(OIDCAttributeMapperHelper.splitClaimPath("a\\\\.b"), Matchers.contains("a\\", "b"));
assertThat(OIDCAttributeMapperHelper.splitClaimPath("a\\\\\\.b"), Matchers.contains("a\\.b"));
assertThat(JsonUtils.splitClaimPath("a.b"), Matchers.contains("a", "b"));
assertThat(JsonUtils.splitClaimPath("a\\.b"), Matchers.contains("a.b"));
assertThat(JsonUtils.splitClaimPath("a\\\\.b"), Matchers.contains("a\\", "b"));
assertThat(JsonUtils.splitClaimPath("a\\\\\\.b"), Matchers.contains("a\\.b"));
assertThat(OIDCAttributeMapperHelper.splitClaimPath("c.a\\\\.b"), Matchers.contains("c", "a\\", "b"));
assertThat(OIDCAttributeMapperHelper.splitClaimPath("c.a\\\\\\.b"), Matchers.contains("c", "a\\.b"));
assertThat(OIDCAttributeMapperHelper.splitClaimPath("c\\\\\\.b.a\\\\\\.b"), Matchers.contains("c\\.b", "a\\.b"));
assertThat(OIDCAttributeMapperHelper.splitClaimPath("c\\h\\.b.a\\\\\\.b"), Matchers.contains("ch.b", "a\\.b"));
assertThat(JsonUtils.splitClaimPath("c.a\\\\.b"), Matchers.contains("c", "a\\", "b"));
assertThat(JsonUtils.splitClaimPath("c.a\\\\\\.b"), Matchers.contains("c", "a\\.b"));
assertThat(JsonUtils.splitClaimPath("c\\\\\\.b.a\\\\\\.b"), Matchers.contains("c\\.b", "a\\.b"));
assertThat(JsonUtils.splitClaimPath("c\\h\\.b.a\\\\\\.b"), Matchers.contains("ch.b", "a\\.b"));
}
}

View file

@ -56,38 +56,35 @@ public class RegexPolicyTest extends AbstractAuthzTest {
@Override
public void addTestRealms(List<RealmRepresentation> testRealms) {
ProtocolMapperRepresentation userAttrFooProtocolMapper = new ProtocolMapperRepresentation();
userAttrFooProtocolMapper.setName("userAttrFoo");
userAttrFooProtocolMapper.setProtocolMapper(UserAttributeMapper.PROVIDER_ID);
userAttrFooProtocolMapper.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
Map<String, String> configFoo = new HashMap<>();
configFoo.put(OIDCAttributeMapperHelper.INCLUDE_IN_ACCESS_TOKEN, "true");
configFoo.put(OIDCAttributeMapperHelper.INCLUDE_IN_ID_TOKEN, "true");
configFoo.put(OIDCAttributeMapperHelper.JSON_TYPE, "String");
configFoo.put("user.attribute", "foo");
configFoo.put("claim.name", "foo");
userAttrFooProtocolMapper.setConfig(configFoo);
Map<String, String> claims = new HashMap<>();
ProtocolMapperRepresentation userAttrBarProtocolMapper = new ProtocolMapperRepresentation();
userAttrBarProtocolMapper.setName("userAttrBar");
userAttrBarProtocolMapper.setProtocolMapper(UserAttributeMapper.PROVIDER_ID);
userAttrBarProtocolMapper.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
Map<String, String> configBar = new HashMap<>();
configBar.put(OIDCAttributeMapperHelper.INCLUDE_IN_ACCESS_TOKEN, "true");
configBar.put(OIDCAttributeMapperHelper.INCLUDE_IN_ID_TOKEN, "true");
configBar.put(OIDCAttributeMapperHelper.JSON_TYPE, "String");
configBar.put("user.attribute", "bar");
configBar.put("claim.name", "bar");
userAttrBarProtocolMapper.setConfig(configBar);
claims.put("user.attribute", "foo");
claims.put("claim.name", "foo");
ProtocolMapperRepresentation userAttrFooProtocolMapper = addClaimMapper("userAttrFoo", claims);
claims.put("user.attribute", "bar");
claims.put("claim.name", "bar");
ProtocolMapperRepresentation userAttrBarProtocolMapper = addClaimMapper("userAttrBar", claims);
claims.put("user.attribute", "json-simple");
claims.put("claim.name", "userinfo");
ProtocolMapperRepresentation userAttrJsonProtocolMapper = addClaimMapper("userAttrJsonSimple", claims);
claims.put("user.attribute", "json-complex");
claims.put("claim.name", "json-complex");
ProtocolMapperRepresentation userAttrJsonComplexProtocolMapper = addClaimMapper("userAttrJsonComplex", claims);
// For JSON-based claims, you can use dot notation for nesting and square brackets to access array fields by index. For example, contact.address[0].country.
testRealms.add(RealmBuilder.create().name("authz-test")
.user(UserBuilder.create().username("marta").password("password").addAttribute("foo", "foo").addAttribute("bar",
"barbar"))
"barbar").addAttribute("json-simple", "{\"tenant\": \"abc\"}")
.addAttribute("json-complex", "{\"userinfo\": {\"tenant\": \"abc\"}, \"some-array\": [\"foo\",\"bar\"]}"))
.user(UserBuilder.create().username("taro").password("password").addAttribute("foo", "faa").addAttribute("bar",
"bbarbar"))
.client(ClientBuilder.create().clientId("resource-server-test").secret("secret").authorizationServicesEnabled(true)
.redirectUris("http://localhost/resource-server-test").directAccessGrants()
.protocolMapper(userAttrFooProtocolMapper, userAttrBarProtocolMapper))
.protocolMapper(userAttrFooProtocolMapper, userAttrBarProtocolMapper, userAttrJsonProtocolMapper, userAttrJsonComplexProtocolMapper))
.build());
}
@ -95,12 +92,21 @@ public class RegexPolicyTest extends AbstractAuthzTest {
public void configureAuthorization() throws Exception {
createResource("Resource A");
createResource("Resource B");
createResource("Resource C");
createResource("Resource D");
createResource("Resource E");
createRegexPolicy("Regex foo Policy", "foo", "foo");
createRegexPolicy("Regex bar Policy", "bar", "^bar.+$");
createRegexPolicy("Regex json-simple Policy", "userinfo.tenant", "^abc$");
createRegexPolicy("Regex json-complex Policy", "json-complex.userinfo.tenant", "^abc$");
createRegexPolicy("Regex json-array Policy", "json-complex.some-array[1]", "bar");
createResourcePermission("Resource A Permission", "Resource A", "Regex foo Policy");
createResourcePermission("Resource B Permission", "Resource B", "Regex bar Policy");
createResourcePermission("Resource C Permission", "Resource C", "Regex json-simple Policy");
createResourcePermission("Resource D Permission", "Resource D", "Regex json-complex Policy");
createResourcePermission("Resource E Permission", "Resource E", "Regex json-array Policy");
}
private void createResource(String name) {
@ -164,6 +170,21 @@ public class RegexPolicyTest extends AbstractAuthzTest {
ticket = authzClient.protection().permission().create(request).getTicket();
response = authzClient.authorization("marta", "password").authorize(new AuthorizationRequest(ticket));
assertNotNull(response.getToken());
request = new PermissionRequest("Resource C");
ticket = authzClient.protection().permission().create(request).getTicket();
response = authzClient.authorization("marta", "password").authorize(new AuthorizationRequest(ticket));
assertNotNull(response.getToken());
request = new PermissionRequest("Resource D");
ticket = authzClient.protection().permission().create(request).getTicket();
response = authzClient.authorization("marta", "password").authorize(new AuthorizationRequest(ticket));
assertNotNull(response.getToken());
request = new PermissionRequest("Resource E");
ticket = authzClient.protection().permission().create(request).getTicket();
response = authzClient.authorization("marta", "password").authorize(new AuthorizationRequest(ticket));
assertNotNull(response.getToken());
}
@Test
@ -188,9 +209,42 @@ public class RegexPolicyTest extends AbstractAuthzTest {
} catch (AuthorizationDeniedException ignore) {
}
// Access Resource C with taro.
request = new PermissionRequest("Resource C");
ticket = authzClient.protection().permission().create(request).getTicket();
try {
authzClient.authorization("taro", "password").authorize(new AuthorizationRequest(ticket));
fail("Should fail.");
} catch (AuthorizationDeniedException ignore) {
}
// Access Resource D with taro.
request = new PermissionRequest("Resource D");
ticket = authzClient.protection().permission().create(request).getTicket();
try {
authzClient.authorization("taro", "password").authorize(new AuthorizationRequest(ticket));
fail("Should fail.");
} catch (AuthorizationDeniedException ignore) {
}
}
private AuthzClient getAuthzClient() {
return AuthzClient.create(getClass().getResourceAsStream("/authorization-test/default-keycloak.json"));
}
private ProtocolMapperRepresentation addClaimMapper(String name, Map<String, String> claims) {
ProtocolMapperRepresentation userAttrBarProtocolMapper = new ProtocolMapperRepresentation();
userAttrBarProtocolMapper.setName(name);
userAttrBarProtocolMapper.setProtocolMapper(UserAttributeMapper.PROVIDER_ID);
userAttrBarProtocolMapper.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
Map<String, String> configBar = new HashMap<>(claims);
configBar.put(OIDCAttributeMapperHelper.INCLUDE_IN_ACCESS_TOKEN, "true");
configBar.put(OIDCAttributeMapperHelper.INCLUDE_IN_ID_TOKEN, "true");
configBar.put(OIDCAttributeMapperHelper.JSON_TYPE, "String");
userAttrBarProtocolMapper.setConfig(configBar);
return userAttrBarProtocolMapper;
}
}