Skip to content

Commit 02e939e

Browse files
committed
Docs: Added javadocs
Improvements: Cleaned up code, resolved readability enhancements
1 parent 76fae0d commit 02e939e

File tree

6 files changed

+228
-82
lines changed

6 files changed

+228
-82
lines changed

cab-token-generator/java/com/google/auth/credentialaccessboundary/ClientSideCredentialAccessBoundaryFactory.java

+130-37
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
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
@@ -43,7 +43,9 @@
4343
import com.google.auth.oauth2.AccessToken;
4444
import com.google.auth.oauth2.CredentialAccessBoundary;
4545
import com.google.auth.oauth2.CredentialAccessBoundary.AccessBoundaryRule;
46+
import com.google.auth.oauth2.DownscopedCredentials;
4647
import com.google.auth.oauth2.GoogleCredentials;
48+
import com.google.auth.oauth2.OAuth2CredentialsWithRefresh;
4749
import com.google.auth.oauth2.OAuth2Utils;
4850
import com.google.auth.oauth2.StsRequestHandler;
4951
import com.google.auth.oauth2.StsTokenExchangeRequest;
@@ -79,6 +81,63 @@
7981
import java.util.concurrent.ExecutionException;
8082
import 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+
*/
82141
public 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

Comments
 (0)