1
1
/*
2
- * Copyright 2024 , Google LLC
2
+ * Copyright 2025 , Google LLC
3
3
*
4
4
* Redistribution and use in source and binary forms, with or without
5
5
* modification, are permitted provided that the following conditions are
43
43
import com .google .auth .oauth2 .AccessToken ;
44
44
import com .google .auth .oauth2 .CredentialAccessBoundary ;
45
45
import com .google .auth .oauth2 .CredentialAccessBoundary .AccessBoundaryRule ;
46
+ import com .google .auth .oauth2 .DownscopedCredentials ;
46
47
import com .google .auth .oauth2 .GoogleCredentials ;
48
+ import com .google .auth .oauth2 .OAuth2CredentialsWithRefresh ;
47
49
import com .google .auth .oauth2 .OAuth2Utils ;
48
50
import com .google .auth .oauth2 .StsRequestHandler ;
49
51
import com .google .auth .oauth2 .StsTokenExchangeRequest ;
79
81
import java .util .concurrent .ExecutionException ;
80
82
import javax .annotation .Nullable ;
81
83
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
+ */
82
141
public class ClientSideCredentialAccessBoundaryFactory {
83
142
static final Duration DEFAULT_REFRESH_MARGIN = Duration .ofMinutes (45 );
84
143
static final Duration DEFAULT_MINIMUM_TOKEN_LIFETIME = Duration .ofMinutes (30 );
@@ -87,7 +146,7 @@ public class ClientSideCredentialAccessBoundaryFactory {
87
146
private final String tokenExchangeEndpoint ;
88
147
private final Duration minimumTokenLifetime ;
89
148
private final Duration refreshMargin ;
90
- private transient RefreshTask refreshTask ;
149
+ private RefreshTask refreshTask ;
91
150
private final Object refreshLock = new byte [0 ];
92
151
private IntermediateCredentials intermediateCredentials = null ;
93
152
private final Clock clock ;
@@ -107,8 +166,7 @@ private ClientSideCredentialAccessBoundaryFactory(Builder builder) {
107
166
this .minimumTokenLifetime = builder .minimumTokenLifetime ;
108
167
this .clock = builder .clock ;
109
168
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.
112
170
try {
113
171
AeadConfig .register ();
114
172
} catch (GeneralSecurityException e ) {
@@ -120,11 +178,11 @@ private ClientSideCredentialAccessBoundaryFactory(Builder builder) {
120
178
}
121
179
122
180
/**
123
- * Generates a Client-Side CAB token given the {@link CredentialAccessBoundary}.
181
+ * Generates a downscoped access token given the {@link CredentialAccessBoundary}.
124
182
*
125
183
* @param accessBoundary The credential access boundary that defines the restrictions for the
126
184
* 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
128
186
* @throws IOException If an I/O error occurs while refreshing the source credentials
129
187
* @throws CelValidationException If the availability condition is an invalid CEL expression
130
188
* @throws GeneralSecurityException If an error occurs during encryption
@@ -133,7 +191,8 @@ public AccessToken generateToken(CredentialAccessBoundary accessBoundary)
133
191
throws IOException , CelValidationException , GeneralSecurityException {
134
192
this .refreshCredentialsIfRequired ();
135
193
136
- String intermediateToken , sessionKey ;
194
+ String intermediateToken ;
195
+ String sessionKey ;
137
196
Date intermediateTokenExpirationTime ;
138
197
139
198
synchronized (refreshLock ) {
@@ -168,21 +227,23 @@ void refreshCredentialsIfRequired() throws IOException {
168
227
RefreshType refreshType = determineRefreshType ();
169
228
170
229
if (refreshType == RefreshType .NONE ) {
171
- return ; // No refresh needed, token is still valid.
230
+ // No refresh needed, token is still valid.
231
+ return ;
172
232
}
173
233
174
234
// If a refresh is required, create or retrieve the refresh task.
175
- RefreshTask refreshTask = getOrCreateRefreshTask ();
235
+ RefreshTask currentRefreshTask = getOrCreateRefreshTask ();
176
236
177
237
// Handle the refresh based on the determined refresh type.
178
238
switch (refreshType ) {
179
239
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 );
183
243
}
184
244
try {
185
- refreshTask .task .get (); // Wait for the refresh task to complete.
245
+ // Wait for the refresh task to complete.
246
+ currentRefreshTask .task .get ();
186
247
} catch (InterruptedException e ) {
187
248
// Restore the interrupted status and throw an exception.
188
249
Thread .currentThread ().interrupt ();
@@ -202,16 +263,20 @@ void refreshCredentialsIfRequired() throws IOException {
202
263
}
203
264
break ;
204
265
case ASYNC :
205
- if (refreshTask .isNew ) {
266
+ if (currentRefreshTask .isNew ) {
206
267
// Starts a new background thread for the refresh task if it's a new task.
207
268
// We create a new thread because the Auth Library doesn't currently include a background
208
269
// executor. Introducing an executor would add complexity in managing its lifecycle and
209
270
// could potentially lead to memory leaks.
210
271
// We limit the number of concurrent refresh threads to 1, so the overhead of creating new
211
272
// threads for asynchronous calls should be acceptable.
212
- new Thread (refreshTask .task ).start ();
273
+ new Thread (currentRefreshTask .task ).start ();
213
274
} // (No else needed - if not new, another thread is handling the refresh)
214
275
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 );
215
280
}
216
281
}
217
282
@@ -228,7 +293,8 @@ private RefreshType determineRefreshType() {
228
293
229
294
Date expirationTime = intermediateAccessToken .getExpirationTime ();
230
295
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 ;
232
298
}
233
299
234
300
Duration remaining = Duration .ofMillis (expirationTime .getTime () - clock .currentTimeMillis ());
@@ -332,15 +398,16 @@ private static AccessToken getTokenFromResponse(
332
398
333
399
// The STS endpoint will only return the expiration time for the intermediate token
334
400
// 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.
337
402
// When no expires_in is returned, we can copy the source credential's expiration time.
338
403
if (intermediateToken .getExpirationTime () == null
339
404
&& sourceAccessToken .getExpirationTime () != null ) {
340
405
return new AccessToken (
341
406
intermediateToken .getTokenValue (), sourceAccessToken .getExpirationTime ());
342
407
}
343
- return intermediateToken ; // Return original if no modification needed
408
+
409
+ // Return original if no modification needed.
410
+ return intermediateToken ;
344
411
}
345
412
346
413
/**
@@ -474,7 +541,7 @@ byte[] serializeCredentialAccessBoundary(CredentialAccessBoundary credentialAcce
474
541
.setAvailableResource (rule .getAvailableResource ());
475
542
476
543
// 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.
478
545
if (rule .getAvailabilityCondition () != null ) {
479
546
String availabilityCondition = rule .getAvailabilityCondition ().getExpression ();
480
547
@@ -503,7 +570,7 @@ private byte[] encryptRestrictions(byte[] restriction, String sessionKey)
503
570
try {
504
571
rawKey = Base64 .getDecoder ().decode (sessionKey );
505
572
} 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.
507
574
throw new IllegalStateException ("Session key is not Base64 encoded" , e );
508
575
}
509
576
@@ -512,7 +579,7 @@ private byte[] encryptRestrictions(byte[] restriction, String sessionKey)
512
579
513
580
Aead aead = keysetHandle .getPrimitive (RegistryConfiguration .get (), Aead .class );
514
581
515
- // For Client-Side CAB token encryption, empty associated data is expected.
582
+ // For downscoped access token encryption, empty associated data is expected.
516
583
// Tink requires a byte[0] to be passed for this case.
517
584
return aead .encrypt (restriction , /* associatedData= */ new byte [0 ]);
518
585
}
@@ -521,6 +588,12 @@ public static Builder newBuilder() {
521
588
return new Builder ();
522
589
}
523
590
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
+ */
524
597
public static class Builder {
525
598
private GoogleCredentials sourceCredential ;
526
599
private HttpTransportFactory transportFactory ;
@@ -535,27 +608,33 @@ private Builder() {}
535
608
/**
536
609
* Sets the required source credential.
537
610
*
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}.
540
615
*/
541
616
@ CanIgnoreReturnValue
542
617
public Builder setSourceCredential (GoogleCredentials sourceCredential ) {
618
+ checkNotNull (sourceCredential , "Source credential must not be null." );
543
619
this .sourceCredential = sourceCredential ;
544
620
return this ;
545
621
}
546
622
547
623
/**
548
- * Sets the minimum acceptable lifetime for a generated CAB token.
624
+ * Sets the minimum acceptable lifetime for a generated downscoped access token.
549
625
*
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}.
554
633
*
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.
557
636
* @return This {@code Builder} object.
558
- * @throws IllegalArgumentException if minimumTokenLifetime is negative or zero.
637
+ * @throws IllegalArgumentException if {@code minimumTokenLifetime} is negative or zero.
559
638
*/
560
639
@ CanIgnoreReturnValue
561
640
public Builder setMinimumTokenLifetime (Duration minimumTokenLifetime ) {
@@ -568,15 +647,19 @@ public Builder setMinimumTokenLifetime(Duration minimumTokenLifetime) {
568
647
}
569
648
570
649
/**
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}.
572
656
*
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}.
576
659
*
577
660
* @param refreshMargin The refresh margin. Must be greater than zero.
578
661
* @return This {@code Builder} object.
579
- * @throws IllegalArgumentException if refreshMargin is negative or zero.
662
+ * @throws IllegalArgumentException if {@code refreshMargin} is negative or zero.
580
663
*/
581
664
@ CanIgnoreReturnValue
582
665
public Builder setRefreshMargin (Duration refreshMargin ) {
@@ -623,6 +706,16 @@ public Builder setClock(Clock clock) {
623
706
return this ;
624
707
}
625
708
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
+ */
626
719
public ClientSideCredentialAccessBoundaryFactory build () {
627
720
checkNotNull (sourceCredential , "Source credential must not be null." );
628
721
0 commit comments