1 /* 2 * Copyright 2020, Google LLC 3 * 4 * Redistribution and use in source and binary forms, with or without 5 * modification, are permitted provided that the following conditions are 6 * met: 7 * 8 * * Redistributions of source code must retain the above copyright 9 * notice, this list of conditions and the following disclaimer. 10 * * Redistributions in binary form must reproduce the above 11 * copyright notice, this list of conditions and the following disclaimer 12 * in the documentation and/or other materials provided with the 13 * distribution. 14 * 15 * * Neither the name of Google LLC nor the names of its 16 * contributors may be used to endorse or promote products derived from 17 * this software without specific prior written permission. 18 * 19 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 20 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 21 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 22 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 23 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 24 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 25 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 26 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 27 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 28 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 */ 31 32 package com.google.auth.oauth2; 33 34 import com.google.api.client.http.GenericUrl; 35 import com.google.api.client.http.HttpBackOffUnsuccessfulResponseHandler; 36 import com.google.api.client.http.HttpBackOffUnsuccessfulResponseHandler.BackOffRequired; 37 import com.google.api.client.http.HttpRequest; 38 import com.google.api.client.http.HttpResponse; 39 import com.google.api.client.http.HttpTransport; 40 import com.google.api.client.json.GenericJson; 41 import com.google.api.client.json.webtoken.JsonWebSignature; 42 import com.google.api.client.util.Base64; 43 import com.google.api.client.util.Clock; 44 import com.google.api.client.util.ExponentialBackOff; 45 import com.google.api.client.util.Key; 46 import com.google.auth.http.HttpTransportFactory; 47 import com.google.common.base.Preconditions; 48 import com.google.common.cache.CacheBuilder; 49 import com.google.common.cache.CacheLoader; 50 import com.google.common.cache.LoadingCache; 51 import com.google.common.collect.ImmutableMap; 52 import com.google.common.collect.ImmutableSet; 53 import com.google.common.util.concurrent.UncheckedExecutionException; 54 import java.io.ByteArrayInputStream; 55 import java.io.IOException; 56 import java.io.UnsupportedEncodingException; 57 import java.math.BigInteger; 58 import java.security.AlgorithmParameters; 59 import java.security.GeneralSecurityException; 60 import java.security.KeyFactory; 61 import java.security.NoSuchAlgorithmException; 62 import java.security.PublicKey; 63 import java.security.cert.CertificateException; 64 import java.security.cert.CertificateFactory; 65 import java.security.spec.ECGenParameterSpec; 66 import java.security.spec.ECParameterSpec; 67 import java.security.spec.ECPoint; 68 import java.security.spec.ECPublicKeySpec; 69 import java.security.spec.InvalidKeySpecException; 70 import java.security.spec.InvalidParameterSpecException; 71 import java.security.spec.RSAPublicKeySpec; 72 import java.util.List; 73 import java.util.Map; 74 import java.util.Set; 75 import java.util.concurrent.ExecutionException; 76 import java.util.concurrent.TimeUnit; 77 78 /** 79 * Handle verification of Google-signed JWT tokens. 80 * 81 * @author Jeff Ching 82 * @since 0.21.0 83 */ 84 public class TokenVerifier { 85 private static final String IAP_CERT_URL = "https://www.gstatic.com/iap/verify/public_key-jwk"; 86 private static final String FEDERATED_SIGNON_CERT_URL = 87 "https://www.googleapis.com/oauth2/v3/certs"; 88 private static final Set<String> SUPPORTED_ALGORITHMS = ImmutableSet.of("RS256", "ES256"); 89 90 private final String audience; 91 private final String certificatesLocation; 92 private final String issuer; 93 private final PublicKey publicKey; 94 private final Clock clock; 95 private final LoadingCache<String, Map<String, PublicKey>> publicKeyCache; 96 TokenVerifier(Builder builder)97 private TokenVerifier(Builder builder) { 98 this.audience = builder.audience; 99 this.certificatesLocation = builder.certificatesLocation; 100 this.issuer = builder.issuer; 101 this.publicKey = builder.publicKey; 102 this.clock = builder.clock; 103 this.publicKeyCache = 104 CacheBuilder.newBuilder() 105 .expireAfterWrite(1, TimeUnit.HOURS) 106 .build(new PublicKeyLoader(builder.httpTransportFactory)); 107 } 108 newBuilder()109 public static Builder newBuilder() { 110 return new Builder() 111 .setClock(Clock.SYSTEM) 112 .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY); 113 } 114 115 /** 116 * Verify an encoded JWT token. 117 * 118 * @param token encoded JWT token 119 * @return the parsed JsonWebSignature instance for additional validation if necessary 120 * @throws VerificationException thrown if any verification fails 121 */ verify(String token)122 public JsonWebSignature verify(String token) throws VerificationException { 123 JsonWebSignature jsonWebSignature; 124 try { 125 jsonWebSignature = JsonWebSignature.parse(OAuth2Utils.JSON_FACTORY, token); 126 } catch (IOException e) { 127 throw new VerificationException("Error parsing JsonWebSignature token", e); 128 } 129 130 // Verify the expected audience if an audience is provided in the verifyOptions 131 if (audience != null && !audience.equals(jsonWebSignature.getPayload().getAudience())) { 132 throw new VerificationException("Expected audience does not match"); 133 } 134 135 // Verify the expected issuer if an issuer is provided in the verifyOptions 136 if (issuer != null && !issuer.equals(jsonWebSignature.getPayload().getIssuer())) { 137 throw new VerificationException("Expected issuer does not match"); 138 } 139 140 Long expiresAt = jsonWebSignature.getPayload().getExpirationTimeSeconds(); 141 if (expiresAt != null && expiresAt <= clock.currentTimeMillis() / 1000) { 142 throw new VerificationException("Token is expired"); 143 } 144 145 // Short-circuit signature types 146 if (!SUPPORTED_ALGORITHMS.contains(jsonWebSignature.getHeader().getAlgorithm())) { 147 throw new VerificationException( 148 "Unexpected signing algorithm: expected either RS256 or ES256"); 149 } 150 151 PublicKey publicKeyToUse = publicKey; 152 if (publicKeyToUse == null) { 153 try { 154 String certificateLocation = getCertificateLocation(jsonWebSignature); 155 publicKeyToUse = 156 publicKeyCache.get(certificateLocation).get(jsonWebSignature.getHeader().getKeyId()); 157 } catch (ExecutionException | UncheckedExecutionException e) { 158 throw new VerificationException("Error fetching PublicKey from certificate location", e); 159 } 160 } 161 162 if (publicKeyToUse == null) { 163 throw new VerificationException( 164 "Could not find PublicKey for provided keyId: " 165 + jsonWebSignature.getHeader().getKeyId()); 166 } 167 168 try { 169 if (jsonWebSignature.verifySignature(publicKeyToUse)) { 170 return jsonWebSignature; 171 } 172 throw new VerificationException("Invalid signature"); 173 } catch (GeneralSecurityException e) { 174 throw new VerificationException("Error validating token", e); 175 } 176 } 177 getCertificateLocation(JsonWebSignature jsonWebSignature)178 private String getCertificateLocation(JsonWebSignature jsonWebSignature) 179 throws VerificationException { 180 if (certificatesLocation != null) return certificatesLocation; 181 182 switch (jsonWebSignature.getHeader().getAlgorithm()) { 183 case "RS256": 184 return FEDERATED_SIGNON_CERT_URL; 185 case "ES256": 186 return IAP_CERT_URL; 187 } 188 189 throw new VerificationException("Unknown algorithm"); 190 } 191 192 public static class Builder { 193 private String audience; 194 private String certificatesLocation; 195 private String issuer; 196 private PublicKey publicKey; 197 private Clock clock; 198 private HttpTransportFactory httpTransportFactory; 199 200 /** 201 * Set a target audience to verify. 202 * 203 * @param audience the audience claim to verify 204 * @return the builder 205 */ setAudience(String audience)206 public Builder setAudience(String audience) { 207 this.audience = audience; 208 return this; 209 } 210 211 /** 212 * Override the location URL that contains published public keys. Defaults to well-known Google 213 * locations. 214 * 215 * @param certificatesLocation URL to published public keys 216 * @return the builder 217 */ setCertificatesLocation(String certificatesLocation)218 public Builder setCertificatesLocation(String certificatesLocation) { 219 this.certificatesLocation = certificatesLocation; 220 return this; 221 } 222 223 /** 224 * Set the issuer to verify. 225 * 226 * @param issuer the issuer claim to verify 227 * @return the builder 228 */ setIssuer(String issuer)229 public Builder setIssuer(String issuer) { 230 this.issuer = issuer; 231 return this; 232 } 233 234 /** 235 * Set the PublicKey for verifying the signature. This will ignore the key id from the JWT token 236 * header. 237 * 238 * @param publicKey the public key to validate the signature 239 * @return the builder 240 */ setPublicKey(PublicKey publicKey)241 public Builder setPublicKey(PublicKey publicKey) { 242 this.publicKey = publicKey; 243 return this; 244 } 245 246 /** 247 * Set the clock for checking token expiry. Used for testing. 248 * 249 * @param clock the clock to use. Defaults to the system clock 250 * @return the builder 251 */ setClock(Clock clock)252 public Builder setClock(Clock clock) { 253 this.clock = clock; 254 return this; 255 } 256 257 /** 258 * Set the HttpTransportFactory used for requesting public keys from the certificate URL. Used 259 * mostly for testing. 260 * 261 * @param httpTransportFactory the HttpTransportFactory used to build certificate URL requests 262 * @return the builder 263 */ setHttpTransportFactory(HttpTransportFactory httpTransportFactory)264 public Builder setHttpTransportFactory(HttpTransportFactory httpTransportFactory) { 265 this.httpTransportFactory = httpTransportFactory; 266 return this; 267 } 268 269 /** 270 * Build the custom TokenVerifier for verifying tokens. 271 * 272 * @return the customized TokenVerifier 273 */ build()274 public TokenVerifier build() { 275 return new TokenVerifier(this); 276 } 277 } 278 279 /** Custom CacheLoader for mapping certificate urls to the contained public keys. */ 280 static class PublicKeyLoader extends CacheLoader<String, Map<String, PublicKey>> { 281 private static final int DEFAULT_NUMBER_OF_RETRIES = 2; 282 private static final int INITIAL_RETRY_INTERVAL_MILLIS = 1000; 283 private static final double RETRY_RANDOMIZATION_FACTOR = 0.1; 284 private static final double RETRY_MULTIPLIER = 2; 285 private final HttpTransportFactory httpTransportFactory; 286 287 /** 288 * Data class used for deserializing a JSON Web Key Set (JWKS) from an external HTTP request. 289 */ 290 public static class JsonWebKeySet extends GenericJson { 291 @Key public List<JsonWebKey> keys; 292 } 293 294 /** Data class used for deserializing a single JSON Web Key. */ 295 public static class JsonWebKey { 296 @Key public String alg; 297 298 @Key public String crv; 299 300 @Key public String kid; 301 302 @Key public String kty; 303 304 @Key public String use; 305 306 @Key public String x; 307 308 @Key public String y; 309 310 @Key public String e; 311 312 @Key public String n; 313 } 314 PublicKeyLoader(HttpTransportFactory httpTransportFactory)315 PublicKeyLoader(HttpTransportFactory httpTransportFactory) { 316 super(); 317 this.httpTransportFactory = httpTransportFactory; 318 } 319 320 @Override load(String certificateUrl)321 public Map<String, PublicKey> load(String certificateUrl) throws Exception { 322 HttpTransport httpTransport = httpTransportFactory.create(); 323 JsonWebKeySet jwks; 324 HttpRequest request = 325 httpTransport 326 .createRequestFactory() 327 .buildGetRequest(new GenericUrl(certificateUrl)) 328 .setParser(OAuth2Utils.JSON_FACTORY.createJsonObjectParser()); 329 request.setNumberOfRetries(DEFAULT_NUMBER_OF_RETRIES); 330 331 ExponentialBackOff backoff = 332 new ExponentialBackOff.Builder() 333 .setInitialIntervalMillis(INITIAL_RETRY_INTERVAL_MILLIS) 334 .setRandomizationFactor(RETRY_RANDOMIZATION_FACTOR) 335 .setMultiplier(RETRY_MULTIPLIER) 336 .build(); 337 338 request.setUnsuccessfulResponseHandler( 339 new HttpBackOffUnsuccessfulResponseHandler(backoff) 340 .setBackOffRequired(BackOffRequired.ALWAYS)); 341 342 HttpResponse response = request.execute(); 343 jwks = response.parseAs(JsonWebKeySet.class); 344 345 ImmutableMap.Builder<String, PublicKey> keyCacheBuilder = new ImmutableMap.Builder<>(); 346 if (jwks.keys == null) { 347 // Fall back to x509 formatted specification 348 for (String keyId : jwks.keySet()) { 349 String publicKeyPem = (String) jwks.get(keyId); 350 keyCacheBuilder.put(keyId, buildPublicKey(publicKeyPem)); 351 } 352 } else { 353 for (JsonWebKey key : jwks.keys) { 354 try { 355 keyCacheBuilder.put(key.kid, buildPublicKey(key)); 356 } catch (NoSuchAlgorithmException 357 | InvalidKeySpecException 358 | InvalidParameterSpecException ignored) { 359 ignored.printStackTrace(); 360 } 361 } 362 } 363 364 ImmutableMap<String, PublicKey> keyCache = keyCacheBuilder.build(); 365 366 if (keyCache.isEmpty()) { 367 throw new VerificationException( 368 "No valid public key returned by the keystore: " + certificateUrl); 369 } 370 371 return keyCache; 372 } 373 buildPublicKey(JsonWebKey key)374 private PublicKey buildPublicKey(JsonWebKey key) 375 throws NoSuchAlgorithmException, InvalidParameterSpecException, InvalidKeySpecException { 376 if ("ES256".equals(key.alg)) { 377 return buildEs256PublicKey(key); 378 } else if ("RS256".equals((key.alg))) { 379 return buildRs256PublicKey(key); 380 } else { 381 return null; 382 } 383 } 384 buildPublicKey(String publicPem)385 private PublicKey buildPublicKey(String publicPem) 386 throws CertificateException, UnsupportedEncodingException { 387 return CertificateFactory.getInstance("X.509") 388 .generateCertificate(new ByteArrayInputStream(publicPem.getBytes("UTF-8"))) 389 .getPublicKey(); 390 } 391 buildRs256PublicKey(JsonWebKey key)392 private PublicKey buildRs256PublicKey(JsonWebKey key) 393 throws NoSuchAlgorithmException, InvalidKeySpecException { 394 Preconditions.checkArgument("RSA".equals(key.kty)); 395 Preconditions.checkNotNull(key.e); 396 Preconditions.checkNotNull(key.n); 397 398 BigInteger modulus = new BigInteger(1, Base64.decodeBase64(key.n)); 399 BigInteger exponent = new BigInteger(1, Base64.decodeBase64(key.e)); 400 401 RSAPublicKeySpec spec = new RSAPublicKeySpec(modulus, exponent); 402 KeyFactory factory = KeyFactory.getInstance("RSA"); 403 return factory.generatePublic(spec); 404 } 405 buildEs256PublicKey(JsonWebKey key)406 private PublicKey buildEs256PublicKey(JsonWebKey key) 407 throws NoSuchAlgorithmException, InvalidParameterSpecException, InvalidKeySpecException { 408 Preconditions.checkArgument("EC".equals(key.kty)); 409 Preconditions.checkArgument("P-256".equals(key.crv)); 410 411 BigInteger x = new BigInteger(1, Base64.decodeBase64(key.x)); 412 BigInteger y = new BigInteger(1, Base64.decodeBase64(key.y)); 413 ECPoint pubPoint = new ECPoint(x, y); 414 AlgorithmParameters parameters = AlgorithmParameters.getInstance("EC"); 415 parameters.init(new ECGenParameterSpec("secp256r1")); 416 ECParameterSpec ecParameters = parameters.getParameterSpec(ECParameterSpec.class); 417 ECPublicKeySpec pubSpec = new ECPublicKeySpec(pubPoint, ecParameters); 418 KeyFactory kf = KeyFactory.getInstance("EC"); 419 return kf.generatePublic(pubSpec); 420 } 421 } 422 423 /** Custom exception for wrapping all verification errors. */ 424 public static class VerificationException extends Exception { VerificationException(String message)425 public VerificationException(String message) { 426 super(message); 427 } 428 VerificationException(String message, Throwable cause)429 public VerificationException(String message, Throwable cause) { 430 super(message, cause); 431 } 432 } 433 } 434