1 /*
2  * Copyright 2021 Google LLC
3  *
4  * Redistribution and use in source and binary forms, with or without
5  * modification, are permitted provided that the following conditions are
6  * met:
7  *
8  *    * Redistributions of source code must retain the above copyright
9  * notice, this list of conditions and the following disclaimer.
10  *    * Redistributions in binary form must reproduce the above
11  * copyright notice, this list of conditions and the following disclaimer
12  * in the documentation and/or other materials provided with the
13  * distribution.
14  *
15  *    * Neither the name of Google LLC nor the names of its
16  * contributors may be used to endorse or promote products derived from
17  * this software without specific prior written permission.
18  *
19  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
20  * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
21  * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
22  * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
23  * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
24  * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
25  * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
26  * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
27  * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
28  * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29  * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30  */
31 
32 package com.google.auth.oauth2;
33 
34 import static org.junit.Assert.assertTrue;
35 import static org.junit.Assert.fail;
36 
37 import com.google.api.client.http.GenericUrl;
38 import com.google.api.client.http.HttpRequest;
39 import com.google.api.client.http.HttpRequestFactory;
40 import com.google.api.client.http.HttpResponse;
41 import com.google.api.client.http.UrlEncodedContent;
42 import com.google.api.client.http.javanet.NetHttpTransport;
43 import com.google.api.client.json.GenericJson;
44 import com.google.api.client.json.JsonFactory;
45 import com.google.api.client.json.JsonObjectParser;
46 import com.google.api.client.json.gson.GsonFactory;
47 import com.google.api.client.util.GenericData;
48 import com.google.auth.http.HttpCredentialsAdapter;
49 import com.google.auth.oauth2.ExternalAccountCredentials.SubjectTokenTypes;
50 import java.io.ByteArrayInputStream;
51 import java.io.File;
52 import java.io.FileInputStream;
53 import java.io.IOException;
54 import java.nio.charset.StandardCharsets;
55 import java.time.Instant;
56 import java.util.HashMap;
57 import java.util.Map;
58 import org.junit.Before;
59 import org.junit.Test;
60 
61 /**
62  * Integration tests for Workload Identity Federation.
63  *
64  * <p>The only requirements for this test suite to run is to set the environment variable
65  * GOOGLE_APPLICATION_CREDENTIALS to point to the same service account keys used in the setup script
66  * (workloadidentityfederation-setup). These tests call GCS to get bucket information. The bucket
67  * name must be provided through the GCS_BUCKET environment variable.
68  */
69 public final class ITWorkloadIdentityFederationTest {
70 
71   // Copy output from workloadidentityfederation-setup.
72   private static final String AUDIENCE_PREFIX =
73       "//iam.googleapis.com/projects/1016721519174/locations/global/workloadIdentityPools/pool-1/providers/";
74   private static final String AWS_ROLE_NAME = "ci-java-test";
75   private static final String AWS_ROLE_ARN = "arn:aws:iam::027472800722:role/ci-java-test";
76 
77   private static final String AWS_AUDIENCE = AUDIENCE_PREFIX + "aws-1";
78   private static final String OIDC_AUDIENCE = AUDIENCE_PREFIX + "oidc-1";
79 
80   private String clientEmail;
81 
82   @Before
setup()83   public void setup() throws IOException {
84     GenericJson keys = getServiceAccountKeyFileAsJson();
85     clientEmail = (String) keys.get("client_email");
86   }
87 
88   /**
89    * IdentityPoolCredentials (OIDC provider): Uses the service account to generate a Google ID token
90    * using the iamcredentials generateIdToken API. This will use the service account client ID as
91    * the sub field of the token. This OIDC token will be used as the external subject token to be
92    * exchanged for a GCP access token via GCP STS endpoint and then to impersonate the original
93    * service account key. Retrieves the OIDC token from a file.
94    */
95   @Test
identityPoolCredentials()96   public void identityPoolCredentials() throws IOException {
97     IdentityPoolCredentials identityPoolCredentials =
98         (IdentityPoolCredentials)
99             ExternalAccountCredentials.fromJson(
100                 buildIdentityPoolCredentialConfig(), OAuth2Utils.HTTP_TRANSPORT_FACTORY);
101 
102     callGcs(identityPoolCredentials);
103   }
104 
105   /**
106    * AwsCredentials (AWS provider): Uses the service account keys to generate a Google ID token
107    * using the iamcredentials generateIdToken API. Exchanges the OIDC ID token for AWS security keys
108    * using AWS STS AssumeRoleWithWebIdentity API. These values will be set as AWS environment
109    * variables to simulate an AWS VM. The Auth library can now read these variables and create a
110    * signed request to AWS GetCallerIdentity. This will be used as the external subject token to be
111    * exchanged for a GCP access token via GCP STS endpoint and then to impersonate the original
112    * service account key.
113    */
114   @Test
awsCredentials()115   public void awsCredentials() throws Exception {
116     String idToken = generateGoogleIdToken(AWS_AUDIENCE);
117 
118     String url =
119         String.format(
120             "https://sts.amazonaws.com/?Action=AssumeRoleWithWebIdentity"
121                 + "&Version=2011-06-15&DurationSeconds=3600&RoleSessionName=%s"
122                 + "&RoleArn=%s&WebIdentityToken=%s",
123             AWS_ROLE_NAME, AWS_ROLE_ARN, idToken);
124 
125     HttpRequestFactory requestFactory = new NetHttpTransport().createRequestFactory();
126     HttpRequest request = requestFactory.buildGetRequest(new GenericUrl(url));
127 
128     JsonObjectParser parser = new JsonObjectParser(GsonFactory.getDefaultInstance());
129     request.setParser(parser);
130 
131     HttpResponse response = request.execute();
132     String rawXml = response.parseAsString();
133 
134     String awsAccessKeyId = getXmlValueByTagName(rawXml, "AccessKeyId");
135     String awsSecretAccessKey = getXmlValueByTagName(rawXml, "SecretAccessKey");
136     String awsSessionToken = getXmlValueByTagName(rawXml, "SessionToken");
137 
138     AwsCredentials awsCredentialWithoutEnvProvider =
139         (AwsCredentials)
140             AwsCredentials.fromJson(buildAwsCredentialConfig(), OAuth2Utils.HTTP_TRANSPORT_FACTORY);
141     TestEnvironmentProvider testEnvironmentProvider = new TestEnvironmentProvider();
142     testEnvironmentProvider
143         .setEnv("AWS_ACCESS_KEY_ID", awsAccessKeyId)
144         .setEnv("AWS_SECRET_ACCESS_KEY", awsSecretAccessKey)
145         .setEnv("AWS_SESSION_TOKEN", awsSessionToken)
146         .setEnv("AWS_REGION", "us-east-2");
147 
148     AwsCredentials awsCredential =
149         AwsCredentials.newBuilder(awsCredentialWithoutEnvProvider)
150             .setEnvironmentProvider(testEnvironmentProvider)
151             .build();
152 
153     callGcs(awsCredential);
154   }
155 
156   /**
157    * AwsCredentials (AWS Provider): Uses the service account keys to generate a Google ID token
158    * using the iamcredentials generateIdToken API. Exchanges the OIDC ID token for AWS security keys
159    * using AWS STS AssumeRoleWithWebIdentity API. These values will be returned as a
160    * AwsSecurityCredentials object and returned by a Supplier. The Auth library can now call get()
161    * from the supplier and create a signed request to AWS GetCallerIdentity. This will be used as
162    * the external subject token to be exchanged for a GCP access token via GCP STS endpoint and then
163    * to impersonate the original service account key.
164    */
165   @Test
awsCredentials_withProgrammaticAuth()166   public void awsCredentials_withProgrammaticAuth() throws Exception {
167     String idToken = generateGoogleIdToken(AWS_AUDIENCE);
168 
169     String url =
170         String.format(
171             "https://sts.amazonaws.com/?Action=AssumeRoleWithWebIdentity"
172                 + "&Version=2011-06-15&DurationSeconds=3600&RoleSessionName=%s"
173                 + "&RoleArn=%s&WebIdentityToken=%s",
174             AWS_ROLE_NAME, AWS_ROLE_ARN, idToken);
175 
176     HttpRequestFactory requestFactory = new NetHttpTransport().createRequestFactory();
177     HttpRequest request = requestFactory.buildGetRequest(new GenericUrl(url));
178 
179     JsonObjectParser parser = new JsonObjectParser(GsonFactory.getDefaultInstance());
180     request.setParser(parser);
181 
182     HttpResponse response = request.execute();
183     String rawXml = response.parseAsString();
184 
185     String awsAccessKeyId = getXmlValueByTagName(rawXml, "AccessKeyId");
186     String awsSecretAccessKey = getXmlValueByTagName(rawXml, "SecretAccessKey");
187     String awsSessionToken = getXmlValueByTagName(rawXml, "SessionToken");
188 
189     AwsSecurityCredentials credentials =
190         new AwsSecurityCredentials(awsAccessKeyId, awsSecretAccessKey, awsSessionToken);
191 
192     AwsSecurityCredentialsSupplier provider =
193         new ITAwsSecurityCredentialsProvider("us-east-2", credentials);
194     AwsCredentials awsCredential =
195         AwsCredentials.newBuilder()
196             .setAwsSecurityCredentialsSupplier(provider)
197             .setSubjectTokenType(SubjectTokenTypes.AWS4)
198             .setAudience(AWS_AUDIENCE)
199             .setServiceAccountImpersonationUrl(
200                 String.format(
201                     "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s:generateAccessToken",
202                     clientEmail))
203             .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY)
204             .build();
205 
206     callGcs(awsCredential);
207   }
208 
209   /**
210    * PluggableCredential (OIDC provider): Uses the service account to generate a Google ID token
211    * using the iamcredentials generateIdToken API. This will use the service account client ID as
212    * the sub field of the token. This OIDC token will be used as the external subject token to be
213    * exchanged for a GCP access token via GCP STS endpoint and then to impersonate the original
214    * service account key. Runs an executable to get the OIDC token.
215    */
216   @Test
pluggableAuthCredentials()217   public void pluggableAuthCredentials() throws IOException {
218     PluggableAuthCredentials pluggableAuthCredentials =
219         (PluggableAuthCredentials)
220             ExternalAccountCredentials.fromJson(
221                 buildPluggableCredentialConfig(), OAuth2Utils.HTTP_TRANSPORT_FACTORY);
222 
223     callGcs(pluggableAuthCredentials);
224   }
225 
226   /**
227    * Sets the service account impersonation object in configuration JSON with a non-default value
228    * for token_lifetime_seconds and validates that the lifetime is used for the access token.
229    */
230   @Test
identityPoolCredentials_withServiceAccountImpersonationOptions()231   public void identityPoolCredentials_withServiceAccountImpersonationOptions() throws IOException {
232     GenericJson identityPoolCredentialConfig = buildIdentityPoolCredentialConfig();
233     Map<String, Object> map = new HashMap<String, Object>();
234     map.put("token_lifetime_seconds", 2800);
235     identityPoolCredentialConfig.put("service_account_impersonation", map);
236 
237     IdentityPoolCredentials identityPoolCredentials =
238         (IdentityPoolCredentials)
239             ExternalAccountCredentials.fromJson(
240                 identityPoolCredentialConfig, OAuth2Utils.HTTP_TRANSPORT_FACTORY);
241     long maxExpirationTime = Instant.now().plusSeconds(2800 + 5).toEpochMilli();
242     long minExpirationtime = Instant.now().plusSeconds(2800 - 5).toEpochMilli();
243 
244     callGcs(identityPoolCredentials);
245     long tokenExpiry = identityPoolCredentials.getAccessToken().getExpirationTimeMillis();
246     assertTrue(minExpirationtime <= tokenExpiry && tokenExpiry <= maxExpirationTime);
247   }
248 
249   /**
250    * IdentityPoolCredentials (OIDC provider): Uses the service account to generate a Google ID token
251    * using the iamcredentials generateIdToken API. This will use the service account client ID as
252    * the sub field of the token. This OIDC token will be used as the external subject token to be
253    * exchanged for a GCP access token via GCP STS endpoint and then to impersonate the original
254    * service account key. Retrieves the OIDC token from a Supplier that returns the subject token
255    * when get() is called.
256    */
257   @Test
identityPoolCredentials_withProgrammaticAuth()258   public void identityPoolCredentials_withProgrammaticAuth() throws IOException {
259 
260     IdentityPoolSubjectTokenSupplier tokenSupplier =
261         (ExternalAccountSupplierContext context) -> {
262           try {
263             return generateGoogleIdToken(OIDC_AUDIENCE);
264           } catch (IOException e) {
265             throw new RuntimeException(e);
266           }
267         };
268 
269     IdentityPoolCredentials identityPoolCredentials =
270         IdentityPoolCredentials.newBuilder()
271             .setSubjectTokenSupplier(tokenSupplier)
272             .setAudience(OIDC_AUDIENCE)
273             .setSubjectTokenType(SubjectTokenTypes.JWT)
274             .setServiceAccountImpersonationUrl(
275                 String.format(
276                     "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s:generateAccessToken",
277                     clientEmail))
278             .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY)
279             .build();
280 
281     callGcs(identityPoolCredentials);
282   }
283 
buildIdentityPoolCredentialConfig()284   private GenericJson buildIdentityPoolCredentialConfig() throws IOException {
285     String idToken = generateGoogleIdToken(OIDC_AUDIENCE);
286 
287     File file =
288         File.createTempFile(
289             "ITWorkloadIdentityFederation", /* suffix= */ null, /* directory= */ null);
290     file.deleteOnExit();
291     OAuth2Utils.writeInputStreamToFile(
292         new ByteArrayInputStream(idToken.getBytes(StandardCharsets.UTF_8)), file.getAbsolutePath());
293 
294     GenericJson config = new GenericJson();
295     config.put("type", "external_account");
296     config.put("audience", OIDC_AUDIENCE);
297     config.put("subject_token_type", "urn:ietf:params:oauth:token-type:jwt");
298     config.put("token_url", "https://sts.googleapis.com/v1/token");
299     config.put(
300         "service_account_impersonation_url",
301         String.format(
302             "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s:generateAccessToken",
303             clientEmail));
304 
305     GenericJson credentialSource = new GenericJson();
306     credentialSource.put("file", file.getAbsolutePath());
307     config.put("credential_source", credentialSource);
308 
309     return config;
310   }
311 
buildPluggableCredentialConfig()312   private GenericJson buildPluggableCredentialConfig() throws IOException {
313     String idToken = generateGoogleIdToken(OIDC_AUDIENCE);
314 
315     Instant expiration_time = Instant.now().plusSeconds(60 * 60);
316 
317     GenericJson executableJson = new GenericJson();
318     executableJson.setFactory(OAuth2Utils.JSON_FACTORY);
319     executableJson.put("success", true);
320     executableJson.put("version", 1);
321     executableJson.put("expiration_time", expiration_time.toEpochMilli());
322     executableJson.put("token_type", "urn:ietf:params:oauth:token-type:jwt");
323     executableJson.put("id_token", idToken);
324 
325     String fileContents =
326         "#!/bin/bash\n"
327             + "echo \""
328             + executableJson.toPrettyString().replace("\"", "\\\"")
329             + "\"\n";
330 
331     File file =
332         File.createTempFile(
333             "ITWorkloadIdentityFederation", /* suffix= */ null, /* directory= */ null);
334     file.deleteOnExit();
335     if (!file.setExecutable(true, true)) {
336       throw new IOException("Unable to make script executable");
337     }
338     OAuth2Utils.writeInputStreamToFile(
339         new ByteArrayInputStream(fileContents.getBytes(StandardCharsets.UTF_8)),
340         file.getAbsolutePath());
341 
342     GenericJson config = new GenericJson();
343     config.put("type", "external_account");
344     config.put("audience", OIDC_AUDIENCE);
345     config.put("subject_token_type", "urn:ietf:params:oauth:token-type:jwt");
346     config.put("token_url", "https://sts.googleapis.com/v1/token");
347     config.put(
348         "service_account_impersonation_url",
349         String.format(
350             "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s:generateAccessToken",
351             clientEmail));
352 
353     GenericJson credentialSource = new GenericJson();
354     config.put("credential_source", credentialSource);
355 
356     GenericJson executableConfig = new GenericJson();
357     credentialSource.put("executable", executableConfig);
358     executableConfig.put("command", file.getAbsolutePath());
359 
360     return config;
361   }
362 
buildAwsCredentialConfig()363   private GenericJson buildAwsCredentialConfig() {
364     GenericJson config = new GenericJson();
365     config.put("type", "external_account");
366     config.put("audience", AWS_AUDIENCE);
367     config.put("subject_token_type", "urn:ietf:params:aws:token-type:aws4_request");
368     config.put("token_url", "https://sts.googleapis.com/v1/token");
369     config.put(
370         "service_account_impersonation_url",
371         String.format(
372             "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s:generateAccessToken",
373             clientEmail));
374 
375     GenericJson credentialSource = new GenericJson();
376     credentialSource.put("environment_id", "aws1");
377     credentialSource.put(
378         "regional_cred_verification_url",
379         "https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15");
380     config.put("credential_source", credentialSource);
381 
382     return config;
383   }
384 
callGcs(GoogleCredentials credentials)385   private void callGcs(GoogleCredentials credentials) throws IOException {
386     String bucketName = System.getenv("GCS_BUCKET");
387     if (bucketName == null) {
388       fail("GCS bucket name not set through GCS_BUCKET env variable.");
389     }
390 
391     String url = "https://storage.googleapis.com/storage/v1/b/" + bucketName;
392 
393     HttpCredentialsAdapter credentialsAdapter = new HttpCredentialsAdapter(credentials);
394     HttpRequestFactory requestFactory =
395         new NetHttpTransport().createRequestFactory(credentialsAdapter);
396     HttpRequest request = requestFactory.buildGetRequest(new GenericUrl(url));
397 
398     JsonObjectParser parser = new JsonObjectParser(GsonFactory.getDefaultInstance());
399     request.setParser(parser);
400 
401     HttpResponse response = request.execute();
402     assertTrue(response.isSuccessStatusCode());
403   }
404 
405   /**
406    * Generates a Google ID token using the iamcredentials generateIdToken API.
407    * https://cloud.google.com/iam/docs/creating-short-lived-service-account-credentials#sa-credentials-oidc
408    */
generateGoogleIdToken(String audience)409   private String generateGoogleIdToken(String audience) throws IOException {
410     GoogleCredentials googleCredentials =
411         GoogleCredentials.getApplicationDefault()
412             .createScoped("https://www.googleapis.com/auth/cloud-platform");
413 
414     HttpCredentialsAdapter credentialsAdapter = new HttpCredentialsAdapter(googleCredentials);
415     HttpRequestFactory requestFactory =
416         new NetHttpTransport().createRequestFactory(credentialsAdapter);
417 
418     String url =
419         String.format(
420             "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s:generateIdToken",
421             clientEmail);
422 
423     GenericData data = new GenericData();
424     data.set("audience", audience);
425     data.set("includeEmail", true);
426     UrlEncodedContent content = new UrlEncodedContent(data);
427 
428     HttpRequest request = requestFactory.buildPostRequest(new GenericUrl(url), content);
429     JsonObjectParser parser = new JsonObjectParser(GsonFactory.getDefaultInstance());
430     request.setParser(parser);
431 
432     HttpResponse response = request.execute();
433     GenericData responseData = response.parseAs(GenericData.class);
434     return (String) responseData.get("token");
435   }
436 
getServiceAccountKeyFileAsJson()437   private GenericJson getServiceAccountKeyFileAsJson() throws IOException {
438     String credentialsPath = System.getenv(DefaultCredentialsProvider.CREDENTIAL_ENV_VAR);
439     JsonFactory jsonFactory = OAuth2Utils.JSON_FACTORY;
440     JsonObjectParser parser = new JsonObjectParser(jsonFactory);
441     return parser.parseAndClose(
442         new FileInputStream(credentialsPath), StandardCharsets.UTF_8, GenericJson.class);
443   }
444 
getXmlValueByTagName(String rawXml, String tagName)445   private String getXmlValueByTagName(String rawXml, String tagName) {
446     int startIndex = rawXml.indexOf("<" + tagName + ">");
447     int endIndex = rawXml.indexOf("</" + tagName + ">", startIndex);
448 
449     if (startIndex >= 0 && endIndex > startIndex) {
450       return rawXml.substring(startIndex + tagName.length() + 2, endIndex);
451     }
452     return null;
453   }
454 
455   private class ITAwsSecurityCredentialsProvider implements AwsSecurityCredentialsSupplier {
456 
457     private String region;
458     private AwsSecurityCredentials credentials;
459 
ITAwsSecurityCredentialsProvider(String region, AwsSecurityCredentials credentials)460     ITAwsSecurityCredentialsProvider(String region, AwsSecurityCredentials credentials) {
461       this.region = region;
462       this.credentials = credentials;
463     }
464 
465     @Override
getRegion(ExternalAccountSupplierContext context)466     public String getRegion(ExternalAccountSupplierContext context) {
467       return this.region;
468     }
469 
470     @Override
getCredentials(ExternalAccountSupplierContext context)471     public AwsSecurityCredentials getCredentials(ExternalAccountSupplierContext context) {
472       return this.credentials;
473     }
474   }
475 }
476