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 { public static enum ValueType {
BOOLEAN, BOOLEAN,
INT, INT,
STRING STRING,
JSON
} }
private String id; private String id;

View file

@ -25,12 +25,10 @@ import org.keycloak.broker.provider.BrokeredIdentityContext;
import org.keycloak.models.IdentityProviderMapperModel; import org.keycloak.models.IdentityProviderMapperModel;
import org.keycloak.protocol.oidc.mappers.OIDCAttributeMapperHelper; import org.keycloak.protocol.oidc.mappers.OIDCAttributeMapperHelper;
import org.keycloak.representations.JsonWebToken; import org.keycloak.representations.JsonWebToken;
import org.keycloak.util.JsonSerialization;
import java.util.LinkedList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** /**
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a> * @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
@ -122,6 +120,11 @@ public abstract class AbstractClaimMapper extends AbstractIdentityProviderMapper
for (Object val : list) { for (Object val : list) {
if (valueEquals(desiredValue, val)) return true; 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; return false;
} }

View file

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

View file

@ -17,6 +17,8 @@
package org.keycloak.protocol.oidc.mappers; 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.models.ProtocolMapperModel;
import org.keycloak.protocol.ProtocolMapper; import org.keycloak.protocol.ProtocolMapper;
import org.keycloak.protocol.ProtocolMapperUtils; import org.keycloak.protocol.ProtocolMapperUtils;
@ -24,6 +26,7 @@ import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.representations.IDToken; import org.keycloak.representations.IDToken;
import org.keycloak.services.ServicesLogger; import org.keycloak.services.ServicesLogger;
import org.keycloak.util.JsonSerialization;
import java.util.*; import java.util.*;
import java.util.function.Function; import java.util.function.Function;
@ -117,6 +120,13 @@ public class OIDCAttributeMapperHelper {
return transform((List<Integer>) attributeValue, OIDCAttributeMapperHelper::getInteger); return transform((List<Integer>) attributeValue, OIDCAttributeMapperHelper::getInteger);
} }
throw new RuntimeException("cannot map type for token claim"); 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: default:
return null; return null;
} }
@ -145,6 +155,17 @@ public class OIDCAttributeMapperHelper {
return null; 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.) // 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) // or any character other than backslash (escaping) and dot (claim component separator)
private static final Pattern CLAIM_COMPONENT = Pattern.compile("^((\\\\.|[^\\\\.])+?)\\."); private static final Pattern CLAIM_COMPONENT = Pattern.compile("^((\\\\.|[^\\\\.])+?)\\.");
@ -267,11 +288,12 @@ public class OIDCAttributeMapperHelper {
ProviderConfigProperty property = new ProviderConfigProperty(); ProviderConfigProperty property = new ProviderConfigProperty();
property.setName(JSON_TYPE); property.setName(JSON_TYPE);
property.setLabel(JSON_TYPE); property.setLabel(JSON_TYPE);
List<String> types = new ArrayList(3); List<String> types = new ArrayList(5);
types.add("String"); types.add("String");
types.add("long"); types.add("long");
types.add("int"); types.add("int");
types.add("boolean"); types.add("boolean");
types.add("JSON");
property.setType(ProviderConfigProperty.LIST_TYPE); property.setType(ProviderConfigProperty.LIST_TYPE);
property.setOptions(types); property.setOptions(types);
property.setHelpText(JSON_TYPE_TOOLTIP); property.setHelpText(JSON_TYPE_TOOLTIP);

View file

@ -73,10 +73,21 @@ public class AbstractJsonUserAttributeMapperTest {
@Test @Test
public void getJsonValue_nestedSimpleValues() throws JsonProcessingException, IOException { public void getJsonValue_nestedSimpleValues() throws JsonProcessingException, IOException {
// JsonNode if path points to JSON object
// null if path points to JSON object Assert.assertEquals(mapper.readTree("{\n"
Assert.assertEquals(null, AbstractJsonUserAttributeMapper.getJsonValue(getJsonNode(), "nest1")); + " \"value1\": \" fgh \",\n"
Assert.assertEquals(null, AbstractJsonUserAttributeMapper.getJsonValue(getJsonNode(), "nest1.nest2")); + " \"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 //unknown field returns null
Assert.assertEquals(null, AbstractJsonUserAttributeMapper.getJsonValue(getJsonNode(), "nest1.value_unknown")); 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 //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[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[0].av_unknown"));
Assert.assertEquals(null, AbstractJsonUserAttributeMapper.getJsonValue(getJsonNode(), "nesta.a[].av1")); Assert.assertEquals(null, AbstractJsonUserAttributeMapper.getJsonValue(getJsonNode(), "nesta.a[].av1"));
Assert.assertEquals(null, AbstractJsonUserAttributeMapper.getJsonValue(getJsonNode(), "nesta.a")); 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.nio.charset.StandardCharsets;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -139,6 +140,8 @@ public class OIDCProtocolMappersTest extends AbstractKeycloakTest {
user.singleAttribute("country", "USA"); user.singleAttribute("country", "USA");
user.singleAttribute("formatted", "6 Foo Street"); user.singleAttribute("formatted", "6 Foo Street");
user.singleAttribute("phone", "617-777-6666"); 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"); List<String> departments = Arrays.asList("finance", "development");
user.getAttributes().put("departments", departments); 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(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-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(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)); 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)); 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, instanceOf(String.class));
assertThat(firstDepartment, anyOf(is("finance"), is("development"))); // Has to be the first item 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()); AccessToken accessToken = oauth.verifyToken(response.getAccessToken());
assertEquals(accessToken.getName(), "Tom Brady"); assertEquals(accessToken.getName(), "Tom Brady");
assertNotNull(accessToken.getAddress()); assertNotNull(accessToken.getAddress());
@ -233,6 +251,11 @@ public class OIDCProtocolMappersTest extends AbstractKeycloakTest {
// Assert allowed origins // Assert allowed origins
Assert.assertNames(accessToken.getAllowedOrigins(), "http://localhost:8180", "https://localhost:8543"); 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(); oauth.openLogout();
} }
@ -253,6 +276,7 @@ public class OIDCProtocolMappersTest extends AbstractKeycloakTest {
|| model.getName().equals("hard-realm") || model.getName().equals("hard-realm")
|| model.getName().equals("hard-app") || model.getName().equals("hard-app")
|| model.getName().equals("test-script-mapper") || model.getName().equals("test-script-mapper")
|| model.getName().equals("json-attribute-mapper")
) { ) {
app.getProtocolMappers().delete(model.getId()); 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.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 (\\.). 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.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.label=Add to ID token
includeInIdToken.tooltip=Should the claim be added to the ID token? includeInIdToken.tooltip=Should the claim be added to the ID token?
includeInAccessToken.label=Add to access token includeInAccessToken.label=Add to access token