From 5014ac72a59d877ef95c616d0b33792b9fc70c25 Mon Sep 17 00:00:00 2001 From: Jeff Ching Date: Wed, 24 Jun 2020 12:58:12 -0700 Subject: [PATCH] feat: add TokenVerifier class that can verify RS256/ES256 tokens (#420) * feat: add TokenVerifier class that can verify RS256/ES256 tokens * test: inject HttpTransportFactory for testing * test: inject HttpTransportFactory for testing * fix: use google-http-client for actual signature verification * chore: lint * test: split test into unit and integration Unit tests mock out the http request activity. Integration tests hit the live urls. * chore: lint * fix: return the JsonWebSignature instance on verify * test: remove IT test as the signature keys can/will change over time * docs: add javadoc for TokenVerifier * docs: add guide for verifying tokens in the README * chore: remove auto-value config changes * chore: tense, lower-case first word, no period * chore: run formatter * chore: more javadoc fixes * chore: remove line from README example * sample: add snippet showing check for additional claim * fix: remove default constructor - users should always use builder --- README.md | 51 +++ .../com/google/auth/oauth2/TokenVerifier.java | 397 ++++++++++++++++++ .../google/auth/oauth2/TokenVerifierTest.java | 270 ++++++++++++ oauth2_http/testresources/federated_keys.json | 20 + oauth2_http/testresources/iap_keys.json | 49 +++ .../testresources/legacy_federated_keys.json | 4 + .../testresources/service_account_keys.json | 4 + 7 files changed, 795 insertions(+) create mode 100644 oauth2_http/java/com/google/auth/oauth2/TokenVerifier.java create mode 100644 oauth2_http/javatests/com/google/auth/oauth2/TokenVerifierTest.java create mode 100644 oauth2_http/testresources/federated_keys.json create mode 100644 oauth2_http/testresources/iap_keys.json create mode 100644 oauth2_http/testresources/legacy_federated_keys.json create mode 100644 oauth2_http/testresources/service_account_keys.json diff --git a/README.md b/README.md index 707a88fa3..9b1b9f18d 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,55 @@ Bigquery bq = new Bigquery.Builder(HTTP_TRANSPORT, JSON_FACTORY, requestInitiali .build(); ``` +## Verifying JWT Tokens (Beta) + +To verify a JWT token, use the [`TokenVerifier`][token-verifier] class. + +### Verifying a Signature + +To verify a signature, use the default [`TokenVerifier`][token-verifier]: + +```java +import com.google.api.client.json.webtoken.JsonWebSignature; +import com.google.auth.oauth2.TokenVerifier; + +TokenVerifier tokenVerifier = TokenVerifier.newBuilder().build(); +try { + JsonWebSignature jsonWebSignature = tokenVerifier.verify(tokenString); + // optionally verify additional claims + if (!"expected-value".equals(jsonWebSignature.getPayload().get("additional-claim"))) { + // handle custom verification error + } +} catch (TokenVerifier.VerificationException e) { + // invalid token +} +``` + +### Customizing the TokenVerifier + +To customize a [`TokenVerifier`][token-verifier], instantiate it via its builder: + +```java +import com.google.api.client.json.webtoken.JsonWebSignature; +import com.google.auth.oauth2.TokenVerifier; + +TokenVerifier tokenVerifier = TokenVerifier.newBuilder() + .setAudience("audience-to-verify") + .setIssuer("issuer-to-verify") + .build(); +try { + JsonWebSignature jsonWebSignature = tokenVerifier.verify(tokenString); + // optionally verify additional claims + if (!"expected-value".equals(jsonWebSignature.getPayload().get("additional-claim"))) { + // handle custom verification error + } +} catch (TokenVerifier.VerificationException e) { + // invalid token +} +``` + +For more options, see the [`TokenVerifier.Builder`][token-verifier-builder] documentation. + ## CI Status Java Version | Status @@ -283,5 +332,7 @@ BSD 3-Clause - See [LICENSE](LICENSE) for more information. [apiary-clients]: https://search.maven.org/search?q=g:com.google.apis [http-credentials-adapter]: https://googleapis.dev/java/google-auth-library/latest/index.html?com/google/auth/http/HttpCredentialsAdapter.html [http-request-initializer]: https://googleapis.dev/java/google-http-client/latest/index.html?com/google/api/client/http/HttpRequestInitializer.html +[token-verifier]: https://googleapis.dev/java/google-auth-library/latest/index.html?com/google/auth/oauth2/TokenVerifier.html +[token-verifier-builder]: https://googleapis.dev/java/google-auth-library/latest/index.html?com/google/auth/oauth2/TokenVerifier.Builder.html [http-transport-factory]: https://googleapis.dev/java/google-auth-library/latest/index.html?com/google/auth/http/HttpTransportFactory.html [google-credentials]: https://googleapis.dev/java/google-auth-library/latest/index.html?com/google/auth/oauth2/GoogleCredentials.html diff --git a/oauth2_http/java/com/google/auth/oauth2/TokenVerifier.java b/oauth2_http/java/com/google/auth/oauth2/TokenVerifier.java new file mode 100644 index 000000000..321b01217 --- /dev/null +++ b/oauth2_http/java/com/google/auth/oauth2/TokenVerifier.java @@ -0,0 +1,397 @@ +/* + * 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 + * + * https://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.auth.oauth2; + +import com.google.api.client.http.GenericUrl; +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.Key; +import com.google.auth.http.HttpTransportFactory; +import com.google.common.annotations.Beta; +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 + */ +@Beta +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 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; + try { + HttpRequest request = + httpTransport + .createRequestFactory() + .buildGetRequest(new GenericUrl(certificateUrl)) + .setParser(OAuth2Utils.JSON_FACTORY.createJsonObjectParser()); + HttpResponse response = request.execute(); + jwks = response.parseAs(JsonWebKeySet.class); + } catch (IOException io) { + return ImmutableMap.of(); + } + + 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(); + } + } + } + + return keyCacheBuilder.build(); + } + + 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); + } + } +} diff --git a/oauth2_http/javatests/com/google/auth/oauth2/TokenVerifierTest.java b/oauth2_http/javatests/com/google/auth/oauth2/TokenVerifierTest.java new file mode 100644 index 000000000..ef2448507 --- /dev/null +++ b/oauth2_http/javatests/com/google/auth/oauth2/TokenVerifierTest.java @@ -0,0 +1,270 @@ +/* + * 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 + * + * https://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.auth.oauth2; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import com.google.api.client.http.HttpTransport; +import com.google.api.client.http.LowLevelHttpRequest; +import com.google.api.client.http.LowLevelHttpResponse; +import com.google.api.client.testing.http.MockHttpTransport; +import com.google.api.client.testing.http.MockLowLevelHttpRequest; +import com.google.api.client.testing.http.MockLowLevelHttpResponse; +import com.google.api.client.util.Clock; +import com.google.auth.http.HttpTransportFactory; +import com.google.common.io.CharStreams; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.util.Arrays; +import java.util.List; +import org.junit.Test; + +public class TokenVerifierTest { + private static final String ES256_TOKEN = + "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Im1wZjBEQSJ9.eyJhdWQiOiIvcHJvamVjdHMvNjUyNTYyNzc2Nzk4L2FwcHMvY2xvdWQtc2FtcGxlcy10ZXN0cy1waHAtaWFwIiwiZW1haWwiOiJjaGluZ29yQGdvb2dsZS5jb20iLCJleHAiOjE1ODQwNDc2MTcsImdvb2dsZSI6eyJhY2Nlc3NfbGV2ZWxzIjpbImFjY2Vzc1BvbGljaWVzLzUxODU1MTI4MDkyNC9hY2Nlc3NMZXZlbHMvcmVjZW50U2VjdXJlQ29ubmVjdERhdGEiLCJhY2Nlc3NQb2xpY2llcy81MTg1NTEyODA5MjQvYWNjZXNzTGV2ZWxzL3Rlc3ROb09wIiwiYWNjZXNzUG9saWNpZXMvNTE4NTUxMjgwOTI0L2FjY2Vzc0xldmVscy9ldmFwb3JhdGlvblFhRGF0YUZ1bGx5VHJ1c3RlZCJdfSwiaGQiOiJnb29nbGUuY29tIiwiaWF0IjoxNTg0MDQ3MDE3LCJpc3MiOiJodHRwczovL2Nsb3VkLmdvb2dsZS5jb20vaWFwIiwic3ViIjoiYWNjb3VudHMuZ29vZ2xlLmNvbToxMTIxODE3MTI3NzEyMDE5NzI4OTEifQ.yKNtdFY5EKkRboYNexBdfugzLhC3VuGyFcuFYA8kgpxMqfyxa41zkML68hYKrWu2kOBTUW95UnbGpsIi_u1fiA"; + + private static final String FEDERATED_SIGNON_RS256_TOKEN = + "eyJhbGciOiJSUzI1NiIsImtpZCI6ImY5ZDk3YjRjYWU5MGJjZDc2YWViMjAwMjZmNmI3NzBjYWMyMjE3ODMiLCJ0eXAiOiJKV1QifQ.eyJhdWQiOiJodHRwczovL2V4YW1wbGUuY29tL3BhdGgiLCJhenAiOiJpbnRlZ3JhdGlvbi10ZXN0c0BjaGluZ29yLXRlc3QuaWFtLmdzZXJ2aWNlYWNjb3VudC5jb20iLCJlbWFpbCI6ImludGVncmF0aW9uLXRlc3RzQGNoaW5nb3ItdGVzdC5pYW0uZ3NlcnZpY2VhY2NvdW50LmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJleHAiOjE1ODc2Mjk4ODgsImlhdCI6MTU4NzYyNjI4OCwiaXNzIjoiaHR0cHM6Ly9hY2NvdW50cy5nb29nbGUuY29tIiwic3ViIjoiMTA0MDI5MjkyODUzMDk5OTc4MjkzIn0.Pj4KsJh7riU7ZIbPMcHcHWhasWEcbVjGP4yx_5E0iOpeDalTdri97E-o0dSSkuVX2FeBIgGUg_TNNgJ3YY97T737jT5DUYwdv6M51dDlLmmNqlu_P6toGCSRC8-Beu5gGmqS2Y82TmpHH9Vhoh5PsK7_rVHk8U6VrrVVKKTWm_IzTFhqX1oYKPdvfyaNLsXPbCt_NFE0C3DNmFkgVhRJu7LtzQQN-ghaqd3Ga3i6KH222OEI_PU4BUTvEiNOqRGoMlT_YOsyFN3XwqQ6jQGWhhkArL1z3CG2BVQjHTKpgVsRyy_H6WTZiju2Q-XWobgH-UPSZbyymV8-cFT9XKEtZQ"; + private static final String LEGACY_FEDERATED_SIGNON_CERT_URL = + "https://www.googleapis.com/oauth2/v1/certs"; + + private static final String SERVICE_ACCOUNT_RS256_TOKEN = + "eyJhbGciOiJSUzI1NiIsImtpZCI6IjJlZjc3YjM4YTFiMDM3MDQ4NzA0MzkxNmFjYmYyN2Q3NGVkZDA4YjEiLCJ0eXAiOiJKV1QifQ.eyJhdWQiOiJodHRwczovL2V4YW1wbGUuY29tL2F1ZGllbmNlIiwiZXhwIjoxNTg3NjMwNTQzLCJpYXQiOjE1ODc2MjY5NDMsImlzcyI6InNvbWUgaXNzdWVyIiwic3ViIjoic29tZSBzdWJqZWN0In0.gGOQW0qQgs4jGUmCsgRV83RqsJLaEy89-ZOG6p1u0Y26FyY06b6Odgd7xXLsSTiiSnch62dl0Lfi9D0x2ByxvsGOCbovmBl2ZZ0zHr1wpc4N0XS9lMUq5RJQbonDibxXG4nC2zroDfvD0h7i-L8KMXeJb9pYwW7LkmrM_YwYfJnWnZ4bpcsDjojmPeUBlACg7tjjOgBFbyQZvUtaERJwSRlaWibvNjof7eCVfZChE0PwBpZc_cGqSqKXv544L4ttqdCnmONjqrTATXwC4gYxruevkjHfYI5ojcQmXoWDJJ0-_jzfyPE4MFFdCFgzLgnfIOwe5ve0MtquKuv2O0pgvg"; + private static final String SERVICE_ACCOUNT_CERT_URL = + "https://www.googleapis.com/robot/v1/metadata/x509/integration-tests%40chingor-test.iam.gserviceaccount.com"; + + private static final List ALL_TOKENS = + Arrays.asList(ES256_TOKEN, FEDERATED_SIGNON_RS256_TOKEN, SERVICE_ACCOUNT_RS256_TOKEN); + + // Fixed to 2020-02-26 08:00:00 to allow expiration tests to pass + private static final Clock FIXED_CLOCK = + new Clock() { + @Override + public long currentTimeMillis() { + return 1582704000000L; + } + }; + + @Test + public void verifyExpiredToken() { + for (String token : ALL_TOKENS) { + TokenVerifier tokenVerifier = TokenVerifier.newBuilder().build(); + try { + tokenVerifier.verify(token); + fail("Should have thrown a VerificationException"); + } catch (TokenVerifier.VerificationException e) { + assertTrue(e.getMessage().contains("expired")); + } + } + } + + @Test + public void verifyExpectedAudience() { + TokenVerifier tokenVerifier = + TokenVerifier.newBuilder().setAudience("expected audience").build(); + for (String token : ALL_TOKENS) { + try { + tokenVerifier.verify(token); + fail("Should have thrown a VerificationException"); + } catch (TokenVerifier.VerificationException e) { + assertTrue(e.getMessage().contains("audience does not match")); + } + } + } + + @Test + public void verifyExpectedIssuer() { + TokenVerifier tokenVerifier = TokenVerifier.newBuilder().setIssuer("expected issuer").build(); + for (String token : ALL_TOKENS) { + try { + tokenVerifier.verify(token); + fail("Should have thrown a VerificationException"); + } catch (TokenVerifier.VerificationException e) { + assertTrue(e.getMessage().contains("issuer does not match")); + } + } + } + + @Test + public void verifyEs256Token404CertificateUrl() { + // Mock HTTP requests + HttpTransportFactory httpTransportFactory = + new HttpTransportFactory() { + @Override + public HttpTransport create() { + return new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) + throws IOException { + return new MockLowLevelHttpRequest() { + @Override + public LowLevelHttpResponse execute() throws IOException { + MockLowLevelHttpResponse response = new MockLowLevelHttpResponse(); + response.setStatusCode(404); + response.setContentType("application/json"); + response.setContent(""); + return response; + } + }; + } + }; + } + }; + TokenVerifier tokenVerifier = + TokenVerifier.newBuilder() + .setClock(FIXED_CLOCK) + .setHttpTransportFactory(httpTransportFactory) + .build(); + try { + tokenVerifier.verify(ES256_TOKEN); + } catch (TokenVerifier.VerificationException e) { + assertTrue(e.getMessage().contains("Could not find PublicKey")); + } + } + + @Test + public void verifyEs256TokenPublicKeyMismatch() { + // Mock HTTP requests + HttpTransportFactory httpTransportFactory = + new HttpTransportFactory() { + @Override + public HttpTransport create() { + return new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) + throws IOException { + return new MockLowLevelHttpRequest() { + @Override + public LowLevelHttpResponse execute() throws IOException { + MockLowLevelHttpResponse response = new MockLowLevelHttpResponse(); + response.setStatusCode(200); + response.setContentType("application/json"); + response.setContent(""); + return response; + } + }; + } + }; + } + }; + TokenVerifier tokenVerifier = + TokenVerifier.newBuilder() + .setClock(FIXED_CLOCK) + .setHttpTransportFactory(httpTransportFactory) + .build(); + try { + tokenVerifier.verify(ES256_TOKEN); + fail("Should have failed verification"); + } catch (TokenVerifier.VerificationException e) { + assertTrue(e.getMessage().contains("Error fetching PublicKey")); + } + } + + @Test + public void verifyEs256Token() throws TokenVerifier.VerificationException, IOException { + HttpTransportFactory httpTransportFactory = + mockTransport( + "https://www.gstatic.com/iap/verify/public_key-jwk", + readResourceAsString("iap_keys.json")); + TokenVerifier tokenVerifier = + TokenVerifier.newBuilder() + .setClock(FIXED_CLOCK) + .setHttpTransportFactory(httpTransportFactory) + .build(); + assertNotNull(tokenVerifier.verify(ES256_TOKEN)); + } + + @Test + public void verifyRs256Token() throws TokenVerifier.VerificationException, IOException { + HttpTransportFactory httpTransportFactory = + mockTransport( + "https://www.googleapis.com/oauth2/v3/certs", + readResourceAsString("federated_keys.json")); + TokenVerifier tokenVerifier = + TokenVerifier.newBuilder() + .setClock(FIXED_CLOCK) + .setHttpTransportFactory(httpTransportFactory) + .build(); + assertNotNull(tokenVerifier.verify(FEDERATED_SIGNON_RS256_TOKEN)); + } + + @Test + public void verifyRs256TokenWithLegacyCertificateUrlFormat() + throws TokenVerifier.VerificationException, IOException { + HttpTransportFactory httpTransportFactory = + mockTransport( + LEGACY_FEDERATED_SIGNON_CERT_URL, readResourceAsString("legacy_federated_keys.json")); + TokenVerifier tokenVerifier = + TokenVerifier.newBuilder() + .setCertificatesLocation(LEGACY_FEDERATED_SIGNON_CERT_URL) + .setClock(FIXED_CLOCK) + .setHttpTransportFactory(httpTransportFactory) + .build(); + assertNotNull(tokenVerifier.verify(FEDERATED_SIGNON_RS256_TOKEN)); + } + + @Test + public void verifyServiceAccountRs256Token() + throws TokenVerifier.VerificationException, IOException { + HttpTransportFactory httpTransportFactory = + mockTransport(SERVICE_ACCOUNT_CERT_URL, readResourceAsString("service_account_keys.json")); + TokenVerifier tokenVerifier = + TokenVerifier.newBuilder() + .setClock(FIXED_CLOCK) + .setCertificatesLocation(SERVICE_ACCOUNT_CERT_URL) + .build(); + assertNotNull(tokenVerifier.verify(SERVICE_ACCOUNT_RS256_TOKEN)); + } + + static String readResourceAsString(String resourceName) throws IOException { + InputStream inputStream = + TokenVerifierTest.class.getClassLoader().getResourceAsStream(resourceName); + try (final Reader reader = new InputStreamReader(inputStream)) { + return CharStreams.toString(reader); + } + } + + static HttpTransportFactory mockTransport(String url, String certificates) { + final String certificatesContent = certificates; + final String certificatesUrl = url; + return new HttpTransportFactory() { + @Override + public HttpTransport create() { + return new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) throws IOException { + assertEquals(certificatesUrl, url); + return new MockLowLevelHttpRequest() { + @Override + public LowLevelHttpResponse execute() throws IOException { + MockLowLevelHttpResponse response = new MockLowLevelHttpResponse(); + response.setStatusCode(200); + response.setContentType("application/json"); + response.setContent(certificatesContent); + return response; + } + }; + } + }; + } + }; + } +} diff --git a/oauth2_http/testresources/federated_keys.json b/oauth2_http/testresources/federated_keys.json new file mode 100644 index 000000000..9986a8e0f --- /dev/null +++ b/oauth2_http/testresources/federated_keys.json @@ -0,0 +1,20 @@ +{ + "keys": [ + { + "kid": "f9d97b4cae90bcd76aeb20026f6b770cac221783", + "e": "AQAB", + "kty": "RSA", + "alg": "RS256", + "n": "ya_7gVJrvqFp5xfYPOco8gBLY38kQDlTlT6ueHtUtbTkRVE1X5tFmPqChnX7wWd2fK7MS4-nclYaGLL7IvJtN9tjrD0h_3_HvnrRZTaVyS-yfWqCQDRq_0VW1LBEygwYRqbO2T0lOocTY-5qUosDvJfe-o-lQYMH7qtDAyiq9XprVzKYTfS545BTECXi0he9ikJl5Q_RAP1BZoaip8F0xX5Y_60G90VyXFWuy16nm5ASW8fwqzdn1lL_ogiO1LirgBFFEXz_t4PwmjWzfQwkoKv4Ab_l9u2FdAoKtFH2CwKaGB8hatIK3bOAJJgRebeU3w6Ah3gxRfi8HWPHbAGjtw", + "use": "sig" + }, + { + "kid": "28b741e8de984a47159f19e6d7783e9d4fa810db", + "e": "AQAB", + "kty": "RSA", + "alg": "RS256", + "n": "zc4ELn-9nLzCZb4PdXGVhtUtzwmQI8HZH8tOIEg9omx6CW-PZ5xtVQ5O5EBG2AA5_K-aOWvVEWyfeHe8WwZltM1cXu6QNdXbpVVYeZ0th9hm7ZflNz7h1PMM9lNXLJjokax5gxGskc8CsjhkwurEot1TD2zbGIQsOYoebQTvJ2AYxIjk77BU20nLplurge8jrK-V1G3zJlp0xIKqxjsfIFYm1Mp-HQhJzdMbjNEScs0dDT4rPxdA-wOVGix0wrPdIE1gM4GxZ7AlSZ7IcjuYMZIe6d6oAeKG0FG0avbtipAQglxTHM3UOge6PmThr_mmiI82oLqGutul-XYgy1S2NQ", + "use": "sig" + } + ] +} \ No newline at end of file diff --git a/oauth2_http/testresources/iap_keys.json b/oauth2_http/testresources/iap_keys.json new file mode 100644 index 000000000..2ba2bfa01 --- /dev/null +++ b/oauth2_http/testresources/iap_keys.json @@ -0,0 +1,49 @@ +{ + "keys" : [ + { + "alg" : "ES256", + "crv" : "P-256", + "kid" : "2nMJtw", + "kty" : "EC", + "use" : "sig", + "x" : "9e1x7YRZg53A5zIJ0p2ZQ9yTrgPLGIf4ntOk-4O2R28", + "y" : "q8iDm7nsnpz1xPdrWBtTZSowzciS3O7bMYtFFJ8saYo" + }, + { + "alg" : "ES256", + "crv" : "P-256", + "kid" : "LYyP2g", + "kty" : "EC", + "use" : "sig", + "x" : "SlXFFkJ3JxMsXyXNrqzE3ozl_0913PmNbccLLWfeQFU", + "y" : "GLSahrZfBErmMUcHP0MGaeVnJdBwquhrhQ8eP05NfCI" + }, + { + "alg" : "ES256", + "crv" : "P-256", + "kid" : "mpf0DA", + "kty" : "EC", + "use" : "sig", + "x" : "fHEdeT3a6KaC1kbwov73ZwB_SiUHEyKQwUUtMCEn0aI", + "y" : "QWOjwPhInNuPlqjxLQyhveXpWqOFcQPhZ3t-koMNbZI" + }, + { + "alg" : "ES256", + "crv" : "P-256", + "kid" : "b9vTLA", + "kty" : "EC", + "use" : "sig", + "x" : "qCByTAvci-jRAD7uQSEhTdOs8iA714IbcY2L--YzynI", + "y" : "WQY0uCoQyPSozWKGQ0anmFeOH5JNXiZa9i6SNqOcm7w" + }, + { + "alg" : "ES256", + "crv" : "P-256", + "kid" : "0oeLcQ", + "kty" : "EC", + "use" : "sig", + "x" : "MdhRXGEoGJLtBjQEIjnYLPkeci9rXnca2TffkI0Kac0", + "y" : "9BoREHfX7g5OK8ELpA_4RcOnFCGSjfR4SGZpBo7juEY" + } + ] +} \ No newline at end of file diff --git a/oauth2_http/testresources/legacy_federated_keys.json b/oauth2_http/testresources/legacy_federated_keys.json new file mode 100644 index 000000000..3a5748399 --- /dev/null +++ b/oauth2_http/testresources/legacy_federated_keys.json @@ -0,0 +1,4 @@ +{ + "f9d97b4cae90bcd76aeb20026f6b770cac221783": "-----BEGIN CERTIFICATE-----\nMIIDJjCCAg6gAwIBAgIILRTfnfU3e2gwDQYJKoZIhvcNAQEFBQAwNjE0MDIGA1UE\nAxMrZmVkZXJhdGVkLXNpZ25vbi5zeXN0ZW0uZ3NlcnZpY2VhY2NvdW50LmNvbTAe\nFw0yMDA0MTQwNDI5MzBaFw0yMDA0MzAxNjQ0MzBaMDYxNDAyBgNVBAMTK2ZlZGVy\nYXRlZC1zaWdub24uc3lzdGVtLmdzZXJ2aWNlYWNjb3VudC5jb20wggEiMA0GCSqG\nSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDJr/uBUmu+oWnnF9g85yjyAEtjfyRAOVOV\nPq54e1S1tORFUTVfm0WY+oKGdfvBZ3Z8rsxLj6dyVhoYsvsi8m0322OsPSH/f8e+\netFlNpXJL7J9aoJANGr/RVbUsETKDBhGps7ZPSU6hxNj7mpSiwO8l976j6VBgwfu\nq0MDKKr1emtXMphN9LnjkFMQJeLSF72KQmXlD9EA/UFmhqKnwXTFflj/rQb3RXJc\nVa7LXqebkBJbx/CrN2fWUv+iCI7UuKuAEUURfP+3g/CaNbN9DCSgq/gBv+X27YV0\nCgq0UfYLApoYHyFq0grds4AkmBF5t5TfDoCHeDFF+LwdY8dsAaO3AgMBAAGjODA2\nMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBYGA1UdJQEB/wQMMAoGCCsG\nAQUFBwMCMA0GCSqGSIb3DQEBBQUAA4IBAQA1Wrx3XsIAAYOaycAkV2mZW1j+Vqxx\nSAeUyuhLoaJ7jntd7LqTuTr+qRnR/fH/CjTbPzngvCyVE6hjClh159YRpf4TJ4aL\nMJ97qDxc/f/pM/7yklIaHHOwqYU10plIyw+m0dnQutPqy1o/aDUytDznNmM6L3v+\ncot2bxyd2PtjGfa1hPNNnEnrZfS2Gc0qqR64RUWbsdLVVQB8MKcaNUqjk9o/1O4p\nNNk2D2VcofdaLPpwSmtzV8wEd4vfzI17qFSPi6gbTfydvxkejk0kdSyWUPw+1YC4\nv2o2rzwXub9hcP2zXyZvTGKPMAkZ8VKuzWuvfuSsTtgcPJ20GpIkin/j\n-----END CERTIFICATE-----\n", + "28b741e8de984a47159f19e6d7783e9d4fa810db": "-----BEGIN CERTIFICATE-----\nMIIDJjCCAg6gAwIBAgIIcog+uwMaMb8wDQYJKoZIhvcNAQEFBQAwNjE0MDIGA1UE\nAxMrZmVkZXJhdGVkLXNpZ25vbi5zeXN0ZW0uZ3NlcnZpY2VhY2NvdW50LmNvbTAe\nFw0yMDA0MjIwNDI5MzBaFw0yMDA1MDgxNjQ0MzBaMDYxNDAyBgNVBAMTK2ZlZGVy\nYXRlZC1zaWdub24uc3lzdGVtLmdzZXJ2aWNlYWNjb3VudC5jb20wggEiMA0GCSqG\nSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDNzgQuf72cvMJlvg91cZWG1S3PCZAjwdkf\ny04gSD2ibHoJb49nnG1VDk7kQEbYADn8r5o5a9URbJ94d7xbBmW0zVxe7pA11dul\nVVh5nS2H2Gbtl+U3PuHU8wz2U1csmOiRrHmDEayRzwKyOGTC6sSi3VMPbNsYhCw5\nih5tBO8nYBjEiOTvsFTbScumW6uB7yOsr5XUbfMmWnTEgqrGOx8gVibUyn4dCEnN\n0xuM0RJyzR0NPis/F0D7A5UaLHTCs90gTWAzgbFnsCVJnshyO5gxkh7p3qgB4obQ\nUbRq9u2KkBCCXFMczdQ6B7o+ZOGv+aaIjzaguoa626X5diDLVLY1AgMBAAGjODA2\nMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBYGA1UdJQEB/wQMMAoGCCsG\nAQUFBwMCMA0GCSqGSIb3DQEBBQUAA4IBAQBEfCN7qgI2GJJAC99PDbafqC1EMBlv\nBT/7UiQdTuDV04+cQH9IpzROW7IZc/ILcqpF6KXUmj6j0sWO+hxKFY66TJKPcypK\n/ZMI58epwwVgGZyYU0BbAIZ9uvOgDfuveMildlMDMg1cJNp7WjBrEJ2DcGfC56wJ\nuKvqB1upxnfh+Ceg3ApU50k6Ld6+dbDDR0Vzt/wGZlZZ5Uj6AwDFe+5p9zEpWg61\nHeny/tSBfgZ19vP2h3ye9ZTK1OFRMNufj8iSzmlkbSqWuy82XVSBRKy5QslqXsYe\nU3gM3EVvXHA/Of3sROFpvznCXNr+Kn03wTv0ny6rnSgHQUzj7p9fydXY\n-----END CERTIFICATE-----\n" +} \ No newline at end of file diff --git a/oauth2_http/testresources/service_account_keys.json b/oauth2_http/testresources/service_account_keys.json new file mode 100644 index 000000000..361bb2e4d --- /dev/null +++ b/oauth2_http/testresources/service_account_keys.json @@ -0,0 +1,4 @@ +{ + "a8611b6a9c0a0a8b940d0f915c326fd1605c8ac6": "-----BEGIN CERTIFICATE-----\nMIIDPDCCAiSgAwIBAgIIFJsPvyc/ZSUwDQYJKoZIhvcNAQEFBQAwQTE/MD0GA1UE\nAxM2aW50ZWdyYXRpb24tdGVzdHMuY2hpbmdvci10ZXN0LmlhbS5nc2VydmljZWFj\nY291bnQuY29tMB4XDTIwMDQwMjIyMjIxN1oXDTIyMDUwMTEzNTYxNVowQTE/MD0G\nA1UEAxM2aW50ZWdyYXRpb24tdGVzdHMuY2hpbmdvci10ZXN0LmlhbS5nc2Vydmlj\nZWFjY291bnQuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6Yys\nP5LIa1rRxQY93FXIJDzq6Tai4VuetffJbltRtYbdwC5Vyl99O2zoVdRlg+iYXK5B\nb6kidjmWOf0kNimQ5FwYvu+xsm6w8vjL/XShkHEKiURszyCua8wvLeGVCiGBg/XU\nDOgYMjzRIH5fTuj3PTZk4sMj02ZCpCQEMQ6ogpLXjaLp3ZXtFhkuHyCxVYbTRr+k\nGU86JAg4XwD6AdC349v+8FEQD7YtJezUAAKEgXh9e5UeL5CpOo3Vsdv/yEVo00jh\nYuWzLM6Oxt55WAhiD29vKrm7VQPSr1XwwqpdyFL2BlmqyTlb3amwvc9qv2kojGvM\nSUqgS83dc0jFqtMvEQIDAQABozgwNjAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQE\nAwIHgDAWBgNVHSUBAf8EDDAKBggrBgEFBQcDAjANBgkqhkiG9w0BAQUFAAOCAQEA\nm3XUMKOtXdpw0oRjykLHdYzTHHFjQTMmhWD172rsxSzwpkFoErAC7bnmEvpcRU7D\nr4M+pE5VuDJ64J3lEpAh7W0zMXezPtGyWr39hVxL3vp3nh4CbCzzkUCfFvBOFFhm\nOI9qnjtMtaozoGi5zLs5jEaFmgR3wfij9KQjNGZJxAg0ZkwcSNb76qOCG1/vG5au\n4UuoIaq8WqSxMqBF/g+NrAE2PZhjNGnUwFPTre3SyR0otYDzJfmpL/tp5VDie8hM\nL5UZU/CmZk46+T9VbvnZ5mkPAjGiPumiptO5iliBOHPtPdn8VrP+aSQM1btHA094\n1HwfbFp7pZHBUn9COAP/1Q==\n-----END CERTIFICATE-----\n", + "2ef77b38a1b0370487043916acbf27d74edd08b1": "-----BEGIN CERTIFICATE-----\nMIIC+jCCAeKgAwIBAgIIIwRR4+AftjswDQYJKoZIhvcNAQEFBQAwIDEeMBwGA1UE\nAxMVMTA0MDI5MjkyODUzMDk5OTc4MjkzMB4XDTIwMDMwNDA1NTIyMloXDTMwMDMw\nMjA1NTIyMlowIDEeMBwGA1UEAxMVMTA0MDI5MjkyODUzMDk5OTc4MjkzMIIBIjAN\nBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAm4jAbNdDEDkG/36wP07lFKMTAnWy\nhtV/Vp4QFSIE456moU/HEmBwJX2wocPgvoxPat7FxUv7mwdgHq7+sczis4DrDIIY\n8XfZ+D98+X+rOfkS1WLXpO76REZE4JCUfkB3NKVMP0kfoCFPf2pafz1NJRrZczUw\nbSi/q1+KYHmbk8YS+Q7Iq7gW9dvQtWrsRH8dQIrToJfGH+rbSQyKUFN7skFOflw4\n/OSuT0wvD6z57JcRFtAD3zgeUuCPNRIbkPQC3vCLwWGLKSYWLJ3eM9PPW9bk+czf\nSxJOie7zRMToh4BchLO6ZQgshoEaBHbwdOTu8455skqlRJMU9SKwA6eqVQIDAQAB\nozgwNjAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8EDDAK\nBggrBgEFBQcDAjANBgkqhkiG9w0BAQUFAAOCAQEAXvt8M2GFK+5UHG0GclIqse8j\n+EgqXvDTkbeTxFP+onbA/RwKM+fzdYpDwrH1dQ6jJervmceewUTTMegfFzhF33GC\nxvjQfhs+yVOXQiBHosd93CgR19dMUYs/r1wuUpwqBGdW2S81ns3yreY72BHrikrl\nHNLD3aSJ6hq5CZ01EFpjTW10ndBdPhJRSWD2g8VI1lpd716HEmrXfPHX73KVkk5/\nWfvrMA1UK/Ag+TWQerKG3iQFUAPIUiyepdaG4uFWTBY9nzLPiC1cx3bVPVZ+5yul\nJN15hmAMd3qPgSbbeQ6JC72zXCfW3buBE2n9cGtRbZF1URJZ3NbvwRS5BD425g==\n-----END CERTIFICATE-----\n" +} \ No newline at end of file