ClientSideCredentialAccessBoundaryFactory.java
/*
* Copyright 2025, Google LLC
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are
* met:
*
* * Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* * Redistributions in binary form must reproduce the above
* copyright notice, this list of conditions and the following disclaimer
* in the documentation and/or other materials provided with the
* distribution.
*
* * Neither the name of Google LLC nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package com.google.auth.credentialaccessboundary;
import static com.google.auth.oauth2.OAuth2Credentials.getFromServiceLoader;
import static com.google.auth.oauth2.OAuth2Utils.TOKEN_EXCHANGE_URL_FORMAT;
import static com.google.common.base.Preconditions.checkNotNull;
import com.google.api.client.util.Clock;
import com.google.auth.Credentials;
import com.google.auth.credentialaccessboundary.protobuf.ClientSideAccessBoundaryProto.ClientSideAccessBoundary;
import com.google.auth.credentialaccessboundary.protobuf.ClientSideAccessBoundaryProto.ClientSideAccessBoundaryRule;
import com.google.auth.http.HttpTransportFactory;
import com.google.auth.oauth2.AccessToken;
import com.google.auth.oauth2.CredentialAccessBoundary;
import com.google.auth.oauth2.CredentialAccessBoundary.AccessBoundaryRule;
import com.google.auth.oauth2.DownscopedCredentials;
import com.google.auth.oauth2.GoogleCredentials;
import com.google.auth.oauth2.OAuth2CredentialsWithRefresh;
import com.google.auth.oauth2.OAuth2Utils;
import com.google.auth.oauth2.StsRequestHandler;
import com.google.auth.oauth2.StsTokenExchangeRequest;
import com.google.auth.oauth2.StsTokenExchangeResponse;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Strings;
import com.google.common.util.concurrent.AbstractFuture;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.ListenableFutureTask;
import com.google.common.util.concurrent.MoreExecutors;
import com.google.crypto.tink.Aead;
import com.google.crypto.tink.InsecureSecretKeyAccess;
import com.google.crypto.tink.KeysetHandle;
import com.google.crypto.tink.RegistryConfiguration;
import com.google.crypto.tink.TinkProtoKeysetFormat;
import com.google.crypto.tink.aead.AeadConfig;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import dev.cel.common.CelAbstractSyntaxTree;
import dev.cel.common.CelOptions;
import dev.cel.common.CelProtoAbstractSyntaxTree;
import dev.cel.common.CelValidationException;
import dev.cel.compiler.CelCompiler;
import dev.cel.compiler.CelCompilerFactory;
import dev.cel.expr.Expr;
import java.io.IOException;
import java.security.GeneralSecurityException;
import java.time.Duration;
import java.util.Base64;
import java.util.Date;
import java.util.List;
import java.util.concurrent.ExecutionException;
import javax.annotation.Nullable;
/**
* A factory for generating downscoped access tokens using a client-side approach.
*
* <p>Downscoped tokens enable the ability to downscope, or restrict, the Identity and Access
* Management (IAM) permissions that a short-lived credential can use for accessing Google Cloud
* Storage. This factory allows clients to efficiently generate multiple downscoped tokens locally,
* minimizing calls to the Security Token Service (STS). This client-side approach is particularly
* beneficial when Credential Access Boundary rules change frequently or when many unique downscoped
* tokens are required. For scenarios where rules change infrequently or a single downscoped
* credential is reused many times, the server-side approach using {@link DownscopedCredentials} is
* more appropriate.
*
* <p>To downscope permissions you must define a {@link CredentialAccessBoundary} which specifies
* the upper bound of permissions that the credential can access. You must also provide a source
* credential which will be used to acquire the downscoped credential.
*
* <p>The factory can be configured with options such as the {@code refreshMargin} and {@code
* minimumTokenLifetime}. The {@code refreshMargin} controls how far in advance of the underlying
* credentials' expiry a refresh is attempted. The {@code minimumTokenLifetime} ensures that
* generated tokens have a minimum usable lifespan. See the {@link Builder} class for more details
* on these options.
*
* <p>Usage:
*
* <pre><code>
* GoogleCredentials sourceCredentials = GoogleCredentials.getApplicationDefault()
* .createScoped("https://www.googleapis.com/auth/cloud-platform");
*
* ClientSideCredentialAccessBoundaryFactory factory =
* ClientSideCredentialAccessBoundaryFactory.newBuilder()
* .setSourceCredential(sourceCredentials)
* .build();
*
* CredentialAccessBoundary.AccessBoundaryRule rule =
* CredentialAccessBoundary.AccessBoundaryRule.newBuilder()
* .setAvailableResource(
* "//storage.googleapis.com/projects/_/buckets/bucket")
* .addAvailablePermission("inRole:roles/storage.objectViewer")
* .build();
*
* CredentialAccessBoundary credentialAccessBoundary =
* CredentialAccessBoundary.newBuilder().addRule(rule).build();
*
* AccessToken downscopedAccessToken = factory.generateToken(credentialAccessBoundary);
*
* OAuth2Credentials credentials = OAuth2Credentials.create(downscopedAccessToken);
*
* Storage storage = StorageOptions.newBuilder().setCredentials(credentials).build().getService();
*
* Blob blob = storage.get(BlobId.of("bucket", "object"));
* System.out.printf("Blob %s retrieved.", blob.getBlobId());
* </code></pre>
*
* Note that {@link OAuth2CredentialsWithRefresh} can instead be used to consume the downscoped
* token, allowing for automatic token refreshes by providing a {@link
* OAuth2CredentialsWithRefresh.OAuth2RefreshHandler}.
*/
public class ClientSideCredentialAccessBoundaryFactory {
static final Duration DEFAULT_REFRESH_MARGIN = Duration.ofMinutes(45);
static final Duration DEFAULT_MINIMUM_TOKEN_LIFETIME = Duration.ofMinutes(30);
private final GoogleCredentials sourceCredential;
private final transient HttpTransportFactory transportFactory;
private final String tokenExchangeEndpoint;
private final Duration minimumTokenLifetime;
private final Duration refreshMargin;
private RefreshTask refreshTask;
private final Object refreshLock = new byte[0];
private IntermediateCredentials intermediateCredentials = null;
private final Clock clock;
private final CelCompiler celCompiler;
enum RefreshType {
NONE,
ASYNC,
BLOCKING
}
private ClientSideCredentialAccessBoundaryFactory(Builder builder) {
this.transportFactory = builder.transportFactory;
this.sourceCredential = builder.sourceCredential;
this.tokenExchangeEndpoint = builder.tokenExchangeEndpoint;
this.refreshMargin = builder.refreshMargin;
this.minimumTokenLifetime = builder.minimumTokenLifetime;
this.clock = builder.clock;
// Initializes the Tink AEAD registry for encrypting the client-side restrictions.
try {
AeadConfig.register();
} catch (GeneralSecurityException e) {
throw new IllegalStateException("Error occurred when registering Tink", e);
}
CelOptions options = CelOptions.current().build();
this.celCompiler = CelCompilerFactory.standardCelCompilerBuilder().setOptions(options).build();
}
/**
* Generates a downscoped access token given the {@link CredentialAccessBoundary}.
*
* @param accessBoundary The credential access boundary that defines the restrictions for the
* generated CAB token.
* @return The downscoped access token in an {@link AccessToken} object
* @throws IOException If an I/O error occurs while refreshing the source credentials
* @throws CelValidationException If the availability condition is an invalid CEL expression
* @throws GeneralSecurityException If an error occurs during encryption
*/
public AccessToken generateToken(CredentialAccessBoundary accessBoundary)
throws IOException, CelValidationException, GeneralSecurityException {
this.refreshCredentialsIfRequired();
String intermediateToken;
String sessionKey;
Date intermediateTokenExpirationTime;
synchronized (refreshLock) {
intermediateToken = this.intermediateCredentials.intermediateAccessToken.getTokenValue();
intermediateTokenExpirationTime =
this.intermediateCredentials.intermediateAccessToken.getExpirationTime();
sessionKey = this.intermediateCredentials.accessBoundarySessionKey;
}
byte[] rawRestrictions = this.serializeCredentialAccessBoundary(accessBoundary);
byte[] encryptedRestrictions = this.encryptRestrictions(rawRestrictions, sessionKey);
// withoutPadding() is used to stay consistent with server-side CAB
// withoutPadding() avoids additional URL encoded token issues (i.e. extra equal signs `=` in
// the path)
String tokenValue =
intermediateToken
+ "."
+ Base64.getUrlEncoder().withoutPadding().encodeToString(encryptedRestrictions);
return new AccessToken(tokenValue, intermediateTokenExpirationTime);
}
/**
* Refreshes the intermediate access token and access boundary session key if required.
*
* <p>This method checks the expiration time of the current intermediate access token and
* initiates a refresh if necessary. The refresh process also refreshes the underlying source
* credentials.
*
* @throws IOException If an error occurs during the refresh process, such as network issues,
* invalid credentials, or problems with the token exchange endpoint.
*/
@VisibleForTesting
void refreshCredentialsIfRequired() throws IOException {
RefreshType refreshType = determineRefreshType();
if (refreshType == RefreshType.NONE) {
// No refresh needed, token is still valid.
return;
}
// If a refresh is required, create or retrieve the refresh task.
RefreshTask currentRefreshTask = getOrCreateRefreshTask();
// Handle the refresh based on the determined refresh type.
switch (refreshType) {
case BLOCKING:
if (currentRefreshTask.isNew) {
// Start a new refresh task only if the task is new.
MoreExecutors.directExecutor().execute(currentRefreshTask.task);
}
try {
// Wait for the refresh task to complete.
currentRefreshTask.task.get();
} catch (InterruptedException e) {
// Restore the interrupted status and throw an exception.
Thread.currentThread().interrupt();
throw new IOException(
"Interrupted while asynchronously refreshing the intermediate credentials", e);
} catch (ExecutionException e) {
// Unwrap the underlying cause of the execution exception.
Throwable cause = e.getCause();
if (cause instanceof IOException) {
throw (IOException) cause;
} else if (cause instanceof RuntimeException) {
throw (RuntimeException) cause;
} else {
// Wrap other exceptions in an IOException.
throw new IOException("Unexpected error refreshing intermediate credentials", cause);
}
}
break;
case ASYNC:
if (currentRefreshTask.isNew) {
// Starts a new background thread for the refresh task if it's a new task.
// We create a new thread because the Auth Library doesn't currently include a background
// executor. Introducing an executor would add complexity in managing its lifecycle and
// could potentially lead to memory leaks.
// We limit the number of concurrent refresh threads to 1, so the overhead of creating new
// threads for asynchronous calls should be acceptable.
new Thread(currentRefreshTask.task).start();
} // (No else needed - if not new, another thread is handling the refresh)
break;
default:
// This should not happen unless RefreshType enum is extended and this method is not
// updated.
throw new IllegalStateException("Unexpected refresh type: " + refreshType);
}
}
private RefreshType determineRefreshType() {
AccessToken intermediateAccessToken;
synchronized (refreshLock) {
if (intermediateCredentials == null
|| intermediateCredentials.intermediateAccessToken == null) {
// A blocking refresh is needed if the intermediate access token doesn't exist.
return RefreshType.BLOCKING;
}
intermediateAccessToken = intermediateCredentials.intermediateAccessToken;
}
Date expirationTime = intermediateAccessToken.getExpirationTime();
if (expirationTime == null) {
// Token does not expire, no refresh needed.
return RefreshType.NONE;
}
Duration remaining = Duration.ofMillis(expirationTime.getTime() - clock.currentTimeMillis());
if (remaining.compareTo(minimumTokenLifetime) <= 0) {
// Intermediate token has expired or remaining lifetime is less than the minimum required
// for CAB token generation. A blocking refresh is necessary.
return RefreshType.BLOCKING;
} else if (remaining.compareTo(refreshMargin) <= 0) {
// The token is nearing expiration, an async refresh is needed.
return RefreshType.ASYNC;
}
// Token is still fresh, no refresh needed.
return RefreshType.NONE;
}
/**
* Atomically creates a single flight refresh task.
*
* <p>Only a single refresh task can be scheduled at a time. If there is an existing task, it will
* be returned for subsequent invocations. However, if a new task is created, it is the
* responsibility of the caller to execute it. The task will clear the single flight slot upon
* completion.
*/
private RefreshTask getOrCreateRefreshTask() {
synchronized (refreshLock) {
if (refreshTask != null) {
// An existing refresh task is already in progress. Return a NEW RefreshTask instance with
// the existing task, but set isNew to false. This indicates to the caller that a new
// refresh task was NOT created.
return new RefreshTask(refreshTask.task, false);
}
final ListenableFutureTask<IntermediateCredentials> task =
ListenableFutureTask.create(this::fetchIntermediateCredentials);
// Store the new refresh task in the refreshTask field before returning. This ensures that
// subsequent calls to this method will return the existing task while it's still in progress.
refreshTask = new RefreshTask(task, true);
return refreshTask;
}
}
/**
* Fetches the credentials by refreshing the source credential and exchanging it for an
* intermediate access token using the STS endpoint.
*
* <p>The source credential is refreshed, and a token exchange request is made to the STS endpoint
* to obtain an intermediate access token and an associated access boundary session key. This
* ensures the intermediate access token meets this factory's refresh margin and minimum lifetime
* requirements.
*
* @return The fetched {@link IntermediateCredentials} containing the intermediate access token
* and access boundary session key.
* @throws IOException If an error occurs during credential refresh or token exchange.
*/
@VisibleForTesting
IntermediateCredentials fetchIntermediateCredentials() throws IOException {
try {
// Force a refresh on the source credentials. The intermediate token's lifetime is tied to the
// source credential's expiration. The factory's refreshMargin might be different from the
// refreshMargin on source credentials. This ensures the intermediate access token
// meets this factory's refresh margin and minimum lifetime requirements.
sourceCredential.refresh();
} catch (IOException e) {
throw new IOException("Unable to refresh the provided source credential.", e);
}
AccessToken sourceAccessToken = sourceCredential.getAccessToken();
if (sourceAccessToken == null || Strings.isNullOrEmpty(sourceAccessToken.getTokenValue())) {
throw new IllegalStateException("The source credential does not have an access token.");
}
StsTokenExchangeRequest request =
StsTokenExchangeRequest.newBuilder(
sourceAccessToken.getTokenValue(), OAuth2Utils.TOKEN_TYPE_ACCESS_TOKEN)
.setRequestTokenType(OAuth2Utils.TOKEN_TYPE_ACCESS_BOUNDARY_INTERMEDIARY_TOKEN)
.build();
StsRequestHandler handler =
StsRequestHandler.newBuilder(
tokenExchangeEndpoint, request, transportFactory.create().createRequestFactory())
.build();
StsTokenExchangeResponse response = handler.exchangeToken();
return new IntermediateCredentials(
getTokenFromResponse(response, sourceAccessToken), response.getAccessBoundarySessionKey());
}
/**
* Extracts the access token from the STS exchange response and sets the appropriate expiration
* time.
*
* @param response The STS token exchange response.
* @param sourceAccessToken The original access token used for the exchange.
* @return The intermediate access token.
*/
private static AccessToken getTokenFromResponse(
StsTokenExchangeResponse response, AccessToken sourceAccessToken) {
AccessToken intermediateToken = response.getAccessToken();
// The STS endpoint will only return the expiration time for the intermediate token
// if the original access token represents a service account.
// The intermediate token's expiration time will always match the source credential expiration.
// When no expires_in is returned, we can copy the source credential's expiration time.
if (intermediateToken.getExpirationTime() == null
&& sourceAccessToken.getExpirationTime() != null) {
return new AccessToken(
intermediateToken.getTokenValue(), sourceAccessToken.getExpirationTime());
}
// Return original if no modification needed.
return intermediateToken;
}
/**
* Completes the refresh task by storing the results and clearing the single flight slot.
*
* <p>This method is called when a refresh task finishes. It stores the refreshed credentials if
* successful. The single-flight "slot" is cleared, allowing subsequent refresh attempts. Any
* exceptions during the refresh are caught and suppressed to prevent indefinite blocking of
* subsequent refresh attempts.
*/
private void finishRefreshTask(ListenableFuture<IntermediateCredentials> finishedTask)
throws ExecutionException {
synchronized (refreshLock) {
try {
this.intermediateCredentials = Futures.getDone(finishedTask);
} finally {
if (this.refreshTask != null && this.refreshTask.task == finishedTask) {
this.refreshTask = null;
}
}
}
}
@VisibleForTesting
String getTokenExchangeEndpoint() {
return tokenExchangeEndpoint;
}
@VisibleForTesting
HttpTransportFactory getTransportFactory() {
return transportFactory;
}
@VisibleForTesting
Duration getRefreshMargin() {
return refreshMargin;
}
@VisibleForTesting
Duration getMinimumTokenLifetime() {
return minimumTokenLifetime;
}
/**
* Holds intermediate credentials obtained from the STS token exchange endpoint.
*
* <p>These credentials include an intermediate access token and an access boundary session key.
*/
@VisibleForTesting
static class IntermediateCredentials {
private final AccessToken intermediateAccessToken;
private final String accessBoundarySessionKey;
IntermediateCredentials(AccessToken accessToken, String accessBoundarySessionKey) {
this.intermediateAccessToken = accessToken;
this.accessBoundarySessionKey = accessBoundarySessionKey;
}
String getAccessBoundarySessionKey() {
return accessBoundarySessionKey;
}
AccessToken getIntermediateAccessToken() {
return intermediateAccessToken;
}
}
/**
* Represents a task for refreshing intermediate credentials, ensuring that only one refresh
* operation is in progress at a time.
*
* <p>The {@code isNew} flag indicates whether this is a newly initiated refresh operation or an
* existing one already in progress. This distinction is used to prevent redundant refreshes.
*/
class RefreshTask extends AbstractFuture<IntermediateCredentials> implements Runnable {
private final ListenableFutureTask<IntermediateCredentials> task;
final boolean isNew;
RefreshTask(ListenableFutureTask<IntermediateCredentials> task, boolean isNew) {
this.task = task;
this.isNew = isNew;
// Add listener to update factory's credentials when the task completes.
task.addListener(
() -> {
try {
finishRefreshTask(task);
} catch (ExecutionException e) {
Throwable cause = e.getCause();
RefreshTask.this.setException(cause);
}
},
MoreExecutors.directExecutor());
// Add callback to set the result or exception based on the outcome.
Futures.addCallback(
task,
new FutureCallback<IntermediateCredentials>() {
@Override
public void onSuccess(IntermediateCredentials result) {
RefreshTask.this.set(result);
}
@Override
public void onFailure(@Nullable Throwable t) {
RefreshTask.this.setException(
t != null ? t : new IOException("Refresh failed with null Throwable."));
}
},
MoreExecutors.directExecutor());
}
@Override
public void run() {
task.run();
}
}
/** Serializes a {@link CredentialAccessBoundary} object into Protobuf wire format. */
@VisibleForTesting
byte[] serializeCredentialAccessBoundary(CredentialAccessBoundary credentialAccessBoundary)
throws CelValidationException {
List<AccessBoundaryRule> rules = credentialAccessBoundary.getAccessBoundaryRules();
ClientSideAccessBoundary.Builder accessBoundaryBuilder = ClientSideAccessBoundary.newBuilder();
for (AccessBoundaryRule rule : rules) {
ClientSideAccessBoundaryRule.Builder ruleBuilder =
accessBoundaryBuilder
.addAccessBoundaryRulesBuilder()
.addAllAvailablePermissions(rule.getAvailablePermissions())
.setAvailableResource(rule.getAvailableResource());
// Availability condition is an optional field from the CredentialAccessBoundary
// CEL compilation is only performed if there is a non-empty availability condition.
if (rule.getAvailabilityCondition() != null) {
String availabilityCondition = rule.getAvailabilityCondition().getExpression();
Expr availabilityConditionExpr = this.compileCel(availabilityCondition);
ruleBuilder.setCompiledAvailabilityCondition(availabilityConditionExpr);
}
}
return accessBoundaryBuilder.build().toByteArray();
}
/** Compiles CEL expression from String to an {@link Expr} proto object. */
private Expr compileCel(String expr) throws CelValidationException {
CelAbstractSyntaxTree ast = celCompiler.parse(expr).getAst();
CelProtoAbstractSyntaxTree astProto = CelProtoAbstractSyntaxTree.fromCelAst(ast);
return astProto.getExpr();
}
/** Encrypts the given bytes using a sessionKey using Tink Aead. */
private byte[] encryptRestrictions(byte[] restriction, String sessionKey)
throws GeneralSecurityException {
byte[] rawKey;
try {
rawKey = Base64.getDecoder().decode(sessionKey);
} catch (IllegalArgumentException e) {
// Session key from the server is expected to be Base64 encoded.
throw new IllegalStateException("Session key is not Base64 encoded", e);
}
KeysetHandle keysetHandle =
TinkProtoKeysetFormat.parseKeyset(rawKey, InsecureSecretKeyAccess.get());
Aead aead = keysetHandle.getPrimitive(RegistryConfiguration.get(), Aead.class);
// For downscoped access token encryption, empty associated data is expected.
// Tink requires a byte[0] to be passed for this case.
return aead.encrypt(restriction, /* associatedData= */ new byte[0]);
}
public static Builder newBuilder() {
return new Builder();
}
/**
* Builder for {@link ClientSideCredentialAccessBoundaryFactory}.
*
* <p>Use this builder to create instances of {@code ClientSideCredentialAccessBoundaryFactory}
* with the desired configuration options.
*/
public static class Builder {
private GoogleCredentials sourceCredential;
private HttpTransportFactory transportFactory;
private String universeDomain;
private String tokenExchangeEndpoint;
private Duration minimumTokenLifetime;
private Duration refreshMargin;
private Clock clock = Clock.SYSTEM; // Default to system clock;
private Builder() {}
/**
* Sets the required source credential.
*
* @param sourceCredential the {@code GoogleCredentials} to set. This is a
* <strong>required</strong> parameter.
* @return this {@code Builder} object for chaining.
* @throws NullPointerException if {@code sourceCredential} is {@code null}.
*/
@CanIgnoreReturnValue
public Builder setSourceCredential(GoogleCredentials sourceCredential) {
checkNotNull(sourceCredential, "Source credential must not be null.");
this.sourceCredential = sourceCredential;
return this;
}
/**
* Sets the minimum acceptable lifetime for a generated downscoped access token.
*
* <p>This parameter ensures that any generated downscoped access token has a minimum validity
* period. If the time remaining before the underlying credentials expire is less than this
* value, the factory will perform a blocking refresh, meaning that it will wait until the
* credentials are refreshed before generating a new downscoped token. This guarantees that the
* generated token will be valid for at least {@code minimumTokenLifetime}. A reasonable value
* should be chosen based on the expected duration of operations using the downscoped token. If
* not set, the default value is defined by {@link #DEFAULT_MINIMUM_TOKEN_LIFETIME}.
*
* @param minimumTokenLifetime The minimum acceptable lifetime for a generated downscoped access
* token. Must be greater than zero.
* @return This {@code Builder} object.
* @throws IllegalArgumentException if {@code minimumTokenLifetime} is negative or zero.
*/
@CanIgnoreReturnValue
public Builder setMinimumTokenLifetime(Duration minimumTokenLifetime) {
checkNotNull(minimumTokenLifetime, "Minimum token lifetime must not be null.");
if (minimumTokenLifetime.isNegative() || minimumTokenLifetime.isZero()) {
throw new IllegalArgumentException("Minimum token lifetime must be greater than zero.");
}
this.minimumTokenLifetime = minimumTokenLifetime;
return this;
}
/**
* Sets the refresh margin for the underlying credentials.
*
* <p>This duration specifies how far in advance of the credentials' expiration time an
* asynchronous refresh should be initiated. This refresh happens in the background, without
* blocking the main thread. If not provided, it will default to the value defined by {@link
* #DEFAULT_REFRESH_MARGIN}.
*
* <p>Note: The {@code refreshMargin} must be at least one minute longer than the {@code
* minimumTokenLifetime}.
*
* @param refreshMargin The refresh margin. Must be greater than zero.
* @return This {@code Builder} object.
* @throws IllegalArgumentException if {@code refreshMargin} is negative or zero.
*/
@CanIgnoreReturnValue
public Builder setRefreshMargin(Duration refreshMargin) {
checkNotNull(refreshMargin, "Refresh margin must not be null.");
if (refreshMargin.isNegative() || refreshMargin.isZero()) {
throw new IllegalArgumentException("Refresh margin must be greater than zero.");
}
this.refreshMargin = refreshMargin;
return this;
}
/**
* Sets the HTTP transport factory.
*
* @param transportFactory the {@code HttpTransportFactory} to set
* @return this {@code Builder} object
*/
@CanIgnoreReturnValue
public Builder setHttpTransportFactory(HttpTransportFactory transportFactory) {
this.transportFactory = transportFactory;
return this;
}
/**
* Sets the optional universe domain.
*
* @param universeDomain the universe domain to set
* @return this {@code Builder} object
*/
@CanIgnoreReturnValue
public Builder setUniverseDomain(String universeDomain) {
this.universeDomain = universeDomain;
return this;
}
/**
* Set the clock for checking token expiry. Used for testing.
*
* @param clock the clock to use. Defaults to the system clock
* @return the builder
*/
public Builder setClock(Clock clock) {
this.clock = clock;
return this;
}
/**
* Creates a new {@code ClientSideCredentialAccessBoundaryFactory} instance based on the current
* builder configuration.
*
* @return A new {@code ClientSideCredentialAccessBoundaryFactory} instance.
* @throws IllegalStateException if the builder is not properly configured (e.g., if the source
* credential is not set).
* @throws IllegalArgumentException if the refresh margin is not at least one minute longer than
* the minimum token lifetime.
*/
public ClientSideCredentialAccessBoundaryFactory build() {
checkNotNull(sourceCredential, "Source credential must not be null.");
// Use the default HTTP transport factory if none was provided.
if (transportFactory == null) {
this.transportFactory =
getFromServiceLoader(HttpTransportFactory.class, OAuth2Utils.HTTP_TRANSPORT_FACTORY);
}
// Default to GDU when not supplied.
if (Strings.isNullOrEmpty(universeDomain)) {
this.universeDomain = Credentials.GOOGLE_DEFAULT_UNIVERSE;
}
// Ensure source credential's universe domain matches.
try {
if (!universeDomain.equals(sourceCredential.getUniverseDomain())) {
throw new IllegalArgumentException(
"The client side access boundary credential's universe domain must be the same as the source "
+ "credential.");
}
} catch (IOException e) {
// Throwing an IOException would be a breaking change, so wrap it here.
throw new IllegalStateException(
"Error occurred when attempting to retrieve source credential universe domain.", e);
}
// Use default values for refreshMargin if not provided.
if (refreshMargin == null) {
this.refreshMargin = DEFAULT_REFRESH_MARGIN;
}
// Use default values for minimumTokenLifetime if not provided.
if (minimumTokenLifetime == null) {
this.minimumTokenLifetime = DEFAULT_MINIMUM_TOKEN_LIFETIME;
}
// Check if refreshMargin is at least one minute longer than minimumTokenLifetime.
Duration minRefreshMargin = minimumTokenLifetime.plusMinutes(1);
if (refreshMargin.compareTo(minRefreshMargin) < 0) {
throw new IllegalArgumentException(
"Refresh margin must be at least one minute longer than the minimum token lifetime.");
}
this.tokenExchangeEndpoint = String.format(TOKEN_EXCHANGE_URL_FORMAT, universeDomain);
return new ClientSideCredentialAccessBoundaryFactory(this);
}
}
}