aboutsummaryrefslogtreecommitdiff
path: root/java_src/src/main/java/com/google/crypto/tink/jwt/JwtValidator.java
blob: 92c58f555b4a68b0a8268f0ea638fecef0eb4edc (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
// 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.CanIgnoreReturnValue;
import com.google.errorprone.annotations.Immutable;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

/** Defines how the headers and claims of a JWT should be validated. */
@Immutable
public final class JwtValidator {
  private static final Duration MAX_CLOCK_SKEW = Duration.ofMinutes(10);

  private final Optional<String> expectedTypeHeader;
  private final boolean ignoreTypeHeader;
  private final Optional<String> expectedIssuer;
  private final boolean ignoreIssuer;
  private final Optional<String> expectedAudience;
  private final boolean ignoreAudiences;
  private final boolean allowMissingExpiration;
  private final boolean expectIssuedInThePast;

  @SuppressWarnings("Immutable") // We do not mutate the clock.
  private final Clock clock;

  private final Duration clockSkew;

  private JwtValidator(Builder builder) {
    this.expectedTypeHeader = builder.expectedTypeHeader;
    this.ignoreTypeHeader = builder.ignoreTypeHeader;
    this.expectedIssuer = builder.expectedIssuer;
    this.ignoreIssuer = builder.ignoreIssuer;
    this.expectedAudience = builder.expectedAudience;
    this.ignoreAudiences = builder.ignoreAudiences;
    this.allowMissingExpiration = builder.allowMissingExpiration;
    this.expectIssuedInThePast = builder.expectIssuedInThePast;
    this.clock = builder.clock;
    this.clockSkew = builder.clockSkew;
  }

  /**
   * Returns a new JwtValidator.Builder.
   *
   * <p>By default, the JwtValidator requires that a token has a valid expiration claim, no issuer
   * and no audience claim. This can be changed using the expect...(),  ignore...() and
   * allowMissingExpiration() methods.
   *
   * <p>If present, the JwtValidator also validates the not-before claim. The validation time can
   * be changed using the setClock() method.
   */
  public static Builder newBuilder() {
    return new Builder();
  }

  /** Builder for JwtValidator */
  public static final class Builder {
    private Optional<String> expectedTypeHeader;
    private boolean ignoreTypeHeader;
    private Optional<String> expectedIssuer;
    private boolean ignoreIssuer;
    private Optional<String> expectedAudience;
    private boolean ignoreAudiences;
    private boolean allowMissingExpiration;
    private boolean expectIssuedInThePast;
    private Clock clock = Clock.systemUTC();
    private Duration clockSkew = Duration.ZERO;

    private Builder() {
      this.expectedTypeHeader = Optional.empty();
      this.ignoreTypeHeader = false;
      this.expectedIssuer = Optional.empty();
      this.ignoreIssuer = false;
      this.expectedAudience = Optional.empty();
      this.ignoreAudiences = false;
      this.allowMissingExpiration = false;
      this.expectIssuedInThePast = false;
    }

    /**
     * Sets the expected type header of the token. When this is set, all tokens with missing or
     * different {@code typ} header are rejected. When this is not set, all token that have a {@code
     * typ} header are rejected. So this must be set for token that have a {@code typ} header.
     *
     * <p>If you want to ignore the type header or if you want to validate it yourself, use
     * ignoreTypeHeader().
     *
     * <p>https://tools.ietf.org/html/rfc7519#section-4.1.1
     */
    @CanIgnoreReturnValue
    public Builder expectTypeHeader(String value) {
      if (value == null) {
        throw new NullPointerException("typ header cannot be null");
      }
      this.expectedTypeHeader = Optional.of(value);
      return this;
    }

    /** Lets the validator ignore the {@code typ} header. */
    @CanIgnoreReturnValue
    public Builder ignoreTypeHeader() {
      this.ignoreTypeHeader = true;
      return this;
    }

    /**
     * Sets the expected issuer claim of the token. When this is set, all tokens with missing or
     * different {@code iss} claims are rejected. When this is not set, all token that have a {@code
     * iss} claim are rejected. So this must be set for token that have a {@code iss} claim.
     *
     * <p>If you want to ignore the issuer claim or if you want to validate it yourself, use
     * ignoreIssuer().
     *
     * <p>https://tools.ietf.org/html/rfc7519#section-4.1.1
     */
    @CanIgnoreReturnValue
    public Builder expectIssuer(String value) {
      if (value == null) {
        throw new NullPointerException("issuer cannot be null");
      }
      this.expectedIssuer = Optional.of(value);
      return this;
    }

    /** Lets the validator ignore the {@code iss} claim. */
    @CanIgnoreReturnValue
    public Builder ignoreIssuer() {
      this.ignoreIssuer = true;
      return this;
    }

    /**
     * Sets the expected audience. When this is set, all tokens that do not contain this audience in
     * their {@code aud} claims are rejected. When this is not set, all token that have {@code aud}
     * claims are rejected. So this must be set for token that have {@code aud} claims.
     *
     * <p>If you want to ignore this claim or if you want to validate it yourself, use
     * ignoreAudiences().
     *
     * <p>https://tools.ietf.org/html/rfc7519#section-4.1.3
     */
    @CanIgnoreReturnValue
    public Builder expectAudience(String value) {
      if (value == null) {
        throw new NullPointerException("audience cannot be null");
      }
      this.expectedAudience = Optional.of(value);
      return this;
    }

    /** Lets the validator ignore the {@code aud} claim. */
    @CanIgnoreReturnValue
    public Builder ignoreAudiences() {
      this.ignoreAudiences = true;
      return this;
    }

    /** Checks that the {@code iat} claim is in the past. */
    @CanIgnoreReturnValue
    public Builder expectIssuedInThePast() {
      this.expectIssuedInThePast = true;
      return this;
    }

    /** Sets the clock used to verify timestamp claims. */
    @CanIgnoreReturnValue
    public Builder setClock(Clock clock) {
      if (clock == null) {
        throw new NullPointerException("clock cannot be null");
      }
      this.clock = clock;
      return this;
    }

    /**
     * Sets the clock skew to tolerate when verifying timestamp claims, to deal with small clock
     * differences among different machines.
     *
     * <p>As recommended by https://tools.ietf.org/html/rfc7519, the clock skew should usually be no
     * more than a few minutes. In this implementation, the maximum value is 10 minutes.
     */
    @CanIgnoreReturnValue
    public Builder setClockSkew(Duration clockSkew) {
      if (clockSkew.compareTo(MAX_CLOCK_SKEW) > 0) {
        throw new IllegalArgumentException("Clock skew too large, max is 10 minutes");
      }
      this.clockSkew = clockSkew;
      return this;
    }

    /**
     * When set, the validator accepts tokens that do not have an expiration set.
     *
     * <p>In most cases, tokens should always have an expiration, so this option should rarely be
     * used.
     */
    @CanIgnoreReturnValue
    public Builder allowMissingExpiration() {
      this.allowMissingExpiration = true;
      return this;
    }

    public JwtValidator build() {
      if (this.ignoreTypeHeader && this.expectedTypeHeader.isPresent()) {
        throw new IllegalArgumentException(
            "ignoreTypeHeader() and expectedTypeHeader() cannot be used together.");
      }
      if (this.ignoreIssuer && this.expectedIssuer.isPresent()) {
        throw new IllegalArgumentException(
            "ignoreIssuer() and expectedIssuer() cannot be used together.");
      }
      if (this.ignoreAudiences && this.expectedAudience.isPresent()) {
        throw new IllegalArgumentException(
            "ignoreAudiences() and expectedAudience() cannot be used together.");
      }
      return new JwtValidator(this);
    }
  }

  private void validateTypeHeader(RawJwt target) throws JwtInvalidException {
    if (this.expectedTypeHeader.isPresent()) {
      if (!target.hasTypeHeader()) {
        throw new JwtInvalidException(
            String.format(
                "invalid JWT; missing expected type header %s.", this.expectedTypeHeader.get()));
      }
      if (!target.getTypeHeader().equals(this.expectedTypeHeader.get())) {
        throw new JwtInvalidException(
            String.format(
                "invalid JWT; expected type header %s, but got %s",
                this.expectedTypeHeader.get(), target.getTypeHeader()));
      }
    } else {
      if (target.hasTypeHeader() && !this.ignoreTypeHeader) {
        throw new JwtInvalidException("invalid JWT; token has type header set, but validator not.");
      }
    }
  }

  private void validateIssuer(RawJwt target) throws JwtInvalidException {
    if (this.expectedIssuer.isPresent()) {
      if (!target.hasIssuer()) {
        throw new JwtInvalidException(
            String.format("invalid JWT; missing expected issuer %s.", this.expectedIssuer.get()));
      }
      if (!target.getIssuer().equals(this.expectedIssuer.get())) {
        throw new JwtInvalidException(
            String.format(
                "invalid JWT; expected issuer %s, but got %s",
                this.expectedIssuer.get(), target.getIssuer()));
      }
    } else {
      if (target.hasIssuer() && !this.ignoreIssuer) {
        throw new JwtInvalidException("invalid JWT; token has issuer set, but validator not.");
      }
    }
  }

  private void validateAudiences(RawJwt target) throws JwtInvalidException {
    if (this.expectedAudience.isPresent()) {
      if (!target.hasAudiences() || !target.getAudiences().contains(this.expectedAudience.get())) {
        throw new JwtInvalidException(
            String.format(
                "invalid JWT; missing expected audience %s.", this.expectedAudience.get()));
      }
    } else {
      if (target.hasAudiences() && !this.ignoreAudiences) {
        throw new JwtInvalidException("invalid JWT; token has audience set, but validator not.");
      }
    }
  }

  /**
   * Validates that all claims in this validator are also present in {@code target}.
   * @throws JwtInvalidException when {@code target} contains an invalid claim or header
   */
  VerifiedJwt validate(RawJwt target) throws JwtInvalidException {
    validateTimestampClaims(target);
    validateTypeHeader(target);
    validateIssuer(target);
    validateAudiences(target);
    return new VerifiedJwt(target);
  }

  private void validateTimestampClaims(RawJwt target) throws JwtInvalidException {
    Instant now = this.clock.instant();

    if (!target.hasExpiration() && !this.allowMissingExpiration) {
      throw new JwtInvalidException("token does not have an expiration set");
    }

    // If expiration = now.minus(clockSkew), then the token is expired.
    if (target.hasExpiration() && !target.getExpiration().isAfter(now.minus(this.clockSkew))) {
      throw new JwtInvalidException("token has expired since " + target.getExpiration());
    }

    // If not_before = now.plus(clockSkew), then the token is fine.
    if (target.hasNotBefore() && target.getNotBefore().isAfter(now.plus(this.clockSkew))) {
      throw new JwtInvalidException("token cannot be used before " + target.getNotBefore());
    }

    // If issued_at = now.plus(clockSkew), then the token is fine.
    if (this.expectIssuedInThePast) {
      if (!target.hasIssuedAt()) {
        throw new JwtInvalidException("token does not have an iat claim");
      }
      if (target.getIssuedAt().isAfter(now.plus(this.clockSkew))) {
        throw new JwtInvalidException(
            "token has a invalid iat claim in the future: " + target.getIssuedAt());
      }
    }
  }

  /**
   * Returns a brief description of a JwtValidator object. The exact details of the representation
   * are unspecified and subject to change.
   */
  @Override
  public String toString() {
    List<String> items = new ArrayList<>();
    if (expectedTypeHeader.isPresent()) {
      items.add("expectedTypeHeader=" + expectedTypeHeader.get());
    }
    if (ignoreTypeHeader) {
      items.add("ignoreTypeHeader");
    }
    if (expectedIssuer.isPresent()) {
      items.add("expectedIssuer=" + expectedIssuer.get());
    }
    if (ignoreIssuer) {
      items.add("ignoreIssuer");
    }
    if (expectedAudience.isPresent()) {
      items.add("expectedAudience=" + expectedAudience.get());
    }
    if (ignoreAudiences) {
      items.add("ignoreAudiences");
    }
    if (allowMissingExpiration) {
      items.add("allowMissingExpiration");
    }
    if (expectIssuedInThePast) {
      items.add("expectIssuedInThePast");
    }
    if (!clockSkew.isZero()) {
      items.add("clockSkew=" + clockSkew);
    }
    StringBuilder b = new StringBuilder();
    b.append("JwtValidator{");
    String currentSeparator = "";
    for (String i : items) {
      b.append(currentSeparator);
      b.append(i);
      currentSeparator = ",";
    }
    b.append("}");
    return b.toString();
  }
}