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