KEYCLOAK-10251 New Claim JSON Type - JSON

This commit is contained in:
Tomasz Prętki 2019-05-10 16:22:18 +02:00 committed by Marek Posolda
parent c883c11e7e
commit 0376e7241a
7 changed files with 78 additions and 16 deletions

View file

@ -26,7 +26,8 @@ public class ClaimTypeModel {
public static enum ValueType {
BOOLEAN,
INT,
STRING
STRING,
JSON
}
private String id;

View file

@ -25,12 +25,10 @@ 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;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
@ -122,6 +120,11 @@ public abstract class AbstractClaimMapper extends AbstractIdentityProviderMapper
for (Object val : list) {
if (valueEquals(desiredValue, val)) return true;
}
} else if (value instanceof JsonNode) {
try {
if (JsonSerialization.readValue(desiredValue, JsonNode.class).equals(value)) return true;
} catch (Exception ignore) {
}
}
return false;
}

View file

@ -224,9 +224,11 @@ public abstract class AbstractJsonUserAttributeMapper extends AbstractIdentityPr
}
return arrayIndex == idx? values : null;
}
if (!currentNode.isValueNode() || currentNode.isNull()) {
logger.debug("JsonNode is not value node for name " + currentFieldName);
if (currentNode.isNull()) {
logger.debug("JsonNode is null node for name " + currentFieldName);
return null;
} else if (!currentNode.isValueNode()) {
return currentNode;
}
String ret = currentNode.asText();
if (ret != null && !ret.trim().isEmpty())

View file

@ -17,6 +17,8 @@
package org.keycloak.protocol.oidc.mappers;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.protocol.ProtocolMapper;
import org.keycloak.protocol.ProtocolMapperUtils;
@ -24,6 +26,7 @@ import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.representations.IDToken;
import org.keycloak.services.ServicesLogger;
import org.keycloak.util.JsonSerialization;
import java.util.*;
import java.util.function.Function;
@ -117,6 +120,13 @@ public class OIDCAttributeMapperHelper {
return transform((List<Integer>) attributeValue, OIDCAttributeMapperHelper::getInteger);
}
throw new RuntimeException("cannot map type for token claim");
case "JSON":
JsonNode jsonNodeObject = getJsonNode(attributeValue);
if (jsonNodeObject != null) return jsonNodeObject;
if (attributeValue instanceof List) {
return transform((List<JsonNode>) attributeValue, OIDCAttributeMapperHelper::getJsonNode);
}
throw new RuntimeException("cannot map type for token claim");
default:
return null;
}
@ -145,6 +155,17 @@ public class OIDCAttributeMapperHelper {
return null;
}
private static JsonNode getJsonNode(Object attributeValue) {
if (attributeValue instanceof JsonNode) return (JsonNode) attributeValue;
if (attributeValue instanceof String) {
try {
return JsonSerialization.readValue(attributeValue.toString(), JsonNode.class);
} catch (Exception ex) {
}
}
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("^((\\\\.|[^\\\\.])+?)\\.");
@ -267,11 +288,12 @@ public class OIDCAttributeMapperHelper {
ProviderConfigProperty property = new ProviderConfigProperty();
property.setName(JSON_TYPE);
property.setLabel(JSON_TYPE);
List<String> types = new ArrayList(3);
List<String> types = new ArrayList(5);
types.add("String");
types.add("long");
types.add("int");
types.add("boolean");
types.add("JSON");
property.setType(ProviderConfigProperty.LIST_TYPE);
property.setOptions(types);
property.setHelpText(JSON_TYPE_TOOLTIP);

View file

@ -73,10 +73,21 @@ public class AbstractJsonUserAttributeMapperTest {
@Test
public void getJsonValue_nestedSimpleValues() throws JsonProcessingException, IOException {
// null if path points to JSON object
Assert.assertEquals(null, AbstractJsonUserAttributeMapper.getJsonValue(getJsonNode(), "nest1"));
Assert.assertEquals(null, AbstractJsonUserAttributeMapper.getJsonValue(getJsonNode(), "nest1.nest2"));
// JsonNode if path points to JSON object
Assert.assertEquals(mapper.readTree("{\n"
+ " \"value1\": \" fgh \",\n"
+ " \"value_null\" : null,\n"
+ " \"value_empty\" : \"\",\n"
+ " \"nest2\":{\n"
+ " \"value_b\" : false,\n"
+ " \"value_i\" : 43\n"
+ " }\n"
+ " }"), AbstractJsonUserAttributeMapper.getJsonValue(getJsonNode(), "nest1"));
Assert.assertEquals(mapper.readTree("{\n"
+ " \"value_b\" : false,\n"
+ " \"value_i\" : 43\n"
+ " }"), AbstractJsonUserAttributeMapper.getJsonValue(getJsonNode(), "nest1.nest2"));
Assert.assertEquals(mapper.readTree("{\"av1\": \"vala1\"}"), AbstractJsonUserAttributeMapper.getJsonValue(getJsonNode(), "nesta.a[0]"));
//unknown field returns null
Assert.assertEquals(null, AbstractJsonUserAttributeMapper.getJsonValue(getJsonNode(), "nest1.value_unknown"));
@ -123,7 +134,6 @@ public class AbstractJsonUserAttributeMapperTest {
//different path erros or nonexisting indexes or fields return null
Assert.assertEquals(null, AbstractJsonUserAttributeMapper.getJsonValue(getJsonNode(), "nesta.a[2].av1"));
Assert.assertEquals(null, AbstractJsonUserAttributeMapper.getJsonValue(getJsonNode(), "nesta.a[0]"));
Assert.assertEquals(null, AbstractJsonUserAttributeMapper.getJsonValue(getJsonNode(), "nesta.a[0].av_unknown"));
Assert.assertEquals(null, AbstractJsonUserAttributeMapper.getJsonValue(getJsonNode(), "nesta.a[].av1"));
Assert.assertEquals(null, AbstractJsonUserAttributeMapper.getJsonValue(getJsonNode(), "nesta.a"));

View file

@ -54,6 +54,7 @@ import javax.ws.rs.core.Response;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -139,6 +140,8 @@ public class OIDCProtocolMappersTest extends AbstractKeycloakTest {
user.singleAttribute("country", "USA");
user.singleAttribute("formatted", "6 Foo Street");
user.singleAttribute("phone", "617-777-6666");
user.singleAttribute("json-attribute", "{\"a\": 1, \"b\": 2, \"c\": [{\"a\": 1, \"b\": 2}], \"d\": {\"a\": 1, \"b\": 2}}");
user.getAttributes().put("json-attribute-multi", Arrays.asList("{\"a\": 1, \"b\": 2, \"c\": [{\"a\": 1, \"b\": 2}], \"d\": {\"a\": 1, \"b\": 2}}", "{\"a\": 1, \"b\": 2, \"c\": [{\"a\": 1, \"b\": 2}], \"d\": {\"a\": 1, \"b\": 2}}"));
List<String> departments = Arrays.asList("finance", "development");
user.getAttributes().put("departments", departments);
@ -165,6 +168,10 @@ public class OIDCProtocolMappersTest extends AbstractKeycloakTest {
app.getProtocolMappers().createMapper(createRoleNameMapper("rename-app-role", "test-app.customer-user", "realm-user")).close();
app.getProtocolMappers().createMapper(createScriptMapper("test-script-mapper1","computed-via-script", "computed-via-script", "String", true, true, "'hello_' + user.username", false)).close();
app.getProtocolMappers().createMapper(createScriptMapper("test-script-mapper2","multiValued-via-script", "multiValued-via-script", "String", true, true, "new java.util.ArrayList(['A','B'])", true)).close();
app.getProtocolMappers().createMapper(createClaimMapper("json-attribute-mapper", "json-attribute", "claim-from-json-attribute",
"JSON", true, true, false)).close();
app.getProtocolMappers().createMapper(createClaimMapper("json-attribute-mapper-multi", "json-attribute-multi", "claim-from-json-attribute-multi",
"JSON", true, true, true)).close();
Response response = app.getProtocolMappers().createMapper(createScriptMapper("test-script-mapper3", "syntax-error-script", "syntax-error-script", "String", true, true, "func_tion foo(){ return 'fail';} foo()", false));
assertThat(response.getStatusInfo().getFamily(), is(Response.Status.Family.CLIENT_ERROR));
@ -200,6 +207,17 @@ public class OIDCProtocolMappersTest extends AbstractKeycloakTest {
assertThat(firstDepartment, instanceOf(String.class));
assertThat(firstDepartment, anyOf(is("finance"), is("development"))); // Has to be the first item
Map jsonClaim = (Map) idToken.getOtherClaims().get("claim-from-json-attribute");
assertThat(jsonClaim.get("a"), instanceOf(int.class));
assertThat(jsonClaim.get("c"), instanceOf(Collection.class));
assertThat(jsonClaim.get("d"), instanceOf(Map.class));
List<Map> jsonClaims = (List<Map>) idToken.getOtherClaims().get("claim-from-json-attribute-multi");
assertEquals(2, jsonClaims.size());
assertThat(jsonClaims.get(0).get("a"), instanceOf(int.class));
assertThat(jsonClaims.get(1).get("c"), instanceOf(Collection.class));
assertThat(jsonClaims.get(1).get("d"), instanceOf(Map.class));
AccessToken accessToken = oauth.verifyToken(response.getAccessToken());
assertEquals(accessToken.getName(), "Tom Brady");
assertNotNull(accessToken.getAddress());
@ -233,6 +251,11 @@ public class OIDCProtocolMappersTest extends AbstractKeycloakTest {
// Assert allowed origins
Assert.assertNames(accessToken.getAllowedOrigins(), "http://localhost:8180", "https://localhost:8543");
jsonClaim = (Map) accessToken.getOtherClaims().get("claim-from-json-attribute");
assertThat(jsonClaim.get("a"), instanceOf(int.class));
assertThat(jsonClaim.get("c"), instanceOf(Collection.class));
assertThat(jsonClaim.get("d"), instanceOf(Map.class));
oauth.openLogout();
}
@ -253,6 +276,7 @@ public class OIDCProtocolMappersTest extends AbstractKeycloakTest {
|| model.getName().equals("hard-realm")
|| model.getName().equals("hard-app")
|| model.getName().equals("test-script-mapper")
|| model.getName().equals("json-attribute-mapper")
) {
app.getProtocolMappers().delete(model.getId());
}

View file

@ -219,7 +219,7 @@ selectRole.tooltip=Enter role in the textbox to the left, or click this button t
tokenClaimName.label=Token Claim Name
tokenClaimName.tooltip=Name of the claim to insert into the token. This can be a fully qualified name like 'address.street'. In this case, a nested json object will be created. To prevent nesting and use dot literally, escape the dot with backslash (\\.).
jsonType.label=Claim JSON Type
jsonType.tooltip=JSON type that should be used to populate the json claim in the token. long, int, boolean, and String are valid values.
jsonType.tooltip=JSON type that should be used to populate the json claim in the token. long, int, boolean, String and JSON are valid values.
includeInIdToken.label=Add to ID token
includeInIdToken.tooltip=Should the claim be added to the ID token?
includeInAccessToken.label=Add to access token