Skip to content

Commit

Permalink
feat: adds configurable token lifetime support (#982)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
aeitzman committed Sep 7, 2022
1 parent 257071a commit 0198733
Show file tree
Hide file tree
Showing 8 changed files with 573 additions and 5 deletions.
28 changes: 28 additions & 0 deletions README.md
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Expand Up @@ -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;
Expand Down Expand Up @@ -85,6 +87,7 @@ abstract static class CredentialSource {
private final String tokenUrl;
private final CredentialSource credentialSource;
private final Collection<String> scopes;
private final ServiceAccountImpersonationOptions serviceAccountImpersonationOptions;

@Nullable private final String tokenInfoUrl;
@Nullable private final String serviceAccountImpersonationUrl;
Expand Down Expand Up @@ -194,6 +197,8 @@ protected ExternalAccountCredentials(
this.environmentProvider =
environmentProvider == null ? SystemEnvironmentProvider.getInstance() : environmentProvider;
this.workforcePoolUserProject = null;
this.serviceAccountImpersonationOptions =
new ServiceAccountImpersonationOptions(new HashMap<String, Object>());

validateTokenUrl(tokenUrl);
if (serviceAccountImpersonationUrl != null) {
Expand Down Expand Up @@ -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<String, Object>())
: builder.serviceAccountImpersonationOptions;

this.workforcePoolUserProject = builder.workforcePoolUserProject;
if (workforcePoolUserProject != null && !isWorkforcePoolConfiguration()) {
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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<String, Object> impersonationOptionsMap =
(Map<String, Object>) json.get("service_account_impersonation");

if (impersonationOptionsMap == null) {
impersonationOptionsMap = new HashMap<String, Object>();
}

if (isAwsCredential(credentialSourceMap)) {
return AwsCredentials.newBuilder()
Expand All @@ -388,6 +403,7 @@ static ExternalAccountCredentials fromJson(
.setQuotaProjectId(quotaProjectId)
.setClientId(clientId)
.setClientSecret(clientSecret)
.setServiceAccountImpersonationOptions(impersonationOptionsMap)
.build();
} else if (isPluggableAuthCredential(credentialSourceMap)) {
return PluggableAuthCredentials.newBuilder()
Expand All @@ -402,6 +418,7 @@ static ExternalAccountCredentials fromJson(
.setClientId(clientId)
.setClientSecret(clientSecret)
.setWorkforcePoolUserProject(userProject)
.setServiceAccountImpersonationOptions(impersonationOptionsMap)
.build();
}
return IdentityPoolCredentials.newBuilder()
Expand All @@ -416,6 +433,7 @@ static ExternalAccountCredentials fromJson(
.setClientId(clientId)
.setClientSecret(clientSecret)
.setWorkforcePoolUserProject(userProject)
.setServiceAccountImpersonationOptions(impersonationOptionsMap)
.build();
}

Expand Down Expand Up @@ -539,6 +557,11 @@ public String getWorkforcePoolUserProject() {
return workforcePoolUserProject;
}

@Nullable
public ServiceAccountImpersonationOptions getServiceAccountImpersonationOptions() {
return serviceAccountImpersonationOptions;
}

EnvironmentProvider getEnvironmentProvider() {
return environmentProvider;
}
Expand Down Expand Up @@ -608,6 +631,63 @@ private static boolean isValidUrl(List<Pattern> patterns, String url) {
return false;
}

/**
* Encapsulates the service account impersonation options portion of the configuration for
* ExternalAccountCredentials.
*
* <p>If token_lifetime_seconds is not specified, the library will default to a 1-hour lifetime.
*
* <pre>
* Sample configuration:
* {
* ...
* "service_account_impersonation": {
* "token_lifetime_seconds": 2800
* }
* }
* </pre>
*/
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<String, Object> 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 {

Expand All @@ -625,6 +705,7 @@ public abstract static class Builder extends GoogleCredentials.Builder {
@Nullable protected String clientSecret;
@Nullable protected Collection<String> scopes;
@Nullable protected String workforcePoolUserProject;
@Nullable protected ServiceAccountImpersonationOptions serviceAccountImpersonationOptions;

protected Builder() {}

Expand All @@ -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. */
Expand Down Expand Up @@ -733,6 +815,12 @@ public Builder setWorkforcePoolUserProject(String workforcePoolUserProject) {
return this;
}

/** Sets the optional service account impersonation options. */
public Builder setServiceAccountImpersonationOptions(Map<String, Object> optionsMap) {
this.serviceAccountImpersonationOptions = new ServiceAccountImpersonationOptions(optionsMap);
return this;
}

Builder setEnvironmentProvider(EnvironmentProvider environmentProvider) {
this.environmentProvider = environmentProvider;
return this;
Expand Down
Expand Up @@ -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 =
Expand Down

0 comments on commit 0198733

Please sign in to comment.