diff --git a/changes.diff b/changes.diff
new file mode 100644
index 000000000000..80f584832471
--- /dev/null
+++ b/changes.diff
@@ -0,0 +1,3373 @@
+diff --git a/.gitignore b/.gitignore
+index bdf3ed927..888ac8247 100644
+--- a/.gitignore
++++ b/.gitignore
+@@ -16,4 +16,8 @@ target/
+ .vscode/
+
+ # MacOS
+-.DS_Store
+\ No newline at end of file
++.DS_Store
++
++# Conductor and Gemini
++conductor/
++Gemini/
+\ No newline at end of file
+diff --git a/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java
+index 5faf29fdb..6739d13f1 100644
+--- a/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java
++++ b/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;
+@@ -82,7 +83,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.";
+@@ -385,7 +386,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));
+ }
+
+@@ -690,6 +690,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() {
+@@ -703,6 +708,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/oauth2_http/java/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentials.java
+index e67ddb89d..bc812984d 100644
+--- a/oauth2_http/java/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentials.java
++++ b/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 static com.google.common.base.Preconditions.checkNotNull;
+
+ import com.google.api.client.http.GenericUrl;
+@@ -44,6 +46,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;
+@@ -55,6 +58,7 @@
+ import java.util.Date;
+ import java.util.Map;
+ import java.util.Objects;
++import java.util.regex.Matcher;
+ import javax.annotation.Nullable;
+
+ /**
+@@ -75,12 +79,12 @@
+ * }
+ *
+ */
+-public class ExternalAccountAuthorizedUserCredentials extends GoogleCredentials {
++public class ExternalAccountAuthorizedUserCredentials extends GoogleCredentials
++ implements RegionalAccessBoundaryProvider {
+
+ private static final String PARSE_ERROR_PREFIX = "Error parsing token refresh response. ";
+
+ private static final long serialVersionUID = -2181779590486283287L;
+-
+ private final String transportFactoryClassName;
+ private final String audience;
+ private final String tokenUrl;
+@@ -216,6 +220,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/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java
+index c4268d167..12e387357 100644
+--- a/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java
++++ b/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java
+@@ -31,12 +31,15 @@
+
+ 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;
+ import com.google.api.client.json.GenericJson;
+ import com.google.api.client.json.JsonObjectParser;
+ import com.google.api.client.util.Data;
++import com.google.api.core.InternalApi;
+ import com.google.auth.RequestMetadataCallback;
+ import com.google.auth.http.HttpTransportFactory;
+ import com.google.common.base.MoreObjects;
+@@ -55,6 +58,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 +68,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;
+
+@@ -570,6 +575,11 @@ protected AccessToken exchangeExternalCredentialForAccessToken(
+ */
+ public abstract String retrieveSubjectToken() throws IOException;
+
++ @Override
++ HttpTransportFactory getTransportFactory() {
++ return transportFactory;
++ }
++
+ public String getAudience() {
+ return audience;
+ }
+@@ -613,6 +623,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/oauth2_http/java/com/google/auth/oauth2/GoogleCredentials.java b/oauth2_http/java/com/google/auth/oauth2/GoogleCredentials.java
+index fbfd147f2..cbcc5801f 100644
+--- a/oauth2_http/java/com/google/auth/oauth2/GoogleCredentials.java
++++ b/oauth2_http/java/com/google/auth/oauth2/GoogleCredentials.java
+@@ -37,6 +37,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;
+@@ -47,6 +49,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;
+@@ -107,6 +111,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 =
+@@ -331,6 +338,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.
+ *
+@@ -374,22 +516,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. */
+@@ -500,6 +679,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();
+ }
+@@ -635,6 +819,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/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java
+index 18d7cd0f8..a5311eed1 100644
+--- a/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java
++++ b/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java
+@@ -43,6 +43,7 @@
+ import com.google.api.client.http.json.JsonHttpContent;
+ 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.ServiceAccountSigner;
+ import com.google.auth.http.HttpCredentialsAdapter;
+@@ -95,7 +96,7 @@
+ *
+ */
+ public class ImpersonatedCredentials extends GoogleCredentials
+- implements ServiceAccountSigner, IdTokenProvider {
++ implements ServiceAccountSigner, IdTokenProvider, RegionalAccessBoundaryProvider {
+
+ private static final long serialVersionUID = -2133257318957488431L;
+ private static final String RFC3339 = "yyyy-MM-dd'T'HH:mm:ssX";
+@@ -325,10 +326,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/oauth2_http/java/com/google/auth/oauth2/OAuth2Credentials.java b/oauth2_http/java/com/google/auth/oauth2/OAuth2Credentials.java
+index dfeb5966a..0835f6dd7 100644
+--- a/oauth2_http/java/com/google/auth/oauth2/OAuth2Credentials.java
++++ b/oauth2_http/java/com/google/auth/oauth2/OAuth2Credentials.java
+@@ -59,7 +59,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;
+@@ -164,6 +163,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) {
+@@ -175,8 +184,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 {
+@@ -264,11 +279,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));
+@@ -373,7 +385,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/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java b/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java
+index 21278e8b6..425023adb 100644
+--- a/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java
++++ b/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.
+@@ -117,6 +118,22 @@ public class OAuth2Utils {
+ 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/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundary.java b/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundary.java
+new file mode 100644
+index 000000000..b2a3f4294
+--- /dev/null
++++ b/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/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundaryManager.java b/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundaryManager.java
+new file mode 100644
+index 000000000..eeea75bc2
+--- /dev/null
++++ b/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/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundaryProvider.java b/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundaryProvider.java
+new file mode 100644
+index 000000000..e34bbafea
+--- /dev/null
++++ b/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/oauth2_http/java/com/google/auth/oauth2/ServiceAccountCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ServiceAccountCredentials.java
+index 5628a5add..9a2c7e65e 100644
+--- a/oauth2_http/java/com/google/auth/oauth2/ServiceAccountCredentials.java
++++ b/oauth2_http/java/com/google/auth/oauth2/ServiceAccountCredentials.java
+@@ -51,6 +51,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;
+@@ -89,7 +90,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";
+@@ -823,11 +824,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();
+@@ -1023,6 +1036,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) {
+@@ -1045,7 +1069,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) {
+@@ -1114,6 +1147,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/oauth2_http/javatests/com/google/auth/TestUtils.java b/oauth2_http/javatests/com/google/auth/TestUtils.java
+index 99d601da8..58ef558a9 100644
+--- a/oauth2_http/javatests/com/google/auth/TestUtils.java
++++ b/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();
+
+@@ -147,7 +152,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/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java
+index e8b401063..2588498b9 100644
+--- a/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java
++++ b/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java
+@@ -64,6 +64,14 @@
+ @RunWith(JUnit4.class)
+ public class AwsCredentialsTest extends BaseSerializationTest {
+
++ @org.junit.Before
++ public void setUp() {}
++
++ @org.junit.After
++ public 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";
+@@ -1399,4 +1407,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) {
++ fail("Timed out waiting for regional access boundary refresh");
++ }
++ }
+ }
+diff --git a/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java
+index 4b1f9c1ca..445c82e15 100644
+--- a/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java
++++ b/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.Assert.assertArrayEquals;
+ import static org.junit.Assert.assertEquals;
+ import static org.junit.Assert.assertFalse;
+@@ -78,6 +79,14 @@
+ @RunWith(JUnit4.class)
+ public class ComputeEngineCredentialsTest extends BaseSerializationTest {
+
++ @org.junit.Before
++ public void setUp() {}
++
++ @org.junit.After
++ public 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 =
+@@ -396,7 +405,6 @@ public 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"));
+ }
+@@ -1146,6 +1154,50 @@ public void idTokenWithAudience_503StatusCode() {
+ GoogleAuthException.class, () -> credentials.idTokenWithAudience("Audience", null));
+ }
+
++ @Test
++ public 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/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentialsTest.java
+index 740cabba5..f44567c83 100644
+--- a/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentialsTest.java
++++ b/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;
+@@ -132,6 +131,11 @@ public void setup() {
+ transportFactory = new MockExternalAccountAuthorizedUserCredentialsTransportFactory();
+ }
+
++ @org.junit.After
++ public void tearDown() {
++ RegionalAccessBoundary.setEnvironmentProviderForTest(null);
++ }
++
+ @Test
+ public void builder_allFields() throws IOException {
+ ExternalAccountAuthorizedUserCredentials credentials =
+@@ -1217,26 +1221,45 @@ public void toString_expectedFormat() {
+ }
+
+ @Test
+- public void serialize() throws IOException, ClassNotFoundException {
++ public 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()
+- .setAudience(AUDIENCE)
+ .setClientId(CLIENT_ID)
+ .setClientSecret(CLIENT_SECRET)
+ .setRefreshToken(REFRESH_TOKEN)
+ .setTokenUrl(TOKEN_URL)
+- .setTokenInfoUrl(TOKEN_INFO_URL)
+- .setRevokeUrl(REVOKE_URL)
+- .setAccessToken(new AccessToken(ACCESS_TOKEN, /* expirationTime= */ null))
+- .setQuotaProjectId(QUOTA_PROJECT)
++ .setAudience(
++ "//iam.googleapis.com/locations/global/workforcePools/pool/providers/provider")
++ .setHttpTransportFactory(transportFactory)
+ .build();
+
+- ExternalAccountAuthorizedUserCredentials deserializedCredentials =
+- serializeAndDeserialize(credentials);
+- assertEquals(credentials, deserializedCredentials);
+- assertEquals(credentials.hashCode(), deserializedCredentials.hashCode());
+- assertEquals(credentials.toString(), deserializedCredentials.toString());
+- assertSame(deserializedCredentials.clock, Clock.SYSTEM);
++ // 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");
++ }
+ }
+
+ static GenericJson buildJsonCredentials() {
+diff --git a/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java
+index 32009f755..c48af6233 100644
+--- a/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java
++++ b/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java
+@@ -32,10 +32,14 @@
+ 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.Assert.assertEquals;
+ import static org.junit.Assert.assertNotNull;
+ import static org.junit.Assert.assertNull;
+ import static org.junit.Assert.assertSame;
++import static org.junit.Assert.assertThrows;
+ import static org.junit.Assert.assertTrue;
+ import static org.junit.Assert.fail;
+
+@@ -50,12 +54,7 @@
+ 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.Before;
+ import org.junit.Test;
+ import org.junit.runner.RunWith;
+@@ -93,6 +92,11 @@ public void setup() {
+ transportFactory = new MockExternalAccountCredentialsTransportFactory();
+ }
+
++ @org.junit.After
++ public void tearDown() {
++ RegionalAccessBoundary.setEnvironmentProviderForTest(null);
++ }
++
+ @Test
+ public void fromStream_identityPoolCredentials() throws IOException {
+ GenericJson json = buildJsonIdentityPoolCredential();
+@@ -1248,6 +1252,274 @@ public 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) {
++ fail("Timed out waiting for regional access boundary refresh");
++ }
++ }
++
+ private GenericJson buildJsonIdentityPoolCredential() {
+ GenericJson json = new GenericJson();
+ json.put(
+diff --git a/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java
+index 5004fd6b6..4226bd0da 100644
+--- a/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java
++++ b/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java
+@@ -31,12 +31,20 @@
+
+ package com.google.auth.oauth2;
+
+-import static org.junit.Assert.*;
++import static com.google.auth.oauth2.RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY;
++import static org.junit.Assert.assertEquals;
++import static org.junit.Assert.assertFalse;
++import static org.junit.Assert.assertNotNull;
++import static org.junit.Assert.assertNull;
++import static org.junit.Assert.assertSame;
++import static org.junit.Assert.assertTrue;
++import static org.junit.Assert.fail;
+
+ import com.google.api.client.http.HttpStatusCodes;
+ 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;
+@@ -46,12 +54,10 @@
+ import java.io.IOException;
+ import java.io.InputStream;
+ import java.net.URI;
+-import java.util.Arrays;
+-import java.util.Collection;
+-import java.util.Collections;
+-import java.util.List;
+-import java.util.Map;
++import java.util.*;
++import java.util.concurrent.atomic.AtomicLong;
+ import java.util.concurrent.atomic.AtomicReference;
++import javax.annotation.Nullable;
+ import org.junit.Test;
+ import org.junit.runner.RunWith;
+ import org.junit.runners.JUnit4;
+@@ -95,6 +101,14 @@ public class GoogleCredentialsTest extends BaseSerializationTest {
+ private static final String GOOGLE_DEFAULT_UNIVERSE = "googleapis.com";
+ private static final String TPC_UNIVERSE = "foo.bar";
+
++ @org.junit.Before
++ public void setUp() {}
++
++ @org.junit.After
++ public void tearDown() {
++ RegionalAccessBoundary.setEnvironmentProviderForTest(null);
++ }
++
+ @Test
+ public void getApplicationDefault_nullTransport_throws() throws IOException {
+ try {
+@@ -782,6 +796,56 @@ public void serialize() throws IOException, ClassNotFoundException {
+ assertEquals(testCredentials.hashCode(), deserializedCredentials.hashCode());
+ assertEquals(testCredentials.toString(), deserializedCredentials.toString());
+ 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
+@@ -932,4 +996,349 @@ public 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) {
++ 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()) {
++ 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/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java
+index cce03e085..92e799ee4 100644
+--- a/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java
++++ b/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java
+@@ -72,6 +72,14 @@ public class IdentityPoolCredentialsTest extends BaseSerializationTest {
+ private static final IdentityPoolSubjectTokenSupplier testProvider =
+ (ExternalAccountSupplierContext context) -> "testSubjectToken";
+
++ @org.junit.Before
++ public void setUp() {}
++
++ @org.junit.After
++ public void tearDown() {
++ RegionalAccessBoundary.setEnvironmentProviderForTest(null);
++ }
++
+ @Test
+ public void createdScoped_clonedCredentialWithAddedScopes() throws IOException {
+ IdentityPoolCredentials credentials =
+@@ -1304,4 +1312,49 @@ void setShouldThrowOnGetCertificatePath(boolean shouldThrow) {
+ this.shouldThrowOnGetCertificatePath = 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/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java
+index 1cfde9cf8..f54806def 100644
+--- a/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java
++++ b/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.Assert.assertArrayEquals;
+ import static org.junit.Assert.assertEquals;
+ import static org.junit.Assert.assertFalse;
+@@ -67,6 +68,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;
+@@ -153,6 +155,11 @@ public 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;
+@@ -163,6 +170,11 @@ public 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);
+@@ -176,6 +188,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;
+ }
+@@ -1302,6 +1315,56 @@ public void serialize() throws IOException, ClassNotFoundException {
+ assertSame(deserializedCredentials.clock, Clock.SYSTEM);
+ }
+
++ @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 = 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() {
+ Calendar c = Calendar.getInstance();
+ c.add(Calendar.SECOND, VALID_LIFETIME);
+diff --git a/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java b/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java
+index 24f6262dd..2cb971a37 100644
+--- a/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java
++++ b/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java
+@@ -64,6 +64,8 @@
+ import java.util.Map;
+ import org.junit.BeforeClass;
+ import org.junit.Test;
++import org.junit.runner.RunWith;
++import org.junit.runners.JUnit4;
+ import org.slf4j.Logger;
+ import org.slf4j.LoggerFactory;
+ import org.slf4j.event.KeyValuePair;
+@@ -73,6 +75,7 @@
+ * credentials test classes with addition of test logging appender setup and test logic for logging.
+ * This duplicates tests setups, but centralizes logging test setup in this class.
+ */
++@RunWith(JUnit4.class)
+ public class LoggingTest {
+
+ private TestAppender setupTestLogger(Class> clazz) {
+@@ -91,6 +94,14 @@ public static void setup() {
+ LoggingUtils.setEnvironmentProvider(testEnvironmentProvider);
+ }
+
++ @org.junit.Before
++ public void setUp() {}
++
++ @org.junit.After
++ public void tearDown() {
++ RegionalAccessBoundary.setEnvironmentProviderForTest(null);
++ }
++
+ @Test
+ public void userCredentials_getRequestMetadata_fromRefreshToken_hasAccessToken()
+ throws IOException {
+@@ -98,6 +109,7 @@ public void userCredentials_getRequestMetadata_fromRefreshToken_hasAccessToken()
+ 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)
+@@ -210,6 +222,7 @@ public void serviceAccountCredentials_idTokenWithAudience_iamFlow_targetAudience
+ 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/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java b/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java
+index d1bfdaecf..08727df4e 100644
+--- a/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java
++++ b/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/oauth2_http/javatests/com/google/auth/oauth2/MockIAMCredentialsServiceTransport.java b/oauth2_http/javatests/com/google/auth/oauth2/MockIAMCredentialsServiceTransport.java
+index cbd57d115..5346f4fdb 100644
+--- a/oauth2_http/javatests/com/google/auth/oauth2/MockIAMCredentialsServiceTransport.java
++++ b/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/oauth2_http/javatests/com/google/auth/oauth2/MockMetadataServerTransport.java b/oauth2_http/javatests/com/google/auth/oauth2/MockMetadataServerTransport.java
+index e7ac6c09d..70012330b 100644
+--- a/oauth2_http/javatests/com/google/auth/oauth2/MockMetadataServerTransport.java
++++ b/oauth2_http/javatests/com/google/auth/oauth2/MockMetadataServerTransport.java
+@@ -73,6 +73,9 @@ public class MockMetadataServerTransport extends MockHttpTransport {
+ private boolean emptyContent;
+ private MockLowLevelHttpRequest request;
+
++ private RegionalAccessBoundary regionalAccessBoundary;
++ private IOException lookupError;
++
+ public MockMetadataServerTransport() {}
+
+ public MockMetadataServerTransport(String accessToken) {
+@@ -120,6 +123,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;
+ }
+@@ -140,6 +151,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) {
+@@ -224,7 +237,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();
+
+@@ -361,4 +374,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/oauth2_http/javatests/com/google/auth/oauth2/MockStsTransport.java b/oauth2_http/javatests/com/google/auth/oauth2/MockStsTransport.java
+index 5b1b3fded..5152a23f5 100644
+--- a/oauth2_http/javatests/com/google/auth/oauth2/MockStsTransport.java
++++ b/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/oauth2_http/javatests/com/google/auth/oauth2/MockTokenServerTransport.java b/oauth2_http/javatests/com/google/auth/oauth2/MockTokenServerTransport.java
+index a61c185b5..b04efd9b8 100644
+--- a/oauth2_http/javatests/com/google/auth/oauth2/MockTokenServerTransport.java
++++ b/oauth2_http/javatests/com/google/auth/oauth2/MockTokenServerTransport.java
+@@ -77,6 +77,21 @@ public class MockTokenServerTransport extends MockHttpTransport {
+ private MockLowLevelHttpRequest request;
+ private ClientAuthenticationType clientAuthenticationType;
+ 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() {}
+
+@@ -175,6 +190,40 @@ public LowLevelHttpRequest buildRequest(String method, String url) throws IOExce
+ final String urlWithoutQuery = (questionMarkPos > 0) ? url.substring(0, questionMarkPos) : url;
+ final String query = (questionMarkPos > 0) ? url.substring(questionMarkPos + 1) : "";
+
++ 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/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java
+index cd321daf3..a6023d778 100644
+--- a/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java
++++ b/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java
+@@ -51,9 +51,21 @@
+ import java.util.Map;
+ import javax.annotation.Nullable;
+ import org.junit.Test;
++import org.junit.runner.RunWith;
++import org.junit.runners.JUnit4;
+
+ /** Tests for {@link PluggableAuthCredentials}. */
++@RunWith(JUnit4.class)
+ public class PluggableAuthCredentialsTest extends BaseSerializationTest {
++
++ @org.junit.Before
++ public void setUp() {}
++
++ @org.junit.After
++ public 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).
+@@ -603,6 +615,52 @@ public void serialize() throws IOException, ClassNotFoundException {
+ 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/oauth2_http/javatests/com/google/auth/oauth2/RegionalAccessBoundaryTest.java b/oauth2_http/javatests/com/google/auth/oauth2/RegionalAccessBoundaryTest.java
+new file mode 100644
+index 000000000..7c7ccd690
+--- /dev/null
++++ b/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/oauth2_http/javatests/com/google/auth/oauth2/ServiceAccountCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/ServiceAccountCredentialsTest.java
+index 1561bb341..c186b7f23 100644
+--- a/oauth2_http/javatests/com/google/auth/oauth2/ServiceAccountCredentialsTest.java
++++ b/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.Assert.assertArrayEquals;
+ import static org.junit.Assert.assertEquals;
+ import static org.junit.Assert.assertFalse;
+@@ -160,6 +161,14 @@ static ServiceAccountCredentials.Builder createDefaultBuilder() throws IOExcepti
+ return createDefaultBuilderWithKey(privateKey);
+ }
+
++ @org.junit.Before
++ public void setUp() {}
++
++ @org.junit.After
++ public void tearDown() {
++ RegionalAccessBoundary.setEnvironmentProviderForTest(null);
++ }
++
+ @Test
+ public void setLifetime() throws IOException {
+ ServiceAccountCredentials.Builder builder = createDefaultBuilder();
+@@ -1802,7 +1811,101 @@ public 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/samples/snippets/pom.xml b/samples/snippets/pom.xml
+index dbf7630e3..e725b2a83 100644
+--- a/samples/snippets/pom.xml
++++ b/samples/snippets/pom.xml
+@@ -80,4 +80,3 @@
+
+
+
+-
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 7f9f0c20774b..8ec35e82d56f 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;
@@ -581,6 +585,11 @@ protected AccessToken exchangeExternalCredentialForAccessToken(
*/
public abstract String retrieveSubjectToken() throws IOException;
+ @Override
+ HttpTransportFactory getTransportFactory() {
+ return transportFactory;
+ }
+
public String getAudience() {
return audience;
}
@@ -624,6 +633,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..05401ced13e2 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,56 @@ 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 Collections.unmodifiableMap(newRequestMetadata);
+ 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;
+ }
+ }
+
+ 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();
- String quotaProjectId = this.getQuotaProjectId();
- if (quotaProjectId != null) {
- return addQuotaProjectIdToRequestMetadata(quotaProjectId, headers);
- }
- return headers;
+ return addQuotaProjectIdToRequestMetadata(getQuotaProjectId(), super.getAdditionalHeaders());
}
/** Default constructor. */
@@ -516,6 +692,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 +832,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/JwtClaims.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/JwtClaims.java
index 5e36ebde1589..abfc95f48cc9 100644
--- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/JwtClaims.java
+++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/JwtClaims.java
@@ -35,7 +35,7 @@
import com.google.common.collect.ImmutableMap;
import java.io.Serializable;
import java.util.Map;
-import javax.annotation.Nullable;
+import org.jspecify.annotations.Nullable;
/**
* Value class representing the set of fields used as the payload of a JWT token.
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 b4a933963fe8..b2861ff39543 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 7efec082fe16..9add3cfecdb3 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
@@ -69,6 +69,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.
@@ -119,6 +120,22 @@ public class OAuth2Utils {
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..c48238e8a478
--- /dev/null
+++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundary.java
@@ -0,0 +1,290 @@
+/*
+ * 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;
+
+ 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();
+
+ // Static thread-isolated flag for granular testing setups
+ private static final ThreadLocal DISABLE_RAB_FOR_TESTS =
+ ThreadLocal.withInitial(() -> false);
+
+ @VisibleForTesting
+ static void disableForTests() {
+ DISABLE_RAB_FOR_TESTS.set(true);
+ }
+
+ @VisibleForTesting
+ static void enableForTests() {
+ DISABLE_RAB_FOR_TESTS.set(false);
+ }
+
+ @VisibleForTesting
+ static void resetForTests() {
+ DISABLE_RAB_FOR_TESTS.remove();
+ }
+
+ /**
+ * 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.
+ *
+ * @return True if the regional access boundary feature is enabled, false otherwise.
+ */
+ static boolean isEnabled() {
+ // 1. Check if granular opt-out flag is active for THIS thread
+ if (DISABLE_RAB_FOR_TESTS.get()) {
+ return false;
+ }
+ // 2. Fallback to standard GA behavior (enabled by default)
+ return true;
+ }
+
+ /**
+ * 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..02f37ae595de
--- /dev/null
+++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundaryManager.java
@@ -0,0 +1,272 @@
+/*
+ * 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.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.SynchronousQueue;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+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);
+
+ private static final int CORE_POOL_SIZE = 0;
+ private static final int MAX_POOL_SIZE = 100;
+ private static final long KEEP_ALIVE_TIME_SECONDS = 60L;
+ private static final int QUEUE_CAPACITY = 100;
+
+ /**
+ * Globally shared bounded thread pool across all independent credential instances to protect JVM native
+ * thread limits and avoid the risks of unbounded thread pools. Uses a finite delay queue to hold parallel
+ * expiration bursts. If concurrency exceeds the capacity of MAX_POOL_SIZE + QUEUE_CAPACITY, tasks are
+ * instantly rejected and the specific credential instance enters backoff cooldown.
+ */
+ private static final ExecutorService REFRESH_EXECUTOR =
+ new ThreadPoolExecutor(
+ CORE_POOL_SIZE,
+ MAX_POOL_SIZE,
+ KEEP_ALIVE_TIME_SECONDS,
+ TimeUnit.SECONDS,
+ new LinkedBlockingQueue<>(QUEUE_CAPACITY),
+ new ThreadFactory() {
+ @Override
+ public Thread newThread(Runnable r) {
+ Thread t = new Thread(r, "RAB-refresh-thread");
+ t.setDaemon(true);
+ return t;
+ }
+ },
+ new ThreadPoolExecutor.AbortPolicy());
+
+ 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 {
+ REFRESH_EXECUTOR.execute(refreshTask);
+ } 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 d794ba18486d..449f7f47a18f 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();
@@ -147,7 +152,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 4764d27ec38f..ebc23fa297fd 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
@@ -61,6 +61,17 @@
/** Tests for {@link AwsCredentials}. */
class AwsCredentialsTest extends BaseSerializationTest {
+ @org.junit.jupiter.api.BeforeEach
+ void setUp() {
+ RegionalAccessBoundary.disableForTests();
+ }
+
+ @org.junit.jupiter.api.AfterEach
+ void tearDown() {
+ RegionalAccessBoundary.resetForTests();
+ 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";
@@ -1393,4 +1404,51 @@ public AwsSecurityCredentials getCredentials(ExternalAccountSupplierContext cont
return credentials;
}
}
+
+ @Test
+ public void testRefresh_regionalAccessBoundarySuccess() throws IOException, InterruptedException {
+ RegionalAccessBoundary.enableForTests();
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider);
+
+ 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) {
+ 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 2f9b4b7e2836..9330a23eaaf6 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
@@ -32,6 +32,7 @@
package com.google.auth.oauth2;
import static com.google.auth.oauth2.ComputeEngineCredentials.METADATA_RESPONSE_EMPTY_CONTENT_ERROR_MESSAGE;
+import static com.google.auth.oauth2.RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY;
import static com.google.auth.oauth2.ImpersonatedCredentialsTest.SA_CLIENT_EMAIL;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -74,6 +75,17 @@
/** Test case for {@link ComputeEngineCredentials}. */
class ComputeEngineCredentialsTest extends BaseSerializationTest {
+ @org.junit.jupiter.api.BeforeEach
+ void setUp() {
+ RegionalAccessBoundary.disableForTests();
+ }
+
+ @org.junit.jupiter.api.AfterEach
+ void tearDown() {
+ RegionalAccessBoundary.resetForTests();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(null);
+ }
+
private static final URI CALL_URI = URI.create("http://googleapis.com/testapi/v1/foo");
private static final String TOKEN_URL =
@@ -392,7 +404,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"));
}
@@ -1195,6 +1206,50 @@ void getProjectId_explicitSet_noMDsCall() {
assertEquals(0, transportFactory.transport.getRequestCount());
}
+ @org.junit.jupiter.api.Test
+ void refresh_regionalAccessBoundarySuccess() throws IOException, InterruptedException {
+ RegionalAccessBoundary.enableForTests();
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider);
+
+ 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 4913e5aec53e..a38d7116136d 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
@@ -127,9 +127,16 @@ public HttpTransport create() {
@BeforeEach
void setup() {
+ RegionalAccessBoundary.disableForTests();
transportFactory = new MockExternalAccountAuthorizedUserCredentialsTransportFactory();
}
+ @org.junit.jupiter.api.AfterEach
+ void tearDown() {
+ RegionalAccessBoundary.resetForTests();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(null);
+ }
+
@Test
void builder_allFields() throws IOException {
ExternalAccountAuthorizedUserCredentials credentials =
@@ -1241,6 +1248,48 @@ void serialize() throws IOException, ClassNotFoundException {
assertSame(deserializedCredentials.clock, Clock.SYSTEM);
}
+ @org.junit.jupiter.api.Test
+ void testRefresh_regionalAccessBoundarySuccess() throws IOException, InterruptedException {
+ RegionalAccessBoundary.enableForTests();
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider);
+
+ 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) {
+ fail("Timed out waiting for regional access boundary refresh");
+ }
+ }
+
static GenericJson buildJsonCredentials() {
GenericJson json = new GenericJson();
json.put(
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 d45979cfb985..68bab398ac34 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,7 +32,11 @@
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.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
@@ -43,6 +47,7 @@
import com.google.api.client.json.GenericJson;
import com.google.api.client.util.Clock;
import com.google.auth.TestUtils;
+import java.util.Collections;
import com.google.auth.http.HttpTransportFactory;
import com.google.auth.oauth2.ExternalAccountCredentials.SubjectTokenTypes;
import com.google.auth.oauth2.ExternalAccountCredentialsTest.TestExternalAccountCredentials.TestCredentialSource;
@@ -87,9 +92,16 @@ public HttpTransport create() {
@BeforeEach
void setup() {
+ RegionalAccessBoundary.disableForTests();
transportFactory = new MockExternalAccountCredentialsTransportFactory();
}
+ @org.junit.jupiter.api.AfterEach
+ void tearDown() {
+ RegionalAccessBoundary.resetForTests();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(null);
+ }
+
@Test
void fromStream_identityPoolCredentials() throws IOException {
GenericJson json = buildJsonIdentityPoolCredential();
@@ -1244,6 +1256,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 {
+ RegionalAccessBoundary.enableForTests();
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider);
+ 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 {
+ RegionalAccessBoundary.enableForTests();
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider);
+ 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 {
+ RegionalAccessBoundary.enableForTests();
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider);
+ 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 {
+ RegionalAccessBoundary.enableForTests();
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider);
+ 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) {
+ 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 503c87d54207..cbceaaa8280f 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,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.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -44,6 +45,8 @@
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 javax.annotation.Nullable;
import com.google.auth.TestUtils;
import com.google.auth.http.HttpTransportFactory;
import com.google.auth.oauth2.ExternalAccountAuthorizedUserCredentialsTest.MockExternalAccountAuthorizedUserCredentialsTransportFactory;
@@ -58,6 +61,7 @@
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 org.junit.jupiter.api.Test;
@@ -99,6 +103,17 @@ 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() {
+ RegionalAccessBoundary.disableForTests();
+ }
+
+ @org.junit.jupiter.api.AfterEach
+ void tearDown() {
+ RegionalAccessBoundary.resetForTests();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(null);
+ }
+
@Test
void getApplicationDefault_nullTransport_throws() throws IOException {
try {
@@ -779,6 +794,56 @@ void serialize() throws IOException, ClassNotFoundException {
assertEquals(testCredentials.hashCode(), deserializedCredentials.hashCode());
assertEquals(testCredentials.toString(), deserializedCredentials.toString());
assertSame(deserializedCredentials.clock, Clock.SYSTEM);
+ assertNotNull(deserializedCredentials.regionalAccessBoundaryManager);
+ }
+
+ @Test
+ public void serialize_removesStaleRabHeaders() throws Exception {
+ RegionalAccessBoundary.enableForTests();
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider);
+
+ 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
@@ -929,4 +994,349 @@ void getCredentialInfo_impersonatedServiceAccount() throws IOException {
assertEquals(
ImpersonatedCredentialsTest.IMPERSONATED_CLIENT_EMAIL, credentialInfo.get("Principal"));
}
+
+ @Test
+ public void regionalAccessBoundary_shouldFetchAndReturnRegionalAccessBoundaryDataSuccessfully()
+ throws IOException, InterruptedException {
+ RegionalAccessBoundary.enableForTests();
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider);
+ 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 {
+ RegionalAccessBoundary.enableForTests();
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider);
+
+ // 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 {
+ RegionalAccessBoundary.enableForTests();
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider);
+ 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 {
+ RegionalAccessBoundary.enableForTests();
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider);
+ 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 {
+ RegionalAccessBoundary.enableForTests();
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider);
+ // 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 {
+ RegionalAccessBoundary.enableForTests();
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider);
+ 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 {
+ RegionalAccessBoundary.enableForTests();
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider);
+ 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) {
+ 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()) {
+ 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 6997c79b0ef2..d6d94fd1ac49 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
@@ -36,6 +36,7 @@
import static com.google.auth.oauth2.OAuth2Utils.JSON_FACTORY;
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;
@@ -77,6 +78,17 @@ class IdentityPoolCredentialsTest extends BaseSerializationTest {
private static final IdentityPoolSubjectTokenSupplier testProvider =
(ExternalAccountSupplierContext context) -> "testSubjectToken";
+ @org.junit.jupiter.api.BeforeEach
+ void setUp() {
+ RegionalAccessBoundary.disableForTests();
+ }
+
+ @org.junit.jupiter.api.AfterEach
+ void tearDown() {
+ RegionalAccessBoundary.resetForTests();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(null);
+ }
+
@Test
void createdScoped_clonedCredentialWithAddedScopes() throws IOException {
IdentityPoolCredentials credentials =
@@ -1308,4 +1320,49 @@ void setShouldThrowOnGetCertificatePath(boolean shouldThrow) {
this.shouldThrowOnGetCertificatePath = shouldThrow;
}
}
+
+ @Test
+ public void testRefresh_regionalAccessBoundarySuccess() throws IOException, InterruptedException {
+ RegionalAccessBoundary.enableForTests();
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider);
+
+ 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 0a70c1ec7839..b17dc898ea2b 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;
@@ -68,6 +69,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;
@@ -148,16 +150,28 @@ 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;
@BeforeEach
void setup() throws IOException {
+ RegionalAccessBoundary.disableForTests();
sourceCredentials = getSourceCredentials();
mockTransportFactory = new MockIAMCredentialsServiceTransportFactory();
}
+ @org.junit.jupiter.api.AfterEach
+ void tearDown() {
+ RegionalAccessBoundary.resetForTests();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(null);
+ }
+
static GoogleCredentials getSourceCredentials() throws IOException {
MockTokenServerTransportFactory transportFactory = new MockTokenServerTransportFactory();
PrivateKey privateKey = OAuth2Utils.privateKeyFromPkcs8(SA_PRIVATE_KEY_PKCS8);
@@ -171,6 +185,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;
}
@@ -1304,6 +1319,56 @@ void refreshAccessToken_afterSerialization_success() throws IOException, ClassNo
assertEquals(ACCESS_TOKEN, token.getTokenValue());
}
+ @Test
+ void refresh_regionalAccessBoundarySuccess() throws IOException, InterruptedException {
+ RegionalAccessBoundary.enableForTests();
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider);
+ // 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 fc9f8ba3e80b..9daee98c2f09 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 725a124fcd15..e63242086dd5 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
@@ -73,6 +73,9 @@ public class MockMetadataServerTransport extends MockHttpTransport {
private boolean emptyContent;
private MockLowLevelHttpRequest request;
+ private RegionalAccessBoundary regionalAccessBoundary;
+ private IOException lookupError;
+
public MockMetadataServerTransport() {}
public MockMetadataServerTransport(String accessToken) {
@@ -120,6 +123,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;
}
@@ -140,6 +151,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) {
@@ -215,7 +228,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();
@@ -352,4 +365,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 a61c185b5704..b04efd9b87b6 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
@@ -77,6 +77,21 @@ public class MockTokenServerTransport extends MockHttpTransport {
private MockLowLevelHttpRequest request;
private ClientAuthenticationType clientAuthenticationType;
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() {}
@@ -175,6 +190,40 @@ public LowLevelHttpRequest buildRequest(String method, String url) throws IOExce
final String urlWithoutQuery = (questionMarkPos > 0) ? url.substring(0, questionMarkPos) : url;
final String query = (questionMarkPos > 0) ? url.substring(questionMarkPos + 1) : "";
+ 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 9832c78215c0..dcca1c8f8750 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
@@ -57,6 +57,18 @@
/** Tests for {@link PluggableAuthCredentials}. */
class PluggableAuthCredentialsTest extends BaseSerializationTest {
+
+ @org.junit.jupiter.api.BeforeEach
+ void setUp() {
+ RegionalAccessBoundary.disableForTests();
+ }
+
+ @org.junit.jupiter.api.AfterEach
+ void tearDown() {
+ RegionalAccessBoundary.resetForTests();
+ 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).
@@ -606,6 +618,52 @@ void serialize() throws IOException, ClassNotFoundException {
assertThrows(NotSerializableException.class, () -> serializeAndDeserialize(testCredentials));
}
+ @Test
+ public void testRefresh_regionalAccessBoundarySuccess() throws IOException, InterruptedException {
+ RegionalAccessBoundary.enableForTests();
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider);
+
+ 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 2c516a9b2b4a..c9dcbdb73898 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.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -157,6 +158,17 @@ static ServiceAccountCredentials.Builder createDefaultBuilder() throws IOExcepti
return createDefaultBuilderWithKey(privateKey);
}
+ @org.junit.jupiter.api.BeforeEach
+ void setUp() {
+ RegionalAccessBoundary.disableForTests();
+ }
+
+ @org.junit.jupiter.api.AfterEach
+ void tearDown() {
+ RegionalAccessBoundary.resetForTests();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(null);
+ }
+
@Test
void setLifetime() throws IOException {
ServiceAccountCredentials.Builder builder = createDefaultBuilder();
@@ -1797,7 +1809,101 @@ void createScopes_existingAccessTokenInvalidated() throws IOException {
assertNull(newAccessToken);
}
- private void verifyJwtAccess(Map> metadata, String expectedScopeClaim)
+ @Test
+ public void refresh_regionalAccessBoundarySuccess() throws IOException, InterruptedException {
+ RegionalAccessBoundary.enableForTests();
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider);
+ // 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 {
+ RegionalAccessBoundary.enableForTests();
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider);
+ 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/oauth2_http/pom.xml b/google-auth-library-java/oauth2_http/pom.xml
index a453c56382bb..fc6f91fda9e0 100644
--- a/google-auth-library-java/oauth2_http/pom.xml
+++ b/google-auth-library-java/oauth2_http/pom.xml
@@ -143,6 +143,13 @@
+
+ org.codehaus.mojo
+ animal-sniffer-maven-plugin
+
+ true
+
+
org.apache.maven.plugins
maven-resources-plugin
@@ -241,6 +248,16 @@
com.google.auto.value
auto-value-annotations
+
+ org.jspecify
+ jspecify
+ 1.0.0
+
+
+ javax.annotation
+ javax.annotation-api
+ 1.3.2
+
com.google.code.findbugs
jsr305
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 @@
-