11/*
2- * Copyright 2024 , Google LLC
2+ * Copyright 2025 , Google LLC
33 *
44 * Redistribution and use in source and binary forms, with or without
55 * modification, are permitted provided that the following conditions are
4343import com .google .auth .oauth2 .AccessToken ;
4444import com .google .auth .oauth2 .CredentialAccessBoundary ;
4545import com .google .auth .oauth2 .CredentialAccessBoundary .AccessBoundaryRule ;
46+ import com .google .auth .oauth2 .DownscopedCredentials ;
4647import com .google .auth .oauth2 .GoogleCredentials ;
48+ import com .google .auth .oauth2 .OAuth2CredentialsWithRefresh ;
4749import com .google .auth .oauth2 .OAuth2Utils ;
4850import com .google .auth .oauth2 .StsRequestHandler ;
4951import com .google .auth .oauth2 .StsTokenExchangeRequest ;
7981import java .util .concurrent .ExecutionException ;
8082import javax .annotation .Nullable ;
8183
84+ /**
85+ * A factory for generating downscoped access tokens using a client-side approach.
86+ *
87+ * <p>Downscoped tokens enable the ability to downscope, or restrict, the Identity and Access
88+ * Management (IAM) permissions that a short-lived credential can use for accessing Google Cloud
89+ * Storage. This factory allows clients to efficiently generate multiple downscoped tokens locally,
90+ * minimizing calls to the Security Token Service (STS). This client-side approach is particularly
91+ * beneficial when Credential Access Boundary rules change frequently or when many unique downscoped
92+ * tokens are required. For scenarios where rules change infrequently or a single downscoped
93+ * credential is reused many times, the server-side approach using {@link DownscopedCredentials} is
94+ * more appropriate.
95+ *
96+ * <p>To downscope permissions you must define a {@link CredentialAccessBoundary} which specifies
97+ * the upper bound of permissions that the credential can access. You must also provide a source
98+ * credential which will be used to acquire the downscoped credential.
99+ *
100+ * <p>The factory can be configured with options such as the {@code refreshMargin} and {@code
101+ * minimumTokenLifetime}. The {@code refreshMargin} controls how far in advance of the underlying
102+ * credentials' expiry a refresh is attempted. The {@code minimumTokenLifetime} ensures that
103+ * generated tokens have a minimum usable lifespan. See the {@link Builder} class for more details
104+ * on these options.
105+ *
106+ * <p>Usage:
107+ *
108+ * <pre><code>
109+ * GoogleCredentials sourceCredentials = GoogleCredentials.getApplicationDefault()
110+ * .createScoped("https://www.googleapis.com/auth/cloud-platform");
111+ *
112+ * ClientSideCredentialAccessBoundaryFactory factory =
113+ * ClientSideCredentialAccessBoundaryFactory.newBuilder()
114+ * .setSourceCredential(sourceCredentials)
115+ * .build();
116+ *
117+ * CredentialAccessBoundary.AccessBoundaryRule rule =
118+ * CredentialAccessBoundary.AccessBoundaryRule.newBuilder()
119+ * .setAvailableResource(
120+ * "//storage.googleapis.com/projects/_/buckets/bucket")
121+ * .addAvailablePermission("inRole:roles/storage.objectViewer")
122+ * .build();
123+ *
124+ * CredentialAccessBoundary credentialAccessBoundary =
125+ * CredentialAccessBoundary.newBuilder().addRule(rule).build();
126+ *
127+ * AccessToken downscopedAccessToken = factory.generateToken(credentialAccessBoundary);
128+ *
129+ * OAuth2Credentials credentials = OAuth2Credentials.create(downscopedAccessToken);
130+ *
131+ * Storage storage = StorageOptions.newBuilder().setCredentials(credentials).build().getService();
132+ *
133+ * Blob blob = storage.get(BlobId.of("bucket", "object"));
134+ * System.out.printf("Blob %s retrieved.", blob.getBlobId());
135+ * </code></pre>
136+ *
137+ * Note that {@link OAuth2CredentialsWithRefresh} can instead be used to consume the downscoped
138+ * token, allowing for automatic token refreshes by providing a {@link
139+ * OAuth2CredentialsWithRefresh.OAuth2RefreshHandler}.
140+ */
82141public class ClientSideCredentialAccessBoundaryFactory {
83142 static final Duration DEFAULT_REFRESH_MARGIN = Duration .ofMinutes (45 );
84143 static final Duration DEFAULT_MINIMUM_TOKEN_LIFETIME = Duration .ofMinutes (30 );
@@ -87,7 +146,7 @@ public class ClientSideCredentialAccessBoundaryFactory {
87146 private final String tokenExchangeEndpoint ;
88147 private final Duration minimumTokenLifetime ;
89148 private final Duration refreshMargin ;
90- private transient RefreshTask refreshTask ;
149+ private RefreshTask refreshTask ;
91150 private final Object refreshLock = new byte [0 ];
92151 private IntermediateCredentials intermediateCredentials = null ;
93152 private final Clock clock ;
@@ -107,8 +166,7 @@ private ClientSideCredentialAccessBoundaryFactory(Builder builder) {
107166 this .minimumTokenLifetime = builder .minimumTokenLifetime ;
108167 this .clock = builder .clock ;
109168
110- // Initializes the Tink AEAD registry for encrypting the client-side
111- // restrictions.
169+ // Initializes the Tink AEAD registry for encrypting the client-side restrictions.
112170 try {
113171 AeadConfig .register ();
114172 } catch (GeneralSecurityException e ) {
@@ -120,11 +178,11 @@ private ClientSideCredentialAccessBoundaryFactory(Builder builder) {
120178 }
121179
122180 /**
123- * Generates a Client-Side CAB token given the {@link CredentialAccessBoundary}.
181+ * Generates a downscoped access token given the {@link CredentialAccessBoundary}.
124182 *
125183 * @param accessBoundary The credential access boundary that defines the restrictions for the
126184 * generated CAB token.
127- * @return The Client-Side CAB token in an {@link AccessToken} object
185+ * @return The downscoped access token in an {@link AccessToken} object
128186 * @throws IOException If an I/O error occurs while refreshing the source credentials
129187 * @throws CelValidationException If the availability condition is an invalid CEL expression
130188 * @throws GeneralSecurityException If an error occurs during encryption
@@ -133,7 +191,8 @@ public AccessToken generateToken(CredentialAccessBoundary accessBoundary)
133191 throws IOException , CelValidationException , GeneralSecurityException {
134192 this .refreshCredentialsIfRequired ();
135193
136- String intermediateToken , sessionKey ;
194+ String intermediateToken ;
195+ String sessionKey ;
137196 Date intermediateTokenExpirationTime ;
138197
139198 synchronized (refreshLock ) {
@@ -168,21 +227,23 @@ void refreshCredentialsIfRequired() throws IOException {
168227 RefreshType refreshType = determineRefreshType ();
169228
170229 if (refreshType == RefreshType .NONE ) {
171- return ; // No refresh needed, token is still valid.
230+ // No refresh needed, token is still valid.
231+ return ;
172232 }
173233
174234 // If a refresh is required, create or retrieve the refresh task.
175- RefreshTask refreshTask = getOrCreateRefreshTask ();
235+ RefreshTask currentRefreshTask = getOrCreateRefreshTask ();
176236
177237 // Handle the refresh based on the determined refresh type.
178238 switch (refreshType ) {
179239 case BLOCKING :
180- if (refreshTask .isNew ) {
181- // Start a new refresh task only if the task is new
182- MoreExecutors .directExecutor ().execute (refreshTask .task );
240+ if (currentRefreshTask .isNew ) {
241+ // Start a new refresh task only if the task is new.
242+ MoreExecutors .directExecutor ().execute (currentRefreshTask .task );
183243 }
184244 try {
185- refreshTask .task .get (); // Wait for the refresh task to complete.
245+ // Wait for the refresh task to complete.
246+ currentRefreshTask .task .get ();
186247 } catch (InterruptedException e ) {
187248 // Restore the interrupted status and throw an exception.
188249 Thread .currentThread ().interrupt ();
@@ -202,16 +263,20 @@ void refreshCredentialsIfRequired() throws IOException {
202263 }
203264 break ;
204265 case ASYNC :
205- if (refreshTask .isNew ) {
266+ if (currentRefreshTask .isNew ) {
206267 // Starts a new background thread for the refresh task if it's a new task.
207268 // We create a new thread because the Auth Library doesn't currently include a background
208269 // executor. Introducing an executor would add complexity in managing its lifecycle and
209270 // could potentially lead to memory leaks.
210271 // We limit the number of concurrent refresh threads to 1, so the overhead of creating new
211272 // threads for asynchronous calls should be acceptable.
212- new Thread (refreshTask .task ).start ();
273+ new Thread (currentRefreshTask .task ).start ();
213274 } // (No else needed - if not new, another thread is handling the refresh)
214275 break ;
276+ default :
277+ // This should not happen unless RefreshType enum is extended and this method is not
278+ // updated.
279+ throw new IllegalStateException ("Unexpected refresh type: " + refreshType );
215280 }
216281 }
217282
@@ -228,7 +293,8 @@ private RefreshType determineRefreshType() {
228293
229294 Date expirationTime = intermediateAccessToken .getExpirationTime ();
230295 if (expirationTime == null ) {
231- return RefreshType .NONE ; // Token does not expire, no refresh needed.
296+ // Token does not expire, no refresh needed.
297+ return RefreshType .NONE ;
232298 }
233299
234300 Duration remaining = Duration .ofMillis (expirationTime .getTime () - clock .currentTimeMillis ());
@@ -332,15 +398,16 @@ private static AccessToken getTokenFromResponse(
332398
333399 // The STS endpoint will only return the expiration time for the intermediate token
334400 // if the original access token represents a service account.
335- // The intermediate token's expiration time will always match the source credential
336- // expiration.
401+ // The intermediate token's expiration time will always match the source credential expiration.
337402 // When no expires_in is returned, we can copy the source credential's expiration time.
338403 if (intermediateToken .getExpirationTime () == null
339404 && sourceAccessToken .getExpirationTime () != null ) {
340405 return new AccessToken (
341406 intermediateToken .getTokenValue (), sourceAccessToken .getExpirationTime ());
342407 }
343- return intermediateToken ; // Return original if no modification needed
408+
409+ // Return original if no modification needed.
410+ return intermediateToken ;
344411 }
345412
346413 /**
@@ -474,7 +541,7 @@ byte[] serializeCredentialAccessBoundary(CredentialAccessBoundary credentialAcce
474541 .setAvailableResource (rule .getAvailableResource ());
475542
476543 // Availability condition is an optional field from the CredentialAccessBoundary
477- // CEL compliation is only performed if there is a non-empty availablity condition.
544+ // CEL compilation is only performed if there is a non-empty availability condition.
478545 if (rule .getAvailabilityCondition () != null ) {
479546 String availabilityCondition = rule .getAvailabilityCondition ().getExpression ();
480547
@@ -503,7 +570,7 @@ private byte[] encryptRestrictions(byte[] restriction, String sessionKey)
503570 try {
504571 rawKey = Base64 .getDecoder ().decode (sessionKey );
505572 } catch (IllegalArgumentException e ) {
506- // Session key from the server is expected to be Base64 encoded
573+ // Session key from the server is expected to be Base64 encoded.
507574 throw new IllegalStateException ("Session key is not Base64 encoded" , e );
508575 }
509576
@@ -512,7 +579,7 @@ private byte[] encryptRestrictions(byte[] restriction, String sessionKey)
512579
513580 Aead aead = keysetHandle .getPrimitive (RegistryConfiguration .get (), Aead .class );
514581
515- // For Client-Side CAB token encryption, empty associated data is expected.
582+ // For downscoped access token encryption, empty associated data is expected.
516583 // Tink requires a byte[0] to be passed for this case.
517584 return aead .encrypt (restriction , /* associatedData= */ new byte [0 ]);
518585 }
@@ -521,6 +588,12 @@ public static Builder newBuilder() {
521588 return new Builder ();
522589 }
523590
591+ /**
592+ * Builder for {@link ClientSideCredentialAccessBoundaryFactory}.
593+ *
594+ * <p>Use this builder to create instances of {@code ClientSideCredentialAccessBoundaryFactory}
595+ * with the desired configuration options.
596+ */
524597 public static class Builder {
525598 private GoogleCredentials sourceCredential ;
526599 private HttpTransportFactory transportFactory ;
@@ -535,27 +608,33 @@ private Builder() {}
535608 /**
536609 * Sets the required source credential.
537610 *
538- * @param sourceCredential the {@code GoogleCredentials} to set
539- * @return this {@code Builder} object
611+ * @param sourceCredential the {@code GoogleCredentials} to set. This is a
612+ * <strong>required</strong> parameter.
613+ * @return this {@code Builder} object for chaining.
614+ * @throws NullPointerException if {@code sourceCredential} is {@code null}.
540615 */
541616 @ CanIgnoreReturnValue
542617 public Builder setSourceCredential (GoogleCredentials sourceCredential ) {
618+ checkNotNull (sourceCredential , "Source credential must not be null." );
543619 this .sourceCredential = sourceCredential ;
544620 return this ;
545621 }
546622
547623 /**
548- * Sets the minimum acceptable lifetime for a generated CAB token.
624+ * Sets the minimum acceptable lifetime for a generated downscoped access token.
549625 *
550- * <p>This value determines the minimum remaining lifetime required on the intermediate token
551- * before a CAB token can be generated. If the intermediate token's remaining lifetime is less
552- * than this value, CAB token generation will be blocked and a refresh will be initiated. This
553- * ensures that generated CAB tokens have a sufficient lifetime for use.
626+ * <p>This parameter ensures that any generated downscoped access token has a minimum validity
627+ * period. If the time remaining before the underlying credentials expire is less than this
628+ * value, the factory will perform a blocking refresh, meaning that it will wait until the
629+ * credentials are refreshed before generating a new downscoped token. This guarantees that the
630+ * generated token will be valid for at least {@code minimumTokenLifetime}. A reasonable value
631+ * should be chosen based on the expected duration of operations using the downscoped token. If
632+ * not set, the default value is defined by {@link #DEFAULT_MINIMUM_TOKEN_LIFETIME}.
554633 *
555- * @param minimumTokenLifetime The minimum acceptable lifetime for a generated CAB token. Must
556- * be greater than zero.
634+ * @param minimumTokenLifetime The minimum acceptable lifetime for a generated downscoped access
635+ * token. Must be greater than zero.
557636 * @return This {@code Builder} object.
558- * @throws IllegalArgumentException if minimumTokenLifetime is negative or zero.
637+ * @throws IllegalArgumentException if {@code minimumTokenLifetime} is negative or zero.
559638 */
560639 @ CanIgnoreReturnValue
561640 public Builder setMinimumTokenLifetime (Duration minimumTokenLifetime ) {
@@ -568,15 +647,19 @@ public Builder setMinimumTokenLifetime(Duration minimumTokenLifetime) {
568647 }
569648
570649 /**
571- * Sets the refresh margin for the intermediate access token.
650+ * Sets the refresh margin for the underlying credentials.
651+ *
652+ * <p>This duration specifies how far in advance of the credentials' expiration time an
653+ * asynchronous refresh should be initiated. This refresh happens in the background, without
654+ * blocking the main thread. If not provided, it will default to the value defined by {@link
655+ * #DEFAULT_REFRESH_MARGIN}.
572656 *
573- * <p>This duration specifies how far in advance of the intermediate access token's expiration
574- * time an asynchronous refresh should be initiated. If not provided, it will default to 30
575- * minutes.
657+ * <p>Note: The {@code refreshMargin} must be at least one minute longer than the {@code
658+ * minimumTokenLifetime}.
576659 *
577660 * @param refreshMargin The refresh margin. Must be greater than zero.
578661 * @return This {@code Builder} object.
579- * @throws IllegalArgumentException if refreshMargin is negative or zero.
662+ * @throws IllegalArgumentException if {@code refreshMargin} is negative or zero.
580663 */
581664 @ CanIgnoreReturnValue
582665 public Builder setRefreshMargin (Duration refreshMargin ) {
@@ -623,6 +706,16 @@ public Builder setClock(Clock clock) {
623706 return this ;
624707 }
625708
709+ /**
710+ * Creates a new {@code ClientSideCredentialAccessBoundaryFactory} instance based on the current
711+ * builder configuration.
712+ *
713+ * @return A new {@code ClientSideCredentialAccessBoundaryFactory} instance.
714+ * @throws IllegalStateException if the builder is not properly configured (e.g., if the source
715+ * credential is not set).
716+ * @throws IllegalArgumentException if the refresh margin is not at least one minute longer than
717+ * the minimum token lifetime.
718+ */
626719 public ClientSideCredentialAccessBoundaryFactory build () {
627720 checkNotNull (sourceCredential , "Source credential must not be null." );
628721
0 commit comments