From cf91b72ade46f9d1ebd4e273c3f6bf195ad17a57 Mon Sep 17 00:00:00 2001 From: Ribhav Pahuja Date: Tue, 21 Jul 2026 23:48:50 +0530 Subject: [PATCH] Add pluggable JSONMapper SPI for custom JSON serialize/deserialize. Allow applications to replace Jackson via Mapper.setMapper() while keeping JacksonJSONMapper as the default. Resolves FusionAuth/fusionauth-jwt#6. --- CHANGES | 9 ++ README.md | 32 ++++- .../fusionauth/jwks/JSONWebKeySetHelper.java | 12 +- .../io/fusionauth/jwt/json/JSONMapper.java | 83 +++++++++++ .../jwt/json/JacksonJSONMapper.java | 103 ++++++++++++++ .../java/io/fusionauth/jwt/json/Mapper.java | 79 ++++++----- .../jwt/json/CustomJSONMapperTest.java | 132 ++++++++++++++++++ 7 files changed, 404 insertions(+), 46 deletions(-) create mode 100644 src/main/java/io/fusionauth/jwt/json/JSONMapper.java create mode 100644 src/main/java/io/fusionauth/jwt/json/JacksonJSONMapper.java create mode 100644 src/test/java/io/fusionauth/jwt/json/CustomJSONMapperTest.java diff --git a/CHANGES b/CHANGES index b96369f9..c1e5952d 100644 --- a/CHANGES +++ b/CHANGES @@ -1,5 +1,14 @@ FusionAuth JWT Changes +Changes in 7.1.0 + * Support custom JSON serialize/deserialize via the new `JSONMapper` SPI. + * `Mapper` remains the public facade and defaults to `JacksonJSONMapper`. + * Register an alternate implementation with `Mapper.setMapper(...)` (for example a Gson-backed mapper). + * Call `Mapper.resetMapper()` to restore the default Jackson mapper. + * Domain types still carry Jackson annotations for the default mapper; custom mappers must handle + property renaming and claim conversion themselves. + Resolves https://github.com/FusionAuth/fusionauth-jwt/issues/6 + Changes in 7.0.0 * Move to Java 25 LTS as the minimum requirement. * OpenID Connect `at_hash` / `c_hash` for Ed448 no longer requires a third-party provider for SHAKE256; the default JCA provides it on Java 25+. diff --git a/README.md b/README.md index 24fa3682..7a114340 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ We are very interested in compensating anyone that can identify a security relat - Generate JWK thumbprint using `SHA-1` or `SHA-256` - Generate ideal HMAC secret lengths for `SHA-256`, `SHA-384` and `SHA-512` - Generate the `at_hash` and `c_hash` claims for OpenID Connect + - Pluggable JSON serialize/deserialize (`JSONMapper`) with Jackson as the default ## Get it @@ -307,12 +308,35 @@ String json = jwk.toJSON(); } ``` +## Custom JSON mapper + +By default FusionAuth JWT uses Jackson via `Mapper` / `JacksonJSONMapper`. If your application already uses another +JSON library (for example Gson), you can register a custom implementation of `JSONMapper`: + +```java +import io.fusionauth.jwt.json.JSONMapper; +import io.fusionauth.jwt.json.Mapper; + +// Register once at application startup (process-wide). +Mapper.setMapper(new MyGsonJSONMapper()); + +// Later, restore the default Jackson implementation if needed. +Mapper.resetMapper(); +``` + +Your implementation must correctly serialize and deserialize JWT headers, payloads, JWKs, and OAuth2 metadata +(property names such as `alg` / `jti`, additional claims maps, and numeric epoch-second date claims). Domain types +still include Jackson annotations for the default mapper; a custom mapper is responsible for applying the same +conventions. + +See `JSONMapper` and `Mapper` for the SPI contract. + ## Building - + ### Maven - ```bash - $ mvn install - ``` +```bash +$ mvn install +``` ### Savant diff --git a/src/main/java/io/fusionauth/jwks/JSONWebKeySetHelper.java b/src/main/java/io/fusionauth/jwks/JSONWebKeySetHelper.java index df6bb77f..10731f41 100644 --- a/src/main/java/io/fusionauth/jwks/JSONWebKeySetHelper.java +++ b/src/main/java/io/fusionauth/jwks/JSONWebKeySetHelper.java @@ -16,13 +16,13 @@ package io.fusionauth.jwks; -import com.fasterxml.jackson.databind.JsonNode; import io.fusionauth.http.AbstractHttpHelper; import io.fusionauth.jwks.domain.JSONWebKey; import io.fusionauth.jwt.json.Mapper; import java.net.HttpURLConnection; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.function.Consumer; @@ -65,14 +65,16 @@ public static List retrieveKeysFromIssuer(String issuer, Consumer retrieveKeysFromWellKnownConfiguration(HttpURLConnection httpURLConnection) { return get(httpURLConnection, is -> { - JsonNode response = Mapper.deserialize(is, JsonNode.class); - JsonNode jwksURI = response.at("/jwks_uri"); - if (jwksURI.isMissingNode()) { + // Use Map so custom JSONMapper implementations (e.g. Gson) work without Jackson JsonNode. + @SuppressWarnings("unchecked") + Map response = Mapper.deserialize(is, Map.class); + Object jwksURI = response != null ? response.get("jwks_uri") : null; + if (jwksURI == null || jwksURI.toString().isEmpty()) { String endpoint = httpURLConnection.getURL().toString(); throw new JSONWebKeySetException("The well-known endpoint [" + endpoint + "] has not defined a JSON Web Key Set endpoint. Missing the [jwks_uri] property."); } - return retrieveKeysFromJWKS(jwksURI.asText()); + return retrieveKeysFromJWKS(jwksURI.toString()); }, JSONWebKeyBuilderException::new); } diff --git a/src/main/java/io/fusionauth/jwt/json/JSONMapper.java b/src/main/java/io/fusionauth/jwt/json/JSONMapper.java new file mode 100644 index 00000000..3702a3c7 --- /dev/null +++ b/src/main/java/io/fusionauth/jwt/json/JSONMapper.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2026, FusionAuth, All Rights Reserved + * + * 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.fusionauth.jwt.json; + +import io.fusionauth.jwt.InvalidJWTException; + +import java.io.InputStream; + +/** + * Pluggable JSON serialize/deserialize SPI used by FusionAuth JWT. + *

+ * The default implementation is {@link JacksonJSONMapper}. Register a custom + * implementation with {@link Mapper#setMapper(JSONMapper)} if you prefer another + * JSON library (for example Gson). + *

+ * Implementations must correctly round-trip the library domain types used for + * JWT headers, JWT payloads, JWKs, and OAuth2 metadata, including: + *

    + *
  • Property renaming (e.g. {@code algorithm} ↔ {@code alg}, {@code uniqueId} ↔ {@code jti})
  • + *
  • Additional/unknown properties via the domain "any" maps
  • + *
  • {@link java.time.ZonedDateTime} claims as numeric UNIX epoch seconds
  • + *
  • Omitting null properties on serialize
  • + *
+ * + * @author Daniel DeGroff + * @see Mapper + * @see JacksonJSONMapper + */ +public interface JSONMapper { + /** + * Deserialize JSON bytes into the given type. + * + * @param bytes the JSON bytes + * @param type the target type + * @param the target type + * @return the deserialized object + * @throws InvalidJWTException if the JSON cannot be de-serialized + */ + T deserialize(byte[] bytes, Class type) throws InvalidJWTException; + + /** + * Deserialize a JSON input stream into the given type. + * + * @param is the JSON input stream + * @param type the target type + * @param the target type + * @return the deserialized object + * @throws InvalidJWTException if the JSON cannot be de-serialized + */ + T deserialize(InputStream is, Class type) throws InvalidJWTException; + + /** + * Serialize an object to JSON with pretty printing. + * + * @param object the object to serialize + * @return pretty-printed JSON bytes + * @throws InvalidJWTException if the object cannot be serialized + */ + byte[] prettyPrint(Object object) throws InvalidJWTException; + + /** + * Serialize an object to compact JSON. + * + * @param object the object to serialize + * @return JSON bytes + * @throws InvalidJWTException if the object cannot be serialized + */ + byte[] serialize(Object object) throws InvalidJWTException; +} diff --git a/src/main/java/io/fusionauth/jwt/json/JacksonJSONMapper.java b/src/main/java/io/fusionauth/jwt/json/JacksonJSONMapper.java new file mode 100644 index 00000000..9b054989 --- /dev/null +++ b/src/main/java/io/fusionauth/jwt/json/JacksonJSONMapper.java @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2026, FusionAuth, All Rights Reserved + * + * 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.fusionauth.jwt.json; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import io.fusionauth.jwt.InvalidJWTException; + +import java.io.IOException; +import java.io.InputStream; + +/** + * Default {@link JSONMapper} backed by Jackson. + * + * @author Daniel DeGroff + */ +public class JacksonJSONMapper implements JSONMapper { + private final ObjectMapper objectMapper; + + /** + * Create a mapper with the default FusionAuth Jackson configuration. + */ + public JacksonJSONMapper() { + this(createDefaultObjectMapper()); + } + + /** + * Create a mapper that uses a provided Jackson {@link ObjectMapper}. + * The mapper is not copied; callers should not reconfigure it after construction + * if it may be used concurrently. + * + * @param objectMapper the Jackson object mapper to use + */ + public JacksonJSONMapper(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + /** + * @return a new ObjectMapper configured the way FusionAuth JWT expects. + */ + public static ObjectMapper createDefaultObjectMapper() { + ObjectMapper mapper = new ObjectMapper(); + mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL) + .configure(SerializationFeature.WRITE_NULL_MAP_VALUES, false) + .configure(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS, true) + .configure(DeserializationFeature.USE_BIG_INTEGER_FOR_INTS, true) + .registerModule(new JacksonModule()); + return mapper; + } + + @Override + public T deserialize(byte[] bytes, Class type) throws InvalidJWTException { + try { + return objectMapper.readValue(bytes, type); + } catch (IOException e) { + throw new InvalidJWTException("The JWT could not be de-serialized.", e); + } + } + + @Override + public T deserialize(InputStream is, Class type) throws InvalidJWTException { + try { + return objectMapper.readValue(is, type); + } catch (IOException e) { + throw new InvalidJWTException("The input stream could not be de-serialized.", e); + } + } + + @Override + public byte[] prettyPrint(Object object) throws InvalidJWTException { + try { + return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(object); + } catch (JsonProcessingException e) { + throw new InvalidJWTException("The object could not be serialized.", e); + } + } + + @Override + public byte[] serialize(Object object) throws InvalidJWTException { + try { + return objectMapper.writeValueAsBytes(object); + } catch (JsonProcessingException e) { + throw new InvalidJWTException("The JWT could not be serialized.", e); + } + } +} diff --git a/src/main/java/io/fusionauth/jwt/json/Mapper.java b/src/main/java/io/fusionauth/jwt/json/Mapper.java index 483966f1..f1dbe0b6 100644 --- a/src/main/java/io/fusionauth/jwt/json/Mapper.java +++ b/src/main/java/io/fusionauth/jwt/json/Mapper.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2016-2019, FusionAuth, All Rights Reserved + * Copyright (c) 2016-2026, FusionAuth, All Rights Reserved * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,61 +16,66 @@ package io.fusionauth.jwt.json; -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; import io.fusionauth.jwt.InvalidJWTException; -import java.io.IOException; import java.io.InputStream; +import java.util.Objects; /** - * Serialize and de-serialize JWT header and payload. + * Serialize and de-serialize JWT header and payload (and other JSON types used by this library). + *

+ * By default this facade delegates to {@link JacksonJSONMapper}. Applications that prefer a + * different JSON library can register a custom {@link JSONMapper}: + *

+ *   Mapper.setMapper(new MyGsonJSONMapper());
+ * 
+ * Call {@link #resetMapper()} to restore the default Jackson implementation. * * @author Daniel DeGroff + * @see JSONMapper + * @see JacksonJSONMapper */ public class Mapper { - private final static ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static volatile JSONMapper mapper = new JacksonJSONMapper(); + + /** + * @return the currently configured {@link JSONMapper} + */ + public static JSONMapper getMapper() { + return mapper; + } + + /** + * Register a custom JSON mapper used for all subsequent serialize/deserialize operations. + * This is a process-wide setting and is not scoped per JWT instance. + * + * @param jsonMapper the mapper implementation; must not be null + */ + public static void setMapper(JSONMapper jsonMapper) { + Objects.requireNonNull(jsonMapper, "jsonMapper"); + mapper = jsonMapper; + } + + /** + * Restore the default {@link JacksonJSONMapper}. + */ + public static void resetMapper() { + mapper = new JacksonJSONMapper(); + } public static T deserialize(byte[] bytes, Class type) throws InvalidJWTException { - try { - return OBJECT_MAPPER.readValue(bytes, type); - } catch (IOException e) { - throw new InvalidJWTException("The JWT could not be de-serialized.", e); - } + return mapper.deserialize(bytes, type); } public static T deserialize(InputStream is, Class type) throws InvalidJWTException { - try { - return OBJECT_MAPPER.readValue(is, type); - } catch (IOException e) { - throw new InvalidJWTException("The input stream could not be de-serialized.", e); - } + return mapper.deserialize(is, type); } public static byte[] prettyPrint(Object object) throws InvalidJWTException { - try { - return OBJECT_MAPPER.writerWithDefaultPrettyPrinter().writeValueAsBytes(object); - } catch (JsonProcessingException e) { - throw new InvalidJWTException("The object could not be serialized.", e); - } + return mapper.prettyPrint(object); } public static byte[] serialize(Object object) throws InvalidJWTException { - try { - return OBJECT_MAPPER.writeValueAsBytes(object); - } catch (JsonProcessingException e) { - throw new InvalidJWTException("The JWT could not be serialized.", e); - } - } - - static { - OBJECT_MAPPER.setSerializationInclusion(JsonInclude.Include.NON_NULL) - .configure(SerializationFeature.WRITE_NULL_MAP_VALUES, false) - .configure(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS, true) - .configure(DeserializationFeature.USE_BIG_INTEGER_FOR_INTS, true) - .registerModule(new JacksonModule()); + return mapper.serialize(object); } } diff --git a/src/test/java/io/fusionauth/jwt/json/CustomJSONMapperTest.java b/src/test/java/io/fusionauth/jwt/json/CustomJSONMapperTest.java new file mode 100644 index 00000000..1684b0fc --- /dev/null +++ b/src/test/java/io/fusionauth/jwt/json/CustomJSONMapperTest.java @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2026, FusionAuth, All Rights Reserved + * + * 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.fusionauth.jwt.json; + +import io.fusionauth.jwt.BaseJWTTest; +import io.fusionauth.jwt.domain.JWT; +import io.fusionauth.jwt.hmac.HMACSigner; +import io.fusionauth.jwt.hmac.HMACVerifier; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.Test; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertTrue; + +/** + * Verifies that a custom {@link JSONMapper} can be registered and is used end-to-end. + */ +public class CustomJSONMapperTest extends BaseJWTTest { + @AfterMethod + public void restoreDefaultMapper() { + Mapper.resetMapper(); + } + + @Test + public void customMapperIsUsedForSerializeAndDeserialize() { + CountingJSONMapper counting = new CountingJSONMapper(new JacksonJSONMapper()); + Mapper.setMapper(counting); + + JWT jwt = new JWT() + .setSubject("custom-mapper") + .setExpiration(ZonedDateTime.of(2030, 1, 1, 0, 0, 0, 0, ZoneOffset.UTC)); + + byte[] bytes = Mapper.serialize(jwt); + assertEquals(counting.serializeCalls.get(), 1); + + JWT roundTrip = Mapper.deserialize(bytes, JWT.class); + assertEquals(counting.deserializeBytesCalls.get(), 1); + assertEquals(roundTrip.subject, "custom-mapper"); + assertEquals(roundTrip.expiration, jwt.expiration); + + assertTrue(new String(Mapper.prettyPrint(jwt), StandardCharsets.UTF_8).contains("custom-mapper")); + assertEquals(counting.prettyPrintCalls.get(), 1); + } + + @Test + public void customMapperUsedWhenEncodingAndDecodingJWT() { + CountingJSONMapper counting = new CountingJSONMapper(new JacksonJSONMapper()); + Mapper.setMapper(counting); + + String encoded = JWT.getEncoder().encode( + new JWT() + .setSubject("encode-decode") + .setIssuedAt(ZonedDateTime.now(ZoneOffset.UTC)) + .setExpiration(ZonedDateTime.now(ZoneOffset.UTC).plusMinutes(5)), + HMACSigner.newSHA256Signer("too many secrets")); + + assertTrue(counting.serializeCalls.get() >= 2, "header and payload should be serialized"); + + JWT decoded = JWT.getDecoder().decode(encoded, HMACVerifier.newVerifier("too many secrets")); + assertEquals(decoded.subject, "encode-decode"); + assertTrue(counting.deserializeBytesCalls.get() >= 2, "header and payload should be deserialized"); + } + + @Test + public void resetMapperRestoresJacksonDefault() { + CountingJSONMapper counting = new CountingJSONMapper(new JacksonJSONMapper()); + Mapper.setMapper(counting); + assertEquals(Mapper.getMapper(), counting); + + Mapper.resetMapper(); + assertTrue(Mapper.getMapper() instanceof JacksonJSONMapper); + } + + /** + * Test double that counts invocations and delegates to a real mapper. + */ + private static final class CountingJSONMapper implements JSONMapper { + private final JSONMapper delegate; + final AtomicInteger serializeCalls = new AtomicInteger(); + final AtomicInteger deserializeBytesCalls = new AtomicInteger(); + final AtomicInteger deserializeStreamCalls = new AtomicInteger(); + final AtomicInteger prettyPrintCalls = new AtomicInteger(); + + CountingJSONMapper(JSONMapper delegate) { + this.delegate = delegate; + } + + @Override + public T deserialize(byte[] bytes, Class type) { + deserializeBytesCalls.incrementAndGet(); + return delegate.deserialize(bytes, type); + } + + @Override + public T deserialize(InputStream is, Class type) { + deserializeStreamCalls.incrementAndGet(); + return delegate.deserialize(is, type); + } + + @Override + public byte[] prettyPrint(Object object) { + prettyPrintCalls.incrementAndGet(); + return delegate.prettyPrint(object); + } + + @Override + public byte[] serialize(Object object) { + serializeCalls.incrementAndGet(); + return delegate.serialize(object); + } + } +}