Skip to content

Commit 0198733

Browse files
authored
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
1 parent 257071a commit 0198733

File tree

8 files changed

+573
-5
lines changed

8 files changed

+573
-5
lines changed

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ credentials as well as utility methods to create them and to get Application Def
2929
* [Accessing resources from Azure](#access-resources-from-microsoft-azure)
3030
* [Accessing resources from an OIDC identity provider](#accessing-resources-from-an-oidc-identity-provider)
3131
* [Accessing resources using Executable-sourced credentials](#using-executable-sourced-credentials-with-oidc-and-saml)
32+
* [Configurable Token Lifetime](#configurable-token-lifetime)
3233
* [Workforce Identity Federation](#workforce-identity-federation)
3334
* [Accessing resources using an OIDC or SAML 2.0 identity provider](#accessing-resources-using-an-oidc-or-saml-20-identity-provider)
3435
* [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.
467468
You can now [use the Auth library](#using-external-identities) to call Google Cloud
468469
resources from an OIDC or SAML provider.
469470

471+
#### Configurable Token Lifetime
472+
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.
473+
474+
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):
475+
```bash
476+
# Generate an AWS configuration file with configurable token lifetime.
477+
gcloud iam workload-identity-pools create-cred-config \
478+
projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/$POOL_ID/providers/$AWS_PROVIDER_ID \
479+
--service-account $SERVICE_ACCOUNT_EMAIL \
480+
--aws \
481+
--output-file /path/to/generated/config.json \
482+
--service-account-token-lifetime-seconds $TOKEN_LIFETIME
483+
```
484+
485+
Where the following variables need to be substituted:
486+
- `$PROJECT_NUMBER`: The Google Cloud project number.
487+
- `$POOL_ID`: The workload identity pool ID.
488+
- `$AWS_PROVIDER_ID`: The AWS provider ID.
489+
- `$SERVICE_ACCOUNT_EMAIL`: The email of the service account to impersonate.
490+
- `$TOKEN_LIFETIME`: The desired lifetime duration of the service account access token in seconds.
491+
492+
The `service-account-token-lifetime-seconds` flag is optional. If not provided, this defaults to one hour.
493+
The minimum allowed value is 600 (10 minutes) and the maximum allowed value is 43200 (12 hours).
494+
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.
495+
496+
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.
497+
470498
### Workforce Identity Federation
471499

472500
[Workforce identity federation](https://cloud.google.com/iam/docs/workforce-identity-federation) lets you use an

oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,13 @@
4343
import com.google.common.base.MoreObjects;
4444
import java.io.IOException;
4545
import java.io.InputStream;
46+
import java.math.BigDecimal;
4647
import java.net.URI;
4748
import java.nio.charset.StandardCharsets;
4849
import java.util.ArrayList;
4950
import java.util.Arrays;
5051
import java.util.Collection;
52+
import java.util.HashMap;
5153
import java.util.List;
5254
import java.util.Locale;
5355
import java.util.Map;
@@ -85,6 +87,7 @@ abstract static class CredentialSource {
8587
private final String tokenUrl;
8688
private final CredentialSource credentialSource;
8789
private final Collection<String> scopes;
90+
private final ServiceAccountImpersonationOptions serviceAccountImpersonationOptions;
8891

8992
@Nullable private final String tokenInfoUrl;
9093
@Nullable private final String serviceAccountImpersonationUrl;
@@ -194,6 +197,8 @@ protected ExternalAccountCredentials(
194197
this.environmentProvider =
195198
environmentProvider == null ? SystemEnvironmentProvider.getInstance() : environmentProvider;
196199
this.workforcePoolUserProject = null;
200+
this.serviceAccountImpersonationOptions =
201+
new ServiceAccountImpersonationOptions(new HashMap<String, Object>());
197202

198203
validateTokenUrl(tokenUrl);
199204
if (serviceAccountImpersonationUrl != null) {
@@ -230,6 +235,10 @@ protected ExternalAccountCredentials(ExternalAccountCredentials.Builder builder)
230235
builder.environmentProvider == null
231236
? SystemEnvironmentProvider.getInstance()
232237
: builder.environmentProvider;
238+
this.serviceAccountImpersonationOptions =
239+
builder.serviceAccountImpersonationOptions == null
240+
? new ServiceAccountImpersonationOptions(new HashMap<String, Object>())
241+
: builder.serviceAccountImpersonationOptions;
233242

234243
this.workforcePoolUserProject = builder.workforcePoolUserProject;
235244
if (workforcePoolUserProject != null && !isWorkforcePoolConfiguration()) {
@@ -275,7 +284,7 @@ ImpersonatedCredentials buildImpersonatedCredentials() {
275284
.setHttpTransportFactory(transportFactory)
276285
.setTargetPrincipal(targetPrincipal)
277286
.setScopes(new ArrayList<>(scopes))
278-
.setLifetime(3600) // 1 hour in seconds
287+
.setLifetime(this.serviceAccountImpersonationOptions.lifetime)
279288
.setIamEndpointOverride(serviceAccountImpersonationUrl)
280289
.build();
281290
}
@@ -375,6 +384,12 @@ static ExternalAccountCredentials fromJson(
375384
String clientSecret = (String) json.get("client_secret");
376385
String quotaProjectId = (String) json.get("quota_project_id");
377386
String userProject = (String) json.get("workforce_pool_user_project");
387+
Map<String, Object> impersonationOptionsMap =
388+
(Map<String, Object>) json.get("service_account_impersonation");
389+
390+
if (impersonationOptionsMap == null) {
391+
impersonationOptionsMap = new HashMap<String, Object>();
392+
}
378393

379394
if (isAwsCredential(credentialSourceMap)) {
380395
return AwsCredentials.newBuilder()
@@ -388,6 +403,7 @@ static ExternalAccountCredentials fromJson(
388403
.setQuotaProjectId(quotaProjectId)
389404
.setClientId(clientId)
390405
.setClientSecret(clientSecret)
406+
.setServiceAccountImpersonationOptions(impersonationOptionsMap)
391407
.build();
392408
} else if (isPluggableAuthCredential(credentialSourceMap)) {
393409
return PluggableAuthCredentials.newBuilder()
@@ -402,6 +418,7 @@ static ExternalAccountCredentials fromJson(
402418
.setClientId(clientId)
403419
.setClientSecret(clientSecret)
404420
.setWorkforcePoolUserProject(userProject)
421+
.setServiceAccountImpersonationOptions(impersonationOptionsMap)
405422
.build();
406423
}
407424
return IdentityPoolCredentials.newBuilder()
@@ -416,6 +433,7 @@ static ExternalAccountCredentials fromJson(
416433
.setClientId(clientId)
417434
.setClientSecret(clientSecret)
418435
.setWorkforcePoolUserProject(userProject)
436+
.setServiceAccountImpersonationOptions(impersonationOptionsMap)
419437
.build();
420438
}
421439

@@ -539,6 +557,11 @@ public String getWorkforcePoolUserProject() {
539557
return workforcePoolUserProject;
540558
}
541559

560+
@Nullable
561+
public ServiceAccountImpersonationOptions getServiceAccountImpersonationOptions() {
562+
return serviceAccountImpersonationOptions;
563+
}
564+
542565
EnvironmentProvider getEnvironmentProvider() {
543566
return environmentProvider;
544567
}
@@ -608,6 +631,63 @@ private static boolean isValidUrl(List<Pattern> patterns, String url) {
608631
return false;
609632
}
610633

634+
/**
635+
* Encapsulates the service account impersonation options portion of the configuration for
636+
* ExternalAccountCredentials.
637+
*
638+
* <p>If token_lifetime_seconds is not specified, the library will default to a 1-hour lifetime.
639+
*
640+
* <pre>
641+
* Sample configuration:
642+
* {
643+
* ...
644+
* "service_account_impersonation": {
645+
* "token_lifetime_seconds": 2800
646+
* }
647+
* }
648+
* </pre>
649+
*/
650+
static final class ServiceAccountImpersonationOptions {
651+
private static final int DEFAULT_TOKEN_LIFETIME_SECONDS = 3600;
652+
private static final int MAXIMUM_TOKEN_LIFETIME_SECONDS = 43200;
653+
private static final int MINIMUM_TOKEN_LIFETIME_SECONDS = 600;
654+
private static final String TOKEN_LIFETIME_SECONDS_KEY = "token_lifetime_seconds";
655+
656+
private final int lifetime;
657+
658+
ServiceAccountImpersonationOptions(Map<String, Object> optionsMap) {
659+
if (!optionsMap.containsKey(TOKEN_LIFETIME_SECONDS_KEY)) {
660+
lifetime = DEFAULT_TOKEN_LIFETIME_SECONDS;
661+
return;
662+
}
663+
664+
try {
665+
Object lifetimeValue = optionsMap.get(TOKEN_LIFETIME_SECONDS_KEY);
666+
if (lifetimeValue instanceof BigDecimal) {
667+
lifetime = ((BigDecimal) lifetimeValue).intValue();
668+
} else if (optionsMap.get(TOKEN_LIFETIME_SECONDS_KEY) instanceof Integer) {
669+
lifetime = (int) lifetimeValue;
670+
} else {
671+
lifetime = Integer.parseInt((String) lifetimeValue);
672+
}
673+
} catch (NumberFormatException | ArithmeticException e) {
674+
throw new IllegalArgumentException(
675+
"Value of \"token_lifetime_seconds\" field could not be parsed into an integer.", e);
676+
}
677+
678+
if (lifetime < MINIMUM_TOKEN_LIFETIME_SECONDS || lifetime > MAXIMUM_TOKEN_LIFETIME_SECONDS) {
679+
throw new IllegalArgumentException(
680+
String.format(
681+
"The \"token_lifetime_seconds\" field must be between %s and %s seconds.",
682+
MINIMUM_TOKEN_LIFETIME_SECONDS, MAXIMUM_TOKEN_LIFETIME_SECONDS));
683+
}
684+
}
685+
686+
int getLifetime() {
687+
return lifetime;
688+
}
689+
}
690+
611691
/** Base builder for external account credentials. */
612692
public abstract static class Builder extends GoogleCredentials.Builder {
613693

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

629710
protected Builder() {}
630711

@@ -642,6 +723,7 @@ protected Builder(ExternalAccountCredentials credentials) {
642723
this.scopes = credentials.scopes;
643724
this.environmentProvider = credentials.environmentProvider;
644725
this.workforcePoolUserProject = credentials.workforcePoolUserProject;
726+
this.serviceAccountImpersonationOptions = credentials.serviceAccountImpersonationOptions;
645727
}
646728

647729
/** Sets the HTTP transport factory, creates the transport used to get access tokens. */
@@ -733,6 +815,12 @@ public Builder setWorkforcePoolUserProject(String workforcePoolUserProject) {
733815
return this;
734816
}
735817

818+
/** Sets the optional service account impersonation options. */
819+
public Builder setServiceAccountImpersonationOptions(Map<String, Object> optionsMap) {
820+
this.serviceAccountImpersonationOptions = new ServiceAccountImpersonationOptions(optionsMap);
821+
return this;
822+
}
823+
736824
Builder setEnvironmentProvider(EnvironmentProvider environmentProvider) {
737825
this.environmentProvider = environmentProvider;
738826
return this;

oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,39 @@ public void refreshAccessToken_withServiceAccountImpersonation() throws IOExcept
142142
transportFactory.transport.getServiceAccountAccessToken(), accessToken.getTokenValue());
143143
}
144144

145+
@Test
146+
public void refreshAccessToken_withServiceAccountImpersonationOptions() throws IOException {
147+
MockExternalAccountCredentialsTransportFactory transportFactory =
148+
new MockExternalAccountCredentialsTransportFactory();
149+
150+
transportFactory.transport.setExpireTime(TestUtils.getDefaultExpireTime());
151+
152+
AwsCredentials awsCredential =
153+
(AwsCredentials)
154+
AwsCredentials.newBuilder(AWS_CREDENTIAL)
155+
.setTokenUrl(transportFactory.transport.getStsUrl())
156+
.setServiceAccountImpersonationUrl(
157+
transportFactory.transport.getServiceAccountImpersonationUrl())
158+
.setHttpTransportFactory(transportFactory)
159+
.setCredentialSource(buildAwsCredentialSource(transportFactory))
160+
.setServiceAccountImpersonationOptions(
161+
ExternalAccountCredentialsTest.buildServiceAccountImpersonationOptions(2800))
162+
.build();
163+
164+
AccessToken accessToken = awsCredential.refreshAccessToken();
165+
166+
assertEquals(
167+
transportFactory.transport.getServiceAccountAccessToken(), accessToken.getTokenValue());
168+
169+
// Validate that default lifetime was set correctly on the request.
170+
GenericJson query =
171+
OAuth2Utils.JSON_FACTORY
172+
.createJsonParser(transportFactory.transport.getLastRequest().getContentAsString())
173+
.parseAndClose(GenericJson.class);
174+
175+
assertEquals("2800s", query.get("lifetime"));
176+
}
177+
145178
@Test
146179
public void retrieveSubjectToken() throws IOException {
147180
MockExternalAccountCredentialsTransportFactory transportFactory =

0 commit comments

Comments
 (0)