Add support for custom type deserialization with Jackson (#495)

- 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
This commit is contained in:
Brian Demers 2019-09-30 17:24:57 -04:00 committed by GitHub
parent a0060d60f9
commit 7090bf39c3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 531 additions and 9 deletions

View File

@ -2,8 +2,12 @@
### 0.11.0 ### 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) * 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. 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. * Moves JSON Serializer/Deserializer implementations to a different package name.
- `io.jsonwebtoken.io.JacksonSerializer` -> `io.jsonwebtoken.jackson.io.JacksonSerializer` - `io.jsonwebtoken.io.JacksonSerializer` -> `io.jsonwebtoken.jackson.io.JacksonSerializer`
- `io.jsonwebtoken.io.JacksonDeserializer` -> `io.jsonwebtoken.jackson.io.JacksonDeserializer` - `io.jsonwebtoken.io.JacksonDeserializer` -> `io.jsonwebtoken.jackson.io.JacksonDeserializer`

View File

@ -170,5 +170,22 @@ public interface Claims extends Map<String, Object>, ClaimsMutator<Claims> {
@Override //only for better/targeted JavaDoc @Override //only for better/targeted JavaDoc
Claims setId(String jti); 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); <T> T get(String claimName, Class<T> requiredType);
} }

View File

@ -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);
}
}
}

View File

@ -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"])
}
}

View File

@ -15,12 +15,19 @@
*/ */
package io.jsonwebtoken.jackson.io; 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.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.DeserializationException;
import io.jsonwebtoken.io.Deserializer; import io.jsonwebtoken.io.Deserializer;
import io.jsonwebtoken.lang.Assert; import io.jsonwebtoken.lang.Assert;
import java.io.IOException; import java.io.IOException;
import java.util.Collections;
import java.util.Map;
/** /**
* @since 0.10.0 * @since 0.10.0
@ -35,6 +42,42 @@ public class JacksonDeserializer<T> implements Deserializer<T> {
this(JacksonSerializer.DEFAULT_OBJECT_MAPPER); 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 @SuppressWarnings({"unchecked", "WeakerAccess", "unused"}) // for end-users providing a custom ObjectMapper
public JacksonDeserializer(ObjectMapper objectMapper) { public JacksonDeserializer(ObjectMapper objectMapper) {
this(objectMapper, (Class<T>) Object.class); this(objectMapper, (Class<T>) Object.class);
@ -60,4 +103,29 @@ public class JacksonDeserializer<T> implements Deserializer<T> {
protected T readValue(byte[] bytes) throws IOException { protected T readValue(byte[] bytes) throws IOException {
return objectMapper.readValue(bytes, returnType); 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);
}
}
} }

View File

@ -17,7 +17,9 @@ package io.jsonwebtoken.jackson.io
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import io.jsonwebtoken.io.DeserializationException 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 io.jsonwebtoken.lang.Strings
import org.junit.Test import org.junit.Test
@ -41,7 +43,7 @@ class JacksonDeserializerTest {
@Test(expected = IllegalArgumentException) @Test(expected = IllegalArgumentException)
void testObjectMapperConstructorWithNullArgument() { void testObjectMapperConstructorWithNullArgument() {
new JacksonDeserializer<>(null) new JacksonDeserializer<>((ObjectMapper) null)
} }
@Test @Test
@ -52,6 +54,62 @@ class JacksonDeserializerTest {
assertEquals expected, result 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 @Test
void testDeserializeFailsWithJsonProcessingException() { void testDeserializeFailsWithJsonProcessingException() {
@ -78,4 +136,8 @@ class JacksonDeserializerTest {
verify ex verify ex
} }
private String base64(String input) {
return Encoders.BASE64.encode(input.getBytes('UTF-8'))
}
} }

View File

@ -18,7 +18,6 @@ package io.jsonwebtoken.jackson.io
import com.fasterxml.jackson.core.JsonProcessingException import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import io.jsonwebtoken.io.SerializationException import io.jsonwebtoken.io.SerializationException
import io.jsonwebtoken.jackson.io.JacksonSerializer
import io.jsonwebtoken.lang.Strings import io.jsonwebtoken.lang.Strings
import org.junit.Test import org.junit.Test

View File

@ -0,0 +1,146 @@
/*
* 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.jackson.io.stubs
class CustomBean {
private String stringValue
private int intValue
private Date dateValue
private short shortValue
private long longValue
private byte byteValue
private byte[] byteArrayValue
private CustomBean nestedValue
String getStringValue() {
return stringValue
}
CustomBean setStringValue(String stringValue) {
this.stringValue = stringValue
return this
}
int getIntValue() {
return intValue
}
CustomBean setIntValue(int intValue) {
this.intValue = intValue
return this
}
Date getDateValue() {
return dateValue
}
CustomBean setDateValue(Date dateValue) {
this.dateValue = dateValue
return this
}
short getShortValue() {
return shortValue
}
CustomBean setShortValue(short shortValue) {
this.shortValue = shortValue
return this
}
long getLongValue() {
return longValue
}
CustomBean setLongValue(long longValue) {
this.longValue = longValue
return this
}
byte getByteValue() {
return byteValue
}
CustomBean setByteValue(byte byteValue) {
this.byteValue = byteValue
return this
}
byte[] getByteArrayValue() {
return byteArrayValue
}
CustomBean setByteArrayValue(byte[] byteArrayValue) {
this.byteArrayValue = byteArrayValue
return this
}
CustomBean getNestedValue() {
return nestedValue
}
CustomBean setNestedValue(CustomBean nestedValue) {
this.nestedValue = nestedValue
return this
}
boolean equals(o) {
if (this.is(o)) return true
if (getClass() != o.class) return false
CustomBean that = (CustomBean) o
if (byteValue != that.byteValue) return false
if (intValue != that.intValue) return false
if (longValue != that.longValue) return false
if (shortValue != that.shortValue) return false
if (!Arrays.equals(byteArrayValue, that.byteArrayValue)) return false
if (dateValue != that.dateValue) return false
if (nestedValue != that.nestedValue) return false
if (stringValue != that.stringValue) return false
return true
}
int hashCode() {
int result
result = stringValue.hashCode()
result = 31 * result + intValue
result = 31 * result + dateValue.hashCode()
result = 31 * result + (int) shortValue
result = 31 * result + (int) (longValue ^ (longValue >>> 32))
result = 31 * result + (int) byteValue
result = 31 * result + Arrays.hashCode(byteArrayValue)
result = 31 * result + nestedValue.hashCode()
return result
}
@Override
public String toString() {
return "CustomBean{" +
"stringValue='" + stringValue + '\'' +
", intValue=" + intValue +
", dateValue=" + dateValue?.time+
", shortValue=" + shortValue +
", longValue=" + longValue +
", byteValue=" + byteValue +
// ", byteArrayValue=" + Arrays.toString(byteArrayValue) +
", nestedValue=" + nestedValue +
'}';
}
}

View File

@ -22,6 +22,14 @@ import java.util.Map;
public class DefaultClaims extends JwtMap implements Claims { public class DefaultClaims extends JwtMap implements Claims {
private static final String CONVERSION_ERROR_MSG = "Cannot convert existing claim value of type '%s' to desired type " +
"'%s'. 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 Deserializer " +
"implementation. You may specify a custom Deserializer for a JwtParser with the desired conversion " +
"configuration via the JwtParserBuilder.deserializeJsonWith() method. " +
"See https://github.com/jwtk/jjwt#custom-json-processor for more information. If using Jackson, you can " +
"specify custom claim POJO types as described in https://github.com/jwtk/jjwt#json-jackson-custom-types";
public DefaultClaims() { public DefaultClaims() {
super(); super();
} }
@ -158,7 +166,7 @@ public class DefaultClaims extends JwtMap implements Claims {
} }
if (!requiredType.isInstance(value)) { if (!requiredType.isInstance(value)) {
throw new RequiredTypeException("Expected value to be of type: " + requiredType + ", but was " + value.getClass()); throw new RequiredTypeException(String.format(CONVERSION_ERROR_MSG, value.getClass(), requiredType));
} }
return requiredType.cast(value); return requiredType.cast(value);

View File

@ -0,0 +1,93 @@
/*
* 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
import io.jsonwebtoken.io.Deserializer
import io.jsonwebtoken.jackson.io.JacksonDeserializer
import org.junit.Test
import static org.hamcrest.CoreMatchers.is
import static org.junit.Assert.assertEquals
import static org.junit.Assert.assertNotNull
import static org.junit.Assert.assertThat
class CustomObjectDeserializationTest {
/**
* Test parsing without and then with a custom deserializer. Ensures custom type is parsed from claims
*/
@Test
void testCustomObjectDeserialization() {
CustomBean customBean = new CustomBean()
customBean.key1 = "value1"
customBean.key2 = 42
String jwtString = Jwts.builder().claim("cust", customBean).compact()
// no custom deserialization, object is a map
Jwt<Header, Claims> jwt = Jwts.parser().parseClaimsJwt(jwtString)
assertNotNull jwt
assertEquals jwt.getBody().get('cust'), [key1: 'value1', key2: 42]
// custom type for 'cust' claim
Deserializer deserializer = new JacksonDeserializer([cust: CustomBean])
jwt = Jwts.parser().deserializeJsonWith(deserializer).parseClaimsJwt(jwtString)
assertNotNull jwt
CustomBean result = jwt.getBody().get("cust", CustomBean)
assertThat result, is(customBean)
}
static class CustomBean {
private String key1
private Integer key2
String getKey1() {
return key1
}
void setKey1(String key1) {
this.key1 = key1
}
Integer getKey2() {
return key2
}
void setKey2(Integer key2) {
this.key2 = key2
}
boolean equals(o) {
if (this.is(o)) return true
if (getClass() != o.class) return false
CustomBean that = (CustomBean) o
if (key1 != that.key1) return false
if (key2 != that.key2) return false
return true
}
int hashCode() {
int result
result = (key1 != null ? key1.hashCode() : 0)
result = 31 * result + (key2 != null ? key2.hashCode() : 0)
return result
}
}
}

View File

@ -45,7 +45,7 @@ class DefaultClaimsTest {
fail() fail()
} catch (RequiredTypeException e) { } catch (RequiredTypeException e) {
assertEquals( assertEquals(
"Expected value to be of type: class java.lang.String, but was class java.lang.Integer", String.format(DefaultClaims.CONVERSION_ERROR_MSG, 'class java.lang.Integer', 'class java.lang.String'),
e.getMessage() e.getMessage()
) )
} }
@ -96,7 +96,7 @@ class DefaultClaimsTest {
} catch (RequiredTypeException e) { } catch (RequiredTypeException e) {
assertEquals( assertEquals(
e.getMessage(), e.getMessage(),
"Expected value to be of type: class java.lang.Short, but was class java.lang.Integer" String.format(DefaultClaims.CONVERSION_ERROR_MSG, 'class java.lang.Integer', 'class java.lang.Short')
) )
} }
} }
@ -110,7 +110,7 @@ class DefaultClaimsTest {
} catch (RequiredTypeException e) { } catch (RequiredTypeException e) {
assertEquals( assertEquals(
e.getMessage(), e.getMessage(),
"Expected value to be of type: class java.lang.Short, but was class java.lang.Integer" String.format(DefaultClaims.CONVERSION_ERROR_MSG, 'class java.lang.Integer', 'class java.lang.Short')
) )
} }
} }
@ -132,7 +132,7 @@ class DefaultClaimsTest {
} catch (RequiredTypeException e) { } catch (RequiredTypeException e) {
assertEquals( assertEquals(
e.getMessage(), e.getMessage(),
"Expected value to be of type: class java.lang.Byte, but was class java.lang.Integer" String.format(DefaultClaims.CONVERSION_ERROR_MSG, 'class java.lang.Integer', 'class java.lang.Byte')
) )
} }
} }
@ -146,7 +146,7 @@ class DefaultClaimsTest {
} catch (RequiredTypeException e) { } catch (RequiredTypeException e) {
assertEquals( assertEquals(
e.getMessage(), e.getMessage(),
"Expected value to be of type: class java.lang.Byte, but was class java.lang.Integer" String.format(DefaultClaims.CONVERSION_ERROR_MSG, 'class java.lang.Integer', 'class java.lang.Byte')
) )
} }
} }