From 0198733b9d294cbee95f1170f814fbfe94baa6fc Mon Sep 17 00:00:00 2001 From: aeitzman <12433791+aeitzman@users.noreply.github.com> Date: Wed, 7 Sep 2022 10:01:06 -0700 Subject: [PATCH] feat: adds configurable token lifetime support (#982) * feat: adding configurable token lifespan support and tests * fix: correcting linting errors * fix: address code review * Adding readme documentation * fix: addressing code review * fix: make impersonation options object public * fix: addressing code review comments * Add check for lifetime min and max value --- README.md | 28 ++ .../oauth2/ExternalAccountCredentials.java | 90 +++++- .../auth/oauth2/AwsCredentialsTest.java | 33 ++ .../ExternalAccountCredentialsTest.java | 287 +++++++++++++++++- .../auth/oauth2/GoogleCredentialsTest.java | 3 +- .../ITWorkloadIdentityFederationTest.java | 25 ++ .../oauth2/IdentityPoolCredentialsTest.java | 79 ++++- .../oauth2/PluggableAuthCredentialsTest.java | 33 ++ 8 files changed, 573 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 762929749..c94afa4bb 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ credentials as well as utility methods to create them and to get Application Def * [Accessing resources from Azure](#access-resources-from-microsoft-azure) * [Accessing resources from an OIDC identity provider](#accessing-resources-from-an-oidc-identity-provider) * [Accessing resources using Executable-sourced credentials](#using-executable-sourced-credentials-with-oidc-and-saml) + * [Configurable Token Lifetime](#configurable-token-lifetime) * [Workforce Identity Federation](#workforce-identity-federation) * [Accessing resources using an OIDC or SAML 2.0 identity provider](#accessing-resources-using-an-oidc-or-saml-20-identity-provider) * [Accessing resources using Executable-sourced credentials](#using-executable-sourced-workforce-credentials-with-oidc-and-saml) @@ -467,6 +468,33 @@ credentials unless they do not meet your specific requirements. You can now [use the Auth library](#using-external-identities) to call Google Cloud resources from an OIDC or SAML provider. +#### Configurable Token Lifetime +When creating a credential configuration with workload identity federation using service account impersonation, you can provide an optional argument to configure the service account access token lifetime. + +To generate the configuration with configurable token lifetime, run the following command (this example uses an AWS configuration, but the token lifetime can be configured for all workload identity federation providers): + ```bash + # Generate an AWS configuration file with configurable token lifetime. + gcloud iam workload-identity-pools create-cred-config \ + projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/$POOL_ID/providers/$AWS_PROVIDER_ID \ + --service-account $SERVICE_ACCOUNT_EMAIL \ + --aws \ + --output-file /path/to/generated/config.json \ + --service-account-token-lifetime-seconds $TOKEN_LIFETIME + ``` + +Where the following variables need to be substituted: +- `$PROJECT_NUMBER`: The Google Cloud project number. +- `$POOL_ID`: The workload identity pool ID. +- `$AWS_PROVIDER_ID`: The AWS provider ID. +- `$SERVICE_ACCOUNT_EMAIL`: The email of the service account to impersonate. +- `$TOKEN_LIFETIME`: The desired lifetime duration of the service account access token in seconds. + +The `service-account-token-lifetime-seconds` flag is optional. If not provided, this defaults to one hour. +The minimum allowed value is 600 (10 minutes) and the maximum allowed value is 43200 (12 hours). +If a lifetime greater than one hour is required, the service account must be added as an allowed value in an Organization Policy that enforces the `constraints/iam.allowServiceAccountCredentialLifetimeExtension` constraint. + +Note that configuring a short lifetime (e.g. 10 minutes) will result in the library initiating the entire token exchange flow every 10 minutes, which will call the 3rd party token provider even if the 3rd party token is not expired. + ### Workforce Identity Federation [Workforce identity federation](https://cloud.google.com/iam/docs/workforce-identity-federation) lets you use an diff --git a/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java index 0d6f3419b..b718d3a7a 100644 --- a/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java @@ -43,11 +43,13 @@ import com.google.common.base.MoreObjects; import java.io.IOException; import java.io.InputStream; +import java.math.BigDecimal; import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; +import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; @@ -85,6 +87,7 @@ abstract static class CredentialSource { private final String tokenUrl; private final CredentialSource credentialSource; private final Collection scopes; + private final ServiceAccountImpersonationOptions serviceAccountImpersonationOptions; @Nullable private final String tokenInfoUrl; @Nullable private final String serviceAccountImpersonationUrl; @@ -194,6 +197,8 @@ protected ExternalAccountCredentials( this.environmentProvider = environmentProvider == null ? SystemEnvironmentProvider.getInstance() : environmentProvider; this.workforcePoolUserProject = null; + this.serviceAccountImpersonationOptions = + new ServiceAccountImpersonationOptions(new HashMap()); validateTokenUrl(tokenUrl); if (serviceAccountImpersonationUrl != null) { @@ -230,6 +235,10 @@ protected ExternalAccountCredentials(ExternalAccountCredentials.Builder builder) builder.environmentProvider == null ? SystemEnvironmentProvider.getInstance() : builder.environmentProvider; + this.serviceAccountImpersonationOptions = + builder.serviceAccountImpersonationOptions == null + ? new ServiceAccountImpersonationOptions(new HashMap()) + : builder.serviceAccountImpersonationOptions; this.workforcePoolUserProject = builder.workforcePoolUserProject; if (workforcePoolUserProject != null && !isWorkforcePoolConfiguration()) { @@ -275,7 +284,7 @@ ImpersonatedCredentials buildImpersonatedCredentials() { .setHttpTransportFactory(transportFactory) .setTargetPrincipal(targetPrincipal) .setScopes(new ArrayList<>(scopes)) - .setLifetime(3600) // 1 hour in seconds + .setLifetime(this.serviceAccountImpersonationOptions.lifetime) .setIamEndpointOverride(serviceAccountImpersonationUrl) .build(); } @@ -375,6 +384,12 @@ static ExternalAccountCredentials fromJson( String clientSecret = (String) json.get("client_secret"); String quotaProjectId = (String) json.get("quota_project_id"); String userProject = (String) json.get("workforce_pool_user_project"); + Map impersonationOptionsMap = + (Map) json.get("service_account_impersonation"); + + if (impersonationOptionsMap == null) { + impersonationOptionsMap = new HashMap(); + } if (isAwsCredential(credentialSourceMap)) { return AwsCredentials.newBuilder() @@ -388,6 +403,7 @@ static ExternalAccountCredentials fromJson( .setQuotaProjectId(quotaProjectId) .setClientId(clientId) .setClientSecret(clientSecret) + .setServiceAccountImpersonationOptions(impersonationOptionsMap) .build(); } else if (isPluggableAuthCredential(credentialSourceMap)) { return PluggableAuthCredentials.newBuilder() @@ -402,6 +418,7 @@ static ExternalAccountCredentials fromJson( .setClientId(clientId) .setClientSecret(clientSecret) .setWorkforcePoolUserProject(userProject) + .setServiceAccountImpersonationOptions(impersonationOptionsMap) .build(); } return IdentityPoolCredentials.newBuilder() @@ -416,6 +433,7 @@ static ExternalAccountCredentials fromJson( .setClientId(clientId) .setClientSecret(clientSecret) .setWorkforcePoolUserProject(userProject) + .setServiceAccountImpersonationOptions(impersonationOptionsMap) .build(); } @@ -539,6 +557,11 @@ public String getWorkforcePoolUserProject() { return workforcePoolUserProject; } + @Nullable + public ServiceAccountImpersonationOptions getServiceAccountImpersonationOptions() { + return serviceAccountImpersonationOptions; + } + EnvironmentProvider getEnvironmentProvider() { return environmentProvider; } @@ -608,6 +631,63 @@ private static boolean isValidUrl(List patterns, String url) { return false; } + /** + * Encapsulates the service account impersonation options portion of the configuration for + * ExternalAccountCredentials. + * + *

If token_lifetime_seconds is not specified, the library will default to a 1-hour lifetime. + * + *

+   * Sample configuration:
+   * {
+   *   ...
+   *   "service_account_impersonation": {
+   *     "token_lifetime_seconds": 2800
+   *    }
+   * }
+   * 
+ */ + static final class ServiceAccountImpersonationOptions { + private static final int DEFAULT_TOKEN_LIFETIME_SECONDS = 3600; + private static final int MAXIMUM_TOKEN_LIFETIME_SECONDS = 43200; + private static final int MINIMUM_TOKEN_LIFETIME_SECONDS = 600; + private static final String TOKEN_LIFETIME_SECONDS_KEY = "token_lifetime_seconds"; + + private final int lifetime; + + ServiceAccountImpersonationOptions(Map optionsMap) { + if (!optionsMap.containsKey(TOKEN_LIFETIME_SECONDS_KEY)) { + lifetime = DEFAULT_TOKEN_LIFETIME_SECONDS; + return; + } + + try { + Object lifetimeValue = optionsMap.get(TOKEN_LIFETIME_SECONDS_KEY); + if (lifetimeValue instanceof BigDecimal) { + lifetime = ((BigDecimal) lifetimeValue).intValue(); + } else if (optionsMap.get(TOKEN_LIFETIME_SECONDS_KEY) instanceof Integer) { + lifetime = (int) lifetimeValue; + } else { + lifetime = Integer.parseInt((String) lifetimeValue); + } + } catch (NumberFormatException | ArithmeticException e) { + throw new IllegalArgumentException( + "Value of \"token_lifetime_seconds\" field could not be parsed into an integer.", e); + } + + if (lifetime < MINIMUM_TOKEN_LIFETIME_SECONDS || lifetime > MAXIMUM_TOKEN_LIFETIME_SECONDS) { + throw new IllegalArgumentException( + String.format( + "The \"token_lifetime_seconds\" field must be between %s and %s seconds.", + MINIMUM_TOKEN_LIFETIME_SECONDS, MAXIMUM_TOKEN_LIFETIME_SECONDS)); + } + } + + int getLifetime() { + return lifetime; + } + } + /** Base builder for external account credentials. */ public abstract static class Builder extends GoogleCredentials.Builder { @@ -625,6 +705,7 @@ public abstract static class Builder extends GoogleCredentials.Builder { @Nullable protected String clientSecret; @Nullable protected Collection scopes; @Nullable protected String workforcePoolUserProject; + @Nullable protected ServiceAccountImpersonationOptions serviceAccountImpersonationOptions; protected Builder() {} @@ -642,6 +723,7 @@ protected Builder(ExternalAccountCredentials credentials) { this.scopes = credentials.scopes; this.environmentProvider = credentials.environmentProvider; this.workforcePoolUserProject = credentials.workforcePoolUserProject; + this.serviceAccountImpersonationOptions = credentials.serviceAccountImpersonationOptions; } /** Sets the HTTP transport factory, creates the transport used to get access tokens. */ @@ -733,6 +815,12 @@ public Builder setWorkforcePoolUserProject(String workforcePoolUserProject) { return this; } + /** Sets the optional service account impersonation options. */ + public Builder setServiceAccountImpersonationOptions(Map optionsMap) { + this.serviceAccountImpersonationOptions = new ServiceAccountImpersonationOptions(optionsMap); + return this; + } + Builder setEnvironmentProvider(EnvironmentProvider environmentProvider) { this.environmentProvider = environmentProvider; return this; diff --git a/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java index 942f6183f..9b67a09bb 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java @@ -142,6 +142,39 @@ public void refreshAccessToken_withServiceAccountImpersonation() throws IOExcept transportFactory.transport.getServiceAccountAccessToken(), accessToken.getTokenValue()); } + @Test + public void refreshAccessToken_withServiceAccountImpersonationOptions() throws IOException { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + transportFactory.transport.setExpireTime(TestUtils.getDefaultExpireTime()); + + AwsCredentials awsCredential = + (AwsCredentials) + AwsCredentials.newBuilder(AWS_CREDENTIAL) + .setTokenUrl(transportFactory.transport.getStsUrl()) + .setServiceAccountImpersonationUrl( + transportFactory.transport.getServiceAccountImpersonationUrl()) + .setHttpTransportFactory(transportFactory) + .setCredentialSource(buildAwsCredentialSource(transportFactory)) + .setServiceAccountImpersonationOptions( + ExternalAccountCredentialsTest.buildServiceAccountImpersonationOptions(2800)) + .build(); + + AccessToken accessToken = awsCredential.refreshAccessToken(); + + assertEquals( + transportFactory.transport.getServiceAccountAccessToken(), accessToken.getTokenValue()); + + // Validate that default lifetime was set correctly on the request. + GenericJson query = + OAuth2Utils.JSON_FACTORY + .createJsonParser(transportFactory.transport.getLastRequest().getContentAsString()) + .parseAndClose(GenericJson.class); + + assertEquals("2800s", query.get("lifetime")); + } + @Test public void retrieveSubjectToken() throws IOException { MockExternalAccountCredentialsTransportFactory transportFactory = diff --git a/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java index 1125ebc51..5d7d188a2 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java @@ -46,6 +46,7 @@ import com.google.auth.oauth2.PluggableAuthCredentials.PluggableAuthCredentialSource; import java.io.ByteArrayInputStream; import java.io.IOException; +import java.math.BigDecimal; import java.net.URI; import java.util.ArrayList; import java.util.Arrays; @@ -202,6 +203,27 @@ public void fromJson_identityPoolCredentialsWorkforce() { assertNotNull(credential.getCredentialSource()); } + @Test + public void fromJson_identityPoolCredentialsWithServiceAccountImpersonationOptions() { + GenericJson identityPoolCredentialJson = buildJsonIdentityPoolCredential(); + identityPoolCredentialJson.set( + "service_account_impersonation", buildServiceAccountImpersonationOptions(2800)); + + ExternalAccountCredentials credential = + ExternalAccountCredentials.fromJson( + identityPoolCredentialJson, OAuth2Utils.HTTP_TRANSPORT_FACTORY); + + assertTrue(credential instanceof IdentityPoolCredentials); + assertEquals( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider", + credential.getAudience()); + assertEquals("subjectTokenType", credential.getSubjectTokenType()); + assertEquals(STS_URL, credential.getTokenUrl()); + assertEquals("tokenInfoUrl", credential.getTokenInfoUrl()); + assertNotNull(credential.getCredentialSource()); + assertEquals(2800, credential.getServiceAccountImpersonationOptions().getLifetime()); + } + @Test public void fromJson_awsCredentials() throws IOException { ExternalAccountCredentials credential = @@ -216,6 +238,24 @@ public void fromJson_awsCredentials() throws IOException { assertNotNull(credential.getCredentialSource()); } + @Test + public void fromJson_awsCredentialsWithServiceAccountImpersonationOptions() throws IOException { + GenericJson awsCredentialJson = buildJsonAwsCredential(); + awsCredentialJson.set( + "service_account_impersonation", buildServiceAccountImpersonationOptions(2800)); + + ExternalAccountCredentials credential = + ExternalAccountCredentials.fromJson(awsCredentialJson, OAuth2Utils.HTTP_TRANSPORT_FACTORY); + + assertTrue(credential instanceof AwsCredentials); + assertEquals("audience", credential.getAudience()); + assertEquals("subjectTokenType", credential.getSubjectTokenType()); + assertEquals(STS_URL, credential.getTokenUrl()); + assertEquals("tokenInfoUrl", credential.getTokenInfoUrl()); + assertNotNull(credential.getCredentialSource()); + assertEquals(2800, credential.getServiceAccountImpersonationOptions().getLifetime()); + } + @Test public void fromJson_pluggableAuthCredentials() { ExternalAccountCredentials credential = @@ -287,6 +327,31 @@ public void fromJson_pluggableAuthCredentials_allExecutableOptionsSet() { assertEquals(5000, source.getTimeoutMs()); } + @Test + public void fromJson_pluggableAuthCredentialsWithServiceAccountImpersonationOptions() { + GenericJson pluggableAuthCredentialJson = buildJsonPluggableAuthCredential(); + pluggableAuthCredentialJson.set( + "service_account_impersonation", buildServiceAccountImpersonationOptions(2800)); + + ExternalAccountCredentials credential = + ExternalAccountCredentials.fromJson( + pluggableAuthCredentialJson, OAuth2Utils.HTTP_TRANSPORT_FACTORY); + + assertTrue(credential instanceof PluggableAuthCredentials); + assertEquals("audience", credential.getAudience()); + assertEquals("subjectTokenType", credential.getSubjectTokenType()); + assertEquals(STS_URL, credential.getTokenUrl()); + assertEquals("tokenInfoUrl", credential.getTokenInfoUrl()); + assertNotNull(credential.getCredentialSource()); + assertEquals(2800, credential.getServiceAccountImpersonationOptions().getLifetime()); + + PluggableAuthCredentialSource source = + (PluggableAuthCredentialSource) credential.getCredentialSource(); + assertEquals("command", source.getCommand()); + assertEquals(30000, source.getTimeoutMs()); // Default timeout is 30s. + assertNull(source.getOutputFilePath()); + } + @Test public void fromJson_nullJson_throws() { try { @@ -475,6 +540,175 @@ public void constructor_builderWithEmptyWorkforceUserProjectAndWorkforceAudience .build(); } + @Test + public void constructor_builder_invalidTokenLifetime_throws() { + Map invalidOptionsMap = new HashMap(); + invalidOptionsMap.put("token_lifetime_seconds", "thisIsAString"); + + try { + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(transportFactory) + .setAudience( + "//iam.googleapis.com/locations/global/workforcePools/pool/providers/provider") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl(STS_URL) + .setTokenInfoUrl("https://tokeninfo.com") + .setServiceAccountImpersonationUrl(SERVICE_ACCOUNT_IMPERSONATION_URL) + .setCredentialSource(new TestCredentialSource(FILE_CREDENTIAL_SOURCE_MAP)) + .setScopes(Arrays.asList("scope1", "scope2")) + .setQuotaProjectId("projectId") + .setClientId("clientId") + .setClientSecret("clientSecret") + .setWorkforcePoolUserProject("workforcePoolUserProject") + .setServiceAccountImpersonationOptions(invalidOptionsMap) + .build(); + fail("Should not be able to continue without exception."); + } catch (IllegalArgumentException exception) { + assertEquals( + "Value of \"token_lifetime_seconds\" field could not be parsed into an integer.", + exception.getMessage()); + assertEquals(NumberFormatException.class, exception.getCause().getClass()); + } + } + + @Test + public void constructor_builder_stringTokenLifetime() { + Map optionsMap = new HashMap(); + optionsMap.put("token_lifetime_seconds", "2800"); + + ExternalAccountCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(transportFactory) + .setAudience( + "//iam.googleapis.com/locations/global/workforcePools/pool/providers/provider") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl(STS_URL) + .setTokenInfoUrl("https://tokeninfo.com") + .setServiceAccountImpersonationUrl(SERVICE_ACCOUNT_IMPERSONATION_URL) + .setCredentialSource(new TestCredentialSource(FILE_CREDENTIAL_SOURCE_MAP)) + .setScopes(Arrays.asList("scope1", "scope2")) + .setQuotaProjectId("projectId") + .setClientId("clientId") + .setClientSecret("clientSecret") + .setWorkforcePoolUserProject("workforcePoolUserProject") + .setServiceAccountImpersonationOptions(optionsMap) + .build(); + + assertEquals(2800, credentials.getServiceAccountImpersonationOptions().getLifetime()); + } + + @Test + public void constructor_builder_bigDecimalTokenLifetime() { + Map optionsMap = new HashMap(); + optionsMap.put("token_lifetime_seconds", new BigDecimal("2800")); + + ExternalAccountCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(transportFactory) + .setAudience( + "//iam.googleapis.com/locations/global/workforcePools/pool/providers/provider") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl(STS_URL) + .setTokenInfoUrl("https://tokeninfo.com") + .setServiceAccountImpersonationUrl(SERVICE_ACCOUNT_IMPERSONATION_URL) + .setCredentialSource(new TestCredentialSource(FILE_CREDENTIAL_SOURCE_MAP)) + .setScopes(Arrays.asList("scope1", "scope2")) + .setQuotaProjectId("projectId") + .setClientId("clientId") + .setClientSecret("clientSecret") + .setWorkforcePoolUserProject("workforcePoolUserProject") + .setServiceAccountImpersonationOptions(optionsMap) + .build(); + + assertEquals(2800, credentials.getServiceAccountImpersonationOptions().getLifetime()); + } + + @Test + public void constructor_builder_integerTokenLifetime() { + Map optionsMap = new HashMap(); + optionsMap.put("token_lifetime_seconds", Integer.valueOf(2800)); + + ExternalAccountCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(transportFactory) + .setAudience( + "//iam.googleapis.com/locations/global/workforcePools/pool/providers/provider") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl(STS_URL) + .setTokenInfoUrl("https://tokeninfo.com") + .setServiceAccountImpersonationUrl(SERVICE_ACCOUNT_IMPERSONATION_URL) + .setCredentialSource(new TestCredentialSource(FILE_CREDENTIAL_SOURCE_MAP)) + .setScopes(Arrays.asList("scope1", "scope2")) + .setQuotaProjectId("projectId") + .setClientId("clientId") + .setClientSecret("clientSecret") + .setWorkforcePoolUserProject("workforcePoolUserProject") + .setServiceAccountImpersonationOptions(optionsMap) + .build(); + + assertEquals(2800, credentials.getServiceAccountImpersonationOptions().getLifetime()); + } + + @Test + public void constructor_builder_lowTokenLifetime_throws() { + Map optionsMap = new HashMap(); + optionsMap.put("token_lifetime_seconds", 599); + + try { + ExternalAccountCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(transportFactory) + .setAudience( + "//iam.googleapis.com/locations/global/workforcePools/pool/providers/provider") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl(STS_URL) + .setTokenInfoUrl("https://tokeninfo.com") + .setServiceAccountImpersonationUrl(SERVICE_ACCOUNT_IMPERSONATION_URL) + .setCredentialSource(new TestCredentialSource(FILE_CREDENTIAL_SOURCE_MAP)) + .setScopes(Arrays.asList("scope1", "scope2")) + .setQuotaProjectId("projectId") + .setClientId("clientId") + .setClientSecret("clientSecret") + .setWorkforcePoolUserProject("workforcePoolUserProject") + .setServiceAccountImpersonationOptions(optionsMap) + .build(); + } catch (IllegalArgumentException e) { + assertEquals( + "The \"token_lifetime_seconds\" field must be between 600 and 43200 seconds.", + e.getMessage()); + } + } + + @Test + public void constructor_builder_highTokenLifetime_throws() { + Map optionsMap = new HashMap(); + optionsMap.put("token_lifetime_seconds", 43201); + + try { + ExternalAccountCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(transportFactory) + .setAudience( + "//iam.googleapis.com/locations/global/workforcePools/pool/providers/provider") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl(STS_URL) + .setTokenInfoUrl("https://tokeninfo.com") + .setServiceAccountImpersonationUrl(SERVICE_ACCOUNT_IMPERSONATION_URL) + .setCredentialSource(new TestCredentialSource(FILE_CREDENTIAL_SOURCE_MAP)) + .setScopes(Arrays.asList("scope1", "scope2")) + .setQuotaProjectId("projectId") + .setClientId("clientId") + .setClientSecret("clientSecret") + .setWorkforcePoolUserProject("workforcePoolUserProject") + .setServiceAccountImpersonationOptions(optionsMap) + .build(); + } catch (IllegalArgumentException e) { + assertEquals( + "The \"token_lifetime_seconds\" field must be between 600 and 43200 seconds.", + e.getMessage()); + } + } + @Test public void exchangeExternalCredentialForAccessToken() throws IOException { ExternalAccountCredentials credential = @@ -590,7 +824,8 @@ public void exchangeExternalCredentialForAccessToken_withServiceAccountImpersona IdentityPoolCredentialsTest.writeIdentityPoolCredentialsStream( transportFactory.transport.getStsUrl(), transportFactory.transport.getMetadataUrl(), - transportFactory.transport.getServiceAccountImpersonationUrl()), + transportFactory.transport.getServiceAccountImpersonationUrl(), + /* serviceAccountImpersonationOptionsMap= */ null), transportFactory); StsTokenExchangeRequest stsTokenExchangeRequest = @@ -601,6 +836,46 @@ public void exchangeExternalCredentialForAccessToken_withServiceAccountImpersona assertEquals( transportFactory.transport.getServiceAccountAccessToken(), returnedToken.getTokenValue()); + + // Validate that default lifetime was set correctly on the request. + GenericJson query = + OAuth2Utils.JSON_FACTORY + .createJsonParser(transportFactory.transport.getLastRequest().getContentAsString()) + .parseAndClose(GenericJson.class); + + assertEquals("3600s", query.get("lifetime")); + } + + @Test + public void exchangeExternalCredentialForAccessToken_withServiceAccountImpersonationOptions() + throws IOException { + transportFactory.transport.setExpireTime(TestUtils.getDefaultExpireTime()); + + ExternalAccountCredentials credential = + ExternalAccountCredentials.fromStream( + IdentityPoolCredentialsTest.writeIdentityPoolCredentialsStream( + transportFactory.transport.getStsUrl(), + transportFactory.transport.getMetadataUrl(), + transportFactory.transport.getServiceAccountImpersonationUrl(), + buildServiceAccountImpersonationOptions(2800)), + transportFactory); + + StsTokenExchangeRequest stsTokenExchangeRequest = + StsTokenExchangeRequest.newBuilder("credential", "subjectTokenType").build(); + + AccessToken returnedToken = + credential.exchangeExternalCredentialForAccessToken(stsTokenExchangeRequest); + + assertEquals( + transportFactory.transport.getServiceAccountAccessToken(), returnedToken.getTokenValue()); + + // Validate that lifetime was set correctly on the request. + GenericJson query = + OAuth2Utils.JSON_FACTORY + .createJsonParser(transportFactory.transport.getLastRequest().getContentAsString()) + .parseAndClose(GenericJson.class); + + assertEquals("2800s", query.get("lifetime")); } @Test @@ -614,7 +889,8 @@ public void exchangeExternalCredentialForAccessToken_withServiceAccountImpersona IdentityPoolCredentialsTest.writeIdentityPoolCredentialsStream( transportFactory.transport.getStsUrl(), transportFactory.transport.getMetadataUrl(), - transportFactory.transport.getServiceAccountImpersonationUrl()), + transportFactory.transport.getServiceAccountImpersonationUrl(), + /* serviceAccountImpersonationOptionsMap= */ null), transportFactory); // Override impersonated credentials. @@ -851,6 +1127,13 @@ private GenericJson buildJsonPluggableAuthWorkforceCredential() { return json; } + static Map buildServiceAccountImpersonationOptions(Integer lifetime) { + Map map = new HashMap(); + map.put("token_lifetime_seconds", lifetime); + + return map; + } + static class TestExternalAccountCredentials extends ExternalAccountCredentials { static class TestCredentialSource extends IdentityPoolCredentials.IdentityPoolCredentialSource { protected TestCredentialSource(Map credentialSourceMap) { diff --git a/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java index 551aecf43..f849ccbb1 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java @@ -241,7 +241,8 @@ public void fromStream_identityPoolCredentials_providesToken() throws IOExceptio IdentityPoolCredentialsTest.writeIdentityPoolCredentialsStream( transportFactory.transport.getStsUrl(), transportFactory.transport.getMetadataUrl(), - /* serviceAccountImpersonationUrl= */ null); + /* serviceAccountImpersonationUrl= */ null, + /* serviceAccountImpersonationOptionsMap= */ null); GoogleCredentials credentials = GoogleCredentials.fromStream(identityPoolCredentialStream, transportFactory); diff --git a/oauth2_http/javatests/com/google/auth/oauth2/ITWorkloadIdentityFederationTest.java b/oauth2_http/javatests/com/google/auth/oauth2/ITWorkloadIdentityFederationTest.java index 3ab290993..9b4e9760b 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/ITWorkloadIdentityFederationTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/ITWorkloadIdentityFederationTest.java @@ -52,6 +52,8 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.time.Instant; +import java.util.HashMap; +import java.util.Map; import org.junit.Before; import org.junit.Test; @@ -168,6 +170,29 @@ public void pluggableAuthCredentials() throws IOException { callGcs(pluggableAuthCredentials); } + /** + * Sets the service account impersonation object in configuration JSON with a non-default value + * for token_lifetime_seconds and validates that the lifetime is used for the access token. + */ + @Test + public void identityPoolCredentials_withServiceAccountImpersonationOptions() throws IOException { + GenericJson identityPoolCredentialConfig = buildIdentityPoolCredentialConfig(); + Map map = new HashMap(); + map.put("token_lifetime_seconds", 2800); + identityPoolCredentialConfig.put("service_account_impersonation", map); + + IdentityPoolCredentials identityPoolCredentials = + (IdentityPoolCredentials) + ExternalAccountCredentials.fromJson( + identityPoolCredentialConfig, OAuth2Utils.HTTP_TRANSPORT_FACTORY); + long maxExpirationTime = Instant.now().plusSeconds(2800 + 5).toEpochMilli(); + long minExpirationtime = Instant.now().plusSeconds(2800 - 5).toEpochMilli(); + + callGcs(identityPoolCredentials); + long tokenExpiry = identityPoolCredentials.getAccessToken().getExpirationTimeMillis(); + assertTrue(minExpirationtime <= tokenExpiry && tokenExpiry <= maxExpirationTime); + } + private GenericJson buildIdentityPoolCredentialConfig() throws IOException { String idToken = generateGoogleIdToken(OIDC_AUDIENCE); diff --git a/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java index e7d48978e..373f9b3fb 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java @@ -386,6 +386,39 @@ public void refreshAccessToken_withServiceAccountImpersonation() throws IOExcept transportFactory.transport.getServiceAccountAccessToken(), accessToken.getTokenValue()); } + @Test + public void refreshAccessToken_withServiceAccountImpersonationOptions() throws IOException { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + transportFactory.transport.setExpireTime(TestUtils.getDefaultExpireTime()); + IdentityPoolCredentials credential = + (IdentityPoolCredentials) + IdentityPoolCredentials.newBuilder(FILE_SOURCED_CREDENTIAL) + .setTokenUrl(transportFactory.transport.getStsUrl()) + .setServiceAccountImpersonationUrl( + transportFactory.transport.getServiceAccountImpersonationUrl()) + .setHttpTransportFactory(transportFactory) + .setCredentialSource( + buildUrlBasedCredentialSource(transportFactory.transport.getMetadataUrl())) + .setServiceAccountImpersonationOptions( + ExternalAccountCredentialsTest.buildServiceAccountImpersonationOptions(2800)) + .build(); + + AccessToken accessToken = credential.refreshAccessToken(); + + assertEquals( + transportFactory.transport.getServiceAccountAccessToken(), accessToken.getTokenValue()); + + // Validate that default lifetime was set correctly on the request. + GenericJson query = + OAuth2Utils.JSON_FACTORY + .createJsonParser(transportFactory.transport.getLastRequest().getContentAsString()) + .parseAndClose(GenericJson.class); + + assertEquals("2800s", query.get("lifetime")); + } + @Test public void refreshAccessToken_workforceWithServiceAccountImpersonation() throws IOException { MockExternalAccountCredentialsTransportFactory transportFactory = @@ -422,6 +455,43 @@ public void refreshAccessToken_workforceWithServiceAccountImpersonation() throws assertEquals(expectedInternalOptions.toString(), query.get("options")); } + @Test + public void refreshAccessToken_workforceWithServiceAccountImpersonationOptions() + throws IOException { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + transportFactory.transport.setExpireTime(TestUtils.getDefaultExpireTime()); + IdentityPoolCredentials credential = + (IdentityPoolCredentials) + IdentityPoolCredentials.newBuilder(FILE_SOURCED_CREDENTIAL) + .setAudience( + "//iam.googleapis.com/locations/global/workforcePools/pool/providers/provider") + .setTokenUrl(transportFactory.transport.getStsUrl()) + .setServiceAccountImpersonationUrl( + transportFactory.transport.getServiceAccountImpersonationUrl()) + .setHttpTransportFactory(transportFactory) + .setCredentialSource( + buildUrlBasedCredentialSource(transportFactory.transport.getMetadataUrl())) + .setWorkforcePoolUserProject("userProject") + .setServiceAccountImpersonationOptions( + ExternalAccountCredentialsTest.buildServiceAccountImpersonationOptions(2800)) + .build(); + + AccessToken accessToken = credential.refreshAccessToken(); + + // Validate that default lifetime was set correctly on the request. + assertEquals( + transportFactory.transport.getServiceAccountAccessToken(), accessToken.getTokenValue()); + + GenericJson query = + OAuth2Utils.JSON_FACTORY + .createJsonParser(transportFactory.transport.getLastRequest().getContentAsString()) + .parseAndClose(GenericJson.class); + + assertEquals("2800s", query.get("lifetime")); + } + @Test public void identityPoolCredentialSource_validFormats() { Map credentialSourceMapWithFileTextSource = new HashMap<>(); @@ -663,7 +733,10 @@ public void builder_emptyWorkforceUserProjectWithWorkforceAudience() { } static InputStream writeIdentityPoolCredentialsStream( - String tokenUrl, String url, @Nullable String serviceAccountImpersonationUrl) + String tokenUrl, + String url, + @Nullable String serviceAccountImpersonationUrl, + @Nullable Map serviceAccountImpersonationOptionsMap) throws IOException { GenericJson json = new GenericJson(); json.put("audience", "audience"); @@ -676,6 +749,10 @@ static InputStream writeIdentityPoolCredentialsStream( json.put("service_account_impersonation_url", serviceAccountImpersonationUrl); } + if (serviceAccountImpersonationOptionsMap != null) { + json.put("service_account_impersonation", serviceAccountImpersonationOptionsMap); + } + GenericJson credentialSource = new GenericJson(); GenericJson headers = new GenericJson(); headers.put("Metadata-Flavor", "Google"); diff --git a/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java index bddccd6e2..cf316ade1 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java @@ -231,6 +231,39 @@ public void refreshAccessToken_withServiceAccountImpersonation() throws IOExcept assertEquals(query.get("subject_token"), "pluggableAuthToken"); } + @Test + public void refreshAccessToken_withServiceAccountImpersonationOptions() throws IOException { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + transportFactory.transport.setExpireTime(TestUtils.getDefaultExpireTime()); + + PluggableAuthCredentials credential = + (PluggableAuthCredentials) + PluggableAuthCredentials.newBuilder(CREDENTIAL) + .setExecutableHandler(options -> "pluggableAuthToken") + .setTokenUrl(transportFactory.transport.getStsUrl()) + .setServiceAccountImpersonationUrl( + transportFactory.transport.getServiceAccountImpersonationUrl()) + .setHttpTransportFactory(transportFactory) + .setServiceAccountImpersonationOptions( + ExternalAccountCredentialsTest.buildServiceAccountImpersonationOptions(2800)) + .build(); + + AccessToken accessToken = credential.refreshAccessToken(); + + assertEquals( + transportFactory.transport.getServiceAccountAccessToken(), accessToken.getTokenValue()); + + // Validate that default lifetime was set correctly on the request. + GenericJson query = + OAuth2Utils.JSON_FACTORY + .createJsonParser(transportFactory.transport.getLastRequest().getContentAsString()) + .parseAndClose(GenericJson.class); + + assertEquals("2800s", query.get("lifetime")); + } + @Test public void pluggableAuthCredentialSource_allFields() { Map source = new HashMap<>();