From a974cbec71461bf6ff53d658775db768bbfdb7b5 Mon Sep 17 00:00:00 2001 From: arithmetic1728 Date: Mon, 28 Jun 2021 16:45:55 -0700 Subject: [PATCH] feat: self signed jwt support --- .../com/google/auth/oauth2/JwtClaims.java | 4 +- .../oauth2/ServiceAccountCredentials.java | 156 ++++++++++++++---- .../ServiceAccountJwtAccessCredentials.java | 21 +-- .../com/google/auth/oauth2/JwtClaimsTest.java | 12 ++ .../oauth2/ServiceAccountCredentialsTest.java | 20 +-- ...erviceAccountJwtAccessCredentialsTest.java | 30 +--- 6 files changed, 148 insertions(+), 95 deletions(-) diff --git a/oauth2_http/java/com/google/auth/oauth2/JwtClaims.java b/oauth2_http/java/com/google/auth/oauth2/JwtClaims.java index 83ee0a0a0..5e36ebde1 100644 --- a/oauth2_http/java/com/google/auth/oauth2/JwtClaims.java +++ b/oauth2_http/java/com/google/auth/oauth2/JwtClaims.java @@ -106,7 +106,9 @@ public JwtClaims merge(JwtClaims other) { * @return true if all required fields have been set; false otherwise */ public boolean isComplete() { - return getAudience() != null && getIssuer() != null && getSubject() != null; + boolean hasScopes = + getAdditionalClaims().containsKey("scope") && !getAdditionalClaims().get("scope").isEmpty(); + return (getAudience() != null || hasScopes) && getIssuer() != null && getSubject() != null; } @AutoValue.Builder diff --git a/oauth2_http/java/com/google/auth/oauth2/ServiceAccountCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ServiceAccountCredentials.java index 377b4dbf1..4d4aba376 100644 --- a/oauth2_http/java/com/google/auth/oauth2/ServiceAccountCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/ServiceAccountCredentials.java @@ -77,6 +77,7 @@ import java.security.spec.InvalidKeySpecException; import java.security.spec.PKCS8EncodedKeySpec; import java.util.Collection; +import java.util.Collections; import java.util.Date; import java.util.List; import java.util.Map; @@ -109,9 +110,10 @@ public class ServiceAccountCredentials extends GoogleCredentials private final Collection defaultScopes; private final String quotaProjectId; private final int lifetime; + private final boolean alwaysUseJwtAccess; + private final String defaultHost; private transient HttpTransportFactory transportFactory; - private transient ServiceAccountJwtAccessCredentials jwtCredentials = null; /** * Constructor with minimum identifying information and custom HTTP transport. @@ -133,6 +135,8 @@ public class ServiceAccountCredentials extends GoogleCredentials * most 43200 (12 hours). If the token is used for calling a Google API, then the value should * be at most 3600 (1 hour). If the given value is 0, then the default value 3600 will be used * when creating the credentials. + * @param alwaysUseJwtAccess whether self signed JWT should be always used. + * @param defaultHost the default host which will be used as audience for self signed JWT. */ ServiceAccountCredentials( String clientId, @@ -146,7 +150,9 @@ public class ServiceAccountCredentials extends GoogleCredentials String serviceAccountUser, String projectId, String quotaProjectId, - int lifetime) { + int lifetime, + boolean alwaysUseJwtAccess, + String defaultHost) { this.clientId = clientId; this.clientEmail = Preconditions.checkNotNull(clientEmail); this.privateKey = Preconditions.checkNotNull(privateKey); @@ -167,18 +173,8 @@ public class ServiceAccountCredentials extends GoogleCredentials throw new IllegalStateException("lifetime must be less than or equal to 43200"); } this.lifetime = lifetime; - - // Use self signed JWT if scopes is not set, see https://google.aip.dev/auth/4111. - if (this.scopes.isEmpty()) { - jwtCredentials = - new ServiceAccountJwtAccessCredentials.Builder() - .setClientEmail(clientEmail) - .setClientId(clientId) - .setPrivateKey(privateKey) - .setPrivateKeyId(privateKeyId) - .setQuotaProjectId(quotaProjectId) - .build(); - } + this.alwaysUseJwtAccess = alwaysUseJwtAccess; + this.defaultHost = defaultHost; } /** @@ -492,7 +488,9 @@ static ServiceAccountCredentials fromPkcs8( serviceAccountUser, projectId, quotaProject, - DEFAULT_LIFETIME_IN_SECONDS); + DEFAULT_LIFETIME_IN_SECONDS, + false, + null); } /** Helper to convert from a PKCS#8 String to an RSA private key */ @@ -698,7 +696,9 @@ public GoogleCredentials createScoped( serviceAccountUser, projectId, quotaProjectId, - lifetime); + lifetime, + alwaysUseJwtAccess, + defaultHost); } /** @@ -714,6 +714,26 @@ public ServiceAccountCredentials createWithCustomLifetime(int lifetime) { return this.toBuilder().setLifetime(lifetime).build(); } + /** + * Clones the service account with a new alwaysUseJwtAccess value. + * + * @param alwaysUseJwtAccess whether self signed JWT should be used + * @return the cloned service account credentials with the given alwaysUseJwtAccess + */ + public ServiceAccountCredentials createWithAlwaysUseJwtAccess(boolean alwaysUseJwtAccess) { + return this.toBuilder().setAlwaysUseJwtAccess(alwaysUseJwtAccess).build(); + } + + /** + * Clones the service account with a new defaultHost value. + * + * @param defaultHost the default host which will be used as audience for self signed JWT. + * @return the cloned service account credentials with the given defaultHost + */ + public ServiceAccountCredentials createWithDefaultHost(String defaultHost) { + return this.toBuilder().setDefaultHost(defaultHost).build(); + } + @Override public GoogleCredentials createDelegated(String user) { return new ServiceAccountCredentials( @@ -728,7 +748,9 @@ public GoogleCredentials createDelegated(String user) { user, projectId, quotaProjectId, - lifetime); + lifetime, + alwaysUseJwtAccess, + defaultHost); } public final String getClientId() { @@ -748,7 +770,7 @@ public final String getPrivateKeyId() { } public final Collection getScopes() { - return scopes; + return scopes.isEmpty() ? defaultScopes : scopes; } public final Collection getDefaultScopes() { @@ -776,6 +798,16 @@ int getLifetime() { return lifetime; } + @VisibleForTesting + boolean getAlwaysUseJwtAccess() { + return alwaysUseJwtAccess; + } + + @VisibleForTesting + String getDefaultHost() { + return defaultHost; + } + @Override public String getAccount() { return getClientEmail(); @@ -833,7 +865,9 @@ public int hashCode() { scopes, defaultScopes, quotaProjectId, - lifetime); + lifetime, + alwaysUseJwtAccess, + defaultHost); } @Override @@ -849,6 +883,8 @@ public String toString() { .add("serviceAccountUser", serviceAccountUser) .add("quotaProjectId", quotaProjectId) .add("lifetime", lifetime) + .add("alwaysUseJwtAccess", alwaysUseJwtAccess) + .add("defaultHost", defaultHost) .toString(); } @@ -867,7 +903,9 @@ public boolean equals(Object obj) { && Objects.equals(this.scopes, other.scopes) && Objects.equals(this.defaultScopes, other.defaultScopes) && Objects.equals(this.quotaProjectId, other.quotaProjectId) - && Objects.equals(this.lifetime, other.lifetime); + && Objects.equals(this.lifetime, other.lifetime) + && Objects.equals(this.alwaysUseJwtAccess, other.alwaysUseJwtAccess) + && Objects.equals(this.defaultHost, other.defaultHost); } String createAssertion(JsonFactory jsonFactory, long currentTime, String audience) @@ -882,11 +920,7 @@ String createAssertion(JsonFactory jsonFactory, long currentTime, String audienc payload.setIssuedAtTimeSeconds(currentTime / 1000); payload.setExpirationTimeSeconds(currentTime / 1000 + this.lifetime); payload.setSubject(serviceAccountUser); - if (scopes.isEmpty()) { - payload.put("scope", Joiner.on(' ').join(defaultScopes)); - } else { - payload.put("scope", Joiner.on(' ').join(scopes)); - } + payload.put("scope", Joiner.on(' ').join(getScopes())); if (audience == null) { payload.setAudience(OAuth2Utils.TOKEN_SERVER_URI.toString()); @@ -937,11 +971,34 @@ String createAssertionForIdToken( } } + @VisibleForTesting + JwtCredentials createSelfSignedJwtCredentials() { + // Create a JwtCredentials for self signed JWT. See https://google.aip.dev/auth/4111. + JwtClaims.Builder claimsBuilder = + JwtClaims.newBuilder().setIssuer(clientEmail).setSubject(clientEmail); + if (!scopes.isEmpty()) { + claimsBuilder.setAdditionalClaims( + Collections.singletonMap("scope", Joiner.on(' ').join(scopes))); + } else if (defaultHost != null) { + claimsBuilder.setAudience(defaultHost); + } else { + claimsBuilder.setAdditionalClaims( + Collections.singletonMap("scope", Joiner.on(' ').join(defaultScopes))); + } + return JwtCredentials.newBuilder() + .setPrivateKey(privateKey) + .setPrivateKeyId(privateKeyId) + .setJwtClaims(claimsBuilder.build()) + .setClock(clock) + .build(); + } + @Override public void getRequestMetadata( final URI uri, Executor executor, final RequestMetadataCallback callback) { - if (jwtCredentials != null && uri != null) { - jwtCredentials.getRequestMetadata(uri, executor, callback); + if (alwaysUseJwtAccess) { + // This will call getRequestMetadata(URI uri), which handles self signed JWT logic. + blockingGetToCallback(uri, callback); } else { super.getRequestMetadata(uri, executor, callback); } @@ -950,14 +1007,19 @@ public void getRequestMetadata( /** Provide the request metadata by putting an access JWT directly in the metadata. */ @Override public Map> getRequestMetadata(URI uri) throws IOException { - if (scopes.isEmpty() && defaultScopes.isEmpty() && uri == null) { - throw new IOException( - "Scopes and uri are not configured for service account. Either pass uri" - + " to getRequestMetadata to use self signed JWT, or specify the scopes" - + " by calling createScoped or passing scopes to constructor."); + if (createScopedRequired()) { + if (!alwaysUseJwtAccess) { + throw new IOException( + "Scopes are not configured for service account. Specify the scopes" + + " by calling createScoped or passing scopes to constructor."); + } else if (defaultHost == null) { + throw new IOException("Scopes and defaultHost are not configured for service account."); + } } - if (jwtCredentials != null && uri != null) { - return jwtCredentials.getRequestMetadata(uri); + if (alwaysUseJwtAccess) { + JwtCredentials jwtCredentials = createSelfSignedJwtCredentials(); + Map> requestMetadata = jwtCredentials.getRequestMetadata(null); + return addQuotaProjectIdToRequestMetadata(quotaProjectId, requestMetadata); } else { return super.getRequestMetadata(uri); } @@ -997,6 +1059,8 @@ public static class Builder extends GoogleCredentials.Builder { private HttpTransportFactory transportFactory; private String quotaProjectId; private int lifetime = DEFAULT_LIFETIME_IN_SECONDS; + private boolean alwaysUseJwtAccess = false; + private String defaultHost; protected Builder() {} @@ -1013,6 +1077,8 @@ protected Builder(ServiceAccountCredentials credentials) { this.projectId = credentials.projectId; this.quotaProjectId = credentials.quotaProjectId; this.lifetime = credentials.lifetime; + this.alwaysUseJwtAccess = credentials.alwaysUseJwtAccess; + this.defaultHost = credentials.defaultHost; } public Builder setClientId(String clientId) { @@ -1077,6 +1143,16 @@ public Builder setLifetime(int lifetime) { return this; } + public Builder setAlwaysUseJwtAccess(boolean alwaysUseJwtAccess) { + this.alwaysUseJwtAccess = alwaysUseJwtAccess; + return this; + } + + public Builder setDefaultHost(String defaultHost) { + this.defaultHost = defaultHost; + return this; + } + public String getClientId() { return clientId; } @@ -1125,6 +1201,14 @@ public int getLifetime() { return lifetime; } + public boolean getAlwaysUseJwtAccess() { + return alwaysUseJwtAccess; + } + + public String getDefaultHost() { + return defaultHost; + } + public ServiceAccountCredentials build() { return new ServiceAccountCredentials( clientId, @@ -1138,7 +1222,9 @@ public ServiceAccountCredentials build() { serviceAccountUser, projectId, quotaProjectId, - lifetime); + lifetime, + alwaysUseJwtAccess, + defaultHost); } } } diff --git a/oauth2_http/java/com/google/auth/oauth2/ServiceAccountJwtAccessCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ServiceAccountJwtAccessCredentials.java index 2380b1cf6..707b657a7 100644 --- a/oauth2_http/java/com/google/auth/oauth2/ServiceAccountJwtAccessCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/ServiceAccountJwtAccessCredentials.java @@ -54,7 +54,6 @@ import java.io.InputStream; import java.io.ObjectInputStream; import java.net.URI; -import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; @@ -332,35 +331,17 @@ public boolean hasRequestMetadataOnly() { return true; } - /** - * Self signed JWT uses uri as audience, which should have the "https://{host}/" format. For - * instance, if the uri is "https://compute.googleapis.com/compute/v1/projects/", then this - * function returns "https://compute.googleapis.com/". - */ - @VisibleForTesting - static URI getUriForSelfSignedJWT(URI uri) { - if (uri == null || uri.getScheme() == null || uri.getHost() == null) { - return uri; - } - try { - return new URI(uri.getScheme(), uri.getHost(), "/", null); - } catch (URISyntaxException unused) { - return uri; - } - } - @Override public void getRequestMetadata( final URI uri, Executor executor, final RequestMetadataCallback callback) { // It doesn't use network. Only some CPU work on par with TLS handshake. So it's preferrable // to do it in the current thread, which is likely to be the network thread. - blockingGetToCallback(getUriForSelfSignedJWT(uri), callback); + blockingGetToCallback(uri, callback); } /** Provide the request metadata by putting an access JWT directly in the metadata. */ @Override public Map> getRequestMetadata(URI uri) throws IOException { - uri = getUriForSelfSignedJWT(uri); if (uri == null) { if (defaultAudience != null) { uri = defaultAudience; diff --git a/oauth2_http/javatests/com/google/auth/oauth2/JwtClaimsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/JwtClaimsTest.java index b67f110de..a54f87e65 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/JwtClaimsTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/JwtClaimsTest.java @@ -136,4 +136,16 @@ public void testMergeAdditionalClaims() { assertEquals("bar", mergedAdditionalClaims.get("foo")); assertEquals("qwer", mergedAdditionalClaims.get("asdf")); } + + @Test + public void testIsComplete() { + // Test JwtClaim is complete if audience is not set but scope is provided. + JwtClaims claims = + JwtClaims.newBuilder() + .setIssuer("issuer-1") + .setSubject("subject-1") + .setAdditionalClaims(Collections.singletonMap("scope", "foo")) + .build(); + assertTrue(claims.isComplete()); + } } diff --git a/oauth2_http/javatests/com/google/auth/oauth2/ServiceAccountCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/ServiceAccountCredentialsTest.java index 3fc903d73..d37db9feb 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/ServiceAccountCredentialsTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/ServiceAccountCredentialsTest.java @@ -106,7 +106,7 @@ public class ServiceAccountCredentialsTest extends BaseSerializationTest { private static final String PROJECT_ID = "project-id"; private static final Collection EMPTY_SCOPES = Collections.emptyList(); private static final URI CALL_URI = URI.create("http://googleapis.com/testapi/v1/foo"); - private static final String JWT_AUDIENCE = "http://googleapis.com/"; + private static final String JWT_AUDIENCE = "http://googleapis.com/testapi/v1/foo"; private static final HttpTransportFactory DUMMY_TRANSPORT_FACTORY = new MockTokenServerTransportFactory(); public static final String DEFAULT_ID_TOKEN = @@ -419,21 +419,16 @@ public void createdScoped_enablesAccessTokens() throws IOException { null); try { - credentials.getRequestMetadata(null); - fail("Should not be able to get token without scopes, defaultScopes and uri"); + credentials.getRequestMetadata(CALL_URI); + fail("Should not be able to get token without scopes"); } catch (IOException e) { assertTrue( "expected to fail with exception", - e.getMessage().contains("Scopes and uri are not configured for service account")); + e.getMessage().contains("Scopes are not configured for service account")); } - // Since scopes are not provided, self signed JWT will be used. - Map> metadata = credentials.getRequestMetadata(CALL_URI); - verifyJwtAccess(metadata); - - // Since scopes are provided, self signed JWT will not be used. GoogleCredentials scopedCredentials = credentials.createScoped(SCOPES); - metadata = scopedCredentials.getRequestMetadata(CALL_URI); + Map> metadata = scopedCredentials.getRequestMetadata(CALL_URI); TestUtils.assertContainsBearerToken(metadata, ACCESS_TOKEN); } @@ -1074,7 +1069,7 @@ public void toString_containsFields() throws IOException { String.format( "ServiceAccountCredentials{clientId=%s, clientEmail=%s, privateKeyId=%s, " + "transportFactoryClassName=%s, tokenServerUri=%s, scopes=%s, defaultScopes=%s, serviceAccountUser=%s, " - + "quotaProjectId=%s, lifetime=3600}", + + "quotaProjectId=%s, lifetime=3600, alwaysUseJwtAccess=false, defaultHost=null}", CLIENT_ID, CLIENT_EMAIL, PRIVATE_KEY_ID, @@ -1340,11 +1335,12 @@ public void getRequestMetadataWithCallback_selfSignedJWT() throws IOException { .setClientEmail(CLIENT_EMAIL) .setPrivateKey(privateKey) .setPrivateKeyId(PRIVATE_KEY_ID) - .setScopes(null, DEFAULT_SCOPES) .setServiceAccountUser(USER) .setProjectId(PROJECT_ID) .setQuotaProjectId("my-quota-project-id") .setHttpTransportFactory(transportFactory) + .setAlwaysUseJwtAccess(true) + .setDefaultHost(JWT_AUDIENCE) .build(); final AtomicBoolean success = new AtomicBoolean(false); diff --git a/oauth2_http/javatests/com/google/auth/oauth2/ServiceAccountJwtAccessCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/ServiceAccountJwtAccessCredentialsTest.java index 81b5f7de3..5020317f2 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/ServiceAccountJwtAccessCredentialsTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/ServiceAccountJwtAccessCredentialsTest.java @@ -92,7 +92,6 @@ public class ServiceAccountJwtAccessCredentialsTest extends BaseSerializationTes private static final String JWT_ACCESS_PREFIX = ServiceAccountJwtAccessCredentials.JWT_ACCESS_PREFIX; private static final URI CALL_URI = URI.create("http://googleapis.com/testapi/v1/foo"); - private static final URI CALL_URI_AUDIENCE = URI.create("http://googleapis.com/"); private static final JsonFactory JSON_FACTORY = GsonFactory.getDefaultInstance(); private static final String QUOTA_PROJECT = "sample-quota-project-id"; @@ -172,29 +171,6 @@ public void getAuthenticationType_returnsJwtAccess() throws IOException { assertEquals(credentials.getAuthenticationType(), "JWTAccess"); } - @Test - public void getUriForSelfSignedJWT() { - assertNull(ServiceAccountJwtAccessCredentials.getUriForSelfSignedJWT(null)); - - URI uri = URI.create("https://compute.googleapis.com/compute/v1/projects/"); - URI expected = URI.create("https://compute.googleapis.com/"); - assertEquals(expected, ServiceAccountJwtAccessCredentials.getUriForSelfSignedJWT(uri)); - } - - @Test - public void getUriForSelfSignedJWT_noHost() { - URI uri = URI.create("file:foo"); - URI expected = URI.create("file:foo"); - assertEquals(expected, ServiceAccountJwtAccessCredentials.getUriForSelfSignedJWT(uri)); - } - - @Test - public void getUriForSelfSignedJWT_forStaticAudience_returnsURI() { - URI uri = URI.create("compute.googleapis.com"); - URI expected = URI.create("compute.googleapis.com"); - assertEquals(expected, ServiceAccountJwtAccessCredentials.getUriForSelfSignedJWT(uri)); - } - @Test public void hasRequestMetadata_returnsTrue() throws IOException { Credentials credentials = @@ -224,7 +200,7 @@ public void getRequestMetadata_blocking_hasJwtAccess() throws IOException { Map> metadata = credentials.getRequestMetadata(CALL_URI); - verifyJwtAccess(metadata, SA_CLIENT_EMAIL, CALL_URI_AUDIENCE, SA_PRIVATE_KEY_ID); + verifyJwtAccess(metadata, SA_CLIENT_EMAIL, CALL_URI, SA_PRIVATE_KEY_ID); } @Test @@ -329,7 +305,7 @@ public void getRequestMetadata_async_hasJwtAccess() throws IOException { credentials.getRequestMetadata(CALL_URI, executor, callback); assertEquals(0, executor.numTasks()); assertNotNull(callback.metadata); - verifyJwtAccess(callback.metadata, SA_CLIENT_EMAIL, CALL_URI_AUDIENCE, SA_PRIVATE_KEY_ID); + verifyJwtAccess(callback.metadata, SA_CLIENT_EMAIL, CALL_URI, SA_PRIVATE_KEY_ID); } @Test @@ -678,7 +654,7 @@ public void fromStream_hasJwtAccess() throws IOException { assertNotNull(credentials); Map> metadata = credentials.getRequestMetadata(CALL_URI); - verifyJwtAccess(metadata, SA_CLIENT_EMAIL, CALL_URI_AUDIENCE, SA_PRIVATE_KEY_ID); + verifyJwtAccess(metadata, SA_CLIENT_EMAIL, CALL_URI, SA_PRIVATE_KEY_ID); } @Test