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