Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
@@ -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+.
Expand Down
32 changes: 28 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
12 changes: 7 additions & 5 deletions src/main/java/io/fusionauth/jwks/JSONWebKeySetHelper.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -65,14 +65,16 @@ public static List<JSONWebKey> retrieveKeysFromIssuer(String issuer, Consumer<Ht
public static List<JSONWebKey> 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<String, Object> 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);
}
Expand Down
83 changes: 83 additions & 0 deletions src/main/java/io/fusionauth/jwt/json/JSONMapper.java
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* The default implementation is {@link JacksonJSONMapper}. Register a custom
* implementation with {@link Mapper#setMapper(JSONMapper)} if you prefer another
* JSON library (for example Gson).
* <p>
* Implementations must correctly round-trip the library domain types used for
* JWT headers, JWT payloads, JWKs, and OAuth2 metadata, including:
* <ul>
* <li>Property renaming (e.g. {@code algorithm} ↔ {@code alg}, {@code uniqueId} ↔ {@code jti})</li>
* <li>Additional/unknown properties via the domain "any" maps</li>
* <li>{@link java.time.ZonedDateTime} claims as numeric UNIX epoch seconds</li>
* <li>Omitting null properties on serialize</li>
* </ul>
*
* @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 <T> the target type
* @return the deserialized object
* @throws InvalidJWTException if the JSON cannot be de-serialized
*/
<T> T deserialize(byte[] bytes, Class<T> type) throws InvalidJWTException;

/**
* Deserialize a JSON input stream into the given type.
*
* @param is the JSON input stream
* @param type the target type
* @param <T> the target type
* @return the deserialized object
* @throws InvalidJWTException if the JSON cannot be de-serialized
*/
<T> T deserialize(InputStream is, Class<T> 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;
}
103 changes: 103 additions & 0 deletions src/main/java/io/fusionauth/jwt/json/JacksonJSONMapper.java
Original file line number Diff line number Diff line change
@@ -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> T deserialize(byte[] bytes, Class<T> 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> T deserialize(InputStream is, Class<T> 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);
}
}
}
79 changes: 42 additions & 37 deletions src/main/java/io/fusionauth/jwt/json/Mapper.java
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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).
* <p>
* By default this facade delegates to {@link JacksonJSONMapper}. Applications that prefer a
* different JSON library can register a custom {@link JSONMapper}:
* <pre>
* Mapper.setMapper(new MyGsonJSONMapper());
* </pre>
* 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> T deserialize(byte[] bytes, Class<T> 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> T deserialize(InputStream is, Class<T> 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);
}
}
Loading