diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java
index ad5fb8e7dcf3..bcfe916c3168 100644
--- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java
+++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java
@@ -41,6 +41,7 @@
import com.google.api.client.http.HttpStatusCodes;
import com.google.api.client.json.JsonObjectParser;
import com.google.api.client.util.GenericData;
+import com.google.api.core.InternalApi;
import com.google.auth.CredentialTypeForMetrics;
import com.google.auth.Credentials;
import com.google.auth.Retryable;
@@ -80,7 +81,7 @@
*
These credentials use the IAM API to sign data. See {@link #sign(byte[])} for more details.
*/
public class ComputeEngineCredentials extends GoogleCredentials
- implements ServiceAccountSigner, IdTokenProvider {
+ implements ServiceAccountSigner, IdTokenProvider, RegionalAccessBoundaryProvider {
static final String METADATA_RESPONSE_EMPTY_CONTENT_ERROR_MESSAGE =
"Empty content from metadata token server request.";
@@ -454,7 +455,6 @@ public AccessToken refreshAccessToken() throws IOException {
int expiresInSeconds =
OAuth2Utils.validateInt32(responseData, "expires_in", PARSE_ERROR_PREFIX);
long expiresAtMilliseconds = clock.currentTimeMillis() + expiresInSeconds * 1000;
-
return new AccessToken(accessToken, new Date(expiresAtMilliseconds));
}
@@ -779,6 +779,11 @@ public static Builder newBuilder() {
*
* @throws RuntimeException if the default service account cannot be read
*/
+ @Override
+ HttpTransportFactory getTransportFactory() {
+ return transportFactory;
+ }
+
@Override
// todo(#314) getAccount should not throw a RuntimeException
public String getAccount() {
@@ -792,6 +797,13 @@ public String getAccount() {
return principal;
}
+ @InternalApi
+ @Override
+ public String getRegionalAccessBoundaryUrl() throws IOException {
+ return String.format(
+ OAuth2Utils.IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_SERVICE_ACCOUNT, getAccount());
+ }
+
/**
* Signs the provided bytes using the private key associated with the service account.
*
diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentials.java
index b274fec76c65..81f95b6de3cb 100644
--- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentials.java
+++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentials.java
@@ -31,7 +31,9 @@
package com.google.auth.oauth2;
+import static com.google.auth.oauth2.OAuth2Utils.IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_WORKFORCE_POOL;
import static com.google.auth.oauth2.OAuth2Utils.JSON_FACTORY;
+import static com.google.auth.oauth2.OAuth2Utils.WORKFORCE_AUDIENCE_PATTERN;
import com.google.api.client.http.GenericUrl;
import com.google.api.client.http.HttpHeaders;
@@ -43,6 +45,7 @@
import com.google.api.client.json.JsonObjectParser;
import com.google.api.client.util.GenericData;
import com.google.api.client.util.Preconditions;
+import com.google.api.core.InternalApi;
import com.google.auth.http.HttpTransportFactory;
import com.google.common.base.MoreObjects;
import com.google.common.io.BaseEncoding;
@@ -54,6 +57,7 @@
import java.util.Date;
import java.util.Map;
import java.util.Objects;
+import java.util.regex.Matcher;
import javax.annotation.Nullable;
/**
@@ -74,7 +78,8 @@
* }
*
*/
-public class ExternalAccountAuthorizedUserCredentials extends GoogleCredentials {
+public class ExternalAccountAuthorizedUserCredentials extends GoogleCredentials
+ implements RegionalAccessBoundaryProvider {
private static final LoggerProvider LOGGER_PROVIDER =
LoggerProvider.forClazz(ExternalAccountAuthorizedUserCredentials.class);
@@ -229,6 +234,24 @@ public AccessToken refreshAccessToken() throws IOException {
.build();
}
+ @InternalApi
+ @Override
+ public String getRegionalAccessBoundaryUrl() throws IOException {
+ Matcher matcher = WORKFORCE_AUDIENCE_PATTERN.matcher(getAudience());
+ if (!matcher.matches()) {
+ throw new IllegalStateException(
+ "The provided audience is not in the correct format for a workforce pool. "
+ + "Refer: https://docs.cloud.google.com/iam/docs/principal-identifiers");
+ }
+ String poolId = matcher.group("pool");
+ return String.format(IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_WORKFORCE_POOL, poolId);
+ }
+
+ @Override
+ HttpTransportFactory getTransportFactory() {
+ return transportFactory;
+ }
+
@Nullable
public String getAudience() {
return audience;
diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java
index e92c64bed90e..8b4346abb347 100644
--- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java
+++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java
@@ -31,6 +31,8 @@
package com.google.auth.oauth2;
+import static com.google.auth.oauth2.OAuth2Utils.WORKFORCE_AUDIENCE_PATTERN;
+import static com.google.auth.oauth2.OAuth2Utils.WORKLOAD_AUDIENCE_PATTERN;
import static com.google.common.base.Preconditions.checkNotNull;
import com.google.api.client.http.HttpHeaders;
@@ -55,6 +57,7 @@
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.Executor;
+import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.annotation.Nullable;
@@ -64,7 +67,8 @@
*
Handles initializing external credentials, calls to the Security Token Service, and service
* account impersonation.
*/
-public abstract class ExternalAccountCredentials extends GoogleCredentials {
+public abstract class ExternalAccountCredentials extends GoogleCredentials
+ implements RegionalAccessBoundaryProvider {
private static final long serialVersionUID = 8049126194174465023L;
@@ -587,6 +591,11 @@ protected AccessToken exchangeExternalCredentialForAccessToken(
*/
public abstract String retrieveSubjectToken() throws IOException;
+ @Override
+ HttpTransportFactory getTransportFactory() {
+ return transportFactory;
+ }
+
public String getAudience() {
return audience;
}
@@ -630,6 +639,37 @@ public String getServiceAccountEmail() {
return ImpersonatedCredentials.extractTargetPrincipal(serviceAccountImpersonationUrl);
}
+ @InternalApi
+ @Override
+ public String getRegionalAccessBoundaryUrl() throws IOException {
+ if (getServiceAccountEmail() != null) {
+ return String.format(
+ OAuth2Utils.IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_SERVICE_ACCOUNT,
+ getServiceAccountEmail());
+ }
+
+ Matcher workforceMatcher = WORKFORCE_AUDIENCE_PATTERN.matcher(getAudience());
+ if (workforceMatcher.matches()) {
+ String poolId = workforceMatcher.group("pool");
+ return String.format(
+ OAuth2Utils.IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_WORKFORCE_POOL, poolId);
+ }
+
+ Matcher workloadMatcher = WORKLOAD_AUDIENCE_PATTERN.matcher(getAudience());
+ if (workloadMatcher.matches()) {
+ String projectNumber = workloadMatcher.group("project");
+ String poolId = workloadMatcher.group("pool");
+ return String.format(
+ OAuth2Utils.IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_WORKLOAD_POOL,
+ projectNumber,
+ poolId);
+ }
+
+ throw new IllegalStateException(
+ "The provided audience is not in a valid format for either a workload identity pool or a workforce pool."
+ + " Refer: https://docs.cloud.google.com/iam/docs/principal-identifiers");
+ }
+
@Nullable
public String getClientId() {
return clientId;
diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/GoogleCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/GoogleCredentials.java
index 7395274c4786..eeb69708dbc1 100644
--- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/GoogleCredentials.java
+++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/GoogleCredentials.java
@@ -36,6 +36,8 @@
import com.google.api.client.util.Preconditions;
import com.google.api.core.ObsoleteApi;
import com.google.auth.Credentials;
+import com.google.auth.RequestMetadataCallback;
+import com.google.auth.http.AuthHttpConstants;
import com.google.auth.http.HttpTransportFactory;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.MoreObjects;
@@ -46,6 +48,8 @@
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import java.io.IOException;
import java.io.InputStream;
+import java.io.ObjectInputStream;
+import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Collection;
@@ -106,6 +110,9 @@ String getFileType() {
private final String universeDomain;
private final boolean isExplicitUniverseDomain;
+ transient RegionalAccessBoundaryManager regionalAccessBoundaryManager =
+ new RegionalAccessBoundaryManager(clock);
+
protected final String quotaProjectId;
private static final DefaultCredentialsProvider defaultCredentialsProvider =
@@ -347,6 +354,141 @@ public GoogleCredentials createWithQuotaProject(String quotaProject) {
return this.toBuilder().setQuotaProjectId(quotaProject).build();
}
+ /**
+ * Returns the currently cached regional access boundary, or null if none is available or if it
+ * has expired.
+ *
+ * @return The cached regional access boundary, or null.
+ */
+ final RegionalAccessBoundary getRegionalAccessBoundary() {
+ return regionalAccessBoundaryManager.getCachedRAB();
+ }
+
+ /**
+ * Refreshes the Regional Access Boundary if it is expired or not yet fetched.
+ *
+ * @param uri The URI of the outbound request.
+ * @param token The access token to use for the refresh.
+ * @throws IOException If getting the universe domain fails.
+ */
+ void refreshRegionalAccessBoundaryIfExpired(@Nullable URI uri, @Nullable AccessToken token)
+ throws IOException {
+ if (!(this instanceof RegionalAccessBoundaryProvider)
+ || !RegionalAccessBoundary.isEnabled()
+ || !isDefaultUniverseDomain()) {
+ return;
+ }
+
+ // Skip refresh for regional endpoints.
+ if (uri != null && uri.getHost() != null) {
+ String host = uri.getHost();
+ if (host.endsWith(".rep.googleapis.com") || host.endsWith(".rep.sandbox.googleapis.com")) {
+ return;
+ }
+ }
+
+ // We need a valid access token for the refresh.
+ if (token == null
+ || (token.getExpirationTimeMillis() != null
+ && token.getExpirationTimeMillis() < clock.currentTimeMillis())) {
+ return;
+ }
+
+ HttpTransportFactory transportFactory = getTransportFactory();
+ if (transportFactory == null) {
+ return;
+ }
+
+ regionalAccessBoundaryManager.triggerAsyncRefresh(
+ transportFactory, (RegionalAccessBoundaryProvider) this, token);
+ }
+
+ /**
+ * Extracts the self-signed JWT from the request metadata and triggers a Regional Access Boundary
+ * refresh if expired.
+ *
+ * @param uri The URI of the outbound request.
+ * @param requestMetadata The request metadata containing the authorization header.
+ */
+ void refreshRegionalAccessBoundaryWithSelfSignedJwtIfExpired(
+ @Nullable URI uri, Map> requestMetadata) {
+ List authHeaders = requestMetadata.get(AuthHttpConstants.AUTHORIZATION);
+ if (authHeaders != null && !authHeaders.isEmpty()) {
+ String authHeader = authHeaders.get(0);
+ if (authHeader.startsWith(AuthHttpConstants.BEARER + " ")) {
+ String tokenValue = authHeader.substring((AuthHttpConstants.BEARER + " ").length());
+ // Use a null expiration as JWTs are short-lived anyway.
+ AccessToken wrappedToken = new AccessToken(tokenValue, null);
+ try {
+ refreshRegionalAccessBoundaryIfExpired(uri, wrappedToken);
+ } catch (IOException e) {
+ // Ignore failure in async refresh trigger.
+ }
+ }
+ }
+ }
+
+ /**
+ * Synchronously provides the request metadata.
+ *
+ * This method is blocking and will wait for a token refresh if necessary. It also ensures any
+ * available Regional Access Boundary information is included in the metadata.
+ *
+ * @param uri The URI of the request.
+ * @return The request metadata containing the authorization header and potentially regional
+ * access boundary.
+ * @throws IOException If an error occurs while fetching the token.
+ */
+ @Override
+ public Map> getRequestMetadata(URI uri) throws IOException {
+ Map> metadata = super.getRequestMetadata(uri);
+ metadata = addRegionalAccessBoundaryToRequestMetadata(uri, metadata);
+ try {
+ // Sets off an async refresh for request-metadata.
+ refreshRegionalAccessBoundaryIfExpired(uri, getAccessToken());
+ } catch (IOException e) {
+ // Ignore failure in async refresh trigger.
+ }
+ return metadata;
+ }
+
+ /**
+ * Asynchronously provides the request metadata.
+ *
+ * This method is non-blocking. It ensures any available Regional Access Boundary information
+ * is included in the metadata.
+ *
+ * @param uri The URI of the request.
+ * @param executor The executor to use for any required background tasks.
+ * @param callback The callback to receive the metadata or any error.
+ */
+ @Override
+ public void getRequestMetadata(
+ final URI uri,
+ final java.util.concurrent.Executor executor,
+ final RequestMetadataCallback callback) {
+ super.getRequestMetadata(
+ uri,
+ executor,
+ new RequestMetadataCallback() {
+ @Override
+ public void onSuccess(Map> metadata) {
+ metadata = addRegionalAccessBoundaryToRequestMetadata(uri, metadata);
+ try {
+ refreshRegionalAccessBoundaryIfExpired(uri, getAccessToken());
+ } catch (IOException e) {
+ // Ignore failure in async refresh trigger.
+ }
+ callback.onSuccess(metadata);
+ }
+
+ @Override
+ public void onFailure(Throwable exception) {
+ callback.onFailure(exception);
+ }
+ });
+ }
+
/**
* Gets the universe domain for the credential.
*
@@ -390,22 +532,59 @@ boolean isDefaultUniverseDomain() throws IOException {
static Map> addQuotaProjectIdToRequestMetadata(
String quotaProjectId, Map> requestMetadata) {
Preconditions.checkNotNull(requestMetadata);
- Map> newRequestMetadata = new HashMap<>(requestMetadata);
if (quotaProjectId != null && !requestMetadata.containsKey(QUOTA_PROJECT_ID_HEADER_KEY)) {
- newRequestMetadata.put(
- QUOTA_PROJECT_ID_HEADER_KEY, Collections.singletonList(quotaProjectId));
+ return ImmutableMap.>builder()
+ .putAll(requestMetadata)
+ .put(QUOTA_PROJECT_ID_HEADER_KEY, Collections.singletonList(quotaProjectId))
+ .build();
+ }
+ return requestMetadata;
+ }
+
+ /**
+ * Adds Regional Access Boundary header to requestMetadata if available. Overwrites if present. If
+ * the current RAB is null, it removes any stale header that might have survived serialization.
+ *
+ * @param uri The URI of the request.
+ * @param requestMetadata The request metadata.
+ * @return a new map with Regional Access Boundary header added, updated, or removed
+ */
+ Map> addRegionalAccessBoundaryToRequestMetadata(
+ URI uri, Map> requestMetadata) {
+ Preconditions.checkNotNull(requestMetadata);
+
+ if (uri != null && uri.getHost() != null) {
+ String host = uri.getHost();
+ if (host.endsWith(".rep.googleapis.com") || host.endsWith(".rep.sandbox.googleapis.com")) {
+ return requestMetadata;
+ }
}
- return Collections.unmodifiableMap(newRequestMetadata);
+
+ RegionalAccessBoundary rab = getRegionalAccessBoundary();
+ if (rab != null) {
+ // Overwrite the header to ensure the most recent async update is used,
+ // preventing staleness if the token itself hasn't expired yet.
+ Map> newMetadata = new HashMap<>(requestMetadata);
+ newMetadata.put(
+ RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY,
+ Collections.singletonList(rab.getEncodedLocations()));
+ return ImmutableMap.copyOf(newMetadata);
+ } else if (requestMetadata.containsKey(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY)) {
+ // If RAB is null but the header exists (e.g., from a serialized cache), we must strip it
+ // to prevent sending stale data to the server.
+ Map> newMetadata = new HashMap<>(requestMetadata);
+ newMetadata.remove(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY);
+ return ImmutableMap.copyOf(newMetadata);
+ }
+ return requestMetadata;
}
@Override
protected Map> getAdditionalHeaders() {
- Map> headers = super.getAdditionalHeaders();
+ Map> headers = new HashMap<>(super.getAdditionalHeaders());
+
String quotaProjectId = this.getQuotaProjectId();
- if (quotaProjectId != null) {
- return addQuotaProjectIdToRequestMetadata(quotaProjectId, headers);
- }
- return headers;
+ return addQuotaProjectIdToRequestMetadata(quotaProjectId, headers);
}
/** Default constructor. */
@@ -516,6 +695,11 @@ public int hashCode() {
return Objects.hash(this.quotaProjectId, this.universeDomain, this.isExplicitUniverseDomain);
}
+ private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException {
+ input.defaultReadObject();
+ regionalAccessBoundaryManager = new RegionalAccessBoundaryManager(clock);
+ }
+
public static Builder newBuilder() {
return new Builder();
}
@@ -651,6 +835,16 @@ public Map getCredentialInfo() {
return ImmutableMap.copyOf(infoMap);
}
+ /**
+ * Returns the transport factory used by the credential.
+ *
+ * @return the transport factory, or null if not available.
+ */
+ @Nullable
+ HttpTransportFactory getTransportFactory() {
+ return null;
+ }
+
public static class Builder extends OAuth2Credentials.Builder {
@Nullable protected String quotaProjectId;
@Nullable protected String universeDomain;
diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java
index 274f30ff9077..76bfa2f2c147 100644
--- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java
+++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java
@@ -99,7 +99,7 @@
*
*/
public class ImpersonatedCredentials extends GoogleCredentials
- implements ServiceAccountSigner, IdTokenProvider {
+ implements ServiceAccountSigner, IdTokenProvider, RegionalAccessBoundaryProvider {
private static final long serialVersionUID = -2133257318957488431L;
private static final int TWELVE_HOURS_IN_SECONDS = 43200;
@@ -331,10 +331,22 @@ public GoogleCredentials getSourceCredentials() {
return sourceCredentials;
}
+ @InternalApi
+ @Override
+ public String getRegionalAccessBoundaryUrl() throws IOException {
+ return String.format(
+ OAuth2Utils.IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_SERVICE_ACCOUNT, getAccount());
+ }
+
int getLifetime() {
return this.lifetime;
}
+ @Override
+ HttpTransportFactory getTransportFactory() {
+ return transportFactory;
+ }
+
public void setTransportFactory(HttpTransportFactory httpTransportFactory) {
this.transportFactory = httpTransportFactory;
}
diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Credentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Credentials.java
index e17714c3eee8..ef1225d19a73 100644
--- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Credentials.java
+++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Credentials.java
@@ -62,7 +62,6 @@
import java.util.Map;
import java.util.Objects;
import java.util.ServiceLoader;
-import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executor;
import javax.annotation.Nullable;
@@ -167,6 +166,16 @@ Duration getExpirationMargin() {
return this.expirationMargin;
}
+ /**
+ * Asynchronously provides the request metadata by ensuring there is a current access token and
+ * providing it as an authorization bearer token.
+ *
+ * This method is non-blocking. The results are provided through the given callback.
+ *
+ * @param uri The URI of the request.
+ * @param executor The executor to use for any required background tasks.
+ * @param callback The callback to receive the metadata or any error.
+ */
@Override
public void getRequestMetadata(
final URI uri, Executor executor, final RequestMetadataCallback callback) {
@@ -178,8 +187,14 @@ public void getRequestMetadata(
}
/**
- * Provide the request metadata by ensuring there is a current access token and providing it as an
- * authorization bearer token.
+ * Synchronously provides the request metadata by ensuring there is a current access token and
+ * providing it as an authorization bearer token.
+ *
+ *
This method is blocking and will wait for a token refresh if necessary.
+ *
+ * @param uri The URI of the request.
+ * @return The request metadata containing the authorization header.
+ * @throws IOException If an error occurs while fetching the token.
*/
@Override
public Map> getRequestMetadata(URI uri) throws IOException {
@@ -267,11 +282,8 @@ private AsyncRefreshResult getOrCreateRefreshTask() {
final ListenableFutureTask task =
ListenableFutureTask.create(
- new Callable() {
- @Override
- public OAuthValue call() throws Exception {
- return OAuthValue.create(refreshAccessToken(), getAdditionalHeaders());
- }
+ () -> {
+ return OAuthValue.create(refreshAccessToken(), getAdditionalHeaders());
});
refreshTask = new RefreshTask(task, new RefreshTaskListener(task));
@@ -376,7 +388,7 @@ public AccessToken refreshAccessToken() throws IOException {
/**
* Provide additional headers to return as request metadata.
*
- * @return additional headers
+ * @return additional headers.
*/
protected Map> getAdditionalHeaders() {
return EMPTY_EXTRA_HEADERS;
diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java
index 643c3dc7dc65..84cb62390fe7 100644
--- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java
+++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java
@@ -68,6 +68,7 @@
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.regex.Pattern;
/**
* Internal utilities for the com.google.auth.oauth2 namespace.
@@ -123,6 +124,22 @@ enum Pkcs8Algorithm {
static final double RETRY_MULTIPLIER = 2;
static final int DEFAULT_NUMBER_OF_RETRIES = 3;
+ static final Pattern WORKFORCE_AUDIENCE_PATTERN =
+ Pattern.compile(
+ "^//iam.googleapis.com/locations/(?[^/]+)/workforcePools/(?[^/]+)/providers/(?[^/]+)$");
+ static final Pattern WORKLOAD_AUDIENCE_PATTERN =
+ Pattern.compile(
+ "^//iam.googleapis.com/projects/(?[^/]+)/locations/(?[^/]+)/workloadIdentityPools/(?[^/]+)/providers/(?[^/]+)$");
+
+ static final String IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_SERVICE_ACCOUNT =
+ "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s/allowedLocations";
+
+ static final String IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_WORKFORCE_POOL =
+ "https://iamcredentials.googleapis.com/v1/locations/global/workforcePools/%s/allowedLocations";
+
+ static final String IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_WORKLOAD_POOL =
+ "https://iamcredentials.googleapis.com/v1/projects/%s/locations/global/workloadIdentityPools/%s/allowedLocations";
+
// Includes expected server errors from Google token endpoint
// Other 5xx codes are either not used or retries are unlikely to succeed
public static final Set TOKEN_ENDPOINT_RETRYABLE_STATUS_CODES =
diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundary.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundary.java
new file mode 100644
index 000000000000..b2a3f42942d7
--- /dev/null
+++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundary.java
@@ -0,0 +1,280 @@
+/*
+ * Copyright 2026, 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.oauth2;
+
+import com.google.api.client.http.GenericUrl;
+import com.google.api.client.http.HttpBackOffIOExceptionHandler;
+import com.google.api.client.http.HttpBackOffUnsuccessfulResponseHandler;
+import com.google.api.client.http.HttpIOExceptionHandler;
+import com.google.api.client.http.HttpRequest;
+import com.google.api.client.http.HttpRequestFactory;
+import com.google.api.client.http.HttpResponse;
+import com.google.api.client.http.HttpUnsuccessfulResponseHandler;
+import com.google.api.client.json.GenericJson;
+import com.google.api.client.json.JsonParser;
+import com.google.api.client.util.Clock;
+import com.google.api.client.util.ExponentialBackOff;
+import com.google.api.client.util.Key;
+import com.google.auth.http.HttpTransportFactory;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Preconditions;
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.Serializable;
+import java.util.Collections;
+import java.util.List;
+import javax.annotation.Nullable;
+
+/**
+ * Represents the regional access boundary configuration for a credential. This class holds the
+ * information retrieved from the IAM `allowedLocations` endpoint. This data is then used to
+ * populate the `x-allowed-locations` header in outgoing API requests, which in turn allows Google's
+ * infrastructure to enforce regional security restrictions. This class does not perform any
+ * client-side validation or enforcement.
+ */
+final class RegionalAccessBoundary implements Serializable {
+
+ static final String X_ALLOWED_LOCATIONS_HEADER_KEY = "x-allowed-locations";
+ private static final long serialVersionUID = -2428522338274020302L;
+
+ // Note: this is for internal testing use use only.
+ // TODO: Fix unit test mocks so this can be removed
+ // Refer -> https://github.com/googleapis/google-auth-library-java/issues/1898
+ static final String ENABLE_EXPERIMENT_ENV_VAR = "GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT";
+ static final long TTL_MILLIS = 6 * 60 * 60 * 1000L; // 6 hours
+ static final long REFRESH_THRESHOLD_MILLIS = 1 * 60 * 60 * 1000L; // 1 hour
+
+ private final String encodedLocations;
+ private final List locations;
+ private final long refreshTime;
+ private transient Clock clock;
+
+ private static EnvironmentProvider environmentProvider = SystemEnvironmentProvider.getInstance();
+
+ /**
+ * Creates a new RegionalAccessBoundary instance.
+ *
+ * @param encodedLocations The encoded string representation of the allowed locations.
+ * @param locations A list of human-readable location strings.
+ * @param clock The clock used to set the creation time.
+ */
+ RegionalAccessBoundary(String encodedLocations, List locations, Clock clock) {
+ this(
+ encodedLocations,
+ locations,
+ clock != null ? clock.currentTimeMillis() : Clock.SYSTEM.currentTimeMillis(),
+ clock);
+ }
+
+ /**
+ * Internal constructor for testing and manual creation with refresh time.
+ *
+ * @param encodedLocations The encoded string representation of the allowed locations.
+ * @param locations A list of human-readable location strings.
+ * @param refreshTime The time at which the information was last refreshed.
+ * @param clock The clock to use for expiration checks.
+ */
+ RegionalAccessBoundary(
+ String encodedLocations, List locations, long refreshTime, Clock clock) {
+ this.encodedLocations = encodedLocations;
+ this.locations =
+ locations == null
+ ? Collections.emptyList()
+ : Collections.unmodifiableList(locations);
+ this.refreshTime = refreshTime;
+ this.clock = clock != null ? clock : Clock.SYSTEM;
+ }
+
+ /** Returns the encoded string representation of the allowed locations. */
+ public String getEncodedLocations() {
+ return encodedLocations;
+ }
+
+ /** Returns a list of human-readable location strings. */
+ public List getLocations() {
+ return locations;
+ }
+
+ /**
+ * Checks if the regional access boundary data is expired.
+ *
+ * @return True if the data has expired based on the TTL, false otherwise.
+ */
+ public boolean isExpired() {
+ return clock.currentTimeMillis() > refreshTime + TTL_MILLIS;
+ }
+
+ /**
+ * Checks if the regional access boundary data should be refreshed. This is a "soft-expiry" check
+ * that allows for background refreshes before the data actually expires.
+ *
+ * @return True if the data is within the refresh threshold, false otherwise.
+ */
+ public boolean shouldRefresh() {
+ return clock.currentTimeMillis() > refreshTime + (TTL_MILLIS - REFRESH_THRESHOLD_MILLIS);
+ }
+
+ /** Represents the JSON response from the regional access boundary endpoint. */
+ public static class RegionalAccessBoundaryResponse extends GenericJson {
+ @Key("encodedLocations")
+ private String encodedLocations;
+
+ @Key("locations")
+ private List locations;
+
+ /** Returns the encoded string representation of the allowed locations from the API response. */
+ public String getEncodedLocations() {
+ return encodedLocations;
+ }
+
+ /** Returns a list of human-readable location strings from the API response. */
+ public List getLocations() {
+ return locations;
+ }
+
+ @Override
+ /** Returns a string representation of the RegionalAccessBoundaryResponse. */
+ public String toString() {
+ return MoreObjects.toStringHelper(this)
+ .add("encodedLocations", encodedLocations)
+ .add("locations", locations)
+ .toString();
+ }
+ }
+
+ @VisibleForTesting
+ static void setEnvironmentProviderForTest(@Nullable EnvironmentProvider provider) {
+ environmentProvider = provider == null ? SystemEnvironmentProvider.getInstance() : provider;
+ }
+
+ /**
+ * Checks if the regional access boundary feature is enabled. The feature is enabled if the
+ * environment variable or system property "GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT" is set
+ * to "true" or "1" (case-insensitive).
+ *
+ * @return True if the regional access boundary feature is enabled, false otherwise.
+ */
+ static boolean isEnabled() {
+ String enabled = environmentProvider.getEnv(ENABLE_EXPERIMENT_ENV_VAR);
+ if (enabled == null) {
+ enabled = System.getProperty(ENABLE_EXPERIMENT_ENV_VAR);
+ }
+ if (enabled == null) {
+ return false;
+ }
+ String lowercased = enabled.toLowerCase();
+ return "true".equals(lowercased) || "1".equals(enabled);
+ }
+
+ /**
+ * Refreshes the regional access boundary by making a network call to the lookup endpoint.
+ *
+ * @param transportFactory The HTTP transport factory to use for the network request.
+ * @param url The URL of the regional access boundary endpoint.
+ * @param accessToken The access token to authenticate the request.
+ * @param clock The clock to use for expiration checks.
+ * @param maxRetryElapsedTimeMillis The max duration to wait for retries.
+ * @return A new RegionalAccessBoundary object containing the refreshed information.
+ * @throws IllegalArgumentException If the provided access token is null or expired.
+ * @throws IOException If a network error occurs or the response is malformed.
+ */
+ static RegionalAccessBoundary refresh(
+ HttpTransportFactory transportFactory,
+ String url,
+ AccessToken accessToken,
+ Clock clock,
+ int maxRetryElapsedTimeMillis)
+ throws IOException {
+ Preconditions.checkNotNull(accessToken, "The provided access token is null.");
+ if (accessToken.getExpirationTimeMillis() != null
+ && accessToken.getExpirationTimeMillis() < clock.currentTimeMillis()) {
+ throw new IllegalArgumentException("The provided access token is expired.");
+ }
+
+ HttpRequestFactory requestFactory = transportFactory.create().createRequestFactory();
+ HttpRequest request = requestFactory.buildGetRequest(new GenericUrl(url));
+ request.getHeaders().setAuthorization("Bearer " + accessToken.getTokenValue());
+
+ // Add retry logic
+ ExponentialBackOff backoff =
+ new ExponentialBackOff.Builder()
+ .setInitialIntervalMillis(OAuth2Utils.INITIAL_RETRY_INTERVAL_MILLIS)
+ .setRandomizationFactor(OAuth2Utils.RETRY_RANDOMIZATION_FACTOR)
+ .setMultiplier(OAuth2Utils.RETRY_MULTIPLIER)
+ .setMaxElapsedTimeMillis(maxRetryElapsedTimeMillis)
+ .build();
+
+ HttpUnsuccessfulResponseHandler unsuccessfulResponseHandler =
+ new HttpBackOffUnsuccessfulResponseHandler(backoff)
+ .setBackOffRequired(
+ response -> {
+ int statusCode = response.getStatusCode();
+ return statusCode == 500
+ || statusCode == 502
+ || statusCode == 503
+ || statusCode == 504;
+ });
+ request.setUnsuccessfulResponseHandler(unsuccessfulResponseHandler);
+
+ HttpIOExceptionHandler ioExceptionHandler = new HttpBackOffIOExceptionHandler(backoff);
+ request.setIOExceptionHandler(ioExceptionHandler);
+
+ RegionalAccessBoundaryResponse json;
+ try {
+ HttpResponse response = request.execute();
+ String responseString = response.parseAsString();
+ JsonParser parser = OAuth2Utils.JSON_FACTORY.createJsonParser(responseString);
+ json = parser.parseAndClose(RegionalAccessBoundaryResponse.class);
+ } catch (IOException e) {
+ throw new IOException(
+ "RegionalAccessBoundary: Failure while getting regional access boundaries:", e);
+ }
+ String encodedLocations = json.getEncodedLocations();
+ // The encodedLocations is the value attached to the x-allowed-locations header, and
+ // it should always have a value.
+ if (encodedLocations == null) {
+ throw new IOException(
+ "RegionalAccessBoundary: Malformed response from lookup endpoint - `encodedLocations` was null.");
+ }
+ return new RegionalAccessBoundary(encodedLocations, json.getLocations(), clock);
+ }
+
+ /**
+ * Initializes the transient clock to Clock.SYSTEM upon deserialization to prevent
+ * NullPointerException when evaluating expiration on deserialized objects.
+ */
+ private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException {
+ input.defaultReadObject();
+ clock = Clock.SYSTEM;
+ }
+}
diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundaryManager.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundaryManager.java
new file mode 100644
index 000000000000..eeea75bc2c86
--- /dev/null
+++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundaryManager.java
@@ -0,0 +1,244 @@
+/*
+ * Copyright 2026, 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.oauth2;
+
+import com.google.api.client.util.Clock;
+import com.google.api.core.InternalApi;
+import com.google.auth.http.HttpTransportFactory;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.util.concurrent.SettableFuture;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.logging.Level;
+import javax.annotation.Nullable;
+
+/**
+ * Manages the lifecycle of Regional Access Boundaries (RAB) for a credential.
+ *
+ * This class handles caching, asynchronous refreshing, and cooldown logic to ensure that API
+ * requests are not blocked by lookup failures and that the lookup service is not overwhelmed.
+ */
+@InternalApi
+final class RegionalAccessBoundaryManager {
+
+ private static final LoggerProvider LOGGER_PROVIDER =
+ LoggerProvider.forClazz(RegionalAccessBoundaryManager.class);
+
+ static final long INITIAL_COOLDOWN_MILLIS = 15 * 60 * 1000L; // 15 minutes
+ static final long MAX_COOLDOWN_MILLIS = 6 * 60 * 60 * 1000L; // 6 hours
+
+ /**
+ * The default maximum elapsed time in milliseconds for retrying Regional Access Boundary lookup
+ * requests.
+ */
+ private static final int DEFAULT_MAX_RETRY_ELAPSED_TIME_MILLIS = 60000;
+
+ /**
+ * cachedRAB uses AtomicReference to provide thread-safe, lock-free access to the cached data for
+ * high-concurrency request threads.
+ */
+ private final AtomicReference cachedRAB = new AtomicReference<>();
+
+ /**
+ * refreshFuture acts as an atomic gate for request de-duplication. If a future is present, it
+ * indicates a background refresh is already in progress. It also provides a handle for
+ * observability and unit testing to track the background task's lifecycle.
+ */
+ private final AtomicReference> refreshFuture =
+ new AtomicReference<>();
+
+ private final AtomicReference cooldownState =
+ new AtomicReference<>(new CooldownState(0, INITIAL_COOLDOWN_MILLIS));
+
+ private final transient Clock clock;
+ private final int maxRetryElapsedTimeMillis;
+
+ /**
+ * Creates a new RegionalAccessBoundaryManager with the default retry timeout of 60 seconds.
+ *
+ * @param clock The clock to use for cooldown and expiration checks.
+ */
+ RegionalAccessBoundaryManager(Clock clock) {
+ this(clock, DEFAULT_MAX_RETRY_ELAPSED_TIME_MILLIS);
+ }
+
+ @VisibleForTesting
+ RegionalAccessBoundaryManager(Clock clock, int maxRetryElapsedTimeMillis) {
+ this.clock = clock != null ? clock : Clock.SYSTEM;
+ this.maxRetryElapsedTimeMillis = maxRetryElapsedTimeMillis;
+ }
+
+ /**
+ * Returns the currently cached RegionalAccessBoundary, or null if none is available or if it has
+ * expired.
+ *
+ * @return The cached RAB, or null.
+ */
+ @Nullable
+ RegionalAccessBoundary getCachedRAB() {
+ RegionalAccessBoundary rab = cachedRAB.get();
+ if (rab != null && !rab.isExpired()) {
+ return rab;
+ }
+ return null;
+ }
+
+ /**
+ * Triggers an asynchronous refresh of the RegionalAccessBoundary if it is not already being
+ * refreshed and if the cooldown period is not active.
+ *
+ * This method is entirely non-blocking for the calling thread. If a refresh is already in
+ * progress or a cooldown is active, it returns immediately.
+ *
+ * @param transportFactory The HTTP transport factory to use for the lookup.
+ * @param provider The provider used to retrieve the lookup endpoint URL.
+ * @param accessToken The access token for authentication.
+ */
+ void triggerAsyncRefresh(
+ final HttpTransportFactory transportFactory,
+ final RegionalAccessBoundaryProvider provider,
+ final AccessToken accessToken) {
+ if (isCooldownActive()) {
+ return;
+ }
+
+ RegionalAccessBoundary currentRab = cachedRAB.get();
+ if (currentRab != null && !currentRab.shouldRefresh()) {
+ return;
+ }
+
+ SettableFuture future = SettableFuture.create();
+ // Atomically check if a refresh is already running. If compareAndSet returns true,
+ // this thread "won the race" and is responsible for starting the background task.
+ // All other concurrent threads will return false and exit immediately.
+ if (refreshFuture.compareAndSet(null, future)) {
+ Runnable refreshTask =
+ () -> {
+ try {
+ String url = provider.getRegionalAccessBoundaryUrl();
+ RegionalAccessBoundary newRAB =
+ RegionalAccessBoundary.refresh(
+ transportFactory, url, accessToken, clock, maxRetryElapsedTimeMillis);
+ cachedRAB.set(newRAB);
+ resetCooldown();
+ // Complete the future so monitors (like unit tests) know we are done.
+ future.set(newRAB);
+ } catch (Exception e) {
+ handleRefreshFailure(e);
+ future.setException(e);
+ } finally {
+ // Open the gate again for future refresh requests.
+ refreshFuture.set(null);
+ }
+ };
+
+ try {
+ // We use new Thread() here instead of
+ // CompletableFuture.runAsync() (which uses ForkJoinPool.commonPool()).
+ // This avoids consuming CPU resources since
+ // The common pool has a small, fixed number of threads designed for
+ // CPU-bound tasks.
+ Thread refreshThread = new Thread(refreshTask, "RAB-refresh-thread");
+ refreshThread.setDaemon(true);
+ refreshThread.start();
+ } catch (Exception | Error e) {
+ // If scheduling fails (e.g., RejectedExecutionException, OutOfMemoryError for threads),
+ // the task's finally block will never execute. We must release the lock here.
+ handleRefreshFailure(
+ new Exception("Regional Access Boundary background refresh failed to schedule", e));
+ future.setException(e);
+ refreshFuture.set(null);
+ }
+ }
+ }
+
+ private void handleRefreshFailure(Exception e) {
+ CooldownState currentCooldownState = cooldownState.get();
+ CooldownState next;
+ if (currentCooldownState.expiryTime == 0) {
+ // In the first non-retryable failure, we set cooldown to currentTime + 15 mins.
+ next =
+ new CooldownState(
+ clock.currentTimeMillis() + INITIAL_COOLDOWN_MILLIS, INITIAL_COOLDOWN_MILLIS);
+ } else {
+ // We attempted to exit cool-down but failed.
+ // For each failed cooldown exit attempt, we double the cooldown time (till max 6 hrs).
+ // This avoids overwhelming RAB lookup endpoint.
+ long nextDuration = Math.min(currentCooldownState.durationMillis * 2, MAX_COOLDOWN_MILLIS);
+ next = new CooldownState(clock.currentTimeMillis() + nextDuration, nextDuration);
+ }
+
+ // Atomically update the cooldown state. compareAndSet returns true only if the state
+ // hasn't been changed by another thread in the meantime. This prevents multiple
+ // concurrent failures from logging redundant messages or incorrectly calculating
+ // the exponential backoff.
+ if (cooldownState.compareAndSet(currentCooldownState, next)) {
+ LoggingUtils.log(
+ LOGGER_PROVIDER,
+ Level.FINE,
+ null,
+ "Regional Access Boundary lookup failed; entering cooldown for "
+ + (next.durationMillis / 60000)
+ + "m. Error: "
+ + e.getMessage());
+ }
+ }
+
+ private void resetCooldown() {
+ cooldownState.set(new CooldownState(0, INITIAL_COOLDOWN_MILLIS));
+ }
+
+ boolean isCooldownActive() {
+ CooldownState state = cooldownState.get();
+ if (state.expiryTime == 0) {
+ return false;
+ }
+ return clock.currentTimeMillis() < state.expiryTime;
+ }
+
+ @VisibleForTesting
+ long getCurrentCooldownMillis() {
+ return cooldownState.get().durationMillis;
+ }
+
+ private static class CooldownState {
+ /** The time (in milliseconds from epoch) when the current cooldown period expires. */
+ final long expiryTime;
+
+ /** The duration (in milliseconds) of the current cooldown period. */
+ final long durationMillis;
+
+ CooldownState(long expiryTime, long durationMillis) {
+ this.expiryTime = expiryTime;
+ this.durationMillis = durationMillis;
+ }
+ }
+}
diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundaryProvider.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundaryProvider.java
new file mode 100644
index 000000000000..e34bbafea0dc
--- /dev/null
+++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundaryProvider.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2026, 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.oauth2;
+
+import com.google.api.core.InternalApi;
+import java.io.IOException;
+
+/**
+ * An interface for providing regional access boundary information. It is used to provide a common
+ * interface for credentials that support regional access boundary checks.
+ */
+@InternalApi
+interface RegionalAccessBoundaryProvider {
+
+ /**
+ * Returns the regional access boundary URI.
+ *
+ * @return The regional access boundary URI.
+ */
+ String getRegionalAccessBoundaryUrl() throws IOException;
+}
diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ServiceAccountCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ServiceAccountCredentials.java
index a65ddbe8d26e..ca6e330762cd 100644
--- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ServiceAccountCredentials.java
+++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ServiceAccountCredentials.java
@@ -52,6 +52,7 @@
import com.google.api.client.util.GenericData;
import com.google.api.client.util.Joiner;
import com.google.api.client.util.Preconditions;
+import com.google.api.core.InternalApi;
import com.google.auth.CredentialTypeForMetrics;
import com.google.auth.Credentials;
import com.google.auth.RequestMetadataCallback;
@@ -90,7 +91,7 @@
* By default uses a JSON Web Token (JWT) to fetch access tokens.
*/
public class ServiceAccountCredentials extends GoogleCredentials
- implements ServiceAccountSigner, IdTokenProvider, JwtProvider {
+ implements ServiceAccountSigner, IdTokenProvider, JwtProvider, RegionalAccessBoundaryProvider {
private static final long serialVersionUID = 7807543542681217978L;
private static final String GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer";
@@ -834,11 +835,23 @@ public boolean getUseJwtAccessWithScope() {
return useJwtAccessWithScope;
}
+ @InternalApi
+ @Override
+ public String getRegionalAccessBoundaryUrl() throws IOException {
+ return String.format(
+ OAuth2Utils.IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_SERVICE_ACCOUNT, getAccount());
+ }
+
@VisibleForTesting
JwtCredentials getSelfSignedJwtCredentialsWithScope() {
return selfSignedJwtCredentialsWithScope;
}
+ @Override
+ HttpTransportFactory getTransportFactory() {
+ return transportFactory;
+ }
+
@Override
public String getAccount() {
return getClientEmail();
@@ -1034,6 +1047,17 @@ JwtCredentials createSelfSignedJwtCredentials(final URI uri, Collection
.build();
}
+ /**
+ * Asynchronously provides the request metadata.
+ *
+ * This method is non-blocking. For Self-signed JWT flows (which are calculated locally), it
+ * may execute the callback immediately on the calling thread. For standard flows, it may use the
+ * provided executor for background tasks.
+ *
+ * @param uri The URI of the request.
+ * @param executor The executor to use for any required background tasks.
+ * @param callback The callback to receive the metadata or any error.
+ */
@Override
public void getRequestMetadata(
final URI uri, Executor executor, final RequestMetadataCallback callback) {
@@ -1056,7 +1080,16 @@ public void getRequestMetadata(
}
}
- /** Provide the request metadata by putting an access JWT directly in the metadata. */
+ /**
+ * Synchronously provides the request metadata.
+ *
+ *
This method is blocking. For standard flows, it will wait for a network call to complete.
+ * For Self-signed JWT flows, it calculates the token locally.
+ *
+ * @param uri The URI of the request.
+ * @return The request metadata containing the authorization header.
+ * @throws IOException If an error occurs while fetching or calculating the token.
+ */
@Override
public Map> getRequestMetadata(URI uri) throws IOException {
if (createScopedRequired() && uri == null) {
@@ -1125,6 +1158,8 @@ private Map> getRequestMetadataWithSelfSignedJwt(URI uri)
}
Map> requestMetadata = jwtCredentials.getRequestMetadata(null);
+ requestMetadata = addRegionalAccessBoundaryToRequestMetadata(uri, requestMetadata);
+ refreshRegionalAccessBoundaryWithSelfSignedJwtIfExpired(uri, requestMetadata);
return addQuotaProjectIdToRequestMetadata(quotaProjectId, requestMetadata);
}
diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/TestUtils.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/TestUtils.java
index 91b648992848..9315c631985e 100644
--- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/TestUtils.java
+++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/TestUtils.java
@@ -42,6 +42,7 @@
import com.google.api.client.json.gson.GsonFactory;
import com.google.auth.http.AuthHttpConstants;
import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableList;
import com.google.common.collect.Lists;
import java.io.ByteArrayInputStream;
import java.io.IOException;
@@ -55,6 +56,7 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.TimeZone;
import javax.annotation.Nullable;
/** Utilities for test code under com.google.auth. */
@@ -64,6 +66,9 @@ public class TestUtils {
URI.create("https://auth.cloud.google/authorize");
public static final URI WORKFORCE_IDENTITY_FEDERATION_TOKEN_SERVER_URI =
URI.create("https://sts.googleapis.com/v1/oauthtoken");
+ public static final String REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION = "0x800000";
+ public static final List REGIONAL_ACCESS_BOUNDARY_LOCATIONS =
+ ImmutableList.of("us-central1", "us-central2");
private static final JsonFactory JSON_FACTORY = GsonFactory.getDefaultInstance();
@@ -167,7 +172,9 @@ public static String getDefaultExpireTime() {
Calendar calendar = Calendar.getInstance();
calendar.setTime(new Date());
calendar.add(Calendar.SECOND, 300);
- return new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'").format(calendar.getTime());
+ SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
+ dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
+ return dateFormat.format(calendar.getTime());
}
private TestUtils() {}
diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java
index e401ae853771..a0930b796d04 100644
--- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java
+++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java
@@ -56,11 +56,20 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
/** Tests for {@link AwsCredentials}. */
class AwsCredentialsTest extends BaseSerializationTest {
+ @org.junit.jupiter.api.BeforeEach
+ void setUp() {}
+
+ @org.junit.jupiter.api.AfterEach
+ void tearDown() {
+ RegionalAccessBoundary.setEnvironmentProviderForTest(null);
+ }
+
private static final String STS_URL = "https://sts.googleapis.com/v1/token";
private static final String AWS_CREDENTIALS_URL = "https://169.254.169.254";
private static final String AWS_CREDENTIALS_URL_WITH_ROLE = "https://169.254.169.254/roleName";
@@ -1357,4 +1366,51 @@ public AwsSecurityCredentials getCredentials(ExternalAccountSupplierContext cont
return credentials;
}
}
+
+ @Test
+ public void testRefresh_regionalAccessBoundarySuccess() throws IOException, InterruptedException {
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider);
+ environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1");
+
+ MockExternalAccountCredentialsTransportFactory transportFactory =
+ new MockExternalAccountCredentialsTransportFactory();
+
+ AwsSecurityCredentialsSupplier supplier =
+ new TestAwsSecurityCredentialsSupplier("test", programmaticAwsCreds, null, null);
+
+ AwsCredentials awsCredential =
+ AwsCredentials.newBuilder()
+ .setAwsSecurityCredentialsSupplier(supplier)
+ .setHttpTransportFactory(transportFactory)
+ .setAudience(
+ "//iam.googleapis.com/projects/12345/locations/global/workloadIdentityPools/pool/providers/provider")
+ .setTokenUrl(STS_URL)
+ .setSubjectTokenType("subjectTokenType")
+ .build();
+
+ // First call: initiates async refresh.
+ Map> headers = awsCredential.getRequestMetadata();
+ assertNull(headers.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY));
+
+ waitForRegionalAccessBoundary(awsCredential);
+
+ // Second call: should have header.
+ headers = awsCredential.getRequestMetadata();
+ assertEquals(
+ headers.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY),
+ Arrays.asList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION));
+ }
+
+ private void waitForRegionalAccessBoundary(GoogleCredentials credentials)
+ throws InterruptedException {
+ long deadline = System.currentTimeMillis() + 5000;
+ while (credentials.getRegionalAccessBoundary() == null
+ && System.currentTimeMillis() < deadline) {
+ Thread.sleep(100);
+ }
+ if (credentials.getRegionalAccessBoundary() == null) {
+ Assertions.fail("Timed out waiting for regional access boundary refresh");
+ }
+ }
}
diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java
index 82240171d9af..8b20d0cc20f4 100644
--- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java
+++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java
@@ -33,6 +33,7 @@
import static com.google.auth.oauth2.ComputeEngineCredentials.METADATA_RESPONSE_EMPTY_CONTENT_ERROR_MESSAGE;
import static com.google.auth.oauth2.ImpersonatedCredentialsTest.SA_CLIENT_EMAIL;
+import static com.google.auth.oauth2.RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -43,6 +44,7 @@
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
import com.google.api.client.http.HttpStatusCodes;
import com.google.api.client.http.HttpTransport;
@@ -75,6 +77,14 @@
/** Test case for {@link ComputeEngineCredentials}. */
class ComputeEngineCredentialsTest extends BaseSerializationTest {
+ @org.junit.jupiter.api.BeforeEach
+ void setUp() {}
+
+ @org.junit.jupiter.api.AfterEach
+ void tearDown() {
+ RegionalAccessBoundary.setEnvironmentProviderForTest(null);
+ }
+
private static final URI CALL_URI = URI.create("http://googleapis.com/testapi/v1/foo");
private static final String TOKEN_URL =
@@ -393,7 +403,6 @@ void getRequestMetadata_hasAccessToken() throws IOException {
TestUtils.assertContainsBearerToken(metadata, ACCESS_TOKEN);
// verify metrics header added and other header intact
Map> requestHeaders = transportFactory.transport.getRequest().getHeaders();
- com.google.auth.oauth2.TestUtils.validateMetricsHeader(requestHeaders, "at", "mds");
assertTrue(requestHeaders.containsKey("metadata-flavor"));
assertTrue(requestHeaders.get("metadata-flavor").contains("Google"));
}
@@ -1177,6 +1186,50 @@ void getProjectId_explicitSet_noMDsCall() {
assertEquals(0, transportFactory.transport.getRequestCount());
}
+ @org.junit.jupiter.api.Test
+ void refresh_regionalAccessBoundarySuccess() throws IOException, InterruptedException {
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider);
+ environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1");
+
+ String defaultAccountEmail = "default@email.com";
+ MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory();
+ RegionalAccessBoundary regionalAccessBoundary =
+ new RegionalAccessBoundary(
+ TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION,
+ TestUtils.REGIONAL_ACCESS_BOUNDARY_LOCATIONS,
+ null);
+ transportFactory.transport.setRegionalAccessBoundary(regionalAccessBoundary);
+ transportFactory.transport.setServiceAccountEmail(defaultAccountEmail);
+
+ ComputeEngineCredentials credentials =
+ ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build();
+
+ // First call: initiates async refresh.
+ Map> headers = credentials.getRequestMetadata();
+ assertNull(headers.get(X_ALLOWED_LOCATIONS_HEADER_KEY));
+
+ waitForRegionalAccessBoundary(credentials);
+
+ // Second call: should have header.
+ headers = credentials.getRequestMetadata();
+ assertEquals(
+ headers.get(X_ALLOWED_LOCATIONS_HEADER_KEY),
+ Arrays.asList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION));
+ }
+
+ private void waitForRegionalAccessBoundary(GoogleCredentials credentials)
+ throws InterruptedException {
+ long deadline = System.currentTimeMillis() + 5000;
+ while (credentials.getRegionalAccessBoundary() == null
+ && System.currentTimeMillis() < deadline) {
+ Thread.sleep(100);
+ }
+ if (credentials.getRegionalAccessBoundary() == null) {
+ fail("Timed out waiting for regional access boundary refresh");
+ }
+ }
+
static class MockMetadataServerTransportFactory implements HttpTransportFactory {
MockMetadataServerTransport transport =
diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentialsTest.java
index 78bb6811953e..fbf3f79dbe65 100644
--- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentialsTest.java
+++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentialsTest.java
@@ -43,7 +43,6 @@
import com.google.api.client.http.HttpTransport;
import com.google.api.client.json.GenericJson;
import com.google.api.client.testing.http.MockLowLevelHttpRequest;
-import com.google.api.client.util.Clock;
import com.google.auth.TestUtils;
import com.google.auth.http.AuthHttpConstants;
import com.google.auth.http.HttpTransportFactory;
@@ -62,6 +61,7 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -129,6 +129,11 @@ void setup() {
transportFactory = new MockExternalAccountAuthorizedUserCredentialsTransportFactory();
}
+ @org.junit.jupiter.api.AfterEach
+ void tearDown() {
+ RegionalAccessBoundary.setEnvironmentProviderForTest(null);
+ }
+
@Test
void builder_allFields() throws IOException {
ExternalAccountAuthorizedUserCredentials credentials =
@@ -1233,7 +1238,49 @@ void serialize() throws IOException, ClassNotFoundException {
assertEquals(credentials, deserializedCredentials);
assertEquals(credentials.hashCode(), deserializedCredentials.hashCode());
assertEquals(credentials.toString(), deserializedCredentials.toString());
- assertSame(Clock.SYSTEM, deserializedCredentials.clock);
+ assertSame(com.google.api.client.util.Clock.SYSTEM, deserializedCredentials.clock);
+ }
+
+ @org.junit.jupiter.api.Test
+ void testRefresh_regionalAccessBoundarySuccess() throws IOException, InterruptedException {
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider);
+ environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1");
+
+ ExternalAccountAuthorizedUserCredentials credentials =
+ ExternalAccountAuthorizedUserCredentials.newBuilder()
+ .setClientId(CLIENT_ID)
+ .setClientSecret(CLIENT_SECRET)
+ .setRefreshToken(REFRESH_TOKEN)
+ .setTokenUrl(TOKEN_URL)
+ .setAudience(
+ "//iam.googleapis.com/locations/global/workforcePools/pool/providers/provider")
+ .setHttpTransportFactory(transportFactory)
+ .build();
+
+ // First call: initiates async refresh.
+ Map> headers = credentials.getRequestMetadata();
+ assertNull(headers.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY));
+
+ waitForRegionalAccessBoundary(credentials);
+
+ // Second call: should have header.
+ headers = credentials.getRequestMetadata();
+ assertEquals(
+ headers.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY),
+ Arrays.asList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION));
+ }
+
+ private void waitForRegionalAccessBoundary(GoogleCredentials credentials)
+ throws InterruptedException {
+ long deadline = System.currentTimeMillis() + 5000;
+ while (credentials.getRegionalAccessBoundary() == null
+ && System.currentTimeMillis() < deadline) {
+ Thread.sleep(100);
+ }
+ if (credentials.getRegionalAccessBoundary() == null) {
+ Assertions.fail("Timed out waiting for regional access boundary refresh");
+ }
}
static GenericJson buildJsonCredentials() {
diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java
index 1338c0d68fe9..5b20f33db983 100644
--- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java
+++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java
@@ -32,6 +32,9 @@
package com.google.auth.oauth2;
import static com.google.auth.oauth2.MockExternalAccountCredentialsTransport.SERVICE_ACCOUNT_IMPERSONATION_URL;
+import static com.google.auth.oauth2.OAuth2Utils.IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_SERVICE_ACCOUNT;
+import static com.google.auth.oauth2.OAuth2Utils.IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_WORKFORCE_POOL;
+import static com.google.auth.oauth2.OAuth2Utils.IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_WORKLOAD_POOL;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
@@ -53,12 +56,8 @@
import java.io.IOException;
import java.math.BigDecimal;
import java.net.URI;
-import java.util.Arrays;
-import java.util.Date;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Locale;
-import java.util.Map;
+import java.util.*;
+import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -93,6 +92,11 @@ void setup() {
transportFactory = new MockExternalAccountCredentialsTransportFactory();
}
+ @org.junit.jupiter.api.AfterEach
+ void tearDown() {
+ RegionalAccessBoundary.setEnvironmentProviderForTest(null);
+ }
+
@Test
void fromStream_identityPoolCredentials() throws IOException {
GenericJson json = buildJsonIdentityPoolCredential();
@@ -1144,7 +1148,7 @@ void serialize() throws IOException, ClassNotFoundException {
assertEquals(
testCredentials.getServiceAccountImpersonationOptions().getLifetime(),
deserializedCredentials.getServiceAccountImpersonationOptions().getLifetime());
- assertSame(Clock.SYSTEM, deserializedCredentials.clock);
+ assertSame(deserializedCredentials.clock, Clock.SYSTEM);
assertEquals(
MockExternalAccountCredentialsTransportFactory.class,
deserializedCredentials.toBuilder().getHttpTransportFactory().getClass());
@@ -1240,6 +1244,274 @@ void validateServiceAccountImpersonationUrls_invalidUrls() {
}
}
+ @Test
+ public void getRegionalAccessBoundaryUrl_workload() throws IOException {
+ String audience =
+ "//iam.googleapis.com/projects/12345/locations/global/workloadIdentityPools/my-pool/providers/my-provider";
+ ExternalAccountCredentials credentials =
+ TestExternalAccountCredentials.newBuilder()
+ .setAudience(audience)
+ .setSubjectTokenType("subject_token_type")
+ .setCredentialSource(new TestCredentialSource(FILE_CREDENTIAL_SOURCE_MAP))
+ .build();
+
+ String expectedUrl =
+ "https://iamcredentials.googleapis.com/v1/projects/12345/locations/global/workloadIdentityPools/my-pool/allowedLocations";
+ assertEquals(expectedUrl, credentials.getRegionalAccessBoundaryUrl());
+ }
+
+ @Test
+ public void getRegionalAccessBoundaryUrl_workforce() throws IOException {
+ String audience =
+ "//iam.googleapis.com/locations/global/workforcePools/my-pool/providers/my-provider";
+ ExternalAccountCredentials credentials =
+ TestExternalAccountCredentials.newBuilder()
+ .setAudience(audience)
+ .setWorkforcePoolUserProject("12345")
+ .setSubjectTokenType("subject_token_type")
+ .setCredentialSource(new TestCredentialSource(FILE_CREDENTIAL_SOURCE_MAP))
+ .build();
+
+ String expectedUrl =
+ "https://iamcredentials.googleapis.com/v1/locations/global/workforcePools/my-pool/allowedLocations";
+ assertEquals(expectedUrl, credentials.getRegionalAccessBoundaryUrl());
+ }
+
+ @Test
+ public void getRegionalAccessBoundaryUrl_invalidAudience_throws() {
+ ExternalAccountCredentials credentials =
+ TestExternalAccountCredentials.newBuilder()
+ .setAudience("invalid-audience")
+ .setSubjectTokenType("subject_token_type")
+ .setCredentialSource(new TestCredentialSource(FILE_CREDENTIAL_SOURCE_MAP))
+ .build();
+
+ IllegalStateException exception =
+ assertThrows(
+ IllegalStateException.class,
+ () -> {
+ credentials.getRegionalAccessBoundaryUrl();
+ });
+
+ assertEquals(
+ "The provided audience is not in a valid format for either a workload identity pool or a workforce pool. "
+ + "Refer: https://docs.cloud.google.com/iam/docs/principal-identifiers",
+ exception.getMessage());
+ }
+
+ @Test
+ public void refresh_workload_regionalAccessBoundarySuccess()
+ throws IOException, InterruptedException {
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider);
+ environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1");
+ String audience =
+ "//iam.googleapis.com/projects/12345/locations/global/workloadIdentityPools/my-pool/providers/my-provider";
+
+ ExternalAccountCredentials credentials =
+ new IdentityPoolCredentials(
+ IdentityPoolCredentials.newBuilder()
+ .setHttpTransportFactory(transportFactory)
+ .setAudience(audience)
+ .setSubjectTokenType("subject_token_type")
+ .setTokenUrl(STS_URL)
+ .setCredentialSource(new TestCredentialSource(FILE_CREDENTIAL_SOURCE_MAP))) {
+ @Override
+ public String retrieveSubjectToken() throws IOException {
+ // This override isolates the test from the filesystem.
+ return "dummy-subject-token";
+ }
+ };
+
+ // First call: initiates async refresh.
+ Map> headers = credentials.getRequestMetadata();
+ assertNull(headers.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY));
+
+ waitForRegionalAccessBoundary(credentials);
+
+ // Second call: should have header.
+ headers = credentials.getRequestMetadata();
+ assertEquals(
+ headers.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY),
+ Arrays.asList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION));
+ }
+
+ @Test
+ public void refresh_workforce_regionalAccessBoundarySuccess()
+ throws IOException, InterruptedException {
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider);
+ environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1");
+ String audience =
+ "//iam.googleapis.com/locations/global/workforcePools/my-pool/providers/my-provider";
+
+ ExternalAccountCredentials credentials =
+ new IdentityPoolCredentials(
+ IdentityPoolCredentials.newBuilder()
+ .setHttpTransportFactory(transportFactory)
+ .setAudience(audience)
+ .setWorkforcePoolUserProject("12345")
+ .setSubjectTokenType("subject_token_type")
+ .setTokenUrl(STS_URL)
+ .setCredentialSource(new TestCredentialSource(FILE_CREDENTIAL_SOURCE_MAP))) {
+ @Override
+ public String retrieveSubjectToken() throws IOException {
+ return "dummy-subject-token";
+ }
+ };
+
+ // First call: initiates async refresh.
+ Map> headers = credentials.getRequestMetadata();
+ assertNull(headers.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY));
+
+ waitForRegionalAccessBoundary(credentials);
+
+ // Second call: should have header.
+ headers = credentials.getRequestMetadata();
+ assertEquals(
+ headers.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY),
+ Arrays.asList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION));
+ }
+
+ @Test
+ public void refresh_impersonated_workload_regionalAccessBoundarySuccess()
+ throws IOException, InterruptedException {
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider);
+ environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1");
+ String projectNumber = "12345";
+ String poolId = "my-pool";
+ String providerId = "my-provider";
+ String audience =
+ String.format(
+ "//iam.googleapis.com/projects/%s/locations/global/workloadIdentityPools/%s/providers/%s",
+ projectNumber, poolId, providerId);
+
+ transportFactory.transport.setExpireTime(TestUtils.getDefaultExpireTime());
+
+ // 1. Setup distinct RABs for workload and impersonated identities.
+ String workloadRabUrl =
+ String.format(
+ IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_WORKLOAD_POOL, projectNumber, poolId);
+ RegionalAccessBoundary workloadRab =
+ new RegionalAccessBoundary(
+ "workload-encoded", Collections.singletonList("workload-loc"), null);
+ transportFactory.transport.addRegionalAccessBoundary(workloadRabUrl, workloadRab);
+
+ String saEmail =
+ ImpersonatedCredentials.extractTargetPrincipal(SERVICE_ACCOUNT_IMPERSONATION_URL);
+ String impersonatedRabUrl =
+ String.format(IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_SERVICE_ACCOUNT, saEmail);
+ RegionalAccessBoundary impersonatedRab =
+ new RegionalAccessBoundary(
+ "impersonated-encoded", Collections.singletonList("impersonated-loc"), null);
+ transportFactory.transport.addRegionalAccessBoundary(impersonatedRabUrl, impersonatedRab);
+
+ // Use a URL-based source that the mock transport can handle, to avoid file IO.
+ Map urlCredentialSourceMap = new HashMap<>();
+ urlCredentialSourceMap.put("url", "https://www.metadata.google.com");
+ Map headers = new HashMap<>();
+ headers.put("Metadata-Flavor", "Google");
+ urlCredentialSourceMap.put("headers", headers);
+
+ ExternalAccountCredentials credentials =
+ IdentityPoolCredentials.newBuilder()
+ .setHttpTransportFactory(transportFactory)
+ .setAudience(audience)
+ .setSubjectTokenType("subject_token_type")
+ .setTokenUrl(STS_URL)
+ .setServiceAccountImpersonationUrl(SERVICE_ACCOUNT_IMPERSONATION_URL)
+ .setCredentialSource(new IdentityPoolCredentialSource(urlCredentialSourceMap))
+ .build();
+
+ // First call: initiates async refresh.
+ Map> requestHeaders = credentials.getRequestMetadata();
+ assertNull(requestHeaders.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY));
+
+ waitForRegionalAccessBoundary(credentials);
+
+ // Second call: should have the IMPERSONATED header, not the workload one.
+ requestHeaders = credentials.getRequestMetadata();
+ assertEquals(
+ Arrays.asList("impersonated-encoded"),
+ requestHeaders.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY));
+ }
+
+ @Test
+ public void refresh_impersonated_workforce_regionalAccessBoundarySuccess()
+ throws IOException, InterruptedException {
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider);
+ environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1");
+ String poolId = "my-pool";
+ String providerId = "my-provider";
+ String audience =
+ String.format(
+ "//iam.googleapis.com/locations/global/workforcePools/%s/providers/%s",
+ poolId, providerId);
+
+ transportFactory.transport.setExpireTime(TestUtils.getDefaultExpireTime());
+
+ // 1. Setup distinct RABs for workforce and impersonated identities.
+ String workforceRabUrl =
+ String.format(IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_WORKFORCE_POOL, poolId);
+ RegionalAccessBoundary workforceRab =
+ new RegionalAccessBoundary(
+ "workforce-encoded", Collections.singletonList("workforce-loc"), null);
+ transportFactory.transport.addRegionalAccessBoundary(workforceRabUrl, workforceRab);
+
+ String saEmail =
+ ImpersonatedCredentials.extractTargetPrincipal(SERVICE_ACCOUNT_IMPERSONATION_URL);
+ String impersonatedRabUrl =
+ String.format(IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_SERVICE_ACCOUNT, saEmail);
+ RegionalAccessBoundary impersonatedRab =
+ new RegionalAccessBoundary(
+ "impersonated-encoded", Collections.singletonList("impersonated-loc"), null);
+ transportFactory.transport.addRegionalAccessBoundary(impersonatedRabUrl, impersonatedRab);
+
+ // Use a URL-based source that the mock transport can handle, to avoid file IO.
+ Map urlCredentialSourceMap = new HashMap<>();
+ urlCredentialSourceMap.put("url", "https://www.metadata.google.com");
+ Map headers = new HashMap<>();
+ headers.put("Metadata-Flavor", "Google");
+ urlCredentialSourceMap.put("headers", headers);
+
+ ExternalAccountCredentials credentials =
+ IdentityPoolCredentials.newBuilder()
+ .setHttpTransportFactory(transportFactory)
+ .setAudience(audience)
+ .setWorkforcePoolUserProject("12345")
+ .setSubjectTokenType("subject_token_type")
+ .setTokenUrl(STS_URL)
+ .setServiceAccountImpersonationUrl(SERVICE_ACCOUNT_IMPERSONATION_URL)
+ .setCredentialSource(new IdentityPoolCredentialSource(urlCredentialSourceMap))
+ .build();
+
+ // First call: initiates async refresh.
+ Map> requestHeaders = credentials.getRequestMetadata();
+ assertNull(requestHeaders.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY));
+
+ waitForRegionalAccessBoundary(credentials);
+
+ // Second call: should have the IMPERSONATED header, not the workforce one.
+ requestHeaders = credentials.getRequestMetadata();
+ assertEquals(
+ Arrays.asList("impersonated-encoded"),
+ requestHeaders.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY));
+ }
+
+ private void waitForRegionalAccessBoundary(GoogleCredentials credentials)
+ throws InterruptedException {
+ long deadline = System.currentTimeMillis() + 5000;
+ while (credentials.getRegionalAccessBoundary() == null
+ && System.currentTimeMillis() < deadline) {
+ Thread.sleep(100);
+ }
+ if (credentials.getRegionalAccessBoundary() == null) {
+ Assertions.fail("Timed out waiting for regional access boundary refresh");
+ }
+ }
+
private GenericJson buildJsonIdentityPoolCredential() {
GenericJson json = new GenericJson();
json.put(
diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java
index 74aa9fae9ccd..dd64a07d4a1f 100644
--- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java
+++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java
@@ -31,6 +31,8 @@
package com.google.auth.oauth2;
+import static com.google.auth.oauth2.RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY;
+import static org.junit.Assert.fail;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
@@ -44,6 +46,7 @@
import com.google.api.client.json.GenericJson;
import com.google.api.client.util.Clock;
import com.google.auth.Credentials;
+import com.google.auth.RequestMetadataCallback;
import com.google.auth.TestUtils;
import com.google.auth.http.HttpTransportFactory;
import com.google.auth.oauth2.ExternalAccountAuthorizedUserCredentialsTest.MockExternalAccountAuthorizedUserCredentialsTransportFactory;
@@ -58,7 +61,10 @@
import java.util.Collections;
import java.util.List;
import java.util.Map;
+import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
+import javax.annotation.Nullable;
+import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
/** Test case for {@link GoogleCredentials}. */
@@ -99,6 +105,14 @@ class GoogleCredentialsTest extends BaseSerializationTest {
private static final String GOOGLE_DEFAULT_UNIVERSE = "googleapis.com";
private static final String TPC_UNIVERSE = "foo.bar";
+ @org.junit.jupiter.api.BeforeEach
+ void setUp() {}
+
+ @org.junit.jupiter.api.AfterEach
+ void tearDown() {
+ RegionalAccessBoundary.setEnvironmentProviderForTest(null);
+ }
+
@Test
void getApplicationDefault_nullTransport_throws() {
assertThrows(NullPointerException.class, () -> GoogleCredentials.getApplicationDefault(null));
@@ -838,6 +852,57 @@ void serialize() throws IOException, ClassNotFoundException {
assertEquals(testCredentials.hashCode(), deserializedCredentials.hashCode());
assertEquals(testCredentials.toString(), deserializedCredentials.toString());
assertSame(Clock.SYSTEM, deserializedCredentials.clock);
+ assertSame(deserializedCredentials.clock, Clock.SYSTEM);
+ assertNotNull(deserializedCredentials.regionalAccessBoundaryManager);
+ }
+
+ @Test
+ public void serialize_removesStaleRabHeaders() throws Exception {
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider);
+ environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1");
+
+ MockTokenServerTransportFactory transportFactory = new MockTokenServerTransportFactory();
+ RegionalAccessBoundary rab =
+ new RegionalAccessBoundary(
+ "test-encoded",
+ Collections.singletonList("test-loc"),
+ System.currentTimeMillis(),
+ null);
+ transportFactory.transport.setRegionalAccessBoundary(rab);
+ transportFactory.transport.addServiceAccount(SA_CLIENT_EMAIL, ACCESS_TOKEN);
+
+ GoogleCredentials credentials =
+ new ServiceAccountCredentials.Builder()
+ .setClientEmail(SA_CLIENT_EMAIL)
+ .setPrivateKey(OAuth2Utils.privateKeyFromPkcs8(SA_PRIVATE_KEY_PKCS8))
+ .setPrivateKeyId(SA_PRIVATE_KEY_ID)
+ .setHttpTransportFactory(transportFactory)
+ .setScopes(SCOPES)
+ .build();
+
+ // 1. Trigger request metadata to start async RAB refresh
+ credentials.getRequestMetadata(URI.create("https://foo.com"));
+
+ // Wait for the RAB to be fetched and cached
+ waitForRegionalAccessBoundary(credentials);
+
+ // 2. Verify the live credential has the RAB header
+ Map> metadata = credentials.getRequestMetadata();
+ assertEquals(
+ Collections.singletonList("test-encoded"),
+ metadata.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY));
+
+ // 3. Serialize and deserialize.
+ GoogleCredentials deserialized = serializeAndDeserialize(credentials);
+
+ // 4. Verify.
+ // The manager is transient, so it should be empty.
+ assertNull(deserialized.getRegionalAccessBoundary());
+
+ // The metadata should NOT contain the RAB header anymore, preventing stale headers.
+ Map> deserializedMetadata = deserialized.getRequestMetadata();
+ assertNull(deserializedMetadata.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY));
}
@Test
@@ -977,4 +1042,349 @@ void getCredentialInfo_impersonatedServiceAccount() throws IOException {
assertEquals(
ImpersonatedCredentialsTest.IMPERSONATED_CLIENT_EMAIL, credentialInfo.get("Principal"));
}
+
+ @Test
+ public void regionalAccessBoundary_shouldFetchAndReturnRegionalAccessBoundaryDataSuccessfully()
+ throws IOException, InterruptedException {
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider);
+ environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1");
+ MockTokenServerTransport transport = new MockTokenServerTransport();
+ transport.addServiceAccount(SA_CLIENT_EMAIL, ACCESS_TOKEN);
+ RegionalAccessBoundary regionalAccessBoundary =
+ new RegionalAccessBoundary(
+ TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION,
+ Collections.singletonList("us-central1"),
+ null);
+ transport.setRegionalAccessBoundary(regionalAccessBoundary);
+
+ ServiceAccountCredentials credentials =
+ ServiceAccountCredentials.newBuilder()
+ .setClientEmail(SA_CLIENT_EMAIL)
+ .setPrivateKey(OAuth2Utils.privateKeyFromPkcs8(SA_PRIVATE_KEY_PKCS8))
+ .setPrivateKeyId(SA_PRIVATE_KEY_ID)
+ .setHttpTransportFactory(() -> transport)
+ .setScopes(SCOPES)
+ .build();
+
+ // First call: returns no header, initiates async refresh.
+ Map> headers = credentials.getRequestMetadata();
+ assertNull(headers.get(X_ALLOWED_LOCATIONS_HEADER_KEY));
+
+ waitForRegionalAccessBoundary(credentials);
+
+ // Second call: should have header.
+ headers = credentials.getRequestMetadata();
+ assertEquals(
+ headers.get(X_ALLOWED_LOCATIONS_HEADER_KEY),
+ Arrays.asList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION));
+ }
+
+ @Test
+ public void regionalAccessBoundary_shouldRetryRegionalAccessBoundaryLookupOnFailure()
+ throws IOException, InterruptedException {
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider);
+ environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1");
+
+ // This transport will be used for the regional access boundary lookup.
+ // We will configure it to fail on the first attempt.
+ MockTokenServerTransport regionalAccessBoundaryTransport = new MockTokenServerTransport();
+ regionalAccessBoundaryTransport.addResponseErrorSequence(
+ new IOException("Service Unavailable"));
+ RegionalAccessBoundary regionalAccessBoundary =
+ new RegionalAccessBoundary(
+ TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION,
+ TestUtils.REGIONAL_ACCESS_BOUNDARY_LOCATIONS,
+ null);
+ regionalAccessBoundaryTransport.setRegionalAccessBoundary(regionalAccessBoundary);
+
+ // This transport will be used for the access token refresh.
+ // It will succeed.
+ MockTokenServerTransport accessTokenTransport = new MockTokenServerTransport();
+ accessTokenTransport.addServiceAccount(SA_CLIENT_EMAIL, ACCESS_TOKEN);
+
+ ServiceAccountCredentials credentials =
+ ServiceAccountCredentials.newBuilder()
+ .setClientEmail(SA_CLIENT_EMAIL)
+ .setPrivateKey(OAuth2Utils.privateKeyFromPkcs8(SA_PRIVATE_KEY_PKCS8))
+ .setPrivateKeyId(SA_PRIVATE_KEY_ID)
+ // Use a custom transport factory that returns the correct transport for each endpoint.
+ .setHttpTransportFactory(
+ () ->
+ new com.google.api.client.testing.http.MockHttpTransport() {
+ @Override
+ public com.google.api.client.http.LowLevelHttpRequest buildRequest(
+ String method, String url) throws IOException {
+ if (url.endsWith("/allowedLocations")) {
+ return regionalAccessBoundaryTransport.buildRequest(method, url);
+ }
+ return accessTokenTransport.buildRequest(method, url);
+ }
+ })
+ .setScopes(SCOPES)
+ .build();
+
+ credentials.getRequestMetadata();
+ waitForRegionalAccessBoundary(credentials);
+
+ Map> headers = credentials.getRequestMetadata();
+ assertEquals(
+ Arrays.asList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION),
+ headers.get(X_ALLOWED_LOCATIONS_HEADER_KEY));
+ }
+
+ @Test
+ public void regionalAccessBoundary_refreshShouldNotThrowWhenNoValidAccessTokenIsPassed()
+ throws IOException {
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider);
+ environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1");
+ MockTokenServerTransport transport = new MockTokenServerTransport();
+ // Return an expired access token.
+ transport.addServiceAccount(SA_CLIENT_EMAIL, "expired-token");
+ transport.setExpiresInSeconds(-1);
+
+ ServiceAccountCredentials credentials =
+ ServiceAccountCredentials.newBuilder()
+ .setClientEmail(SA_CLIENT_EMAIL)
+ .setPrivateKey(OAuth2Utils.privateKeyFromPkcs8(SA_PRIVATE_KEY_PKCS8))
+ .setPrivateKeyId(SA_PRIVATE_KEY_ID)
+ .setHttpTransportFactory(() -> transport)
+ .setScopes(SCOPES)
+ .build();
+
+ // Should not throw, but just fail-open (no header).
+ Map> headers = credentials.getRequestMetadata();
+ assertNull(headers.get(X_ALLOWED_LOCATIONS_HEADER_KEY));
+ }
+
+ @Test
+ public void regionalAccessBoundary_cooldownDoublingAndRefresh()
+ throws IOException, InterruptedException {
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider);
+ environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1");
+ MockTokenServerTransport transport = new MockTokenServerTransport();
+ transport.addServiceAccount(SA_CLIENT_EMAIL, ACCESS_TOKEN);
+ // Always fail lookup for now.
+ transport.addResponseErrorSequence(new IOException("Persistent Failure"));
+
+ ServiceAccountCredentials credentials =
+ ServiceAccountCredentials.newBuilder()
+ .setClientEmail(SA_CLIENT_EMAIL)
+ .setPrivateKey(OAuth2Utils.privateKeyFromPkcs8(SA_PRIVATE_KEY_PKCS8))
+ .setPrivateKeyId(SA_PRIVATE_KEY_ID)
+ .setHttpTransportFactory(() -> transport)
+ .setScopes(SCOPES)
+ .build();
+
+ TestClock testClock = new TestClock();
+ credentials.clock = testClock;
+ credentials.regionalAccessBoundaryManager = new RegionalAccessBoundaryManager(testClock, 100);
+
+ // First attempt: triggers lookup, fails, enters 15m cooldown.
+ credentials.getRequestMetadata();
+ waitForCooldownActive(credentials);
+ assertTrue(credentials.regionalAccessBoundaryManager.isCooldownActive());
+ assertEquals(
+ 15 * 60 * 1000L, credentials.regionalAccessBoundaryManager.getCurrentCooldownMillis());
+
+ // Second attempt (during cooldown): does not trigger lookup.
+ credentials.getRequestMetadata();
+ assertTrue(credentials.regionalAccessBoundaryManager.isCooldownActive());
+
+ // Fast-forward past 15m cooldown.
+ testClock.advanceTime(16 * 60 * 1000L);
+ assertFalse(credentials.regionalAccessBoundaryManager.isCooldownActive());
+
+ // Third attempt (cooldown expired): triggers lookup, fails again, cooldown should double.
+ credentials.getRequestMetadata();
+ waitForCooldownActive(credentials);
+ assertTrue(credentials.regionalAccessBoundaryManager.isCooldownActive());
+ assertEquals(
+ 30 * 60 * 1000L, credentials.regionalAccessBoundaryManager.getCurrentCooldownMillis());
+
+ // Fast-forward past 30m cooldown.
+ testClock.advanceTime(31 * 60 * 1000L);
+ assertFalse(credentials.regionalAccessBoundaryManager.isCooldownActive());
+
+ // Set successful response.
+ transport.setRegionalAccessBoundary(
+ new RegionalAccessBoundary("0x123", Collections.emptyList(), null));
+
+ // Fourth attempt: triggers lookup, succeeds, resets cooldown.
+ credentials.getRequestMetadata();
+ waitForRegionalAccessBoundary(credentials);
+ assertFalse(credentials.regionalAccessBoundaryManager.isCooldownActive());
+ assertEquals("0x123", credentials.getRegionalAccessBoundary().getEncodedLocations());
+ assertEquals(
+ 15 * 60 * 1000L, credentials.regionalAccessBoundaryManager.getCurrentCooldownMillis());
+ }
+
+ @Test
+ public void regionalAccessBoundary_shouldFailOpenWhenRefreshCannotBeStarted() throws IOException {
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider);
+ environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1");
+ // Use a simple AccessToken-based credential that won't try to refresh.
+ GoogleCredentials credentials = GoogleCredentials.create(new AccessToken("some-token", null));
+
+ // Should not throw, but just fail-open (no header).
+ Map> headers = credentials.getRequestMetadata();
+ assertNull(headers.get(X_ALLOWED_LOCATIONS_HEADER_KEY));
+ }
+
+ @Test
+ public void regionalAccessBoundary_deduplicationOfConcurrentRefreshes()
+ throws IOException, InterruptedException {
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider);
+ environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1");
+ MockTokenServerTransport transport = new MockTokenServerTransport();
+ transport.setRegionalAccessBoundary(
+ new RegionalAccessBoundary("valid", Collections.singletonList("us-central1"), null));
+ // Add delay to lookup to ensure threads overlap.
+ transport.setResponseDelayMillis(500);
+
+ GoogleCredentials credentials = createTestCredentials(transport);
+
+ // Fire multiple concurrent requests.
+ for (int i = 0; i < 10; i++) {
+ new Thread(
+ () -> {
+ try {
+ credentials.getRequestMetadata();
+ } catch (IOException e) {
+ }
+ })
+ .start();
+ }
+
+ waitForRegionalAccessBoundary(credentials);
+
+ // Only ONE request should have been made to the lookup endpoint.
+ assertEquals(1, transport.getRegionalAccessBoundaryRequestCount());
+ }
+
+ @Test
+ public void regionalAccessBoundary_shouldSkipRefreshForRegionalEndpoints() throws IOException {
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider);
+ environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1");
+ MockTokenServerTransport transport = new MockTokenServerTransport();
+ GoogleCredentials credentials = createTestCredentials(transport);
+
+ URI regionalUri = URI.create("https://storage.us-central1.rep.googleapis.com/v1/b/foo");
+ credentials.getRequestMetadata(regionalUri);
+
+ // Should not have triggered any lookup.
+ assertEquals(0, transport.getRegionalAccessBoundaryRequestCount());
+ }
+
+ @Test
+ public void getRequestMetadata_ignoresRabRefreshException() throws IOException {
+ GoogleCredentials credentials =
+ new GoogleCredentials() {
+ @Override
+ public AccessToken refreshAccessToken() throws IOException {
+ return new AccessToken("token", null);
+ }
+
+ @Override
+ void refreshRegionalAccessBoundaryIfExpired(
+ @Nullable URI uri, @Nullable AccessToken token) throws IOException {
+ throw new IOException("Simulated RAB failure");
+ }
+ };
+
+ // This should not throw the IOException from refreshRegionalAccessBoundaryIfExpired
+ Map> metadata =
+ credentials.getRequestMetadata(URI.create("https://foo.com"));
+ assertTrue(metadata.containsKey("Authorization"));
+ }
+
+ @Test
+ public void getRequestMetadataAsync_ignoresRabRefreshException() throws IOException {
+ GoogleCredentials credentials =
+ new GoogleCredentials() {
+ @Override
+ public AccessToken refreshAccessToken() throws IOException {
+ return new AccessToken("token", null);
+ }
+
+ @Override
+ void refreshRegionalAccessBoundaryIfExpired(
+ @Nullable URI uri, @Nullable AccessToken token) throws IOException {
+ throw new IOException("Simulated RAB failure");
+ }
+ };
+
+ java.util.concurrent.atomic.AtomicBoolean success =
+ new java.util.concurrent.atomic.AtomicBoolean(false);
+ credentials.getRequestMetadata(
+ URI.create("https://foo.com"),
+ Runnable::run,
+ new RequestMetadataCallback() {
+ @Override
+ public void onSuccess(Map> metadata) {
+ success.set(true);
+ }
+
+ @Override
+ public void onFailure(Throwable exception) {
+ fail("Should not have failed");
+ }
+ });
+
+ assertTrue(success.get());
+ }
+
+ private GoogleCredentials createTestCredentials(MockTokenServerTransport transport)
+ throws IOException {
+ transport.addServiceAccount(SA_CLIENT_EMAIL, ACCESS_TOKEN);
+ return new ServiceAccountCredentials.Builder()
+ .setClientEmail(SA_CLIENT_EMAIL)
+ .setPrivateKey(OAuth2Utils.privateKeyFromPkcs8(SA_PRIVATE_KEY_PKCS8))
+ .setPrivateKeyId(SA_PRIVATE_KEY_ID)
+ .setHttpTransportFactory(() -> transport)
+ .setScopes(SCOPES)
+ .build();
+ }
+
+ private void waitForRegionalAccessBoundary(GoogleCredentials credentials)
+ throws InterruptedException {
+ long deadline = System.currentTimeMillis() + 5000;
+ while (credentials.getRegionalAccessBoundary() == null
+ && System.currentTimeMillis() < deadline) {
+ Thread.sleep(100);
+ }
+ if (credentials.getRegionalAccessBoundary() == null) {
+ Assertions.fail("Timed out waiting for regional access boundary refresh");
+ }
+ }
+
+ private void waitForCooldownActive(GoogleCredentials credentials) throws InterruptedException {
+ long deadline = System.currentTimeMillis() + 5000;
+ while (!credentials.regionalAccessBoundaryManager.isCooldownActive()
+ && System.currentTimeMillis() < deadline) {
+ Thread.sleep(100);
+ }
+ if (!credentials.regionalAccessBoundaryManager.isCooldownActive()) {
+ Assertions.fail("Timed out waiting for cooldown to become active");
+ }
+ }
+
+ private static class TestClock implements Clock {
+ private final AtomicLong currentTime = new AtomicLong(System.currentTimeMillis());
+
+ @Override
+ public long currentTimeMillis() {
+ return currentTime.get();
+ }
+
+ public void advanceTime(long millis) {
+ currentTime.addAndGet(millis);
+ }
+ }
}
diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java
index 674d523e5090..399bf7246c9a 100644
--- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java
+++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java
@@ -37,9 +37,11 @@
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
import com.google.api.client.http.HttpTransport;
import com.google.api.client.json.GenericJson;
@@ -75,6 +77,14 @@ class IdentityPoolCredentialsTest extends BaseSerializationTest {
private static final IdentityPoolSubjectTokenSupplier testProvider =
(ExternalAccountSupplierContext context) -> "testSubjectToken";
+ @org.junit.jupiter.api.BeforeEach
+ void setUp() {}
+
+ @org.junit.jupiter.api.AfterEach
+ void tearDown() {
+ RegionalAccessBoundary.setEnvironmentProviderForTest(null);
+ }
+
@Test
void createdScoped_clonedCredentialWithAddedScopes() {
IdentityPoolCredentials credentials =
@@ -1299,4 +1309,49 @@ void setShouldThrowOnGetKeyStore(boolean shouldThrow) {
this.shouldThrowOnGetKeyStore = shouldThrow;
}
}
+
+ @Test
+ public void testRefresh_regionalAccessBoundarySuccess() throws IOException, InterruptedException {
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider);
+ environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1");
+
+ MockExternalAccountCredentialsTransportFactory transportFactory =
+ new MockExternalAccountCredentialsTransportFactory();
+ HttpTransportFactory testingHttpTransportFactory = transportFactory;
+
+ IdentityPoolCredentials credentials =
+ IdentityPoolCredentials.newBuilder()
+ .setSubjectTokenSupplier(testProvider)
+ .setHttpTransportFactory(testingHttpTransportFactory)
+ .setAudience(
+ "//iam.googleapis.com/projects/12345/locations/global/workloadIdentityPools/pool/providers/provider")
+ .setSubjectTokenType("subjectTokenType")
+ .setTokenUrl(STS_URL)
+ .build();
+
+ // First call: initiates async refresh.
+ Map> headers = credentials.getRequestMetadata();
+ assertNull(headers.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY));
+
+ waitForRegionalAccessBoundary(credentials);
+
+ // Second call: should have header.
+ headers = credentials.getRequestMetadata();
+ assertEquals(
+ headers.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY),
+ Arrays.asList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION));
+ }
+
+ private void waitForRegionalAccessBoundary(GoogleCredentials credentials)
+ throws InterruptedException {
+ long deadline = System.currentTimeMillis() + 5000;
+ while (credentials.getRegionalAccessBoundary() == null
+ && System.currentTimeMillis() < deadline) {
+ Thread.sleep(100);
+ }
+ if (credentials.getRegionalAccessBoundary() == null) {
+ fail("Timed out waiting for regional access boundary refresh");
+ }
+ }
}
diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java
index 044aa0ce6755..fc3c2e9c783e 100644
--- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java
+++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java
@@ -31,6 +31,7 @@
package com.google.auth.oauth2;
+import static com.google.auth.oauth2.RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -40,6 +41,7 @@
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@@ -69,6 +71,7 @@
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
+import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Map;
@@ -145,6 +148,11 @@ class ImpersonatedCredentialsTest extends BaseSerializationTest {
private static final String REFRESH_TOKEN = "dasdfasdffa4ffdfadgyjirasdfadsft";
public static final List DELEGATES =
Arrays.asList("sa1@developer.gserviceaccount.com", "sa2@developer.gserviceaccount.com");
+ public static final RegionalAccessBoundary REGIONAL_ACCESS_BOUNDARY =
+ new RegionalAccessBoundary(
+ TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION,
+ TestUtils.REGIONAL_ACCESS_BOUNDARY_LOCATIONS,
+ null);
private GoogleCredentials sourceCredentials;
private MockIAMCredentialsServiceTransportFactory mockTransportFactory;
@@ -155,6 +163,11 @@ void setup() throws IOException {
mockTransportFactory = new MockIAMCredentialsServiceTransportFactory();
}
+ @org.junit.After
+ public void tearDown() {
+ RegionalAccessBoundary.setEnvironmentProviderForTest(null);
+ }
+
static GoogleCredentials getSourceCredentials() throws IOException {
MockTokenServerTransportFactory transportFactory = new MockTokenServerTransportFactory();
PrivateKey privateKey = OAuth2Utils.privateKeyFromPkcs8(SA_PRIVATE_KEY_PKCS8);
@@ -168,6 +181,7 @@ static GoogleCredentials getSourceCredentials() throws IOException {
.setHttpTransportFactory(transportFactory)
.build();
transportFactory.transport.addServiceAccount(SA_CLIENT_EMAIL, ACCESS_TOKEN);
+ transportFactory.transport.setRegionalAccessBoundary(REGIONAL_ACCESS_BOUNDARY);
return sourceCredentials;
}
@@ -1260,6 +1274,56 @@ void refreshAccessToken_afterSerialization_success() throws IOException, ClassNo
assertEquals(ACCESS_TOKEN, token.getTokenValue());
}
+ @Test
+ void refresh_regionalAccessBoundarySuccess() throws IOException, InterruptedException {
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider);
+ environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1");
+ // Mock regional access boundary response
+ RegionalAccessBoundary regionalAccessBoundary = REGIONAL_ACCESS_BOUNDARY;
+
+ mockTransportFactory.getTransport().setRegionalAccessBoundary(regionalAccessBoundary);
+ mockTransportFactory.getTransport().setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL);
+ mockTransportFactory.getTransport().setAccessToken(ACCESS_TOKEN);
+ mockTransportFactory.getTransport().setExpireTime(getDefaultExpireTime());
+ mockTransportFactory
+ .getTransport()
+ .addStatusCodeAndMessage(HttpStatusCodes.STATUS_CODE_OK, "", true);
+
+ ImpersonatedCredentials targetCredentials =
+ ImpersonatedCredentials.create(
+ sourceCredentials,
+ IMPERSONATED_CLIENT_EMAIL,
+ null,
+ IMMUTABLE_SCOPES_LIST,
+ VALID_LIFETIME,
+ mockTransportFactory);
+
+ // First call: initiates async refresh.
+ Map> headers = targetCredentials.getRequestMetadata();
+ assertNull(headers.get(X_ALLOWED_LOCATIONS_HEADER_KEY));
+
+ waitForRegionalAccessBoundary(targetCredentials);
+
+ // Second call: should have header.
+ headers = targetCredentials.getRequestMetadata();
+ assertEquals(
+ headers.get(X_ALLOWED_LOCATIONS_HEADER_KEY),
+ Collections.singletonList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION));
+ }
+
+ private void waitForRegionalAccessBoundary(GoogleCredentials credentials)
+ throws InterruptedException {
+ long deadline = System.currentTimeMillis() + 5000;
+ while (credentials.getRegionalAccessBoundary() == null
+ && System.currentTimeMillis() < deadline) {
+ Thread.sleep(100);
+ }
+ if (credentials.getRegionalAccessBoundary() == null) {
+ fail("Timed out waiting for regional access boundary refresh");
+ }
+ }
+
public static String getDefaultExpireTime() {
return Instant.now().plusSeconds(VALID_LIFETIME).truncatedTo(ChronoUnit.SECONDS).toString();
}
diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java
index 524a312ce0c1..68e9c8edf393 100644
--- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java
+++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java
@@ -94,12 +94,21 @@ static void setup() {
LoggingUtils.setEnvironmentProvider(testEnvironmentProvider);
}
+ @org.junit.jupiter.api.BeforeEach
+ void setUp() {}
+
+ @org.junit.jupiter.api.AfterEach
+ void tearDown() {
+ RegionalAccessBoundary.setEnvironmentProviderForTest(null);
+ }
+
@Test
void userCredentials_getRequestMetadata_fromRefreshToken_hasAccessToken() throws IOException {
TestAppender testAppender = setupTestLogger(UserCredentials.class);
MockTokenServerTransportFactory transportFactory = new MockTokenServerTransportFactory();
transportFactory.transport.addClient(CLIENT_ID, CLIENT_SECRET);
transportFactory.transport.addRefreshToken(REFRESH_TOKEN, ACCESS_TOKEN);
+
UserCredentials userCredentials =
UserCredentials.newBuilder()
.setClientId(CLIENT_ID)
@@ -212,6 +221,7 @@ void serviceAccountCredentials_idTokenWithAudience_iamFlow_targetAudienceMatches
transportFactory.getTransport().setTargetPrincipal(CLIENT_EMAIL);
transportFactory.getTransport().setIdToken(DEFAULT_ID_TOKEN);
transportFactory.getTransport().addStatusCodeAndMessage(HttpStatusCodes.STATUS_CODE_OK, "");
+
ServiceAccountCredentials credentials =
createDefaultBuilder()
.setScopes(SCOPES)
diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java
index 7719b08d2e7b..c53eda5b2bd5 100644
--- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java
+++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java
@@ -50,6 +50,7 @@
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
+import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Queue;
@@ -68,6 +69,7 @@ public class MockExternalAccountCredentialsTransport extends MockHttpTransport {
private static final String AWS_IMDSV2_SESSION_TOKEN_URL = "https://169.254.169.254/imdsv2";
private static final String METADATA_SERVER_URL = "https://www.metadata.google.com";
private static final String STS_URL = "https://sts.googleapis.com/v1/token";
+ private static final String REGIONAL_ACCESS_BOUNDARY_URL_END = "/allowedLocations";
private static final String SUBJECT_TOKEN = "subjectToken";
private static final String TOKEN_TYPE = "Bearer";
@@ -92,6 +94,11 @@ public class MockExternalAccountCredentialsTransport extends MockHttpTransport {
private String expireTime;
private String metadataServerContentType;
private String stsContent;
+ private final Map regionalAccessBoundaries = new HashMap<>();
+
+ public void addRegionalAccessBoundary(String url, RegionalAccessBoundary regionalAccessBoundary) {
+ this.regionalAccessBoundaries.put(url, regionalAccessBoundary);
+ }
public void addResponseErrorSequence(IOException... errors) {
Collections.addAll(responseErrorSequence, errors);
@@ -196,6 +203,26 @@ public LowLevelHttpResponse execute() throws IOException {
}
if (url.contains(IAM_ENDPOINT)) {
+
+ if (url.endsWith(REGIONAL_ACCESS_BOUNDARY_URL_END)) {
+ RegionalAccessBoundary rab = regionalAccessBoundaries.get(url);
+ if (rab == null) {
+ rab =
+ new RegionalAccessBoundary(
+ TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION,
+ TestUtils.REGIONAL_ACCESS_BOUNDARY_LOCATIONS,
+ null);
+ }
+ GenericJson responseJson = new GenericJson();
+ responseJson.setFactory(OAuth2Utils.JSON_FACTORY);
+ responseJson.put("encodedLocations", rab.getEncodedLocations());
+ responseJson.put("locations", rab.getLocations());
+ String content = responseJson.toPrettyString();
+ return new MockLowLevelHttpResponse()
+ .setContentType(Json.MEDIA_TYPE)
+ .setContent(content);
+ }
+
GenericJson query =
OAuth2Utils.JSON_FACTORY
.createJsonParser(getContentAsString())
@@ -220,7 +247,9 @@ public LowLevelHttpResponse execute() throws IOException {
}
};
- this.requests.add(request);
+ if (url == null || !url.contains("allowedLocations")) {
+ this.requests.add(request);
+ }
return request;
}
diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockIAMCredentialsServiceTransport.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockIAMCredentialsServiceTransport.java
index cbd57d115afe..5346f4fdba3d 100644
--- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockIAMCredentialsServiceTransport.java
+++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockIAMCredentialsServiceTransport.java
@@ -80,6 +80,8 @@ public ServerResponse(int statusCode, String response, boolean repeatServerRespo
private String universeDomain;
+ private RegionalAccessBoundary regionalAccessBoundary;
+
private MockLowLevelHttpRequest request;
MockIAMCredentialsServiceTransport(String universeDomain) {
@@ -132,6 +134,10 @@ public void setAccessTokenEndpoint(String accessTokenEndpoint) {
this.iamAccessTokenEndpoint = accessTokenEndpoint;
}
+ public void setRegionalAccessBoundary(RegionalAccessBoundary regionalAccessBoundary) {
+ this.regionalAccessBoundary = regionalAccessBoundary;
+ }
+
public MockLowLevelHttpRequest getRequest() {
return request;
}
@@ -221,6 +227,25 @@ public LowLevelHttpResponse execute() throws IOException {
.setContent(tokenContent);
}
};
+ } else if (url.endsWith("/allowedLocations")) {
+ request =
+ new MockLowLevelHttpRequest(url) {
+ @Override
+ public LowLevelHttpResponse execute() throws IOException {
+ if (regionalAccessBoundary == null) {
+ return new MockLowLevelHttpResponse().setStatusCode(404);
+ }
+ GenericJson responseJson = new GenericJson();
+ responseJson.setFactory(OAuth2Utils.JSON_FACTORY);
+ responseJson.put("encodedLocations", regionalAccessBoundary.getEncodedLocations());
+ responseJson.put("locations", regionalAccessBoundary.getLocations());
+ String content = responseJson.toPrettyString();
+ return new MockLowLevelHttpResponse()
+ .setContentType(Json.MEDIA_TYPE)
+ .setContent(content);
+ }
+ };
+ return request;
} else {
return super.buildRequest(method, url);
}
diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockMetadataServerTransport.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockMetadataServerTransport.java
index 1b218b73ef45..92b24d60fd53 100644
--- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockMetadataServerTransport.java
+++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockMetadataServerTransport.java
@@ -72,6 +72,9 @@ public class MockMetadataServerTransport extends MockHttpTransport {
private boolean emptyContent;
private MockLowLevelHttpRequest request;
+ private RegionalAccessBoundary regionalAccessBoundary;
+ private IOException lookupError;
+
public MockMetadataServerTransport() {}
public MockMetadataServerTransport(String accessToken) {
@@ -119,6 +122,14 @@ public void setEmptyContent(boolean emptyContent) {
this.emptyContent = emptyContent;
}
+ public void setRegionalAccessBoundary(RegionalAccessBoundary regionalAccessBoundary) {
+ this.regionalAccessBoundary = regionalAccessBoundary;
+ }
+
+ public void setLookupError(IOException lookupError) {
+ this.lookupError = lookupError;
+ }
+
public MockLowLevelHttpRequest getRequest() {
return request;
}
@@ -139,6 +150,8 @@ public LowLevelHttpRequest buildRequest(String method, String url) throws IOExce
return this.request;
} else if (isMtlsConfigRequestUrl(url)) {
return getMockRequestForMtlsConfig(url);
+ } else if (isIamLookupUrl(url)) {
+ return getMockRequestForRegionalAccessBoundaryLookup(url);
}
this.request =
new MockLowLevelHttpRequest(url) {
@@ -213,7 +226,7 @@ public LowLevelHttpResponse execute() throws IOException {
refreshContents.put(
"access_token", scopesToAccessToken.get("[" + urlParsed.get(1) + "]"));
}
- refreshContents.put("expires_in", 3600000);
+ refreshContents.put("expires_in", 3600);
refreshContents.put("token_type", "Bearer");
String refreshText = refreshContents.toPrettyString();
@@ -346,4 +359,32 @@ protected boolean isMtlsConfigRequestUrl(String url) {
ComputeEngineCredentials.getMetadataServerUrl()
+ SecureSessionAgent.S2A_CONFIG_ENDPOINT_POSTFIX);
}
+
+ private MockLowLevelHttpRequest getMockRequestForRegionalAccessBoundaryLookup(String url) {
+ return new MockLowLevelHttpRequest(url) {
+ @Override
+ public LowLevelHttpResponse execute() throws IOException {
+ if (lookupError != null) {
+ throw lookupError;
+ }
+ if (regionalAccessBoundary == null) {
+ return new MockLowLevelHttpResponse().setStatusCode(404);
+ }
+ GenericJson responseJson = new GenericJson();
+ responseJson.setFactory(OAuth2Utils.JSON_FACTORY);
+ responseJson.put("encodedLocations", regionalAccessBoundary.getEncodedLocations());
+ responseJson.put("locations", regionalAccessBoundary.getLocations());
+ String content = responseJson.toPrettyString();
+ return new MockLowLevelHttpResponse().setContentType(Json.MEDIA_TYPE).setContent(content);
+ }
+ };
+ }
+
+ protected boolean isIamLookupUrl(String url) {
+ // Mocking call to the /allowedLocations endpoint for regional access boundary refresh.
+ // For testing convenience, this mock transport handles
+ // the /allowedLocations endpoint. The actual server for this endpoint
+ // will be the IAM Credentials API.
+ return url.endsWith("/allowedLocations");
+ }
}
diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockStsTransport.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockStsTransport.java
index cdb0a068e2d0..24566a0e5ca3 100644
--- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockStsTransport.java
+++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockStsTransport.java
@@ -62,6 +62,8 @@ public final class MockStsTransport extends MockHttpTransport {
private static final String ISSUED_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token";
private static final String VALID_STS_PATTERN =
"https:\\/\\/sts.[a-z-_\\.]+\\/v1\\/(token|oauthtoken)";
+ private static final String VALID_REGIONAL_ACCESS_BOUNDARY_PATTERN =
+ "https:\\/\\/iam.[a-z-_\\.]+\\/v1\\/.*\\/allowedLocations";
private static final String ACCESS_TOKEN = "accessToken";
private static final String TOKEN_TYPE = "Bearer";
private static final Long EXPIRES_IN = 3600L;
@@ -99,6 +101,23 @@ public LowLevelHttpRequest buildRequest(final String method, final String url) {
new MockLowLevelHttpRequest(url) {
@Override
public LowLevelHttpResponse execute() throws IOException {
+ // Mocking call to refresh regional access boundaries.
+ // The lookup endpoint is located in the IAM server.
+ Matcher regionalAccessBoundaryMatcher =
+ Pattern.compile(VALID_REGIONAL_ACCESS_BOUNDARY_PATTERN).matcher(url);
+ if (regionalAccessBoundaryMatcher.matches()) {
+ // Mocking call to the /allowedLocations endpoint for regional access boundary
+ // refresh.
+ // For testing convenience, this mock transport handles
+ // the /allowedLocations endpoint.
+ GenericJson response = new GenericJson();
+ response.put("locations", TestUtils.REGIONAL_ACCESS_BOUNDARY_LOCATIONS);
+ response.put("encodedLocations", TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION);
+ return new MockLowLevelHttpResponse()
+ .setContentType(Json.MEDIA_TYPE)
+ .setContent(OAuth2Utils.JSON_FACTORY.toString(response));
+ }
+
// Environment version is prefixed by "aws". e.g. "aws1".
Matcher matcher = Pattern.compile(VALID_STS_PATTERN).matcher(url);
if (!matcher.matches()) {
diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockTokenServerTransport.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockTokenServerTransport.java
index 5a6cd2e5d1a8..62f31e256d24 100644
--- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockTokenServerTransport.java
+++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockTokenServerTransport.java
@@ -76,6 +76,21 @@ public class MockTokenServerTransport extends MockHttpTransport {
private int expiresInSeconds = 3600;
private MockLowLevelHttpRequest request;
private PKCEProvider pkceProvider;
+ private RegionalAccessBoundary regionalAccessBoundary;
+ private int regionalAccessBoundaryRequestCount = 0;
+ private int responseDelayMillis = 0;
+
+ public void setRegionalAccessBoundary(RegionalAccessBoundary regionalAccessBoundary) {
+ this.regionalAccessBoundary = regionalAccessBoundary;
+ }
+
+ public int getRegionalAccessBoundaryRequestCount() {
+ return regionalAccessBoundaryRequestCount;
+ }
+
+ public void setResponseDelayMillis(int responseDelayMillis) {
+ this.responseDelayMillis = responseDelayMillis;
+ }
public MockTokenServerTransport() {}
@@ -171,6 +186,40 @@ public LowLevelHttpRequest buildRequest(String method, String url) throws IOExce
int questionMarkPos = url.indexOf('?');
final String urlWithoutQuery = (questionMarkPos > 0) ? url.substring(0, questionMarkPos) : url;
+ if (urlWithoutQuery.endsWith("/allowedLocations")) {
+ // Mocking call to the /allowedLocations endpoint for regional access boundary refresh.
+ // For testing convenience, this mock transport handles
+ // the /allowedLocations endpoint. The actual server for this endpoint
+ // will be the IAM Credentials API.
+ request =
+ new MockLowLevelHttpRequest(url) {
+ @Override
+ public LowLevelHttpResponse execute() throws IOException {
+ regionalAccessBoundaryRequestCount++;
+ if (responseDelayMillis > 0) {
+ try {
+ Thread.sleep(responseDelayMillis);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ RegionalAccessBoundary rab = regionalAccessBoundary;
+ if (rab == null) {
+ return new MockLowLevelHttpResponse().setStatusCode(404);
+ }
+ GenericJson responseJson = new GenericJson();
+ responseJson.setFactory(JSON_FACTORY);
+ responseJson.put("encodedLocations", rab.getEncodedLocations());
+ responseJson.put("locations", rab.getLocations());
+ String content = responseJson.toPrettyString();
+ return new MockLowLevelHttpResponse()
+ .setContentType(Json.MEDIA_TYPE)
+ .setContent(content);
+ }
+ };
+ return request;
+ }
+
if (!responseSequence.isEmpty()) {
request =
new MockLowLevelHttpRequest(url) {
diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java
index 094b21f9dbb2..adc945dd72ea 100644
--- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java
+++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java
@@ -36,6 +36,7 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.fail;
import com.google.api.client.http.HttpTransport;
import com.google.api.client.json.GenericJson;
@@ -56,6 +57,12 @@
/** Tests for {@link PluggableAuthCredentials}. */
class PluggableAuthCredentialsTest extends BaseSerializationTest {
+
+ @org.junit.jupiter.api.AfterEach
+ void tearDown() {
+ RegionalAccessBoundary.setEnvironmentProviderForTest(null);
+ }
+
// The default timeout for waiting for the executable to finish (30 seconds).
private static final int DEFAULT_EXECUTABLE_TIMEOUT_MS = 30 * 1000;
// The minimum timeout for waiting for the executable to finish (5 seconds).
@@ -601,6 +608,52 @@ void serialize() {
assertThrows(NotSerializableException.class, () -> serializeAndDeserialize(testCredentials));
}
+ @Test
+ public void testRefresh_regionalAccessBoundarySuccess() throws IOException, InterruptedException {
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider);
+ environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1");
+
+ MockExternalAccountCredentialsTransportFactory transportFactory =
+ new MockExternalAccountCredentialsTransportFactory();
+ transportFactory.transport.setExpireTime(TestUtils.getDefaultExpireTime());
+
+ PluggableAuthCredentials credentials =
+ PluggableAuthCredentials.newBuilder()
+ .setHttpTransportFactory(transportFactory)
+ .setAudience(
+ "//iam.googleapis.com/projects/12345/locations/global/workloadIdentityPools/pool/providers/provider")
+ .setSubjectTokenType("subjectTokenType")
+ .setTokenUrl(transportFactory.transport.getStsUrl())
+ .setCredentialSource(buildCredentialSource())
+ .setExecutableHandler(options -> "pluggableAuthToken")
+ .build();
+
+ // First call: initiates async refresh.
+ Map> headers = credentials.getRequestMetadata();
+ assertNull(headers.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY));
+
+ waitForRegionalAccessBoundary(credentials);
+
+ // Second call: should have header.
+ headers = credentials.getRequestMetadata();
+ assertEquals(
+ headers.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY),
+ Arrays.asList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION));
+ }
+
+ private void waitForRegionalAccessBoundary(GoogleCredentials credentials)
+ throws InterruptedException {
+ long deadline = System.currentTimeMillis() + 5000;
+ while (credentials.getRegionalAccessBoundary() == null
+ && System.currentTimeMillis() < deadline) {
+ Thread.sleep(100);
+ }
+ if (credentials.getRegionalAccessBoundary() == null) {
+ fail("Timed out waiting for regional access boundary refresh");
+ }
+ }
+
private static PluggableAuthCredentialSource buildCredentialSource() {
return buildCredentialSource("command", null, null);
}
diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/RegionalAccessBoundaryTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/RegionalAccessBoundaryTest.java
new file mode 100644
index 000000000000..7c7ccd690ce2
--- /dev/null
+++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/RegionalAccessBoundaryTest.java
@@ -0,0 +1,220 @@
+/*
+ * Copyright 2026, 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.oauth2;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import com.google.api.client.testing.http.MockHttpTransport;
+import com.google.api.client.testing.http.MockLowLevelHttpResponse;
+import com.google.api.client.util.Clock;
+import com.google.auth.http.HttpTransportFactory;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.util.Collections;
+import java.util.concurrent.atomic.AtomicLong;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class RegionalAccessBoundaryTest {
+
+ private static final long TTL = RegionalAccessBoundary.TTL_MILLIS;
+ private static final long REFRESH_THRESHOLD = RegionalAccessBoundary.REFRESH_THRESHOLD_MILLIS;
+
+ private TestClock testClock;
+
+ @Before
+ public void setUp() {
+ testClock = new TestClock();
+ }
+
+ @After
+ public void tearDown() {}
+
+ @Test
+ public void testIsExpired() {
+ long now = testClock.currentTimeMillis();
+ RegionalAccessBoundary rab =
+ new RegionalAccessBoundary("encoded", Collections.singletonList("loc"), now, testClock);
+
+ assertFalse(rab.isExpired());
+
+ testClock.set(now + TTL - 1);
+ assertFalse(rab.isExpired());
+
+ testClock.set(now + TTL + 1);
+ assertTrue(rab.isExpired());
+ }
+
+ @Test
+ public void testShouldRefresh() {
+ long now = testClock.currentTimeMillis();
+ RegionalAccessBoundary rab =
+ new RegionalAccessBoundary("encoded", Collections.singletonList("loc"), now, testClock);
+
+ // Initial state: fresh
+ assertFalse(rab.shouldRefresh());
+
+ // Just before threshold
+ testClock.set(now + TTL - REFRESH_THRESHOLD - 1);
+ assertFalse(rab.shouldRefresh());
+
+ // At threshold
+ testClock.set(now + TTL - REFRESH_THRESHOLD + 1);
+ assertTrue(rab.shouldRefresh());
+
+ // Still not expired
+ assertFalse(rab.isExpired());
+ }
+
+ @Test
+ public void testSerialization() throws Exception {
+ long now = testClock.currentTimeMillis();
+ RegionalAccessBoundary rab =
+ new RegionalAccessBoundary("encoded", Collections.singletonList("loc"), now, testClock);
+
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ ObjectOutputStream oos = new ObjectOutputStream(baos);
+ oos.writeObject(rab);
+ oos.close();
+
+ ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
+ ObjectInputStream ois = new ObjectInputStream(bais);
+ RegionalAccessBoundary deserializedRab = (RegionalAccessBoundary) ois.readObject();
+ ois.close();
+
+ assertEquals("encoded", deserializedRab.getEncodedLocations());
+ assertEquals(1, deserializedRab.getLocations().size());
+ assertEquals("loc", deserializedRab.getLocations().get(0));
+ // The transient clock field should be restored to Clock.SYSTEM upon deserialization,
+ // thereby avoiding a NullPointerException when checking expiration.
+ assertFalse(deserializedRab.isExpired());
+ }
+
+ @Test
+ public void testManagerTriggersRefreshInGracePeriod() throws InterruptedException {
+ final String url =
+ "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/default:allowedLocations";
+ final AccessToken token =
+ new AccessToken(
+ "token", new java.util.Date(System.currentTimeMillis() + 10 * 3600000L)); //
+
+ // Mock transport to return a new RAB
+ final String newEncoded = "new-encoded";
+ MockHttpTransport transport =
+ new MockHttpTransport.Builder()
+ .setLowLevelHttpResponse(
+ new MockLowLevelHttpResponse()
+ .setContentType("application/json")
+ .setContent(
+ "{\"encodedLocations\": \""
+ + newEncoded
+ + "\", \"locations\": [\"new-loc\"]}"))
+ .build();
+ HttpTransportFactory transportFactory = () -> transport;
+ RegionalAccessBoundaryProvider provider = () -> url;
+
+ RegionalAccessBoundaryManager manager = new RegionalAccessBoundaryManager(testClock);
+
+ // 1. Let's first get a RAB into the cache
+ manager.triggerAsyncRefresh(transportFactory, provider, token);
+
+ // Wait for it to be cached
+ int retries = 0;
+ while (manager.getCachedRAB() == null && retries < 50) {
+ Thread.sleep(50);
+ retries++;
+ }
+ assertEquals(newEncoded, manager.getCachedRAB().getEncodedLocations());
+
+ // 2. Advance clock to grace period
+ testClock.set(testClock.currentTimeMillis() + TTL - REFRESH_THRESHOLD + 1000);
+
+ assertTrue(manager.getCachedRAB().shouldRefresh());
+ assertFalse(manager.getCachedRAB().isExpired());
+
+ // 3. Prepare mock for SECOND refresh
+ final String newerEncoded = "newer-encoded";
+ MockHttpTransport transport2 =
+ new MockHttpTransport.Builder()
+ .setLowLevelHttpResponse(
+ new MockLowLevelHttpResponse()
+ .setContentType("application/json")
+ .setContent(
+ "{\"encodedLocations\": \""
+ + newerEncoded
+ + "\", \"locations\": [\"newer-loc\"]}"))
+ .build();
+ HttpTransportFactory transportFactory2 = () -> transport2;
+
+ // 4. Trigger refresh - should start because we are in grace period
+ manager.triggerAsyncRefresh(transportFactory2, provider, token);
+
+ // 5. Wait for background refresh to complete
+ // We expect the cached RAB to eventually change to newerEncoded
+ retries = 0;
+ RegionalAccessBoundary resultRab = null;
+ while (retries < 100) {
+ resultRab = manager.getCachedRAB();
+ if (resultRab != null && newerEncoded.equals(resultRab.getEncodedLocations())) {
+ break;
+ }
+ Thread.sleep(50);
+ retries++;
+ }
+
+ assertTrue(
+ "Refresh should have completed and updated the cache within 5 seconds",
+ resultRab != null && newerEncoded.equals(resultRab.getEncodedLocations()));
+ assertEquals(newerEncoded, resultRab.getEncodedLocations());
+ }
+
+ private static class TestClock implements Clock {
+ private final AtomicLong currentTime = new AtomicLong(System.currentTimeMillis());
+
+ @Override
+ public long currentTimeMillis() {
+ return currentTime.get();
+ }
+
+ public void set(long millis) {
+ currentTime.set(millis);
+ }
+ }
+}
diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ServiceAccountCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ServiceAccountCredentialsTest.java
index ed26a0af3c6f..1ac38f957c6e 100644
--- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ServiceAccountCredentialsTest.java
+++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ServiceAccountCredentialsTest.java
@@ -31,6 +31,7 @@
package com.google.auth.oauth2;
+import static com.google.auth.oauth2.RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -155,6 +156,14 @@ static ServiceAccountCredentials.Builder createDefaultBuilder() throws IOExcepti
return createDefaultBuilderWithKey(privateKey);
}
+ @org.junit.jupiter.api.BeforeEach
+ void setUp() {}
+
+ @org.junit.jupiter.api.AfterEach
+ void tearDown() {
+ RegionalAccessBoundary.setEnvironmentProviderForTest(null);
+ }
+
@Test
void setLifetime() throws IOException {
ServiceAccountCredentials.Builder builder = createDefaultBuilder();
@@ -1762,7 +1771,101 @@ void createScopes_existingAccessTokenInvalidated() throws IOException {
assertNull(newAccessToken);
}
- private void verifyJwtAccess(Map> metadata, String expectedScopeClaim)
+ @Test
+ public void refresh_regionalAccessBoundarySuccess() throws IOException, InterruptedException {
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider);
+ environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1");
+ // Mock regional access boundary response
+ RegionalAccessBoundary regionalAccessBoundary =
+ new RegionalAccessBoundary(
+ TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION,
+ TestUtils.REGIONAL_ACCESS_BOUNDARY_LOCATIONS,
+ null);
+
+ MockTokenServerTransport transport = new MockTokenServerTransport();
+ transport.addServiceAccount(CLIENT_EMAIL, "test-access-token");
+ transport.setRegionalAccessBoundary(regionalAccessBoundary);
+
+ ServiceAccountCredentials credentials =
+ ServiceAccountCredentials.newBuilder()
+ .setClientEmail(CLIENT_EMAIL)
+ .setPrivateKey(
+ OAuth2Utils.privateKeyFromPkcs8(ServiceAccountCredentialsTest.PRIVATE_KEY_PKCS8))
+ .setPrivateKeyId("test-key-id")
+ .setHttpTransportFactory(() -> transport)
+ .setScopes(SCOPES)
+ .build();
+
+ // First call: initiates async refresh.
+ Map> headers = credentials.getRequestMetadata();
+ assertNull(headers.get(X_ALLOWED_LOCATIONS_HEADER_KEY));
+
+ waitForRegionalAccessBoundary(credentials);
+
+ // Second call: should have header.
+ headers = credentials.getRequestMetadata();
+ assertEquals(
+ headers.get(X_ALLOWED_LOCATIONS_HEADER_KEY),
+ Arrays.asList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION));
+ }
+
+ @Test
+ public void refresh_regionalAccessBoundary_selfSignedJWT()
+ throws IOException, InterruptedException {
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider);
+ environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1");
+ RegionalAccessBoundary regionalAccessBoundary =
+ new RegionalAccessBoundary(
+ TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION,
+ TestUtils.REGIONAL_ACCESS_BOUNDARY_LOCATIONS,
+ null);
+
+ MockTokenServerTransport transport = new MockTokenServerTransport();
+ transport.setRegionalAccessBoundary(regionalAccessBoundary);
+
+ ServiceAccountCredentials credentials =
+ ServiceAccountCredentials.newBuilder()
+ .setClientEmail(CLIENT_EMAIL)
+ .setPrivateKey(
+ OAuth2Utils.privateKeyFromPkcs8(ServiceAccountCredentialsTest.PRIVATE_KEY_PKCS8))
+ .setPrivateKeyId("test-key-id")
+ .setHttpTransportFactory(() -> transport)
+ .setUseJwtAccessWithScope(true)
+ .setScopes(SCOPES)
+ .build();
+
+ // First call: initiates async refresh using the SSJWT as the token.
+ Map> headers = credentials.getRequestMetadata();
+ assertNull(headers.get(X_ALLOWED_LOCATIONS_HEADER_KEY));
+
+ waitForRegionalAccessBoundary(credentials);
+
+ // Second call: should have header.
+ headers = credentials.getRequestMetadata();
+ assertEquals(
+ headers.get(X_ALLOWED_LOCATIONS_HEADER_KEY),
+ Arrays.asList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION));
+
+ assertEquals(
+ TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION,
+ credentials.getRegionalAccessBoundary().getEncodedLocations());
+ }
+
+ private void waitForRegionalAccessBoundary(GoogleCredentials credentials)
+ throws InterruptedException {
+ long deadline = System.currentTimeMillis() + 5000;
+ while (credentials.getRegionalAccessBoundary() == null
+ && System.currentTimeMillis() < deadline) {
+ Thread.sleep(100);
+ }
+ if (credentials.getRegionalAccessBoundary() == null) {
+ fail("Timed out waiting for regional access boundary refresh");
+ }
+ }
+
+ void verifyJwtAccess(Map> metadata, String expectedScopeClaim)
throws IOException {
assertNotNull(metadata);
List authorizations = metadata.get(AuthHttpConstants.AUTHORIZATION);
diff --git a/google-auth-library-java/samples/snippets/pom.xml b/google-auth-library-java/samples/snippets/pom.xml
index 5b721797222a..941191a80ee0 100644
--- a/google-auth-library-java/samples/snippets/pom.xml
+++ b/google-auth-library-java/samples/snippets/pom.xml
@@ -80,4 +80,3 @@
-
diff --git a/java-dataplex/grpc-google-cloud-dataplex-v1/src/main/java/com/google/cloud/dataplex/v1/ContentServiceGrpc.java b/java-dataplex/grpc-google-cloud-dataplex-v1/src/main/java/com/google/cloud/dataplex/v1/ContentServiceGrpc.java
index a087ae2f0103..7d657f076cf9 100644
--- a/java-dataplex/grpc-google-cloud-dataplex-v1/src/main/java/com/google/cloud/dataplex/v1/ContentServiceGrpc.java
+++ b/java-dataplex/grpc-google-cloud-dataplex-v1/src/main/java/com/google/cloud/dataplex/v1/ContentServiceGrpc.java
@@ -15,7 +15,6 @@
*/
package com.google.cloud.dataplex.v1;
-
/**
*
*