From 3f37172638c9252fcec7ed6437a333c71af87c71 Mon Sep 17 00:00:00 2001 From: Jeff Ching Date: Mon, 5 Aug 2019 12:49:01 -0700 Subject: [PATCH] feat: add JwtCredentials with custom claims (#290) * Implement JwtCredentials class. Switch cache to index by claims * DI clock and lifeSpanSeconds * Adding Serializable and adding test * Lock for JwtCredentials * Add tests for verifying access tokens and withClaims * Add tests for withClaims for ServiceAccountJwtAccessCredentials * Fix dependency issues * Add CLOCK_SKEW (5 minutes) for shouldRefresh() * Adding some javadocs * withClaims -> jwtWithClaims and create JwtProvider interface * Fix javadoc * Address some PR review nits * expiry -> expiryInSeconds * Disallow null values in JwtCredentials.Builder * Update license header for the added files * Address PR review comments * Remove extra whitespace * Fix lint * Refactor JwtCredentials.Claims -> JwtClaims * fix formatting * javadocs 'New claims' -> 'new claims' --- .../com/google/auth/oauth2/JwtClaims.java | 108 ++++++ .../google/auth/oauth2/JwtCredentials.java | 259 +++++++++++++++ .../com/google/auth/oauth2/JwtProvider.java | 48 +++ .../oauth2/ServiceAccountCredentials.java | 22 +- .../ServiceAccountJwtAccessCredentials.java | 104 +++--- .../auth/oauth2/JwtCredentialsTest.java | 314 ++++++++++++++++++ ...erviceAccountJwtAccessCredentialsTest.java | 53 +++ oauth2_http/pom.xml | 20 ++ pom.xml | 23 ++ renovate.json | 4 + 10 files changed, 903 insertions(+), 52 deletions(-) create mode 100644 oauth2_http/java/com/google/auth/oauth2/JwtClaims.java create mode 100644 oauth2_http/java/com/google/auth/oauth2/JwtCredentials.java create mode 100644 oauth2_http/java/com/google/auth/oauth2/JwtProvider.java create mode 100644 oauth2_http/javatests/com/google/auth/oauth2/JwtCredentialsTest.java diff --git a/oauth2_http/java/com/google/auth/oauth2/JwtClaims.java b/oauth2_http/java/com/google/auth/oauth2/JwtClaims.java new file mode 100644 index 000000000..a3120b1ab --- /dev/null +++ b/oauth2_http/java/com/google/auth/oauth2/JwtClaims.java @@ -0,0 +1,108 @@ +/* + * Copyright 2019, 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.auto.value.AutoValue; +import java.io.Serializable; +import javax.annotation.Nullable; + +/** + * Value class representing the set of fields used as the payload of a JWT token. + * + *

To create and customize claims, use the builder: + * + *


+ * Claims claims = Claims.newBuilder()
+ *     .setAudience("https://example.com/some-audience")
+ *     .setIssuer("some-issuer@example.com")
+ *     .setSubject("some-subject@example.com")
+ *     .build();
+ * 
+ */ +@AutoValue +public abstract class JwtClaims implements Serializable { + private static final long serialVersionUID = 4974444151019426702L; + + @Nullable + abstract String getAudience(); + + @Nullable + abstract String getIssuer(); + + @Nullable + abstract String getSubject(); + + static Builder newBuilder() { + return new AutoValue_JwtClaims.Builder(); + } + + /** + * Returns a new Claims instance with overridden fields. + * + *

Any non-null field will overwrite the value from the original claims instance. + * + * @param other claims to override + * @return new claims + */ + public JwtClaims merge(JwtClaims other) { + return newBuilder() + .setAudience(other.getAudience() == null ? getAudience() : other.getAudience()) + .setIssuer(other.getIssuer() == null ? getIssuer() : other.getIssuer()) + .setSubject(other.getSubject() == null ? getSubject() : other.getSubject()) + .build(); + } + + /** + * Returns whether or not this set of claims is complete. + * + *

Audience, issuer, and subject are required to be set in order to use the claim set for a JWT + * token. An incomplete Claims instance is useful for overriding claims when using {@link + * ServiceAccountJwtAccessCredentials#jwtWithClaims(JwtClaims)} or {@link + * JwtCredentials#jwtWithClaims(JwtClaims)}. + * + * @return + */ + public boolean isComplete() { + return getAudience() != null && getIssuer() != null && getSubject() != null; + } + + @AutoValue.Builder + abstract static class Builder { + abstract Builder setAudience(String audience); + + abstract Builder setIssuer(String issuer); + + abstract Builder setSubject(String subject); + + abstract JwtClaims build(); + } +} diff --git a/oauth2_http/java/com/google/auth/oauth2/JwtCredentials.java b/oauth2_http/java/com/google/auth/oauth2/JwtCredentials.java new file mode 100644 index 000000000..92af7cdaf --- /dev/null +++ b/oauth2_http/java/com/google/auth/oauth2/JwtCredentials.java @@ -0,0 +1,259 @@ +/* + * Copyright 2019, 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.json.webtoken.JsonWebSignature; +import com.google.api.client.json.webtoken.JsonWebToken; +import com.google.api.client.util.Clock; +import com.google.auth.Credentials; +import com.google.auth.http.AuthHttpConstants; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Preconditions; +import java.io.IOException; +import java.net.URI; +import java.security.GeneralSecurityException; +import java.security.PrivateKey; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +/** + * Credentials class for calling Google APIs using a JWT with custom claims. + * + *

Uses a JSON Web Token (JWT) directly in the request metadata to provide authorization. + * + *


+ * JwtClaims claims = JwtClaims.newBuilder()
+ *     .setAudience("https://example.com/some-audience")
+ *     .setIssuer("some-issuer@example.com")
+ *     .setSubject("some-subject@example.com")
+ *     .build();
+ * Credentials = JwtCredentials.newBuilder()
+ *     .setPrivateKey(privateKey)
+ *     .setPrivateKeyId("private-key-id")
+ *     .setJwtClaims(claims)
+ *     .build();
+ * 
+ */ +public class JwtCredentials extends Credentials implements JwtProvider { + private static final String JWT_ACCESS_PREFIX = OAuth2Utils.BEARER_PREFIX; + private static final String JWT_INCOMPLETE_ERROR_MESSAGE = + "JWT claims must contain audience, " + "issuer, and subject."; + private static final long CLOCK_SKEW = TimeUnit.MINUTES.toSeconds(5); + + // byte[] is serializable, so the lock variable can be final + private final Object lock = new byte[0]; + private final PrivateKey privateKey; + private final String privateKeyId; + private final JwtClaims jwtClaims; + private final Long lifeSpanSeconds; + @VisibleForTesting transient Clock clock; + + private transient String jwt; + // The date (represented as seconds since the epoch) that the generated JWT expires + private transient Long expiryInSeconds; + + private JwtCredentials(Builder builder) { + this.privateKey = Preconditions.checkNotNull(builder.getPrivateKey()); + this.privateKeyId = Preconditions.checkNotNull(builder.getPrivateKeyId()); + this.jwtClaims = Preconditions.checkNotNull(builder.getJwtClaims()); + Preconditions.checkState(jwtClaims.isComplete(), JWT_INCOMPLETE_ERROR_MESSAGE); + this.lifeSpanSeconds = Preconditions.checkNotNull(builder.getLifeSpanSeconds()); + this.clock = Preconditions.checkNotNull(builder.getClock()); + } + + public static Builder newBuilder() { + return new Builder(); + } + + /** Refresh the token by discarding the cached token and metadata and rebuilding a new one. */ + @Override + public void refresh() throws IOException { + JsonWebSignature.Header header = new JsonWebSignature.Header(); + header.setAlgorithm("RS256"); + header.setType("JWT"); + header.setKeyId(privateKeyId); + + JsonWebToken.Payload payload = new JsonWebToken.Payload(); + payload.setAudience(jwtClaims.getAudience()); + payload.setIssuer(jwtClaims.getIssuer()); + payload.setSubject(jwtClaims.getSubject()); + + long currentTime = clock.currentTimeMillis(); + payload.setIssuedAtTimeSeconds(currentTime / 1000); + payload.setExpirationTimeSeconds(currentTime / 1000 + lifeSpanSeconds); + + synchronized (lock) { + this.expiryInSeconds = payload.getExpirationTimeSeconds(); + + try { + this.jwt = + JsonWebSignature.signUsingRsaSha256( + privateKey, OAuth2Utils.JSON_FACTORY, header, payload); + } catch (GeneralSecurityException e) { + throw new IOException( + "Error signing service account JWT access header with private key.", e); + } + } + } + + private boolean shouldRefresh() { + return expiryInSeconds == null + || getClock().currentTimeMillis() / 1000 > expiryInSeconds - CLOCK_SKEW; + } + + /** + * Returns a copy of these credentials with modified claims. + * + * @param newClaims new claims. Any unspecified claim fields default to the the current values. + * @return new credentials + */ + @Override + public JwtCredentials jwtWithClaims(JwtClaims newClaims) { + return JwtCredentials.newBuilder() + .setPrivateKey(privateKey) + .setPrivateKeyId(privateKeyId) + .setJwtClaims(jwtClaims.merge(newClaims)) + .build(); + } + + @Override + public String getAuthenticationType() { + return "JWT"; + } + + @Override + public Map> getRequestMetadata(URI uri) throws IOException { + synchronized (lock) { + if (shouldRefresh()) { + refresh(); + } + List newAuthorizationHeaders = Collections.singletonList(JWT_ACCESS_PREFIX + jwt); + return Collections.singletonMap(AuthHttpConstants.AUTHORIZATION, newAuthorizationHeaders); + } + } + + @Override + public boolean hasRequestMetadata() { + return true; + } + + @Override + public boolean hasRequestMetadataOnly() { + return true; + } + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof JwtCredentials)) { + return false; + } + JwtCredentials other = (JwtCredentials) obj; + return Objects.equals(this.privateKey, other.privateKey) + && Objects.equals(this.privateKeyId, other.privateKeyId) + && Objects.equals(this.jwtClaims, other.jwtClaims) + && Objects.equals(this.lifeSpanSeconds, other.lifeSpanSeconds); + } + + @Override + public int hashCode() { + return Objects.hash(this.privateKey, this.privateKeyId, this.jwtClaims, this.lifeSpanSeconds); + } + + Clock getClock() { + if (clock == null) { + clock = Clock.SYSTEM; + } + return clock; + } + + public static class Builder { + private PrivateKey privateKey; + private String privateKeyId; + private JwtClaims jwtClaims; + private Clock clock = Clock.SYSTEM; + private Long lifeSpanSeconds = TimeUnit.HOURS.toSeconds(1); + + protected Builder() {} + + public Builder setPrivateKey(PrivateKey privateKey) { + this.privateKey = Preconditions.checkNotNull(privateKey); + return this; + } + + public PrivateKey getPrivateKey() { + return privateKey; + } + + public Builder setPrivateKeyId(String privateKeyId) { + this.privateKeyId = Preconditions.checkNotNull(privateKeyId); + return this; + } + + public String getPrivateKeyId() { + return privateKeyId; + } + + public Builder setJwtClaims(JwtClaims claims) { + this.jwtClaims = Preconditions.checkNotNull(claims); + return this; + } + + public JwtClaims getJwtClaims() { + return jwtClaims; + } + + public Builder setLifeSpanSeconds(Long lifeSpanSeconds) { + this.lifeSpanSeconds = Preconditions.checkNotNull(lifeSpanSeconds); + return this; + } + + public Long getLifeSpanSeconds() { + return lifeSpanSeconds; + } + + Builder setClock(Clock clock) { + this.clock = Preconditions.checkNotNull(clock); + return this; + } + + Clock getClock() { + return clock; + } + + public JwtCredentials build() { + return new JwtCredentials(this); + } + } +} diff --git a/oauth2_http/java/com/google/auth/oauth2/JwtProvider.java b/oauth2_http/java/com/google/auth/oauth2/JwtProvider.java new file mode 100644 index 000000000..eadee9600 --- /dev/null +++ b/oauth2_http/java/com/google/auth/oauth2/JwtProvider.java @@ -0,0 +1,48 @@ +/* + * Copyright 2019, 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.common.annotations.Beta; + +/** Interface for creating custom JWT tokens */ +@Beta +public interface JwtProvider { + + /** + * Returns a new JwtCredentials instance with modified claims. + * + * @param newClaims new claims. Any unspecified claim fields will default to the the current + * values. + * @return new credentials + */ + JwtCredentials jwtWithClaims(JwtClaims newClaims); +} diff --git a/oauth2_http/java/com/google/auth/oauth2/ServiceAccountCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ServiceAccountCredentials.java index da89f9685..e70af3a70 100644 --- a/oauth2_http/java/com/google/auth/oauth2/ServiceAccountCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/ServiceAccountCredentials.java @@ -83,7 +83,8 @@ * *

By default uses a JSON Web Token (JWT) to fetch access tokens. */ -public class ServiceAccountCredentials extends GoogleCredentials implements ServiceAccountSigner { +public class ServiceAccountCredentials extends GoogleCredentials + implements JwtProvider, ServiceAccountSigner { private static final long serialVersionUID = 7807543542681217978L; private static final String GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer"; @@ -525,6 +526,25 @@ public byte[] sign(byte[] toSign) { } } + /** + * Returns a new JwtCredentials instance with modified claims. + * + * @param newClaims new claims. Any unspecified claim fields will default to the the current + * values. + * @return new credentials + */ + @Override + public JwtCredentials jwtWithClaims(JwtClaims newClaims) { + JwtClaims.Builder claimsBuilder = + JwtClaims.newBuilder().setIssuer(clientEmail).setSubject(clientEmail); + return JwtCredentials.newBuilder() + .setPrivateKey(privateKey) + .setPrivateKeyId(privateKeyId) + .setJwtClaims(claimsBuilder.build().merge(newClaims)) + .setClock(clock) + .build(); + } + @Override public int hashCode() { return Objects.hash( diff --git a/oauth2_http/java/com/google/auth/oauth2/ServiceAccountJwtAccessCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ServiceAccountJwtAccessCredentials.java index 16b61c6d4..12aa73775 100644 --- a/oauth2_http/java/com/google/auth/oauth2/ServiceAccountJwtAccessCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/ServiceAccountJwtAccessCredentials.java @@ -36,14 +36,11 @@ import com.google.api.client.json.GenericJson; import com.google.api.client.json.JsonFactory; import com.google.api.client.json.JsonObjectParser; -import com.google.api.client.json.webtoken.JsonWebSignature; -import com.google.api.client.json.webtoken.JsonWebToken; import com.google.api.client.util.Clock; import com.google.api.client.util.Preconditions; import com.google.auth.Credentials; import com.google.auth.RequestMetadataCallback; import com.google.auth.ServiceAccountSigner; -import com.google.auth.http.AuthHttpConstants; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.MoreObjects; import com.google.common.base.Throwables; @@ -56,13 +53,11 @@ import java.io.InputStream; import java.io.ObjectInputStream; import java.net.URI; -import java.security.GeneralSecurityException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; import java.security.Signature; import java.security.SignatureException; -import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Objects; @@ -76,18 +71,21 @@ *

Uses a JSON Web Token (JWT) directly in the request metadata to provide authorization. */ public class ServiceAccountJwtAccessCredentials extends Credentials - implements ServiceAccountSigner { + implements JwtProvider, ServiceAccountSigner { private static final long serialVersionUID = -7274955171379494197L; static final String JWT_ACCESS_PREFIX = OAuth2Utils.BEARER_PREFIX; + @VisibleForTesting static final long LIFE_SPAN_SECS = TimeUnit.HOURS.toSeconds(1); + private static final long CLOCK_SKEW = TimeUnit.MINUTES.toSeconds(5); private final String clientId; private final String clientEmail; private final PrivateKey privateKey; private final String privateKeyId; private final URI defaultAudience; - private transient LoadingCache tokenCache; + + private transient LoadingCache credentialsCache; // Until we expose this to the users it can remain transient and non-serializable @VisibleForTesting transient Clock clock = Clock.SYSTEM; @@ -128,7 +126,7 @@ private ServiceAccountJwtAccessCredentials( this.privateKey = Preconditions.checkNotNull(privateKey); this.privateKeyId = privateKeyId; this.defaultAudience = defaultAudience; - this.tokenCache = createCache(); + this.credentialsCache = createCache(); } /** @@ -253,10 +251,10 @@ public static ServiceAccountJwtAccessCredentials fromStream( fileType, SERVICE_ACCOUNT_FILE_TYPE)); } - private LoadingCache createCache() { + private LoadingCache createCache() { return CacheBuilder.newBuilder() .maximumSize(100) - .expireAfterWrite(LIFE_SPAN_SECS - 300, TimeUnit.SECONDS) + .expireAfterWrite(LIFE_SPAN_SECS - CLOCK_SKEW, TimeUnit.SECONDS) .ticker( new Ticker() { @Override @@ -265,14 +263,43 @@ public long read() { } }) .build( - new CacheLoader() { + new CacheLoader() { @Override - public String load(URI key) throws Exception { - return generateJwtAccess(key); + public JwtCredentials load(JwtClaims claims) throws Exception { + return JwtCredentials.newBuilder() + .setPrivateKey(privateKey) + .setPrivateKeyId(privateKeyId) + .setJwtClaims(claims) + .setLifeSpanSeconds(LIFE_SPAN_SECS) + .setClock(clock) + .build(); } }); } + /** + * Returns a new JwtCredentials instance with modified claims. + * + * @param newClaims new claims. Any unspecified claim fields will default to the the current + * values. + * @return new credentials + */ + @Override + public JwtCredentials jwtWithClaims(JwtClaims newClaims) { + JwtClaims.Builder claimsBuilder = + JwtClaims.newBuilder().setIssuer(clientEmail).setSubject(clientEmail); + if (defaultAudience != null) { + claimsBuilder.setAudience(defaultAudience.toString()); + } + return JwtCredentials.newBuilder() + .setPrivateKey(privateKey) + .setPrivateKeyId(privateKeyId) + .setJwtClaims(claimsBuilder.build().merge(newClaims)) + .setLifeSpanSeconds(LIFE_SPAN_SECS) + .setClock(clock) + .build(); + } + @Override public String getAuthenticationType() { return "JWTAccess"; @@ -308,21 +335,16 @@ public Map> getRequestMetadata(URI uri) throws IOException + "defaultAudience to be specified"); } } - String assertion = getJwtAccess(uri); - String authorizationHeader = JWT_ACCESS_PREFIX + assertion; - List newAuthorizationHeaders = Collections.singletonList(authorizationHeader); - return Collections.singletonMap(AuthHttpConstants.AUTHORIZATION, newAuthorizationHeaders); - } - /** Discard any cached data */ - @Override - public void refresh() { - tokenCache.invalidateAll(); - } - - private String getJwtAccess(URI uri) throws IOException { try { - return tokenCache.get(uri); + JwtClaims defaultClaims = + JwtClaims.newBuilder() + .setAudience(uri.toString()) + .setIssuer(clientEmail) + .setSubject(clientEmail) + .build(); + JwtCredentials credentials = credentialsCache.get(defaultClaims); + return credentials.getRequestMetadata(uri); } catch (ExecutionException e) { Throwables.propagateIfPossible(e.getCause(), IOException.class); // Should never happen @@ -337,30 +359,10 @@ private String getJwtAccess(URI uri) throws IOException { } } - private String generateJwtAccess(URI uri) throws IOException { - JsonWebSignature.Header header = new JsonWebSignature.Header(); - header.setAlgorithm("RS256"); - header.setType("JWT"); - header.setKeyId(privateKeyId); - - JsonWebToken.Payload payload = new JsonWebToken.Payload(); - long currentTime = clock.currentTimeMillis(); - // Both copies of the email are required - payload.setIssuer(clientEmail); - payload.setSubject(clientEmail); - payload.setAudience(uri.toString()); - payload.setIssuedAtTimeSeconds(currentTime / 1000); - payload.setExpirationTimeSeconds(currentTime / 1000 + LIFE_SPAN_SECS); - - JsonFactory jsonFactory = OAuth2Utils.JSON_FACTORY; - - String assertion; - try { - assertion = JsonWebSignature.signUsingRsaSha256(privateKey, jsonFactory, header, payload); - } catch (GeneralSecurityException e) { - throw new IOException("Error signing service account JWT access header with private key.", e); - } - return assertion; + /** Discard any cached data */ + @Override + public void refresh() { + credentialsCache.invalidateAll(); } public final String getClientId() { @@ -427,7 +429,7 @@ public boolean equals(Object obj) { private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException { input.defaultReadObject(); clock = Clock.SYSTEM; - tokenCache = createCache(); + credentialsCache = createCache(); } public static Builder newBuilder() { diff --git a/oauth2_http/javatests/com/google/auth/oauth2/JwtCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/JwtCredentialsTest.java new file mode 100644 index 000000000..53fdf34ba --- /dev/null +++ b/oauth2_http/javatests/com/google/auth/oauth2/JwtCredentialsTest.java @@ -0,0 +1,314 @@ +/* + * Copyright 2019, 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 static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.fail; + +import com.google.api.client.json.JsonFactory; +import com.google.api.client.json.jackson2.JacksonFactory; +import com.google.api.client.json.webtoken.JsonWebSignature; +import com.google.api.client.util.Clock; +import com.google.auth.http.AuthHttpConstants; +import java.io.IOException; +import java.security.PrivateKey; +import java.util.List; +import java.util.Map; +import org.junit.Test; + +public class JwtCredentialsTest extends BaseSerializationTest { + private static final String PRIVATE_KEY_ID = "d84a4fefcf50791d4a90f2d7af17469d6282df9d"; + private static final String PRIVATE_KEY = + "-----BEGIN PRIVATE KEY-----\n" + + "MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBALX0PQoe1igW12i" + + "kv1bN/r9lN749y2ijmbc/mFHPyS3hNTyOCjDvBbXYbDhQJzWVUikh4mvGBA07qTj79Xc3yBDfKP2IeyYQIFe0t0" + + "zkd7R9Zdn98Y2rIQC47aAbDfubtkU1U72t4zL11kHvoa0/RuFZjncvlr42X7be7lYh4p3NAgMBAAECgYASk5wDw" + + "4Az2ZkmeuN6Fk/y9H+Lcb2pskJIXjrL533vrDWGOC48LrsThMQPv8cxBky8HFSEklPpkfTF95tpD43iVwJRB/Gr" + + "CtGTw65IfJ4/tI09h6zGc4yqvIo1cHX/LQ+SxKLGyir/dQM925rGt/VojxY5ryJR7GLbCzxPnJm/oQJBANwOCO6" + + "D2hy1LQYJhXh7O+RLtA/tSnT1xyMQsGT+uUCMiKS2bSKx2wxo9k7h3OegNJIu1q6nZ6AbxDK8H3+d0dUCQQDTrP" + + "SXagBxzp8PecbaCHjzNRSQE2in81qYnrAFNB4o3DpHyMMY6s5ALLeHKscEWnqP8Ur6X4PvzZecCWU9BKAZAkAut" + + "LPknAuxSCsUOvUfS1i87ex77Ot+w6POp34pEX+UWb+u5iFn2cQacDTHLV1LtE80L8jVLSbrbrlH43H0DjU5AkEA" + + "gidhycxS86dxpEljnOMCw8CKoUBd5I880IUahEiUltk7OLJYS/Ts1wbn3kPOVX3wyJs8WBDtBkFrDHW2ezth2QJ" + + "ADj3e1YhMVdjJW5jqwlD/VNddGjgzyunmiZg0uOXsHXbytYmsA545S8KRQFaJKFXYYFo2kOjqOiC1T2cAzMDjCQ" + + "==\n-----END PRIVATE KEY-----\n"; + private static final String JWT_ACCESS_PREFIX = + ServiceAccountJwtAccessCredentials.JWT_ACCESS_PREFIX; + private static final JsonFactory JSON_FACTORY = JacksonFactory.getDefaultInstance(); + + static PrivateKey getPrivateKey() { + try { + return ServiceAccountCredentials.privateKeyFromPkcs8(PRIVATE_KEY); + } catch (IOException ex) { + return null; + } + } + + @Test + public void serialize() throws IOException, ClassNotFoundException { + JwtClaims claims = + JwtClaims.newBuilder() + .setAudience("some-audience") + .setIssuer("some-issuer") + .setSubject("some-subject") + .build(); + JwtCredentials credentials = + JwtCredentials.newBuilder() + .setJwtClaims(claims) + .setPrivateKey(getPrivateKey()) + .setPrivateKeyId(PRIVATE_KEY_ID) + .build(); + + JwtCredentials deserializedCredentials = serializeAndDeserialize(credentials); + assertEquals(credentials, deserializedCredentials); + assertEquals(credentials.hashCode(), deserializedCredentials.hashCode()); + assertEquals(credentials.toString(), deserializedCredentials.toString()); + assertSame(deserializedCredentials.getClock(), Clock.SYSTEM); + } + + @Test + public void builder_requiresPrivateKey() { + try { + JwtClaims claims = + JwtClaims.newBuilder() + .setAudience("some-audience") + .setIssuer("some-issuer") + .setSubject("some-subject") + .build(); + JwtCredentials.newBuilder().setJwtClaims(claims).setPrivateKeyId(PRIVATE_KEY_ID).build(); + fail("Should throw exception"); + } catch (NullPointerException ex) { + // expected + } + } + + @Test + public void builder_requiresPrivateKeyId() { + try { + JwtClaims claims = + JwtClaims.newBuilder() + .setAudience("some-audience") + .setIssuer("some-issuer") + .setSubject("some-subject") + .build(); + JwtCredentials.newBuilder().setJwtClaims(claims).setPrivateKey(getPrivateKey()).build(); + fail("Should throw exception"); + } catch (NullPointerException ex) { + // expected + } + } + + @Test + public void builder_requiresClaims() { + try { + JwtCredentials.newBuilder() + .setPrivateKeyId(PRIVATE_KEY_ID) + .setPrivateKey(getPrivateKey()) + .build(); + fail("Should throw exception"); + } catch (NullPointerException ex) { + // expected + } + } + + @Test + public void builder_requiresCompleteClaims() { + try { + JwtClaims claims = JwtClaims.newBuilder().build(); + JwtCredentials.newBuilder() + .setJwtClaims(claims) + .setPrivateKeyId(PRIVATE_KEY_ID) + .setPrivateKey(getPrivateKey()) + .build(); + fail("Should throw exception"); + } catch (IllegalStateException ex) { + // expected + } + } + + @Test + public void claims_merge_overwritesFields() { + JwtClaims claims1 = + JwtClaims.newBuilder() + .setAudience("audience-1") + .setIssuer("issuer-1") + .setSubject("subject-1") + .build(); + JwtClaims claims2 = + JwtClaims.newBuilder() + .setAudience("audience-2") + .setIssuer("issuer-2") + .setSubject("subject-2") + .build(); + JwtClaims merged = claims1.merge(claims2); + + assertEquals("audience-2", merged.getAudience()); + assertEquals("issuer-2", merged.getIssuer()); + assertEquals("subject-2", merged.getSubject()); + } + + @Test + public void claims_merge_defaultValues() { + JwtClaims claims1 = + JwtClaims.newBuilder() + .setAudience("audience-1") + .setIssuer("issuer-1") + .setSubject("subject-1") + .build(); + JwtClaims claims2 = JwtClaims.newBuilder().setAudience("audience-2").build(); + JwtClaims merged = claims1.merge(claims2); + + assertEquals("audience-2", merged.getAudience()); + assertEquals("issuer-1", merged.getIssuer()); + assertEquals("subject-1", merged.getSubject()); + } + + @Test + public void claims_merge_null() { + JwtClaims claims1 = JwtClaims.newBuilder().build(); + JwtClaims claims2 = JwtClaims.newBuilder().build(); + JwtClaims merged = claims1.merge(claims2); + + assertNull(merged.getAudience()); + assertNull(merged.getIssuer()); + assertNull(merged.getSubject()); + } + + @Test + public void claims_equals() { + JwtClaims claims1 = + JwtClaims.newBuilder() + .setAudience("audience-1") + .setIssuer("issuer-1") + .setSubject("subject-1") + .build(); + JwtClaims claims2 = + JwtClaims.newBuilder() + .setAudience("audience-1") + .setIssuer("issuer-1") + .setSubject("subject-1") + .build(); + + assertEquals(claims1, claims2); + } + + @Test + public void jwtWithClaims_overwritesClaims() throws IOException { + JwtClaims claims = + JwtClaims.newBuilder() + .setAudience("some-audience") + .setIssuer("some-issuer") + .setSubject("some-subject") + .build(); + JwtCredentials credentials = + JwtCredentials.newBuilder() + .setJwtClaims(claims) + .setPrivateKey(getPrivateKey()) + .setPrivateKeyId(PRIVATE_KEY_ID) + .build(); + JwtClaims claims2 = + JwtClaims.newBuilder() + .setAudience("some-audience2") + .setIssuer("some-issuer2") + .setSubject("some-subject2") + .build(); + JwtCredentials credentials2 = credentials.jwtWithClaims(claims2); + Map> metadata = credentials2.getRequestMetadata(); + verifyJwtAccess(metadata, "some-audience2", "some-issuer2", "some-subject2", PRIVATE_KEY_ID); + } + + @Test + public void jwtWithClaims_defaultsClaims() throws IOException { + JwtClaims claims = + JwtClaims.newBuilder() + .setAudience("some-audience") + .setIssuer("some-issuer") + .setSubject("some-subject") + .build(); + JwtCredentials credentials = + JwtCredentials.newBuilder() + .setJwtClaims(claims) + .setPrivateKey(getPrivateKey()) + .setPrivateKeyId(PRIVATE_KEY_ID) + .build(); + JwtClaims claims2 = JwtClaims.newBuilder().build(); + JwtCredentials credentials2 = credentials.jwtWithClaims(claims2); + Map> metadata = credentials2.getRequestMetadata(); + verifyJwtAccess(metadata, "some-audience", "some-issuer", "some-subject", PRIVATE_KEY_ID); + } + + @Test + public void getRequestMetadata_hasJwtAccess() throws IOException { + JwtClaims claims = + JwtClaims.newBuilder() + .setAudience("some-audience") + .setIssuer("some-issuer") + .setSubject("some-subject") + .build(); + JwtCredentials credentials = + JwtCredentials.newBuilder() + .setJwtClaims(claims) + .setPrivateKey(getPrivateKey()) + .setPrivateKeyId(PRIVATE_KEY_ID) + .build(); + + Map> metadata = credentials.getRequestMetadata(); + verifyJwtAccess(metadata, "some-audience", "some-issuer", "some-subject", PRIVATE_KEY_ID); + } + + private void verifyJwtAccess( + Map> metadata, + String expectedAudience, + String expectedIssuer, + String expectedSubject, + String expectedKeyId) + throws IOException { + assertNotNull(metadata); + List authorizations = metadata.get(AuthHttpConstants.AUTHORIZATION); + assertNotNull("Authorization headers not found", authorizations); + String assertion = null; + for (String authorization : authorizations) { + if (authorization.startsWith(JWT_ACCESS_PREFIX)) { + assertNull("Multiple bearer assertions found", assertion); + assertion = authorization.substring(JWT_ACCESS_PREFIX.length()); + } + } + assertNotNull("Bearer assertion not found", assertion); + JsonWebSignature signature = JsonWebSignature.parse(JSON_FACTORY, assertion); + assertEquals(expectedIssuer, signature.getPayload().getIssuer()); + assertEquals(expectedSubject, signature.getPayload().getSubject()); + assertEquals(expectedAudience, signature.getPayload().getAudience()); + assertEquals(expectedKeyId, signature.getHeader().getKeyId()); + } +} diff --git a/oauth2_http/javatests/com/google/auth/oauth2/ServiceAccountJwtAccessCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/ServiceAccountJwtAccessCredentialsTest.java index 0b9c10463..5435148c6 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/ServiceAccountJwtAccessCredentialsTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/ServiceAccountJwtAccessCredentialsTest.java @@ -680,6 +680,59 @@ public void fromStream_noPrivateKeyId_throws() throws IOException { testFromStreamException(serviceAccountStream, "private_key_id"); } + @Test + public void jwtWithClaims_overrideAudience() throws IOException { + PrivateKey privateKey = ServiceAccountCredentials.privateKeyFromPkcs8(SA_PRIVATE_KEY_PKCS8); + ServiceAccountJwtAccessCredentials credentials = + ServiceAccountJwtAccessCredentials.newBuilder() + .setClientId(SA_CLIENT_ID) + .setClientEmail(SA_CLIENT_EMAIL) + .setPrivateKey(privateKey) + .setPrivateKeyId(SA_PRIVATE_KEY_ID) + .build(); + Credentials withAudience = + credentials.jwtWithClaims(JwtClaims.newBuilder().setAudience("new-audience").build()); + + Map> metadata = withAudience.getRequestMetadata(CALL_URI); + + verifyJwtAccess(metadata, SA_CLIENT_EMAIL, URI.create("new-audience"), SA_PRIVATE_KEY_ID); + } + + @Test + public void jwtWithClaims_noAudience() throws IOException { + PrivateKey privateKey = ServiceAccountCredentials.privateKeyFromPkcs8(SA_PRIVATE_KEY_PKCS8); + ServiceAccountJwtAccessCredentials credentials = + ServiceAccountJwtAccessCredentials.newBuilder() + .setClientId(SA_CLIENT_ID) + .setClientEmail(SA_CLIENT_EMAIL) + .setPrivateKey(privateKey) + .setPrivateKeyId(SA_PRIVATE_KEY_ID) + .build(); + try { + credentials.jwtWithClaims(JwtClaims.newBuilder().build()); + fail("Expected to throw exception for missing audience"); + } catch (IllegalStateException ex) { + // expected exception + } + } + + @Test + public void jwtWithClaims_defaultAudience() throws IOException { + PrivateKey privateKey = ServiceAccountCredentials.privateKeyFromPkcs8(SA_PRIVATE_KEY_PKCS8); + ServiceAccountJwtAccessCredentials credentials = + ServiceAccountJwtAccessCredentials.newBuilder() + .setClientId(SA_CLIENT_ID) + .setClientEmail(SA_CLIENT_EMAIL) + .setPrivateKey(privateKey) + .setPrivateKeyId(SA_PRIVATE_KEY_ID) + .setDefaultAudience(URI.create("default-audience")) + .build(); + Credentials withAudience = credentials.jwtWithClaims(JwtClaims.newBuilder().build()); + + Map> metadata = withAudience.getRequestMetadata(CALL_URI); + verifyJwtAccess(metadata, SA_CLIENT_EMAIL, URI.create("default-audience"), SA_PRIVATE_KEY_ID); + } + private void verifyJwtAccess( Map> metadata, String expectedEmail, diff --git a/oauth2_http/pom.xml b/oauth2_http/pom.xml index 1a5b735f5..541bba6b9 100644 --- a/oauth2_http/pom.xml +++ b/oauth2_http/pom.xml @@ -40,10 +40,30 @@ org.apache.maven.plugins maven-javadoc-plugin + + org.apache.maven.plugins + maven-dependency-plugin + + com.google.auto.value:auto-value + + + + com.google.auto.value + auto-value-annotations + + + com.google.auto.value + auto-value + provided + + + com.google.code.findbugs + jsr305 + com.google.auth google-auth-library-credentials diff --git a/pom.xml b/pom.xml index 776cd46db..9f3d156fa 100644 --- a/pom.xml +++ b/pom.xml @@ -61,6 +61,8 @@ 4.12 28.0-android 1.9.74 + 1.6.2 + 3.0.2 false @@ -98,6 +100,22 @@ guava ${project.guava.version} + + com.google.auto.value + auto-value-annotations + ${project.autovalue.version} + + + com.google.auto.value + auto-value + ${project.autovalue.version} + provided + + + com.google.code.findbugs + jsr305 + ${project.findbugs.version} + junit junit @@ -180,6 +198,11 @@ sponge_log + + org.apache.maven.plugins + maven-dependency-plugin + 3.1.1 + com.coveo fmt-maven-plugin diff --git a/renovate.json b/renovate.json index 68e63ff15..ca74999ac 100644 --- a/renovate.json +++ b/renovate.json @@ -6,6 +6,10 @@ { "packagePatterns": ["^com.google.appengine:appengine-"], "groupName": "AppEngine packages" + }, + { + "packagePatterns": ["^com.google.auto.value:auto-"], + "groupName": "AutoValue packages" } ] }