From cb167c5e392f45634a311a26a14588173f278e4c Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Thu, 6 Aug 2026 14:45:44 +0530 Subject: [PATCH 1/2] fix: Fix the failed parsing on array or types other than string in the Jwt header --- EXAMPLES.md | 19 +++++ README.md | 3 + .../main/java/com/auth0/android/jwt/JWT.java | 72 ++++++++++++++++- .../java/com/auth0/android/jwt/JWTTest.java | 79 +++++++++++++++++++ 4 files changed, 169 insertions(+), 4 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 2ff86bf..8664a78 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -10,6 +10,7 @@ - [Issued At ("iat")](#issued-at-iat) - [JWT ID ("jti")](#jwt-id-jti) - [Time Validation](#time-validation) + - [Header Claims](#header-claims) - [Private Claims](#private-claims) - [Claim Class](#claim-class) - [Primitives](#primitives) @@ -91,6 +92,24 @@ boolean expiringSoon = jwt.expiresIn(60); // true if the token expires within th ``` +## Header Claims + +Header parameters can be read as `Claim`s, which support any JSON value type allowed by the JOSE spec (RFC 7515) — strings such as `alg` and `kid`, arrays such as the `x5c` certificate chain, and nested objects such as `jwk`. If the parameter can't be found, a `BaseClaim` is returned. + +```java +String alg = jwt.getHeaderClaim("alg").asString(); +List x5c = jwt.getHeaderClaim("x5c").asList(String.class); +``` + +You can also obtain all the header claims at once by calling `getHeaderClaims`. + +```java +Map headerClaims = jwt.getHeaderClaims(); +``` + +> **Note** +> The older `getHeader()` method (which returns a `Map`) is deprecated. It still works and returns structured values as their JSON text, but `getHeaderClaim` should be preferred for reading non-string header parameters. + ## Private Claims Additional Claims defined in the token can be obtained by calling `getClaim` and passing the Claim name. If the claim can't be found, a BaseClaim will be returned. BaseClaim will return null on every method call except for the `asList` and `asArray`. diff --git a/README.md b/README.md index a72cb67..2efd7a2 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,9 @@ String issuer = jwt.getIssuer(); //get registered claims String claim = jwt.getClaim("isAdmin").asString(); //get custom claims boolean isExpired = jwt.isExpired(10); // Do time validation with 10 seconds leeway boolean expiringSoon = jwt.expiresIn(60); // true if the token expires within the next 60 seconds + +String alg = jwt.getHeaderClaim("alg").asString(); //get header parameters +List x5c = jwt.getHeaderClaim("x5c").asList(String.class); //supports structured header values ``` A `DecodeException` will raise with a detailed message if the token has: diff --git a/lib/src/main/java/com/auth0/android/jwt/JWT.java b/lib/src/main/java/com/auth0/android/jwt/JWT.java index fd163ee..465975a 100644 --- a/lib/src/main/java/com/auth0/android/jwt/JWT.java +++ b/lib/src/main/java/com/auth0/android/jwt/JWT.java @@ -9,11 +9,14 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; -import com.google.gson.reflect.TypeToken; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; import java.lang.reflect.Type; import java.nio.charset.Charset; +import java.util.Collections; import java.util.Date; +import java.util.HashMap; import java.util.List; import java.util.Map; @@ -27,6 +30,8 @@ public class JWT implements Parcelable { private final String token; private Map header; + + private Map headerTree; private JWTPayload payload; private String signature; @@ -43,14 +48,43 @@ public JWT(@NonNull String token) { /** * Get the Header values from this JWT as a Map of Strings. + *

+ * Structured header parameters (e.g. the "x5c" certificate chain array or a nested + * "jwk" object) are returned as their JSON text representation. To read header + * parameters as typed values, prefer {@link #getHeaderClaim(String)}. * * @return the Header values of the JWT. + * @deprecated Use {@link #getHeaderClaim(String)} or {@link #getHeaderClaims()} instead, + * which support non-string header parameters such as "x5c" and "jwk". */ + @Deprecated @NonNull public Map getHeader() { return header; } + /** + * Get a header Claim given its name. If the Claim wasn't specified in the JWT header, a BaseClaim will be returned. + * + * @param name the name of the header Claim to retrieve. + * @return a valid Claim. + */ + @NonNull + public Claim getHeaderClaim(@NonNull String name) { + final Claim claim = headerTree.get(name); + return claim != null ? claim : new BaseClaim(); + } + + /** + * Get all the header Claims. + * + * @return a valid Map of header Claims. + */ + @NonNull + public Map getHeaderClaims() { + return headerTree; + } + /** * Get the Signature from this JWT as a Base64 encoded String. * @@ -229,13 +263,43 @@ public JWT[] newArray(int size) { private void decode(String token) { final String[] parts = splitToken(token); - Type mapType = new TypeToken>() { - }.getType(); - header = parseJson(base64Decode(parts[0]), mapType); + parseHeader(base64Decode(parts[0])); payload = parseJson(base64Decode(parts[1]), JWTPayload.class); signature = parts[2]; } + private void parseHeader(String json) { + final JsonObject object; + try { + JsonElement element = getGson().fromJson(json, JsonElement.class); + if (element == null || !element.isJsonObject()) { + throw new DecodeException("The token's header had an invalid JSON format."); + } + object = element.getAsJsonObject(); + } catch (DecodeException e) { + throw e; + } catch (Exception e) { + throw new DecodeException("The token's header had an invalid JSON format.", e); + } + + Map stringHeader = new HashMap<>(); + Map tree = new HashMap<>(); + for (Map.Entry entry : object.entrySet()) { + JsonElement value = entry.getValue(); + tree.put(entry.getKey(), new ClaimImpl(value)); + stringHeader.put(entry.getKey(), stringifyHeaderValue(value)); + } + header = Collections.unmodifiableMap(stringHeader); + headerTree = Collections.unmodifiableMap(tree); + } + + private String stringifyHeaderValue(JsonElement value) { + if (value.isJsonPrimitive()) { + return value.getAsString(); + } + return value.toString(); + } + private String[] splitToken(String token) { String[] parts = token.split("\\."); if (parts.length == 2 && token.endsWith(".")) { diff --git a/lib/src/test/java/com/auth0/android/jwt/JWTTest.java b/lib/src/test/java/com/auth0/android/jwt/JWTTest.java index ec4b437..377a3d5 100644 --- a/lib/src/test/java/com/auth0/android/jwt/JWTTest.java +++ b/lib/src/test/java/com/auth0/android/jwt/JWTTest.java @@ -84,6 +84,72 @@ public void shouldGetHeader() { assertThat(jwt.getHeader(), is(hasEntry("alg", "HS256"))); } + @Test + public void shouldGetHeaderClaimAsArray() { + // header: {"alg":"RS256","x5c":["MIICert1","MIICert2"]} + JWT jwt = jwtWithHeader("{\"alg\":\"RS256\",\"x5c\":[\"MIICert1\",\"MIICert2\"]}"); + assertThat(jwt, is(notNullValue())); + assertThat(jwt.getHeaderClaim("x5c"), is(instanceOf(ClaimImpl.class))); + assertThat(jwt.getHeaderClaim("x5c").asList(String.class), is(hasSize(2))); + assertThat(jwt.getHeaderClaim("x5c").asList(String.class), is(hasItems("MIICert1", "MIICert2"))); + } + + @Test + public void shouldGetHeaderClaimAsObject() { + // header: {"alg":"RS256","jwk":{"kty":"RSA","kid":"abc"}} + JWT jwt = jwtWithHeader("{\"alg\":\"RS256\",\"jwk\":{\"kty\":\"RSA\",\"kid\":\"abc\"}}"); + assertThat(jwt, is(notNullValue())); + @SuppressWarnings("unchecked") + Map jwk = jwt.getHeaderClaim("jwk").asObject(Map.class); + assertThat(jwk, is(notNullValue())); + assertThat(jwk, is(hasEntry("kty", "RSA"))); + assertThat(jwk, is(hasEntry("kid", "abc"))); + } + + @Test + public void shouldGetHeaderClaimAsString() { + JWT jwt = jwtWithHeader("{\"alg\":\"HS256\",\"typ\":\"JWT\"}"); + assertThat(jwt, is(notNullValue())); + assertThat(jwt.getHeaderClaim("alg").asString(), is("HS256")); + assertThat(jwt.getHeaderClaim("typ").asString(), is("JWT")); + } + + @Test + public void shouldGetBaseClaimIfHeaderClaimIsMissing() { + JWT jwt = jwtWithHeader("{\"alg\":\"HS256\"}"); + assertThat(jwt, is(notNullValue())); + assertThat(jwt.getHeaderClaim("notExisting"), is(notNullValue())); + assertThat(jwt.getHeaderClaim("notExisting"), is(not(instanceOf(ClaimImpl.class)))); + assertThat(jwt.getHeaderClaim("notExisting"), is(instanceOf(BaseClaim.class))); + } + + @Test + public void shouldGetAllHeaderClaims() { + JWT jwt = jwtWithHeader("{\"alg\":\"RS256\",\"x5c\":[\"MIICert1\"]}"); + assertThat(jwt, is(notNullValue())); + Map claims = jwt.getHeaderClaims(); + assertThat(claims, is(notNullValue())); + assertThat(claims.get("alg").asString(), is("RS256")); + assertThat(claims.get("x5c").asList(String.class), is(hasItems("MIICert1"))); + } + + @Test + public void shouldGetLegacyHeaderStringForStructuredValue() { + JWT jwt = jwtWithHeader("{\"alg\":\"RS256\",\"x5c\":[\"MIICert1\",\"MIICert2\"]}"); + assertThat(jwt, is(notNullValue())); + // Legacy Map must not throw on structured values; returns the JSON text. + assertThat(jwt.getHeader(), is(hasEntry("alg", "RS256"))); + assertThat(jwt.getHeader(), is(hasEntry("x5c", "[\"MIICert1\",\"MIICert2\"]"))); + } + + @Test + public void shouldThrowIfHeaderHasInvalidJSONFormat() { + exception.expect(DecodeException.class); + exception.expectMessage("The token's header had an invalid JSON format."); + // header decodes to the non-JSON-object string "notJson" + new JWT(String.format("%s.e30.sig", encodeString("notJson"))); + } + @Test public void shouldGetSignature() { JWT jwt = new JWT("eyJhbGciOiJIUzI1NiJ9.e30.XmNK3GpH3Ys_7wsYBfq4C3M6goz71I7dTgUkuIa5lyQ"); @@ -466,6 +532,19 @@ private JWT customTimeJWT(@Nullable Long iatMs, @Nullable Long expMs) { return new JWT(String.format("%s.%s.%s", header, body, signature)); } + /** + * Creates a new JWT with a custom header JSON, an empty payload and a dummy signature. + * + * @param headerJson the raw JSON to use as the token header. + * @return a JWT + */ + private JWT jwtWithHeader(String headerJson) { + String header = encodeString(headerJson); + String body = encodeString("{}"); + String signature = "sign"; + return new JWT(String.format("%s.%s.%s", header, body, signature)); + } + private String encodeString(String source) { byte[] bytes = Base64.encode(source.getBytes(), Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING); return new String(bytes, Charset.defaultCharset()); From f5cb15ecdcb690b859b28167cc8aecc68edafc1f Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Fri, 7 Aug 2026 10:22:50 +0530 Subject: [PATCH 2/2] Addressed review comments from @Utkrisht --- .../main/java/com/auth0/android/jwt/JWT.java | 21 ++++++----- .../java/com/auth0/android/jwt/JWTTest.java | 35 +++++++++++++++++++ 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/lib/src/main/java/com/auth0/android/jwt/JWT.java b/lib/src/main/java/com/auth0/android/jwt/JWT.java index 465975a..cbd7fd5 100644 --- a/lib/src/main/java/com/auth0/android/jwt/JWT.java +++ b/lib/src/main/java/com/auth0/android/jwt/JWT.java @@ -269,18 +269,16 @@ private void decode(String token) { } private void parseHeader(String json) { - final JsonObject object; + final JsonElement element; try { - JsonElement element = getGson().fromJson(json, JsonElement.class); - if (element == null || !element.isJsonObject()) { - throw new DecodeException("The token's header had an invalid JSON format."); - } - object = element.getAsJsonObject(); - } catch (DecodeException e) { - throw e; + element = new Gson().fromJson(json, JsonElement.class); } catch (Exception e) { throw new DecodeException("The token's header had an invalid JSON format.", e); } + if (element == null || !element.isJsonObject()) { + throw new DecodeException("The token's header had an invalid JSON format."); + } + final JsonObject object = element.getAsJsonObject(); Map stringHeader = new HashMap<>(); Map tree = new HashMap<>(); @@ -289,11 +287,16 @@ private void parseHeader(String json) { tree.put(entry.getKey(), new ClaimImpl(value)); stringHeader.put(entry.getKey(), stringifyHeaderValue(value)); } - header = Collections.unmodifiableMap(stringHeader); + //Kept mutable to preserve the behaviour of the Map that Gson used to return. + header = stringHeader; headerTree = Collections.unmodifiableMap(tree); } + @Nullable private String stringifyHeaderValue(JsonElement value) { + if (value.isJsonNull()) { + return null; + } if (value.isJsonPrimitive()) { return value.getAsString(); } diff --git a/lib/src/test/java/com/auth0/android/jwt/JWTTest.java b/lib/src/test/java/com/auth0/android/jwt/JWTTest.java index 377a3d5..1d9af17 100644 --- a/lib/src/test/java/com/auth0/android/jwt/JWTTest.java +++ b/lib/src/test/java/com/auth0/android/jwt/JWTTest.java @@ -142,6 +142,25 @@ public void shouldGetLegacyHeaderStringForStructuredValue() { assertThat(jwt.getHeader(), is(hasEntry("x5c", "[\"MIICert1\",\"MIICert2\"]"))); } + @Test + public void shouldGetNullLegacyHeaderValueForJsonNull() { + JWT jwt = jwtWithHeader("{\"alg\":\"HS256\",\"kid\":null}"); + assertThat(jwt, is(notNullValue())); + // A JSON null must stay an actual null, not the literal String "null". + assertThat(jwt.getHeader().containsKey("kid"), is(true)); + assertThat(jwt.getHeader().get("kid"), is(nullValue())); + assertThat(jwt.getHeaderClaim("kid").asString(), is(nullValue())); + } + + @Test + public void shouldReturnMutableLegacyHeader() { + JWT jwt = jwtWithHeader("{\"alg\":\"HS256\"}"); + assertThat(jwt, is(notNullValue())); + // The legacy Map was mutable before this change; keep it that way. + jwt.getHeader().put("custom", "value"); + assertThat(jwt.getHeader(), is(hasEntry("custom", "value"))); + } + @Test public void shouldThrowIfHeaderHasInvalidJSONFormat() { exception.expect(DecodeException.class); @@ -150,6 +169,22 @@ public void shouldThrowIfHeaderHasInvalidJSONFormat() { new JWT(String.format("%s.e30.sig", encodeString("notJson"))); } + @Test + public void shouldThrowIfHeaderIsMalformedJSON() { + exception.expect(DecodeException.class); + exception.expectMessage("The token's header had an invalid JSON format."); + // header decodes to malformed JSON, which makes the parser itself throw + new JWT(String.format("%s.e30.sig", encodeString("{\"alg\":"))); + } + + @Test + public void shouldThrowIfHeaderIsEmpty() { + exception.expect(DecodeException.class); + exception.expectMessage("The token's header had an invalid JSON format."); + // an empty header parses to a null JsonElement + new JWT(String.format("%s.e30.sig", encodeString(""))); + } + @Test public void shouldGetSignature() { JWT jwt = new JWT("eyJhbGciOiJIUzI1NiJ9.e30.XmNK3GpH3Ys_7wsYBfq4C3M6goz71I7dTgUkuIa5lyQ");