xref: /aosp_15_r20/external/tink/java_src/src/main/java/com/google/crypto/tink/jwt/JwtValidator.java (revision e7b1675dde1b92d52ec075b0a92829627f2c52a5)
1 // Copyright 2020 Google LLC
2 //
3 // Licensed under the Apache License, Version 2.0 (the "License");
4 // you may not use this file except in compliance with the License.
5 // You may obtain a copy of the License at
6 //
7 //      http://www.apache.org/licenses/LICENSE-2.0
8 //
9 // Unless required by applicable law or agreed to in writing, software
10 // distributed under the License is distributed on an "AS IS" BASIS,
11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 // See the License for the specific language governing permissions and
13 // limitations under the License.
14 //
15 ////////////////////////////////////////////////////////////////////////////////
16 
17 package com.google.crypto.tink.jwt;
18 
19 import com.google.errorprone.annotations.CanIgnoreReturnValue;
20 import com.google.errorprone.annotations.Immutable;
21 import java.time.Clock;
22 import java.time.Duration;
23 import java.time.Instant;
24 import java.util.ArrayList;
25 import java.util.List;
26 import java.util.Optional;
27 
28 /** Defines how the headers and claims of a JWT should be validated. */
29 @Immutable
30 public final class JwtValidator {
31   private static final Duration MAX_CLOCK_SKEW = Duration.ofMinutes(10);
32 
33   private final Optional<String> expectedTypeHeader;
34   private final boolean ignoreTypeHeader;
35   private final Optional<String> expectedIssuer;
36   private final boolean ignoreIssuer;
37   private final Optional<String> expectedAudience;
38   private final boolean ignoreAudiences;
39   private final boolean allowMissingExpiration;
40   private final boolean expectIssuedInThePast;
41 
42   @SuppressWarnings("Immutable") // We do not mutate the clock.
43   private final Clock clock;
44 
45   private final Duration clockSkew;
46 
JwtValidator(Builder builder)47   private JwtValidator(Builder builder) {
48     this.expectedTypeHeader = builder.expectedTypeHeader;
49     this.ignoreTypeHeader = builder.ignoreTypeHeader;
50     this.expectedIssuer = builder.expectedIssuer;
51     this.ignoreIssuer = builder.ignoreIssuer;
52     this.expectedAudience = builder.expectedAudience;
53     this.ignoreAudiences = builder.ignoreAudiences;
54     this.allowMissingExpiration = builder.allowMissingExpiration;
55     this.expectIssuedInThePast = builder.expectIssuedInThePast;
56     this.clock = builder.clock;
57     this.clockSkew = builder.clockSkew;
58   }
59 
60   /**
61    * Returns a new JwtValidator.Builder.
62    *
63    * <p>By default, the JwtValidator requires that a token has a valid expiration claim, no issuer
64    * and no audience claim. This can be changed using the expect...(),  ignore...() and
65    * allowMissingExpiration() methods.
66    *
67    * <p>If present, the JwtValidator also validates the not-before claim. The validation time can
68    * be changed using the setClock() method.
69    */
newBuilder()70   public static Builder newBuilder() {
71     return new Builder();
72   }
73 
74   /** Builder for JwtValidator */
75   public static final class Builder {
76     private Optional<String> expectedTypeHeader;
77     private boolean ignoreTypeHeader;
78     private Optional<String> expectedIssuer;
79     private boolean ignoreIssuer;
80     private Optional<String> expectedAudience;
81     private boolean ignoreAudiences;
82     private boolean allowMissingExpiration;
83     private boolean expectIssuedInThePast;
84     private Clock clock = Clock.systemUTC();
85     private Duration clockSkew = Duration.ZERO;
86 
Builder()87     private Builder() {
88       this.expectedTypeHeader = Optional.empty();
89       this.ignoreTypeHeader = false;
90       this.expectedIssuer = Optional.empty();
91       this.ignoreIssuer = false;
92       this.expectedAudience = Optional.empty();
93       this.ignoreAudiences = false;
94       this.allowMissingExpiration = false;
95       this.expectIssuedInThePast = false;
96     }
97 
98     /**
99      * Sets the expected type header of the token. When this is set, all tokens with missing or
100      * different {@code typ} header are rejected. When this is not set, all token that have a {@code
101      * typ} header are rejected. So this must be set for token that have a {@code typ} header.
102      *
103      * <p>If you want to ignore the type header or if you want to validate it yourself, use
104      * ignoreTypeHeader().
105      *
106      * <p>https://tools.ietf.org/html/rfc7519#section-4.1.1
107      */
108     @CanIgnoreReturnValue
expectTypeHeader(String value)109     public Builder expectTypeHeader(String value) {
110       if (value == null) {
111         throw new NullPointerException("typ header cannot be null");
112       }
113       this.expectedTypeHeader = Optional.of(value);
114       return this;
115     }
116 
117     /** Lets the validator ignore the {@code typ} header. */
118     @CanIgnoreReturnValue
ignoreTypeHeader()119     public Builder ignoreTypeHeader() {
120       this.ignoreTypeHeader = true;
121       return this;
122     }
123 
124     /**
125      * Sets the expected issuer claim of the token. When this is set, all tokens with missing or
126      * different {@code iss} claims are rejected. When this is not set, all token that have a {@code
127      * iss} claim are rejected. So this must be set for token that have a {@code iss} claim.
128      *
129      * <p>If you want to ignore the issuer claim or if you want to validate it yourself, use
130      * ignoreIssuer().
131      *
132      * <p>https://tools.ietf.org/html/rfc7519#section-4.1.1
133      */
134     @CanIgnoreReturnValue
expectIssuer(String value)135     public Builder expectIssuer(String value) {
136       if (value == null) {
137         throw new NullPointerException("issuer cannot be null");
138       }
139       this.expectedIssuer = Optional.of(value);
140       return this;
141     }
142 
143     /** Lets the validator ignore the {@code iss} claim. */
144     @CanIgnoreReturnValue
ignoreIssuer()145     public Builder ignoreIssuer() {
146       this.ignoreIssuer = true;
147       return this;
148     }
149 
150     /**
151      * Sets the expected audience. When this is set, all tokens that do not contain this audience in
152      * their {@code aud} claims are rejected. When this is not set, all token that have {@code aud}
153      * claims are rejected. So this must be set for token that have {@code aud} claims.
154      *
155      * <p>If you want to ignore this claim or if you want to validate it yourself, use
156      * ignoreAudiences().
157      *
158      * <p>https://tools.ietf.org/html/rfc7519#section-4.1.3
159      */
160     @CanIgnoreReturnValue
expectAudience(String value)161     public Builder expectAudience(String value) {
162       if (value == null) {
163         throw new NullPointerException("audience cannot be null");
164       }
165       this.expectedAudience = Optional.of(value);
166       return this;
167     }
168 
169     /** Lets the validator ignore the {@code aud} claim. */
170     @CanIgnoreReturnValue
ignoreAudiences()171     public Builder ignoreAudiences() {
172       this.ignoreAudiences = true;
173       return this;
174     }
175 
176     /** Checks that the {@code iat} claim is in the past. */
177     @CanIgnoreReturnValue
expectIssuedInThePast()178     public Builder expectIssuedInThePast() {
179       this.expectIssuedInThePast = true;
180       return this;
181     }
182 
183     /** Sets the clock used to verify timestamp claims. */
184     @CanIgnoreReturnValue
setClock(Clock clock)185     public Builder setClock(Clock clock) {
186       if (clock == null) {
187         throw new NullPointerException("clock cannot be null");
188       }
189       this.clock = clock;
190       return this;
191     }
192 
193     /**
194      * Sets the clock skew to tolerate when verifying timestamp claims, to deal with small clock
195      * differences among different machines.
196      *
197      * <p>As recommended by https://tools.ietf.org/html/rfc7519, the clock skew should usually be no
198      * more than a few minutes. In this implementation, the maximum value is 10 minutes.
199      */
200     @CanIgnoreReturnValue
setClockSkew(Duration clockSkew)201     public Builder setClockSkew(Duration clockSkew) {
202       if (clockSkew.compareTo(MAX_CLOCK_SKEW) > 0) {
203         throw new IllegalArgumentException("Clock skew too large, max is 10 minutes");
204       }
205       this.clockSkew = clockSkew;
206       return this;
207     }
208 
209     /**
210      * When set, the validator accepts tokens that do not have an expiration set.
211      *
212      * <p>In most cases, tokens should always have an expiration, so this option should rarely be
213      * used.
214      */
215     @CanIgnoreReturnValue
allowMissingExpiration()216     public Builder allowMissingExpiration() {
217       this.allowMissingExpiration = true;
218       return this;
219     }
220 
build()221     public JwtValidator build() {
222       if (this.ignoreTypeHeader && this.expectedTypeHeader.isPresent()) {
223         throw new IllegalArgumentException(
224             "ignoreTypeHeader() and expectedTypeHeader() cannot be used together.");
225       }
226       if (this.ignoreIssuer && this.expectedIssuer.isPresent()) {
227         throw new IllegalArgumentException(
228             "ignoreIssuer() and expectedIssuer() cannot be used together.");
229       }
230       if (this.ignoreAudiences && this.expectedAudience.isPresent()) {
231         throw new IllegalArgumentException(
232             "ignoreAudiences() and expectedAudience() cannot be used together.");
233       }
234       return new JwtValidator(this);
235     }
236   }
237 
validateTypeHeader(RawJwt target)238   private void validateTypeHeader(RawJwt target) throws JwtInvalidException {
239     if (this.expectedTypeHeader.isPresent()) {
240       if (!target.hasTypeHeader()) {
241         throw new JwtInvalidException(
242             String.format(
243                 "invalid JWT; missing expected type header %s.", this.expectedTypeHeader.get()));
244       }
245       if (!target.getTypeHeader().equals(this.expectedTypeHeader.get())) {
246         throw new JwtInvalidException(
247             String.format(
248                 "invalid JWT; expected type header %s, but got %s",
249                 this.expectedTypeHeader.get(), target.getTypeHeader()));
250       }
251     } else {
252       if (target.hasTypeHeader() && !this.ignoreTypeHeader) {
253         throw new JwtInvalidException("invalid JWT; token has type header set, but validator not.");
254       }
255     }
256   }
257 
validateIssuer(RawJwt target)258   private void validateIssuer(RawJwt target) throws JwtInvalidException {
259     if (this.expectedIssuer.isPresent()) {
260       if (!target.hasIssuer()) {
261         throw new JwtInvalidException(
262             String.format("invalid JWT; missing expected issuer %s.", this.expectedIssuer.get()));
263       }
264       if (!target.getIssuer().equals(this.expectedIssuer.get())) {
265         throw new JwtInvalidException(
266             String.format(
267                 "invalid JWT; expected issuer %s, but got %s",
268                 this.expectedIssuer.get(), target.getIssuer()));
269       }
270     } else {
271       if (target.hasIssuer() && !this.ignoreIssuer) {
272         throw new JwtInvalidException("invalid JWT; token has issuer set, but validator not.");
273       }
274     }
275   }
276 
validateAudiences(RawJwt target)277   private void validateAudiences(RawJwt target) throws JwtInvalidException {
278     if (this.expectedAudience.isPresent()) {
279       if (!target.hasAudiences() || !target.getAudiences().contains(this.expectedAudience.get())) {
280         throw new JwtInvalidException(
281             String.format(
282                 "invalid JWT; missing expected audience %s.", this.expectedAudience.get()));
283       }
284     } else {
285       if (target.hasAudiences() && !this.ignoreAudiences) {
286         throw new JwtInvalidException("invalid JWT; token has audience set, but validator not.");
287       }
288     }
289   }
290 
291   /**
292    * Validates that all claims in this validator are also present in {@code target}.
293    * @throws JwtInvalidException when {@code target} contains an invalid claim or header
294    */
validate(RawJwt target)295   VerifiedJwt validate(RawJwt target) throws JwtInvalidException {
296     validateTimestampClaims(target);
297     validateTypeHeader(target);
298     validateIssuer(target);
299     validateAudiences(target);
300     return new VerifiedJwt(target);
301   }
302 
validateTimestampClaims(RawJwt target)303   private void validateTimestampClaims(RawJwt target) throws JwtInvalidException {
304     Instant now = this.clock.instant();
305 
306     if (!target.hasExpiration() && !this.allowMissingExpiration) {
307       throw new JwtInvalidException("token does not have an expiration set");
308     }
309 
310     // If expiration = now.minus(clockSkew), then the token is expired.
311     if (target.hasExpiration() && !target.getExpiration().isAfter(now.minus(this.clockSkew))) {
312       throw new JwtInvalidException("token has expired since " + target.getExpiration());
313     }
314 
315     // If not_before = now.plus(clockSkew), then the token is fine.
316     if (target.hasNotBefore() && target.getNotBefore().isAfter(now.plus(this.clockSkew))) {
317       throw new JwtInvalidException("token cannot be used before " + target.getNotBefore());
318     }
319 
320     // If issued_at = now.plus(clockSkew), then the token is fine.
321     if (this.expectIssuedInThePast) {
322       if (!target.hasIssuedAt()) {
323         throw new JwtInvalidException("token does not have an iat claim");
324       }
325       if (target.getIssuedAt().isAfter(now.plus(this.clockSkew))) {
326         throw new JwtInvalidException(
327             "token has a invalid iat claim in the future: " + target.getIssuedAt());
328       }
329     }
330   }
331 
332   /**
333    * Returns a brief description of a JwtValidator object. The exact details of the representation
334    * are unspecified and subject to change.
335    */
336   @Override
toString()337   public String toString() {
338     List<String> items = new ArrayList<>();
339     if (expectedTypeHeader.isPresent()) {
340       items.add("expectedTypeHeader=" + expectedTypeHeader.get());
341     }
342     if (ignoreTypeHeader) {
343       items.add("ignoreTypeHeader");
344     }
345     if (expectedIssuer.isPresent()) {
346       items.add("expectedIssuer=" + expectedIssuer.get());
347     }
348     if (ignoreIssuer) {
349       items.add("ignoreIssuer");
350     }
351     if (expectedAudience.isPresent()) {
352       items.add("expectedAudience=" + expectedAudience.get());
353     }
354     if (ignoreAudiences) {
355       items.add("ignoreAudiences");
356     }
357     if (allowMissingExpiration) {
358       items.add("allowMissingExpiration");
359     }
360     if (expectIssuedInThePast) {
361       items.add("expectIssuedInThePast");
362     }
363     if (!clockSkew.isZero()) {
364       items.add("clockSkew=" + clockSkew);
365     }
366     StringBuilder b = new StringBuilder();
367     b.append("JwtValidator{");
368     String currentSeparator = "";
369     for (String i : items) {
370       b.append(currentSeparator);
371       b.append(i);
372       currentSeparator = ",";
373     }
374     b.append("}");
375     return b.toString();
376   }
377 }
378