Skip to content

Commit

Permalink
Add support for custom type deserialization with Jackson (#495)
Browse files Browse the repository at this point in the history
- Adds new constructor JacksonDeserializer(Map<String, Class> claimTypeMap), which will enable later calls Claims.get("key", CustomType.class) to work as expectd
 - Adds new Maps utility class to make map creation fluent

Fixes: #369
  • Loading branch information
bdemers committed Oct 3, 2019
1 parent b62d6f0 commit d5c3865
Show file tree
Hide file tree
Showing 11 changed files with 531 additions and 9 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@

### 0.11.0

This minor release:

* Updates the Jackson dependency version to [2.9.10](https://github.com/FasterXML/jackson/wiki/Jackson-Release-2.9#patches)
to address three security vulnerabilities in Jackson.
* Adds support for custom types when deserializing with Jackson. To use configure your parser with `Jwts.parserBuilder().deserializeJsonWith(new JacksonDeserializer(Maps.of("claimName", YourType.class).build())).build()`.
* Adds `io.jsonwebtoken.lang.Maps` utility class to make creation of maps fluent.
* Moves JSON Serializer/Deserializer implementations to a different package name.
- `io.jsonwebtoken.io.JacksonSerializer` -> `io.jsonwebtoken.jackson.io.JacksonSerializer`
- `io.jsonwebtoken.io.JacksonDeserializer` -> `io.jsonwebtoken.jackson.io.JacksonDeserializer`
Expand Down
17 changes: 17 additions & 0 deletions api/src/main/java/io/jsonwebtoken/Claims.java
Original file line number Diff line number Diff line change
Expand Up @@ -170,5 +170,22 @@ public interface Claims extends Map<String, Object>, ClaimsMutator<Claims> {
@Override //only for better/targeted JavaDoc
Claims setId(String jti);

/**
* Returns the JWTs claim ({@code claimName}) value as a type {@code requiredType}, or {@code null} if not present.
*
* <p>JJWT only converts simple String, Date, Long, Integer, Short and Byte types automatically. Anything more
* complex is expected to be already converted to your desired type by the JSON
* {@link io.jsonwebtoken.io.Deserializer Deserializer} implementation. You may specify a custom Deserializer for a
* JwtParser with the desired conversion configuration via the {@link JwtParserBuilder#deserializeJsonWith} method.
* See <a href="https://github.com/jwtk/jjwt#custom-json-processor">custom JSON processor</a></a> for more
* information. If using Jackson, you can specify custom claim POJO types as described in
* <a href="https://github.com/jwtk/jjwt#json-jackson-custom-types">custom claim types</a>.
*
* @param claimName name of claim
* @param requiredType the type of the value expected to be returned
* @param <T> the type of the value expected to be returned
* @return the JWT {@code claimName} value or {@code null} if not present.
* @throws RequiredTypeException throw if the claim value is not null and not of type {@code requiredType}
*/
<T> T get(String claimName, Class<T> requiredType);
}
87 changes: 87 additions & 0 deletions api/src/main/java/io/jsonwebtoken/lang/Maps.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* Copyright (C) 2019 jsonwebtoken.io
*
* 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 io.jsonwebtoken.lang;

import java.util.Collections;
import java.util.HashMap;
import java.util.Map;

/**
* Utility class to help with the manipulation of working with Maps.
* @since 0.11.0
*/
public final class Maps {

private Maps() {} //prevent instantiation

/**
* Creates a new map builder with a single entry.
* <p> Typical usage: <pre>{@code
* Map<K,V> result = Maps.of("key1", value1)
* .and("key2", value2)
* // ...
* .build();
* }</pre>
* @param key the key of an map entry to be added
* @param value the value of map entry to be added
* @param <K> the maps key type
* @param <V> the maps value type
* Creates a new map builder with a single entry.
*/
public static <K, V> MapBuilder<K, V> of(K key, V value) {
return new HashMapBuilder<K, V>().and(key, value);
}

/**
* Utility Builder class for fluently building maps:
* <p> Typical usage: <pre>{@code
* Map<K,V> result = Maps.of("key1", value1)
* .and("key2", value2)
* // ...
* .build();
* }</pre>
* @param <K> the maps key type
* @param <V> the maps value type
*/
public interface MapBuilder<K, V> {
/**
* Add a new entry to this map builder
* @param key the key of an map entry to be added
* @param value the value of map entry to be added
* @return the current MapBuilder to allow for method chaining.
*/
MapBuilder and(K key, V value);

/**
* Returns a the resulting Map object from this MapBuilder.
* @return Returns a the resulting Map object from this MapBuilder.
*/
Map<K, V> build();
}

private static class HashMapBuilder<K, V> implements MapBuilder<K, V> {

private final Map<K, V> data = new HashMap<>();

public MapBuilder and(K key, V value) {
data.put(key, value);
return this;
}
public Map<K, V> build() {
return Collections.unmodifiableMap(data);
}
}
}
38 changes: 38 additions & 0 deletions api/src/test/groovy/io/jsonwebtoken/lang/MapsTest.groovy
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*
* Copyright (C) 2019 jsonwebtoken.io
*
* 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 io.jsonwebtoken.lang

import org.junit.Test

import static org.hamcrest.MatcherAssert.assertThat
import static org.hamcrest.CoreMatchers.is

class MapsTest {

@Test
void testSingleMapOf() {
assertThat Maps.of("aKey", "aValue").build(), is([aKey: "aValue"])
}

@Test
void testMapOfAnd() {
assertThat Maps.of("aKey1", "aValue1")
.and("aKey2", "aValue2")
.and("aKey3", "aValue3")
.build(),
is([aKey1: "aValue1", aKey2: "aValue2", aKey3: "aValue3"])
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,19 @@
*/
package io.jsonwebtoken.jackson.io;

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.ObjectMapper;

import com.fasterxml.jackson.databind.deser.std.UntypedObjectDeserializer;
import com.fasterxml.jackson.databind.module.SimpleModule;
import io.jsonwebtoken.io.DeserializationException;
import io.jsonwebtoken.io.Deserializer;
import io.jsonwebtoken.lang.Assert;

import java.io.IOException;
import java.util.Collections;
import java.util.Map;

/**
* @since 0.10.0
Expand All @@ -35,6 +42,42 @@ public JacksonDeserializer() {
this(JacksonSerializer.DEFAULT_OBJECT_MAPPER);
}

/**
* Creates a new JacksonDeserializer where the values of the claims can be parsed into given types. A common usage
* example is to parse custom User object out of a claim, for example the claims:
* <pre>{@code
* {
* "issuer": "https://issuer.example.com",
* "user": {
* "firstName": "Jill",
* "lastName": "Coder"
* }
* }}</pre>
* Passing a map of {@code ["user": User.class]} to this constructor would result in the {@code user} claim being
* transformed to an instance of your custom {@code User} class, instead of the default of {@code Map}.
* <p>
* Because custom type parsing requires modifying the state of a Jackson {@code ObjectMapper}, this
* constructor creates a new internal {@code ObjectMapper} instance and customizes it to support the
* specified {@code claimTypeMap}. This ensures that the JJWT parsing behavior does not unexpectedly
* modify the state of another application-specific {@code ObjectMapper}.
* <p>
* If you would like to use your own {@code ObjectMapper} instance that also supports custom types for
* JWT {@code Claims}, you will need to first customize your {@code ObjectMapper} instance by registering
* your custom types and then use the {@link JacksonDeserializer(ObjectMapper)} constructor instead.
*
* @param claimTypeMap The claim name-to-class map used to deserialize claims into the given type
*/
public JacksonDeserializer(Map<String, Class> claimTypeMap) {
// DO NOT reuse JacksonSerializer.DEFAULT_OBJECT_MAPPER as this could result in sharing the custom deserializer
// between instances
this(new ObjectMapper());
Assert.notNull(claimTypeMap, "Claim type map cannot be null.");
// register a new Deserializer
SimpleModule module = new SimpleModule();
module.addDeserializer(Object.class, new MappedTypeDeserializer(Collections.unmodifiableMap(claimTypeMap)));
objectMapper.registerModule(module);
}

@SuppressWarnings({"unchecked", "WeakerAccess", "unused"}) // for end-users providing a custom ObjectMapper
public JacksonDeserializer(ObjectMapper objectMapper) {
this(objectMapper, (Class<T>) Object.class);
Expand All @@ -60,4 +103,29 @@ public T deserialize(byte[] bytes) throws DeserializationException {
protected T readValue(byte[] bytes) throws IOException {
return objectMapper.readValue(bytes, returnType);
}

/**
* A Jackson {@link com.fasterxml.jackson.databind.JsonDeserializer JsonDeserializer}, that will convert claim
* values to types based on {@code claimTypeMap}.
*/
private static class MappedTypeDeserializer extends UntypedObjectDeserializer {

private final Map<String, Class> claimTypeMap;

private MappedTypeDeserializer(Map<String, Class> claimTypeMap) {
super(null, null);
this.claimTypeMap = claimTypeMap;
}

@Override
public Object deserialize(JsonParser parser, DeserializationContext context) throws IOException {
// check if the current claim key is mapped, if so traverse it's value
if (claimTypeMap != null && claimTypeMap.containsKey(parser.currentName())) {
Class type = claimTypeMap.get(parser.currentName());
return parser.readValueAsTree().traverse(parser.getCodec()).readValueAs(type);
}
// otherwise default to super
return super.deserialize(parser, context);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ package io.jsonwebtoken.jackson.io

import com.fasterxml.jackson.databind.ObjectMapper
import io.jsonwebtoken.io.DeserializationException
import io.jsonwebtoken.jackson.io.JacksonDeserializer
import io.jsonwebtoken.io.Encoders
import io.jsonwebtoken.jackson.io.stubs.CustomBean
import io.jsonwebtoken.lang.Maps
import io.jsonwebtoken.lang.Strings
import org.junit.Test

Expand All @@ -41,7 +43,7 @@ class JacksonDeserializerTest {

@Test(expected = IllegalArgumentException)
void testObjectMapperConstructorWithNullArgument() {
new JacksonDeserializer<>(null)
new JacksonDeserializer<>((ObjectMapper) null)
}

@Test
Expand All @@ -52,6 +54,62 @@ class JacksonDeserializerTest {
assertEquals expected, result
}

@Test
void testDeserializeWithCustomObject() {

long currentTime = System.currentTimeMillis()

byte[] serialized = """{
"oneKey":"oneValue",
"custom": {
"stringValue": "s-value",
"intValue": "11",
"dateValue": ${currentTime},
"shortValue": 22,
"longValue": 33,
"byteValue": 15,
"byteArrayValue": "${base64('bytes')}",
"nestedValue": {
"stringValue": "nested-value",
"intValue": "111",
"dateValue": ${currentTime + 1},
"shortValue": 222,
"longValue": 333,
"byteValue": 10,
"byteArrayValue": "${base64('bytes2')}"
}
}
}
""".getBytes(Strings.UTF_8)

CustomBean expectedCustomBean = new CustomBean()
.setByteArrayValue("bytes".getBytes("UTF-8"))
.setByteValue(0xF as byte)
.setDateValue(new Date(currentTime))
.setIntValue(11)
.setShortValue(22 as short)
.setLongValue(33L)
.setStringValue("s-value")
.setNestedValue(new CustomBean()
.setByteArrayValue("bytes2".getBytes("UTF-8"))
.setByteValue(0xA as byte)
.setDateValue(new Date(currentTime+1))
.setIntValue(111)
.setShortValue(222 as short)
.setLongValue(333L)
.setStringValue("nested-value")
)

def expected = [oneKey: "oneValue", custom: expectedCustomBean]
def result = new JacksonDeserializer(Maps.of("custom", CustomBean).build()).deserialize(serialized)
assertEquals expected, result
}

@Test(expected = IllegalArgumentException)
void testNullClaimTypeMap() {
new JacksonDeserializer((Map) null)
}

@Test
void testDeserializeFailsWithJsonProcessingException() {

Expand All @@ -78,4 +136,8 @@ class JacksonDeserializerTest {

verify ex
}

private String base64(String input) {
return Encoders.BASE64.encode(input.getBytes('UTF-8'))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ package io.jsonwebtoken.jackson.io
import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.ObjectMapper
import io.jsonwebtoken.io.SerializationException
import io.jsonwebtoken.jackson.io.JacksonSerializer
import io.jsonwebtoken.lang.Strings
import org.junit.Test

Expand Down
Loading

0 comments on commit d5c3865

Please sign in to comment.