| // Copyright 2020 Google LLC |
| // |
| // 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 com.google.crypto.tink.jwt; |
| |
| import com.google.errorprone.annotations.Immutable; |
| import com.google.gson.JsonArray; |
| import com.google.gson.JsonElement; |
| import com.google.gson.JsonNull; |
| import com.google.gson.JsonObject; |
| import com.google.gson.JsonParseException; |
| import com.google.gson.JsonPrimitive; |
| import java.time.Instant; |
| import java.util.ArrayList; |
| import java.util.Arrays; |
| import java.util.Collections; |
| import java.util.HashSet; |
| import java.util.List; |
| import java.util.Optional; |
| import java.util.Set; |
| |
| /** |
| * An unencoded and unsigned <a href="https://tools.ietf.org/html/rfc7519">JSON Web Token</a> (JWT). |
| * |
| * <p>It contains all payload claims and a subset of the headers. It does not contain any headers |
| * that depend on the key, such as "alg" or "kid", because these headers are chosen when the token |
| * is signed and encoded, and should not be chosen by the user. This ensures that the key can be |
| * changed without any changes to the user code. |
| */ |
| @Immutable |
| public final class RawJwt { |
| |
| private static final long MAX_TIMESTAMP_VALUE = 253402300799L; // 31 Dec 9999, 23:59:59 GMT |
| |
| @SuppressWarnings("Immutable") // We do not mutate the payload. |
| private final JsonObject payload; |
| |
| private final Optional<String> typeHeader; |
| |
| private RawJwt(Builder builder) { |
| if (!builder.payload.has(JwtNames.CLAIM_EXPIRATION) && !builder.withoutExpiration) { |
| throw new IllegalArgumentException( |
| "neither setExpiration() nor withoutExpiration() was called"); |
| } |
| if (builder.payload.has(JwtNames.CLAIM_EXPIRATION) && builder.withoutExpiration) { |
| throw new IllegalArgumentException( |
| "setExpiration() and withoutExpiration() must not be called together"); |
| } |
| this.typeHeader = builder.typeHeader; |
| this.payload = builder.payload.deepCopy(); |
| } |
| |
| private RawJwt(Optional<String> typeHeader, String jsonPayload) throws JwtInvalidException { |
| this.typeHeader = typeHeader; |
| this.payload = JsonUtil.parseJson(jsonPayload); |
| validateStringClaim(JwtNames.CLAIM_ISSUER); |
| validateStringClaim(JwtNames.CLAIM_SUBJECT); |
| validateStringClaim(JwtNames.CLAIM_JWT_ID); |
| validateTimestampClaim(JwtNames.CLAIM_EXPIRATION); |
| validateTimestampClaim(JwtNames.CLAIM_NOT_BEFORE); |
| validateTimestampClaim(JwtNames.CLAIM_ISSUED_AT); |
| validateAudienceClaim(); |
| } |
| |
| private void validateStringClaim(String name) throws JwtInvalidException { |
| if (!this.payload.has(name)) { |
| return; |
| } |
| if (!this.payload.get(name).isJsonPrimitive() |
| || !this.payload.get(name).getAsJsonPrimitive().isString()) { |
| throw new JwtInvalidException("invalid JWT payload: claim " + name + " is not a string."); |
| } |
| } |
| |
| private void validateTimestampClaim(String name) throws JwtInvalidException { |
| if (!this.payload.has(name)) { |
| return; |
| } |
| if (!this.payload.get(name).isJsonPrimitive() |
| || !this.payload.get(name).getAsJsonPrimitive().isNumber()) { |
| throw new JwtInvalidException("invalid JWT payload: claim " + name + " is not a number."); |
| } |
| double timestamp = this.payload.get(name).getAsJsonPrimitive().getAsDouble(); |
| if ((timestamp > MAX_TIMESTAMP_VALUE) || (timestamp < 0)) { |
| throw new JwtInvalidException( |
| "invalid JWT payload: claim " + name + " has an invalid timestamp"); |
| } |
| } |
| |
| private void validateAudienceClaim() throws JwtInvalidException { |
| if (!this.payload.has(JwtNames.CLAIM_AUDIENCE)) { |
| return; |
| } |
| if (this.payload.get(JwtNames.CLAIM_AUDIENCE).isJsonPrimitive() |
| && this.payload.get(JwtNames.CLAIM_AUDIENCE).getAsJsonPrimitive().isString()) { |
| return; |
| } |
| |
| // aud is not a string, it must be an JsonArray of strings. |
| // getAudiences makes sure that all entries are strings. |
| List<String> audiences = this.getAudiences(); |
| if (audiences.size() < 1) { |
| throw new JwtInvalidException( |
| "invalid JWT payload: claim " + JwtNames.CLAIM_AUDIENCE + " is present but empty."); |
| } |
| } |
| |
| static RawJwt fromJsonPayload(Optional<String> typeHeader, String jsonPayload) |
| throws JwtInvalidException { |
| return new RawJwt(typeHeader, jsonPayload); |
| } |
| |
| /** |
| * Returns a new RawJwt.Builder. |
| */ |
| public static Builder newBuilder() { |
| return new Builder(); |
| } |
| |
| /** Builder for RawJwt */ |
| public static final class Builder { |
| private Optional<String> typeHeader; |
| private boolean withoutExpiration; |
| private final JsonObject payload; |
| |
| private Builder() { |
| typeHeader = Optional.empty(); |
| withoutExpiration = false; |
| payload = new JsonObject(); |
| } |
| |
| /** |
| * Sets the Type Header Parameter. |
| * |
| * <p>When set, this value should be set to a shortended IANA MediaType, see |
| * https://tools.ietf.org/html/rfc7519#section-5.1 and |
| * https://tools.ietf.org/html/rfc8725#section-3.11 |
| */ |
| public Builder setTypeHeader(String value) { |
| typeHeader = Optional.of(value); |
| return this; |
| } |
| |
| /** |
| * Sets the issuer claim that identifies the principal that issued the JWT. |
| * |
| * <p>https://tools.ietf.org/html/rfc7519#section-4.1.1 |
| */ |
| public Builder setIssuer(String value) { |
| if (!JsonUtil.isValidString(value)) { |
| throw new IllegalArgumentException(); |
| } |
| payload.add(JwtNames.CLAIM_ISSUER, new JsonPrimitive(value)); |
| return this; |
| } |
| |
| /** |
| * Sets the subject claim identifying the principal that is the subject of the JWT. |
| * |
| * <p>https://tools.ietf.org/html/rfc7519#section-4.1.2 |
| */ |
| public Builder setSubject(String value) { |
| if (!JsonUtil.isValidString(value)) { |
| throw new IllegalArgumentException(); |
| } |
| payload.add(JwtNames.CLAIM_SUBJECT, new JsonPrimitive(value)); |
| return this; |
| } |
| |
| /** |
| * Sets the audience that the JWT is intended for. |
| * |
| * Sets the {@code aud} claim as a string. This method can't be used |
| * together with {@code setAudiences} or {@code addAudience}. |
| * |
| * <p>https://tools.ietf.org/html/rfc7519#section-4.1.3 |
| */ |
| public Builder setAudience(String value) { |
| if (payload.has(JwtNames.CLAIM_AUDIENCE) |
| && payload.get(JwtNames.CLAIM_AUDIENCE).isJsonArray()) { |
| throw new IllegalArgumentException( |
| "setAudience can't be used together with setAudiences or addAudience"); |
| } |
| if (!JsonUtil.isValidString(value)) { |
| throw new IllegalArgumentException("invalid string"); |
| } |
| payload.add(JwtNames.CLAIM_AUDIENCE, new JsonPrimitive(value)); |
| return this; |
| } |
| |
| /** |
| * Sets the audiences that the JWT is intended for. |
| * |
| * Sets the {@code aud} claim as an array of strings. This method can't be used |
| * together with {@code setAudience}. |
| * |
| * <p>https://tools.ietf.org/html/rfc7519#section-4.1.3 |
| */ |
| public Builder setAudiences(List<String> values) { |
| if (payload.has(JwtNames.CLAIM_AUDIENCE) |
| && !payload.get(JwtNames.CLAIM_AUDIENCE).isJsonArray()) { |
| throw new IllegalArgumentException("setAudiences can't be used together with setAudience"); |
| } |
| if (values.isEmpty()) { |
| throw new IllegalArgumentException("audiences must not be empty"); |
| } |
| JsonArray audiences = new JsonArray(); |
| for (String value : values) { |
| if (!JsonUtil.isValidString(value)) { |
| throw new IllegalArgumentException("invalid string"); |
| } |
| audiences.add(value); |
| } |
| payload.add(JwtNames.CLAIM_AUDIENCE, audiences); |
| return this; |
| } |
| |
| /** |
| * Adds an audience that the JWT is intended for. |
| * |
| * The {@code aud} claim will always be encoded as an array of strings. This method |
| * can't be used together with {@code setAudience}. |
| * |
| * <p>https://tools.ietf.org/html/rfc7519#section-4.1.3 |
| */ |
| public Builder addAudience(String value) { |
| if (!JsonUtil.isValidString(value)) { |
| throw new IllegalArgumentException("invalid string"); |
| } |
| JsonArray audiences; |
| if (payload.has(JwtNames.CLAIM_AUDIENCE)) { |
| JsonElement aud = payload.get(JwtNames.CLAIM_AUDIENCE); |
| if (!aud.isJsonArray()) { |
| throw new IllegalArgumentException( |
| "addAudience can't be used together with setAudience"); |
| } |
| audiences = aud.getAsJsonArray(); |
| } else { |
| audiences = new JsonArray(); |
| } |
| audiences.add(value); |
| payload.add(JwtNames.CLAIM_AUDIENCE, audiences); |
| return this; |
| } |
| |
| /** |
| * Sets the JWT ID claim that provides a unique identifier for the JWT. |
| * |
| * <p>https://tools.ietf.org/html/rfc7519#section-4.1.7 |
| */ |
| public Builder setJwtId(String value) { |
| if (!JsonUtil.isValidString(value)) { |
| throw new IllegalArgumentException(); |
| } |
| payload.add(JwtNames.CLAIM_JWT_ID, new JsonPrimitive(value)); |
| return this; |
| } |
| |
| private void setTimestampClaim(String name, Instant value) { |
| // We round the timestamp to a whole number. We always round down. |
| long timestamp = value.getEpochSecond(); |
| if ((timestamp > MAX_TIMESTAMP_VALUE) || (timestamp < 0)) { |
| throw new IllegalArgumentException( |
| "timestamp of claim " + name + " is out of range"); |
| } |
| payload.add(name, new JsonPrimitive(timestamp)); |
| } |
| |
| /** |
| * Sets the {@code exp} claim that identifies the instant on or after which the token MUST NOT |
| * be accepted for processing. |
| * |
| * <p>This API requires {@link java.time.Instant} which is unavailable on Android until API |
| * level 26. To use it on older Android devices, enable API desugaring as shown in |
| * https://developer.android.com/studio/write/java8-support#library-desugaring. |
| * |
| * <p>https://tools.ietf.org/html/rfc7519#section-4.1.4 |
| */ |
| public Builder setExpiration(Instant value) { |
| setTimestampClaim(JwtNames.CLAIM_EXPIRATION, value); |
| return this; |
| } |
| |
| /** |
| * Allow generating tokens without an expiration. |
| * |
| * <p>For most applications of JWT, an expiration date should be set. This function makes sure |
| * that this is not forgotten, by requiring to user to explicitly state that no expiration |
| * should be set. |
| */ |
| public Builder withoutExpiration() { |
| this.withoutExpiration = true; |
| return this; |
| } |
| |
| /** |
| * Sets the {@code nbf} claim that identifies the instant before which the token MUST NOT be |
| * accepted for processing. |
| * |
| * <p>This API requires {@link java.time.Instant} which is unavailable on Android until API |
| * level 26. To use it on older Android devices, enable API desugaring as shown in |
| * https://developer.android.com/studio/write/java8-support#library-desugaring. |
| * |
| * <p>https://tools.ietf.org/html/rfc7519#section-4.1.5 |
| */ |
| public Builder setNotBefore(Instant value) { |
| setTimestampClaim(JwtNames.CLAIM_NOT_BEFORE, value); |
| return this; |
| } |
| |
| /** |
| * Sets the {@code iat} claim that identifies the instant at which the JWT was issued. |
| * |
| * <p>This API requires {@link java.time.Instant} which is unavailable on Android until API |
| * level 26. To use it on older Android devices, enable API desugaring as shown in |
| * https://developer.android.com/studio/write/java8-support#library-desugaring. |
| * |
| * <p>https://tools.ietf.org/html/rfc7519#section-4.1.6 |
| */ |
| public Builder setIssuedAt(Instant value) { |
| setTimestampClaim(JwtNames.CLAIM_ISSUED_AT, value); |
| return this; |
| } |
| |
| /** Adds a custom claim of type {@code boolean} to the JWT. */ |
| public Builder addBooleanClaim(String name, boolean value) { |
| JwtNames.validate(name); |
| payload.add(name, new JsonPrimitive(value)); |
| return this; |
| } |
| |
| /** Adds a custom claim of type {@code double} to the JWT. */ |
| public Builder addNumberClaim(String name, double value) { |
| JwtNames.validate(name); |
| payload.add(name, new JsonPrimitive(value)); |
| return this; |
| } |
| |
| /** Adds a custom claim of type {@code String} to the JWT. */ |
| public Builder addStringClaim(String name, String value) { |
| if (!JsonUtil.isValidString(value)) { |
| throw new IllegalArgumentException(); |
| } |
| JwtNames.validate(name); |
| payload.add(name, new JsonPrimitive(value)); |
| return this; |
| } |
| |
| /** Adds a custom claim with value null. */ |
| public Builder addNullClaim(String name) { |
| JwtNames.validate(name); |
| payload.add(name, JsonNull.INSTANCE); |
| return this; |
| } |
| |
| /** Adds a custom claim encoded in a JSON {@code String} to the JWT. */ |
| public Builder addJsonObjectClaim(String name, String encodedJsonObject) |
| throws JwtInvalidException { |
| JwtNames.validate(name); |
| payload.add(name, JsonUtil.parseJson(encodedJsonObject)); |
| return this; |
| } |
| |
| /** Adds a custom claim encoded in a JSON {@code String} to the JWT. */ |
| public Builder addJsonArrayClaim(String name, String encodedJsonArray) |
| throws JwtInvalidException { |
| JwtNames.validate(name); |
| payload.add(name, JsonUtil.parseJsonArray(encodedJsonArray)); |
| return this; |
| } |
| |
| public RawJwt build() { |
| return new RawJwt(this); |
| } |
| } |
| |
| String getJsonPayload() { |
| return payload.toString(); |
| } |
| |
| boolean hasBooleanClaim(String name) { |
| JwtNames.validate(name); |
| return (payload.has(name) |
| && payload.get(name).isJsonPrimitive() |
| && payload.get(name).getAsJsonPrimitive().isBoolean()); |
| } |
| |
| Boolean getBooleanClaim(String name) throws JwtInvalidException { |
| JwtNames.validate(name); |
| if (!payload.has(name)) { |
| throw new JwtInvalidException("claim " + name + " does not exist"); |
| } |
| if (!payload.get(name).isJsonPrimitive() |
| || !payload.get(name).getAsJsonPrimitive().isBoolean()) { |
| throw new JwtInvalidException("claim " + name + " is not a boolean"); |
| } |
| return payload.get(name).getAsBoolean(); |
| } |
| |
| boolean hasNumberClaim(String name) { |
| JwtNames.validate(name); |
| return (payload.has(name) |
| && payload.get(name).isJsonPrimitive() |
| && payload.get(name).getAsJsonPrimitive().isNumber()); |
| } |
| |
| Double getNumberClaim(String name) throws JwtInvalidException { |
| JwtNames.validate(name); |
| if (!payload.has(name)) { |
| throw new JwtInvalidException("claim " + name + " does not exist"); |
| } |
| if (!payload.get(name).isJsonPrimitive() |
| || !payload.get(name).getAsJsonPrimitive().isNumber()) { |
| throw new JwtInvalidException("claim " + name + " is not a number"); |
| } |
| return payload.get(name).getAsDouble(); |
| } |
| |
| boolean hasStringClaim(String name) { |
| JwtNames.validate(name); |
| return (payload.has(name) |
| && payload.get(name).isJsonPrimitive() |
| && payload.get(name).getAsJsonPrimitive().isString()); |
| } |
| |
| String getStringClaim(String name) throws JwtInvalidException { |
| JwtNames.validate(name); |
| return getStringClaimInternal(name); |
| } |
| |
| private String getStringClaimInternal(String name) throws JwtInvalidException { |
| if (!payload.has(name)) { |
| throw new JwtInvalidException("claim " + name + " does not exist"); |
| } |
| if (!payload.get(name).isJsonPrimitive() |
| || !payload.get(name).getAsJsonPrimitive().isString()) { |
| throw new JwtInvalidException("claim " + name + " is not a string"); |
| } |
| return payload.get(name).getAsString(); |
| } |
| |
| boolean isNullClaim(String name) { |
| JwtNames.validate(name); |
| try { |
| return JsonNull.INSTANCE.equals(payload.get(name)); |
| } catch (JsonParseException ex) { |
| return false; |
| } |
| } |
| |
| boolean hasJsonObjectClaim(String name) { |
| JwtNames.validate(name); |
| return (payload.has(name) && payload.get(name).isJsonObject()); |
| } |
| |
| String getJsonObjectClaim(String name) throws JwtInvalidException { |
| JwtNames.validate(name); |
| if (!payload.has(name)) { |
| throw new JwtInvalidException("claim " + name + " does not exist"); |
| } |
| if (!payload.get(name).isJsonObject()) { |
| throw new JwtInvalidException("claim " + name + " is not a JSON object"); |
| } |
| return payload.get(name).getAsJsonObject().toString(); |
| } |
| |
| boolean hasJsonArrayClaim(String name) { |
| JwtNames.validate(name); |
| return (payload.has(name) && payload.get(name).isJsonArray()); |
| } |
| |
| String getJsonArrayClaim(String name) throws JwtInvalidException { |
| JwtNames.validate(name); |
| if (!payload.has(name)) { |
| throw new JwtInvalidException("claim " + name + " does not exist"); |
| } |
| if (!payload.get(name).isJsonArray()) { |
| throw new JwtInvalidException("claim " + name + " is not a JSON array"); |
| } |
| return payload.get(name).getAsJsonArray().toString(); |
| } |
| |
| boolean hasTypeHeader() { |
| return typeHeader.isPresent(); |
| } |
| |
| String getTypeHeader() throws JwtInvalidException { |
| if (!typeHeader.isPresent()) { |
| throw new JwtInvalidException("type header is not set"); |
| } |
| return typeHeader.get(); |
| } |
| |
| boolean hasIssuer() { |
| return payload.has(JwtNames.CLAIM_ISSUER); |
| } |
| |
| String getIssuer() throws JwtInvalidException { |
| return getStringClaimInternal(JwtNames.CLAIM_ISSUER); |
| } |
| |
| boolean hasSubject() { |
| return payload.has(JwtNames.CLAIM_SUBJECT); |
| } |
| |
| String getSubject() throws JwtInvalidException { |
| return getStringClaimInternal(JwtNames.CLAIM_SUBJECT); |
| } |
| |
| boolean hasJwtId() { |
| return payload.has(JwtNames.CLAIM_JWT_ID); |
| } |
| |
| String getJwtId() throws JwtInvalidException { |
| return getStringClaimInternal(JwtNames.CLAIM_JWT_ID); |
| } |
| |
| boolean hasAudiences() { |
| // If an audience claim is present, it is always a JsonArray with length > 0. |
| return payload.has(JwtNames.CLAIM_AUDIENCE); |
| } |
| |
| List<String> getAudiences() throws JwtInvalidException { |
| if (!hasAudiences()) { |
| throw new JwtInvalidException("claim aud does not exist"); |
| } |
| JsonElement aud = payload.get(JwtNames.CLAIM_AUDIENCE); |
| if (aud.isJsonPrimitive()) { |
| if (!aud.getAsJsonPrimitive().isString()) { |
| throw new JwtInvalidException( |
| String.format("invalid audience: got %s; want a string", aud)); |
| } |
| return Collections.unmodifiableList(Arrays.asList(aud.getAsString())); |
| } |
| if (!aud.isJsonArray()) { |
| throw new JwtInvalidException("claim aud is not a string or a JSON array"); |
| } |
| |
| JsonArray audiences = aud.getAsJsonArray(); |
| List<String> result = new ArrayList<>(audiences.size()); |
| for (int i = 0; i < audiences.size(); i++) { |
| if (!audiences.get(i).isJsonPrimitive() |
| || !audiences.get(i).getAsJsonPrimitive().isString()) { |
| throw new JwtInvalidException( |
| String.format("invalid audience: got %s; want a string", audiences.get(i))); |
| } |
| String audience = audiences.get(i).getAsString(); |
| result.add(audience); |
| } |
| |
| return Collections.unmodifiableList(result); |
| } |
| |
| private Instant getInstant(String name) throws JwtInvalidException { |
| if (!payload.has(name)) { |
| throw new JwtInvalidException("claim " + name + " does not exist"); |
| } |
| if (!payload.get(name).isJsonPrimitive() |
| || !payload.get(name).getAsJsonPrimitive().isNumber()) { |
| throw new JwtInvalidException("claim " + name + " is not a timestamp"); |
| } |
| try { |
| double millis = payload.get(name).getAsJsonPrimitive().getAsDouble() * 1000; |
| return Instant.ofEpochMilli((long) millis); |
| } catch (NumberFormatException ex) { |
| throw new JwtInvalidException("claim " + name + " is not a timestamp: " + ex); |
| } |
| } |
| |
| boolean hasExpiration() { |
| return payload.has(JwtNames.CLAIM_EXPIRATION); |
| } |
| |
| Instant getExpiration() throws JwtInvalidException { |
| return getInstant(JwtNames.CLAIM_EXPIRATION); |
| } |
| |
| boolean hasNotBefore() { |
| return payload.has(JwtNames.CLAIM_NOT_BEFORE); |
| } |
| |
| Instant getNotBefore() throws JwtInvalidException { |
| return getInstant(JwtNames.CLAIM_NOT_BEFORE); |
| } |
| |
| boolean hasIssuedAt() { |
| return payload.has(JwtNames.CLAIM_ISSUED_AT); |
| } |
| |
| Instant getIssuedAt() throws JwtInvalidException { |
| return getInstant(JwtNames.CLAIM_ISSUED_AT); |
| } |
| |
| /** Returns all custom claim names. */ |
| Set<String> customClaimNames() { |
| HashSet<String> names = new HashSet<>(); |
| for (String name : this.payload.keySet()) { |
| if (!JwtNames.isRegisteredName(name)) { |
| names.add(name); |
| } |
| } |
| return Collections.unmodifiableSet(names); |
| } |
| |
| /** |
| * Returns a brief description of a RawJwt object. The exact details of the representation are |
| * unspecified and subject to change. |
| */ |
| @Override |
| public String toString() { |
| JsonObject header = new JsonObject(); |
| if (typeHeader.isPresent()) { |
| header.add("typ", new JsonPrimitive(typeHeader.get())); |
| } |
| return header + "." + payload; |
| } |
| } |