Skip to content
Open
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
19 changes: 19 additions & 0 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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<String> x5c = jwt.getHeaderClaim("x5c").asList(String.class);
```

You can also obtain all the header claims at once by calling `getHeaderClaims`.

```java
Map<String, Claim> headerClaims = jwt.getHeaderClaims();
```

> **Note**
> The older `getHeader()` method (which returns a `Map<String, String>`) 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`.
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> x5c = jwt.getHeaderClaim("x5c").asList(String.class); //supports structured header values
```

A `DecodeException` will raise with a detailed message if the token has:
Expand Down
72 changes: 68 additions & 4 deletions lib/src/main/java/com/auth0/android/jwt/JWT.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -27,6 +30,8 @@ public class JWT implements Parcelable {
private final String token;

private Map<String, String> header;

private Map<String, Claim> headerTree;
private JWTPayload payload;
private String signature;

Expand All @@ -43,14 +48,43 @@ public JWT(@NonNull String token) {

/**
* Get the Header values from this JWT as a Map of Strings.
* <p>
* 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<String, String> 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<String, Claim> getHeaderClaims() {
return headerTree;
}

/**
* Get the Signature from this JWT as a Base64 encoded String.
*
Expand Down Expand Up @@ -229,13 +263,43 @@ public JWT[] newArray(int size) {

private void decode(String token) {
final String[] parts = splitToken(token);
Type mapType = new TypeToken<Map<String, String>>() {
}.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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The catch (DecodeException e) { throw e; } then catch (Exception e) re-wrap pattern works but is a bit convoluted. Simpler: validate isJsonObject() outside the try, since fromJson(..., JsonElement.class) only throws on malformed JSON.

final JsonObject object;
try {
JsonElement element = getGson().fromJson(json, JsonElement.class);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getGson() registers the JWTDeserializer for JWTPayload, which is irrelevant for parsing the header into a raw JsonElement. A plain new Gson() (or reused static instance) is clearer and avoids the unnecessary type-adapter setup on every decode.

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<String, String> stringHeader = new HashMap<>();
Map<String, Claim> tree = new HashMap<>();
for (Map.Entry<String, JsonElement> entry : object.entrySet()) {
JsonElement value = entry.getValue();
tree.put(entry.getKey(), new ClaimImpl(value));
stringHeader.put(entry.getKey(), stringifyHeaderValue(value));
}
header = Collections.unmodifiableMap(stringHeader);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Behavior change: getHeader() previously returned Gson's mutable map; it's now unmodifiable. Any caller that mutates the returned map will now hit UnsupportedOperationException. Since getHeader() is public API, call this out in the changelog/@deprecated note, or keep it mutable to stay source-compatible.

headerTree = Collections.unmodifiableMap(tree);
}

private String stringifyHeaderValue(JsonElement value) {
if (value.isJsonPrimitive()) {
return value.getAsString();
}
return value.toString();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A JSON null header value (e.g. {"kid":null}) isn't a primitive, so this returns the literal string "null" instead of null. The old Map<String,String> deserialization stored an actual null. Add an isJsonNull() check

}

private String[] splitToken(String token) {
String[] parts = token.split("\\.");
if (parts.length == 2 && token.endsWith(".")) {
Expand Down
79 changes: 79 additions & 0 deletions lib/src/test/java/com/auth0/android/jwt/JWTTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> 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<String, Claim> 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<String,String> 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");
Expand Down Expand Up @@ -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());
Expand Down
Loading