/* * Copyright 2020, Google LLC * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are * met: * * * Redistributions of source code must retain the above copyright * notice, this list of conditions and the following disclaimer. * * Redistributions in binary form must reproduce the above * copyright notice, this list of conditions and the following disclaimer * in the documentation and/or other materials provided with the * distribution. * * * Neither the name of Google LLC nor the names of its * contributors may be used to endorse or promote products derived from * this software without specific prior written permission. * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ package com.google.auth.oauth2; import com.google.api.client.http.GenericUrl; import com.google.api.client.http.HttpBackOffUnsuccessfulResponseHandler; import com.google.api.client.http.HttpBackOffUnsuccessfulResponseHandler.BackOffRequired; import com.google.api.client.http.HttpRequest; import com.google.api.client.http.HttpResponse; import com.google.api.client.http.HttpTransport; import com.google.api.client.json.GenericJson; import com.google.api.client.json.webtoken.JsonWebSignature; import com.google.api.client.util.Base64; import com.google.api.client.util.Clock; import com.google.api.client.util.ExponentialBackOff; import com.google.api.client.util.Key; import com.google.auth.http.HttpTransportFactory; import com.google.common.base.Preconditions; import com.google.common.cache.CacheBuilder; import com.google.common.cache.CacheLoader; import com.google.common.cache.LoadingCache; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.google.common.util.concurrent.UncheckedExecutionException; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.UnsupportedEncodingException; import java.math.BigInteger; import java.security.AlgorithmParameters; import java.security.GeneralSecurityException; import java.security.KeyFactory; import java.security.NoSuchAlgorithmException; import java.security.PublicKey; import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; import java.security.spec.ECGenParameterSpec; import java.security.spec.ECParameterSpec; import java.security.spec.ECPoint; import java.security.spec.ECPublicKeySpec; import java.security.spec.InvalidKeySpecException; import java.security.spec.InvalidParameterSpecException; import java.security.spec.RSAPublicKeySpec; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; /** * Handle verification of Google-signed JWT tokens. * * @author Jeff Ching * @since 0.21.0 */ public class TokenVerifier { private static final String IAP_CERT_URL = "https://www.gstatic.com/iap/verify/public_key-jwk"; private static final String FEDERATED_SIGNON_CERT_URL = "https://www.googleapis.com/oauth2/v3/certs"; private static final Set SUPPORTED_ALGORITHMS = ImmutableSet.of("RS256", "ES256"); private final String audience; private final String certificatesLocation; private final String issuer; private final PublicKey publicKey; private final Clock clock; private final LoadingCache> publicKeyCache; private TokenVerifier(Builder builder) { this.audience = builder.audience; this.certificatesLocation = builder.certificatesLocation; this.issuer = builder.issuer; this.publicKey = builder.publicKey; this.clock = builder.clock; this.publicKeyCache = CacheBuilder.newBuilder() .expireAfterWrite(1, TimeUnit.HOURS) .build(new PublicKeyLoader(builder.httpTransportFactory)); } public static Builder newBuilder() { return new Builder() .setClock(Clock.SYSTEM) .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY); } /** * Verify an encoded JWT token. * * @param token encoded JWT token * @return the parsed JsonWebSignature instance for additional validation if necessary * @throws VerificationException thrown if any verification fails */ public JsonWebSignature verify(String token) throws VerificationException { JsonWebSignature jsonWebSignature; try { jsonWebSignature = JsonWebSignature.parse(OAuth2Utils.JSON_FACTORY, token); } catch (IOException e) { throw new VerificationException("Error parsing JsonWebSignature token", e); } // Verify the expected audience if an audience is provided in the verifyOptions if (audience != null && !audience.equals(jsonWebSignature.getPayload().getAudience())) { throw new VerificationException("Expected audience does not match"); } // Verify the expected issuer if an issuer is provided in the verifyOptions if (issuer != null && !issuer.equals(jsonWebSignature.getPayload().getIssuer())) { throw new VerificationException("Expected issuer does not match"); } Long expiresAt = jsonWebSignature.getPayload().getExpirationTimeSeconds(); if (expiresAt != null && expiresAt <= clock.currentTimeMillis() / 1000) { throw new VerificationException("Token is expired"); } // Short-circuit signature types if (!SUPPORTED_ALGORITHMS.contains(jsonWebSignature.getHeader().getAlgorithm())) { throw new VerificationException( "Unexpected signing algorithm: expected either RS256 or ES256"); } PublicKey publicKeyToUse = publicKey; if (publicKeyToUse == null) { try { String certificateLocation = getCertificateLocation(jsonWebSignature); publicKeyToUse = publicKeyCache.get(certificateLocation).get(jsonWebSignature.getHeader().getKeyId()); } catch (ExecutionException | UncheckedExecutionException e) { throw new VerificationException("Error fetching PublicKey from certificate location", e); } } if (publicKeyToUse == null) { throw new VerificationException( "Could not find PublicKey for provided keyId: " + jsonWebSignature.getHeader().getKeyId()); } try { if (jsonWebSignature.verifySignature(publicKeyToUse)) { return jsonWebSignature; } throw new VerificationException("Invalid signature"); } catch (GeneralSecurityException e) { throw new VerificationException("Error validating token", e); } } private String getCertificateLocation(JsonWebSignature jsonWebSignature) throws VerificationException { if (certificatesLocation != null) return certificatesLocation; switch (jsonWebSignature.getHeader().getAlgorithm()) { case "RS256": return FEDERATED_SIGNON_CERT_URL; case "ES256": return IAP_CERT_URL; } throw new VerificationException("Unknown algorithm"); } public static class Builder { private String audience; private String certificatesLocation; private String issuer; private PublicKey publicKey; private Clock clock; private HttpTransportFactory httpTransportFactory; /** * Set a target audience to verify. * * @param audience the audience claim to verify * @return the builder */ public Builder setAudience(String audience) { this.audience = audience; return this; } /** * Override the location URL that contains published public keys. Defaults to well-known Google * locations. * * @param certificatesLocation URL to published public keys * @return the builder */ public Builder setCertificatesLocation(String certificatesLocation) { this.certificatesLocation = certificatesLocation; return this; } /** * Set the issuer to verify. * * @param issuer the issuer claim to verify * @return the builder */ public Builder setIssuer(String issuer) { this.issuer = issuer; return this; } /** * Set the PublicKey for verifying the signature. This will ignore the key id from the JWT token * header. * * @param publicKey the public key to validate the signature * @return the builder */ public Builder setPublicKey(PublicKey publicKey) { this.publicKey = publicKey; return this; } /** * Set the clock for checking token expiry. Used for testing. * * @param clock the clock to use. Defaults to the system clock * @return the builder */ public Builder setClock(Clock clock) { this.clock = clock; return this; } /** * Set the HttpTransportFactory used for requesting public keys from the certificate URL. Used * mostly for testing. * * @param httpTransportFactory the HttpTransportFactory used to build certificate URL requests * @return the builder */ public Builder setHttpTransportFactory(HttpTransportFactory httpTransportFactory) { this.httpTransportFactory = httpTransportFactory; return this; } /** * Build the custom TokenVerifier for verifying tokens. * * @return the customized TokenVerifier */ public TokenVerifier build() { return new TokenVerifier(this); } } /** Custom CacheLoader for mapping certificate urls to the contained public keys. */ static class PublicKeyLoader extends CacheLoader> { private static final int DEFAULT_NUMBER_OF_RETRIES = 2; private static final int INITIAL_RETRY_INTERVAL_MILLIS = 1000; private static final double RETRY_RANDOMIZATION_FACTOR = 0.1; private static final double RETRY_MULTIPLIER = 2; private final HttpTransportFactory httpTransportFactory; /** * Data class used for deserializing a JSON Web Key Set (JWKS) from an external HTTP request. */ public static class JsonWebKeySet extends GenericJson { @Key public List keys; } /** Data class used for deserializing a single JSON Web Key. */ public static class JsonWebKey { @Key public String alg; @Key public String crv; @Key public String kid; @Key public String kty; @Key public String use; @Key public String x; @Key public String y; @Key public String e; @Key public String n; } PublicKeyLoader(HttpTransportFactory httpTransportFactory) { super(); this.httpTransportFactory = httpTransportFactory; } @Override public Map load(String certificateUrl) throws Exception { HttpTransport httpTransport = httpTransportFactory.create(); JsonWebKeySet jwks; HttpRequest request = httpTransport .createRequestFactory() .buildGetRequest(new GenericUrl(certificateUrl)) .setParser(OAuth2Utils.JSON_FACTORY.createJsonObjectParser()); request.setNumberOfRetries(DEFAULT_NUMBER_OF_RETRIES); ExponentialBackOff backoff = new ExponentialBackOff.Builder() .setInitialIntervalMillis(INITIAL_RETRY_INTERVAL_MILLIS) .setRandomizationFactor(RETRY_RANDOMIZATION_FACTOR) .setMultiplier(RETRY_MULTIPLIER) .build(); request.setUnsuccessfulResponseHandler( new HttpBackOffUnsuccessfulResponseHandler(backoff) .setBackOffRequired(BackOffRequired.ALWAYS)); HttpResponse response = request.execute(); jwks = response.parseAs(JsonWebKeySet.class); ImmutableMap.Builder keyCacheBuilder = new ImmutableMap.Builder<>(); if (jwks.keys == null) { // Fall back to x509 formatted specification for (String keyId : jwks.keySet()) { String publicKeyPem = (String) jwks.get(keyId); keyCacheBuilder.put(keyId, buildPublicKey(publicKeyPem)); } } else { for (JsonWebKey key : jwks.keys) { try { keyCacheBuilder.put(key.kid, buildPublicKey(key)); } catch (NoSuchAlgorithmException | InvalidKeySpecException | InvalidParameterSpecException ignored) { ignored.printStackTrace(); } } } ImmutableMap keyCache = keyCacheBuilder.build(); if (keyCache.isEmpty()) { throw new VerificationException( "No valid public key returned by the keystore: " + certificateUrl); } return keyCache; } private PublicKey buildPublicKey(JsonWebKey key) throws NoSuchAlgorithmException, InvalidParameterSpecException, InvalidKeySpecException { if ("ES256".equals(key.alg)) { return buildEs256PublicKey(key); } else if ("RS256".equals((key.alg))) { return buildRs256PublicKey(key); } else { return null; } } private PublicKey buildPublicKey(String publicPem) throws CertificateException, UnsupportedEncodingException { return CertificateFactory.getInstance("X.509") .generateCertificate(new ByteArrayInputStream(publicPem.getBytes("UTF-8"))) .getPublicKey(); } private PublicKey buildRs256PublicKey(JsonWebKey key) throws NoSuchAlgorithmException, InvalidKeySpecException { Preconditions.checkArgument("RSA".equals(key.kty)); Preconditions.checkNotNull(key.e); Preconditions.checkNotNull(key.n); BigInteger modulus = new BigInteger(1, Base64.decodeBase64(key.n)); BigInteger exponent = new BigInteger(1, Base64.decodeBase64(key.e)); RSAPublicKeySpec spec = new RSAPublicKeySpec(modulus, exponent); KeyFactory factory = KeyFactory.getInstance("RSA"); return factory.generatePublic(spec); } private PublicKey buildEs256PublicKey(JsonWebKey key) throws NoSuchAlgorithmException, InvalidParameterSpecException, InvalidKeySpecException { Preconditions.checkArgument("EC".equals(key.kty)); Preconditions.checkArgument("P-256".equals(key.crv)); BigInteger x = new BigInteger(1, Base64.decodeBase64(key.x)); BigInteger y = new BigInteger(1, Base64.decodeBase64(key.y)); ECPoint pubPoint = new ECPoint(x, y); AlgorithmParameters parameters = AlgorithmParameters.getInstance("EC"); parameters.init(new ECGenParameterSpec("secp256r1")); ECParameterSpec ecParameters = parameters.getParameterSpec(ECParameterSpec.class); ECPublicKeySpec pubSpec = new ECPublicKeySpec(pubPoint, ecParameters); KeyFactory kf = KeyFactory.getInstance("EC"); return kf.generatePublic(pubSpec); } } /** Custom exception for wrapping all verification errors. */ public static class VerificationException extends Exception { public VerificationException(String message) { super(message); } public VerificationException(String message, Throwable cause) { super(message, cause); } } }