diff --git a/oauth2_http/java/com/google/auth/http/HttpCredentialsAdapter.java b/oauth2_http/java/com/google/auth/http/HttpCredentialsAdapter.java index 90a6c86f1..0d0170334 100644 --- a/oauth2_http/java/com/google/auth/http/HttpCredentialsAdapter.java +++ b/oauth2_http/java/com/google/auth/http/HttpCredentialsAdapter.java @@ -39,6 +39,8 @@ import com.google.api.client.http.HttpUnsuccessfulResponseHandler; import com.google.api.client.util.Preconditions; import com.google.auth.Credentials; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.auth.oauth2.RegionalAccessBoundary; import java.io.IOException; import java.net.URI; import java.util.ArrayList; @@ -61,6 +63,8 @@ public class HttpCredentialsAdapter private static final Pattern INVALID_TOKEN_ERROR = Pattern.compile("\\s*error\\s*=\\s*\"?invalid_token\"?"); + private static final String STALE_RAB_ERROR_MESSAGE = "stale regional access boundary"; + private final Credentials credentials; /** @@ -119,6 +123,12 @@ public void initialize(HttpRequest request) throws IOException { */ @Override public boolean handleResponse(HttpRequest request, HttpResponse response, boolean supportsRetry) { + if (response.getStatusCode() == HttpStatusCodes.STATUS_CODE_BAD_REQUEST) { + if (handleStaleRegionalAccessBoundaryError(request, response)) { + return true; + } + } + boolean refreshToken = false; boolean bearer = false; @@ -152,4 +162,43 @@ public boolean handleResponse(HttpRequest request, HttpResponse response, boolea } return false; } + + private boolean handleStaleRegionalAccessBoundaryError( + HttpRequest request, HttpResponse response) { + if (!(credentials instanceof GoogleCredentials)) { + return false; + } + GoogleCredentials googleCredentials = (GoogleCredentials) credentials; + + // Only check for stale RAB error if we actually sent the header. + if (request.getHeaders().get(RegionalAccessBoundary.HEADER_KEY) == null) { + return false; + } + + // Skip check for STS and IAM Credentials endpoints as per design. + String url = request.getUrl().toString(); + if (url.contains("sts.googleapis.com") || url.contains("iamcredentials.googleapis.com")) { + return false; + } + + try { + // Check for the stale regional access boundary error message in the response body. + // Note: This consumes the response stream. + String content = response.parseAsString(); + if (content != null && content.toLowerCase().contains(STALE_RAB_ERROR_MESSAGE)) { + URI uri = null; + if (request.getUrl() != null) { + uri = request.getUrl().toURI(); + } + googleCredentials.reactiveRefreshRegionalAccessBoundary( + uri, googleCredentials.getAccessToken()); + // Re-initialize headers (this will remove the stale header since cache is cleared) + initialize(request); + return true; + } + } catch (Exception e) { + LOGGER.log(Level.FINE, "Error while checking for stale regional access boundary", e); + } + return false; + } } diff --git a/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java index 208a7d529..44a20d436 100644 --- a/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java @@ -83,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, TrustBoundaryProvider { + implements ServiceAccountSigner, IdTokenProvider, RegionalAccessBoundaryProvider { static final String METADATA_RESPONSE_EMPTY_CONTENT_ERROR_MESSAGE = "Empty content from metadata token server request."; @@ -386,11 +386,7 @@ public AccessToken refreshAccessToken() throws IOException { int expiresInSeconds = OAuth2Utils.validateInt32(responseData, "expires_in", PARSE_ERROR_PREFIX); long expiresAtMilliseconds = clock.currentTimeMillis() + expiresInSeconds * 1000; - AccessToken newAccessToken = new AccessToken(accessToken, new Date(expiresAtMilliseconds)); - - refreshTrustBoundary(newAccessToken, transportFactory); - - return newAccessToken; + return new AccessToken(accessToken, new Date(expiresAtMilliseconds)); } /** @@ -694,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() { @@ -709,7 +710,7 @@ public String getAccount() { @InternalApi @Override - public String getTrustBoundaryUrl() throws IOException { + public String getRegionalAccessBoundaryUrl() throws IOException { return String.format( OAuth2Utils.IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_SERVICE_ACCOUNT, getUniverseDomain(), diff --git a/oauth2_http/java/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentials.java index fc8bf8aef..7b67b4f72 100644 --- a/oauth2_http/java/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentials.java @@ -80,7 +80,7 @@ * */ public class ExternalAccountAuthorizedUserCredentials extends GoogleCredentials - implements TrustBoundaryProvider { + implements RegionalAccessBoundaryProvider { private static final String PARSE_ERROR_PREFIX = "Error parsing token refresh response. "; @@ -214,19 +214,15 @@ public AccessToken refreshAccessToken() throws IOException { this.refreshToken = refreshToken; } - AccessToken newAccessToken = - AccessToken.newBuilder() - .setExpirationTime(expiresAtMilliseconds) - .setTokenValue(accessToken) - .build(); - - refreshTrustBoundary(newAccessToken, transportFactory); - return newAccessToken; + return AccessToken.newBuilder() + .setExpirationTime(expiresAtMilliseconds) + .setTokenValue(accessToken) + .build(); } @InternalApi @Override - public String getTrustBoundaryUrl() throws IOException { + public String getRegionalAccessBoundaryUrl() throws IOException { Matcher matcher = WORKFORCE_AUDIENCE_PATTERN.matcher(getAudience()); if (!matcher.matches()) { throw new IllegalStateException( @@ -238,6 +234,11 @@ public String getTrustBoundaryUrl() throws IOException { IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_WORKFORCE_POOL, getUniverseDomain(), 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 7b4e3664d..ba86a1b69 100644 --- a/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java @@ -69,7 +69,7 @@ * account impersonation. */ public abstract class ExternalAccountCredentials extends GoogleCredentials - implements TrustBoundaryProvider { + implements RegionalAccessBoundaryProvider { private static final long serialVersionUID = 8049126194174465023L; @@ -532,11 +532,7 @@ protected AccessToken exchangeExternalCredentialForAccessToken( this.impersonatedCredentials = this.buildImpersonatedCredentials(); } if (this.impersonatedCredentials != null) { - AccessToken accessToken = this.impersonatedCredentials.refreshAccessToken(); - // After the impersonated credential refreshes, its trust boundary is - // also refreshed. That is the trust boundary we will use. - this.trustBoundary = this.impersonatedCredentials.getTrustBoundary(); - return accessToken; + return this.impersonatedCredentials.refreshAccessToken(); } StsRequestHandler.Builder requestHandler = @@ -565,9 +561,7 @@ protected AccessToken exchangeExternalCredentialForAccessToken( } StsTokenExchangeResponse response = requestHandler.build().exchangeToken(); - AccessToken accessToken = response.getAccessToken(); - refreshTrustBoundary(accessToken, transportFactory); - return accessToken; + return response.getAccessToken(); } /** @@ -581,6 +575,11 @@ protected AccessToken exchangeExternalCredentialForAccessToken( */ public abstract String retrieveSubjectToken() throws IOException; + @Override + HttpTransportFactory getTransportFactory() { + return transportFactory; + } + public String getAudience() { return audience; } @@ -626,7 +625,14 @@ public String getServiceAccountEmail() { @InternalApi @Override - public String getTrustBoundaryUrl() { + public String getRegionalAccessBoundaryUrl() throws IOException { + if (getServiceAccountEmail() != null) { + return String.format( + OAuth2Utils.IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_SERVICE_ACCOUNT, + getUniverseDomain(), + getServiceAccountEmail()); + } + Matcher workforceMatcher = WORKFORCE_AUDIENCE_PATTERN.matcher(getAudience()); if (workforceMatcher.matches()) { String poolId = workforceMatcher.group("pool"); diff --git a/oauth2_http/java/com/google/auth/oauth2/GoogleCredentials.java b/oauth2_http/java/com/google/auth/oauth2/GoogleCredentials.java index f5f208db3..eeaf82616 100644 --- a/oauth2_http/java/com/google/auth/oauth2/GoogleCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/GoogleCredentials.java @@ -35,9 +35,9 @@ import com.google.api.client.json.JsonFactory; import com.google.api.client.json.JsonObjectParser; import com.google.api.client.util.Preconditions; -import com.google.api.core.InternalApi; import com.google.api.core.ObsoleteApi; import com.google.auth.Credentials; +import com.google.auth.RequestMetadataCallback; import com.google.auth.http.HttpTransportFactory; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.MoreObjects; @@ -48,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; @@ -56,6 +58,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.concurrent.Executor; import javax.annotation.Nullable; /** Base type for credentials for authorizing calls to Google APIs using OAuth2. */ @@ -108,7 +111,7 @@ String getFileType() { private final String universeDomain; private final boolean isExplicitUniverseDomain; - TrustBoundary trustBoundary; + transient RABManager rabManager = new RABManager(); protected final String quotaProjectId; @@ -334,58 +337,157 @@ public GoogleCredentials createWithQuotaProject(String quotaProject) { return this.toBuilder().setQuotaProjectId(quotaProject).build(); } - @VisibleForTesting - TrustBoundary getTrustBoundary() { - return trustBoundary; + /** + * 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. + */ + public final RegionalAccessBoundary getRegionalAccessBoundary() { + return rabManager.getCachedRAB(); + } + + /** + * Manually sets the regional access boundary for this credential. This seeds the internal cache + * and bypasses the initial lookup. The manually provided data will follow the standard 6-hour TTL + * expiration logic. + * + * @param rab The regional access boundary to set. + */ + public final void setRegionalAccessBoundary(RegionalAccessBoundary rab) { + rabManager.setManualOverride(rab); } /** - * Refreshes the trust boundary by making a call to the trust boundary URL. + * Invalidates the regional access boundary cache and triggers an immediate asynchronous refresh. * - * @param newAccessToken The new access token to be used for the refresh. - * @param transportFactory The HTTP transport factory to be used for the refresh. - * @throws IOException If the refresh fails and no cached value is available. + * @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. */ - @InternalApi - void refreshTrustBoundary(AccessToken newAccessToken, HttpTransportFactory transportFactory) + public final void reactiveRefreshRegionalAccessBoundary(@Nullable URI uri, AccessToken token) throws IOException { + if (!RegionalAccessBoundary.isEnabled()) { + return; + } - if (!(this instanceof TrustBoundaryProvider) - || !TrustBoundary.isTrustBoundaryEnabled() - || !isDefaultUniverseDomain()) { + String rabUrl = ((RegionalAccessBoundaryProvider) this).getRegionalAccessBoundaryUrl(); + HttpTransportFactory transportFactory = getTransportFactory(); + if (transportFactory == null) { return; } - String trustBoundaryUrl = ((TrustBoundaryProvider) this).getTrustBoundaryUrl(); - TrustBoundary cachedTrustBoundary; + rabManager.reactiveRefresh(transportFactory, rabUrl, token); + } + + /** + * 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; + } - synchronized (lock) { - // Do not refresh if the cached value is already NO_OP. - if (trustBoundary != null && trustBoundary.isNoOp()) { + if (this instanceof ExternalAccountCredentials) { + if (((ExternalAccountCredentials) this).getAudience() == null) { return; } - cachedTrustBoundary = trustBoundary; - } - - TrustBoundary newTrustBoundary; - try { - newTrustBoundary = - TrustBoundary.refresh( - transportFactory, trustBoundaryUrl, newAccessToken, cachedTrustBoundary); - } catch (IOException e) { - // If refresh fails, check for a cached value. - if (cachedTrustBoundary == null) { - // No cached value, so fail hard. - throw new IOException( - "Failed to refresh trust boundary and no cached value is available.", e); + } + + // 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.getExpirationTime() != null + && token.getExpirationTime().before(new java.util.Date()))) { + return; + } + + String rabUrl = ((RegionalAccessBoundaryProvider) this).getRegionalAccessBoundaryUrl(); + HttpTransportFactory transportFactory = getTransportFactory(); + if (transportFactory == null) { return; } - // A lock is required to safely update the shared field. - synchronized (lock) { - trustBoundary = newTrustBoundary; + rabManager.triggerAsyncRefresh(transportFactory, rabUrl, token); + } + + /** + * 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); + RegionalAccessBoundary rab = getRegionalAccessBoundary(); + if (rab != null) { + metadata = new HashMap<>(metadata); + metadata.put( + RegionalAccessBoundary.HEADER_KEY, Collections.singletonList(rab.getEncodedLocations())); } + refreshRegionalAccessBoundaryIfExpired(uri, getAccessToken()); + 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, Executor executor, final RequestMetadataCallback callback) { + super.getRequestMetadata( + uri, + executor, + new RequestMetadataCallback() { + @Override + public void onSuccess(Map> metadata) { + RegionalAccessBoundary rab = getRegionalAccessBoundary(); + if (rab != null) { + metadata = new HashMap<>(metadata); + metadata.put( + RegionalAccessBoundary.HEADER_KEY, + Collections.singletonList(rab.getEncodedLocations())); + } + 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); + } + }); } /** @@ -443,9 +545,10 @@ static Map> addQuotaProjectIdToRequestMetadata( protected Map> getAdditionalHeaders() { Map> headers = new HashMap<>(super.getAdditionalHeaders()); - if (this.trustBoundary != null) { - String headerValue = trustBoundary.isNoOp() ? "" : trustBoundary.getEncodedLocations(); - headers.put(TrustBoundary.TRUST_BOUNDARY_KEY, Collections.singletonList(headerValue)); + RegionalAccessBoundary rab = rabManager.getCachedRAB(); + if (rab != null) { + headers.put( + RegionalAccessBoundary.HEADER_KEY, Collections.singletonList(rab.getEncodedLocations())); } String quotaProjectId = this.getQuotaProjectId(); @@ -503,6 +606,9 @@ protected GoogleCredentials(Builder builder) { } this.source = builder.source; + if (builder.regionalAccessBoundary != null) { + setRegionalAccessBoundary(builder.regionalAccessBoundary); + } } /** @@ -560,6 +666,11 @@ public int hashCode() { return Objects.hash(this.quotaProjectId, this.universeDomain, this.isExplicitUniverseDomain); } + private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException { + input.defaultReadObject(); + rabManager = new RABManager(); + } + public static Builder newBuilder() { return new Builder(); } @@ -695,10 +806,21 @@ 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; @Nullable String source; + @Nullable protected RegionalAccessBoundary regionalAccessBoundary; protected Builder() {} @@ -708,12 +830,14 @@ protected Builder(GoogleCredentials credentials) { if (credentials.isExplicitUniverseDomain) { this.universeDomain = credentials.universeDomain; } + this.regionalAccessBoundary = credentials.getRegionalAccessBoundary(); } protected Builder(GoogleCredentials.Builder builder) { setAccessToken(builder.getAccessToken()); this.quotaProjectId = builder.quotaProjectId; this.universeDomain = builder.universeDomain; + this.regionalAccessBoundary = builder.regionalAccessBoundary; } @Override @@ -732,6 +856,19 @@ public Builder setUniverseDomain(String universeDomain) { return this; } + /** + * Manually sets the regional access boundary for this credential. This seeds the internal cache + * and bypasses the initial lookup. + * + * @param regionalAccessBoundary The regional access boundary to set. + * @return this {@code Builder} object. + */ + @CanIgnoreReturnValue + public Builder setRegionalAccessBoundary(RegionalAccessBoundary regionalAccessBoundary) { + this.regionalAccessBoundary = regionalAccessBoundary; + return this; + } + public String getQuotaProjectId() { return this.quotaProjectId; } diff --git a/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java index eb19f5cbb..ee846e016 100644 --- a/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java @@ -96,7 +96,7 @@ * */ public class ImpersonatedCredentials extends GoogleCredentials - implements ServiceAccountSigner, IdTokenProvider, TrustBoundaryProvider { + implements ServiceAccountSigner, IdTokenProvider, RegionalAccessBoundaryProvider { private static final long serialVersionUID = -2133257318957488431L; private static final String RFC3339 = "yyyy-MM-dd'T'HH:mm:ssX"; @@ -328,7 +328,7 @@ public GoogleCredentials getSourceCredentials() { @InternalApi @Override - public String getTrustBoundaryUrl() throws IOException { + public String getRegionalAccessBoundaryUrl() throws IOException { return String.format( OAuth2Utils.IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_SERVICE_ACCOUNT, getUniverseDomain(), @@ -339,6 +339,11 @@ int getLifetime() { return this.lifetime; } + @Override + HttpTransportFactory getTransportFactory() { + return transportFactory; + } + public void setTransportFactory(HttpTransportFactory httpTransportFactory) { this.transportFactory = httpTransportFactory; } @@ -603,11 +608,7 @@ public AccessToken refreshAccessToken() throws IOException { format.setCalendar(calendar); try { Date date = format.parse(expireTime); - AccessToken newAccessToken = new AccessToken(accessToken, date); - - refreshTrustBoundary(newAccessToken, transportFactory); - - return newAccessToken; + return new AccessToken(accessToken, date); } catch (ParseException pe) { throw new IOException("Error parsing expireTime: " + pe.getMessage()); } diff --git a/oauth2_http/java/com/google/auth/oauth2/OAuth2Credentials.java b/oauth2_http/java/com/google/auth/oauth2/OAuth2Credentials.java index 7ef5485d0..0835f6dd7 100644 --- a/oauth2_http/java/com/google/auth/oauth2/OAuth2Credentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/OAuth2Credentials.java @@ -163,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) { @@ -174,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 { diff --git a/oauth2_http/java/com/google/auth/oauth2/RABManager.java b/oauth2_http/java/com/google/auth/oauth2/RABManager.java new file mode 100644 index 000000000..4cd067385 --- /dev/null +++ b/oauth2_http/java/com/google/auth/oauth2/RABManager.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 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 java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; +import java.util.logging.Level; +import java.util.logging.Logger; +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 RABManager { + + private static final Logger LOGGER = Logger.getLogger(RABManager.class.getName()); + private static final LoggerProvider LOGGER_PROVIDER = LoggerProvider.forClazz(RABManager.class); + + static final long INITIAL_COOLDOWN_MILLIS = 15 * 60 * 1000L; // 15 minutes + static final long MAX_COOLDOWN_MILLIS = 24 * 60 * 60 * 1000L; // 24 hours + + /** + * 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 long cooldownStartTime = 0; + private long currentCooldownMillis = INITIAL_COOLDOWN_MILLIS; + private static Clock clock = Clock.SYSTEM; + + /** + * 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; + } + + /** + * Sets a manual override for the Regional Access Boundary. This seeds the cache. + * + * @param rab The Regional Access Boundary to cache. + */ + void setManualOverride(RegionalAccessBoundary rab) { + cachedRAB.set(rab); + } + + /** + * 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 url The lookup endpoint URL. + * @param accessToken The access token for authentication. + */ + void triggerAsyncRefresh( + final HttpTransportFactory transportFactory, + final String url, + final AccessToken accessToken) { + if (isCooldownActive()) { + return; + } + + RegionalAccessBoundary currentRab = cachedRAB.get(); + if (currentRab != null && !currentRab.isExpired()) { + return; + } + + CompletableFuture future = new CompletableFuture<>(); + // 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)) { + CompletableFuture.runAsync( + () -> { + try { + RegionalAccessBoundary newRAB = + RegionalAccessBoundary.refresh( + transportFactory, url, accessToken, cachedRAB.get()); + cachedRAB.set(newRAB); + resetCooldown(); + // Complete the future so monitors (like unit tests) know we are done. + future.complete(newRAB); + } catch (Exception e) { + handleRefreshFailure(e); + future.completeExceptionally(e); + } finally { + // Open the gate again for future refresh requests. + refreshFuture.set(null); + } + }); + } + } + + /** Invalidates the current cache. Useful for reactive refresh on stale error. */ + void invalidateCache() { + cachedRAB.set(null); + } + + /** + * Invalidates the cache and triggers an immediate asynchronous refresh. + * + * @param transportFactory The HTTP transport factory to use for the lookup. + * @param url The lookup endpoint URL. + * @param accessToken The access token for authentication. + */ + void reactiveRefresh( + final HttpTransportFactory transportFactory, + final String url, + final AccessToken accessToken) { + invalidateCache(); + triggerAsyncRefresh(transportFactory, url, accessToken); + } + + private synchronized void handleRefreshFailure(Exception e) { + if (cooldownStartTime == 0) { + cooldownStartTime = clock.currentTimeMillis(); + currentCooldownMillis = INITIAL_COOLDOWN_MILLIS; + LoggingUtils.log( + LOGGER_PROVIDER, + Level.INFO, + null, + "RAB lookup failed; entering cooldown for " + + (currentCooldownMillis / 60000) + + "m. Error: " + + e.getMessage()); + } else { + // Extend cooldown + currentCooldownMillis = Math.min(currentCooldownMillis * 2, MAX_COOLDOWN_MILLIS); + cooldownStartTime = clock.currentTimeMillis(); + LoggingUtils.log( + LOGGER_PROVIDER, + Level.INFO, + null, + "RAB lookup failed again; extending cooldown to " + + (currentCooldownMillis / 60000) + + "m. Error: " + + e.getMessage()); + } + } + + private synchronized void resetCooldown() { + cooldownStartTime = 0; + currentCooldownMillis = INITIAL_COOLDOWN_MILLIS; + } + + synchronized boolean isCooldownActive() { + if (cooldownStartTime == 0) { + return false; + } + return clock.currentTimeMillis() < cooldownStartTime + currentCooldownMillis; + } + + @VisibleForTesting + synchronized long getCurrentCooldownMillis() { + + return currentCooldownMillis; + } + + @VisibleForTesting + static void setClockForTest(Clock testClock) { + clock = testClock; + } +} diff --git a/oauth2_http/java/com/google/auth/oauth2/TrustBoundary.java b/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundary.java similarity index 59% rename from oauth2_http/java/com/google/auth/oauth2/TrustBoundary.java rename to oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundary.java index ebbb110de..d59e0bb67 100644 --- a/oauth2_http/java/com/google/auth/oauth2/TrustBoundary.java +++ b/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundary.java @@ -41,6 +41,7 @@ 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; @@ -48,39 +49,57 @@ import com.google.common.base.MoreObjects; import com.google.common.base.Preconditions; import java.io.IOException; +import java.io.Serializable; import java.util.Collections; import java.util.Date; import java.util.List; import javax.annotation.Nullable; /** - * Represents the trust 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 + * 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 TrustBoundary { +public final class RegionalAccessBoundary implements Serializable { + + public static final String HEADER_KEY = "x-allowed-locations"; + private static final long serialVersionUID = -2428522338274020302L; + + static final String ENABLE_EXPERIMENT_ENV_VAR = "GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT"; + static final long TTL_MILLIS = 6 * 60 * 60 * 1000L; // 6 hours + private static int maxRetryElapsedTimeMillis = 60000; // 1 minute - static final String TRUST_BOUNDARY_KEY = "x-allowed-locations"; - static final String GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED_ENV_VAR = - "GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT"; - private static final String NO_OP_VALUE = "0x0"; private final String encodedLocations; private final List locations; + private final long refreshTime; + private static Clock clock = Clock.SYSTEM; + + /** + * Creates a new RegionalAccessBoundary instance. + * + * @param encodedLocations The encoded string representation of the allowed locations. + * @param locations A list of human-readable location strings. + */ + RegionalAccessBoundary(String encodedLocations, List locations) { + this(encodedLocations, locations, clock.currentTimeMillis()); + } /** - * Creates a new TrustBoundary instance. + * 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. */ - TrustBoundary(String encodedLocations, List locations) { + RegionalAccessBoundary(String encodedLocations, List locations, long refreshTime) { this.encodedLocations = encodedLocations; this.locations = locations == null ? Collections.emptyList() : Collections.unmodifiableList(locations); + this.refreshTime = refreshTime; } private static EnvironmentProvider environmentProvider = SystemEnvironmentProvider.getInstance(); @@ -95,17 +114,22 @@ public List getLocations() { return locations; } + /** Returns the time at which the information was last refreshed. */ + public long getRefreshTime() { + return refreshTime; + } + /** - * Checks if this TrustBoundary represents a "no-op" (no restrictions). + * Checks if the regional access boundary data is expired. * - * @return True if the encoded locations indicate no restrictions, false otherwise. + * @return True if the data has expired based on the TTL, false otherwise. */ - public boolean isNoOp() { - return NO_OP_VALUE.equals(encodedLocations); + public boolean isExpired() { + return clock.currentTimeMillis() > refreshTime + TTL_MILLIS; } - /** Represents the JSON response from the trust boundary endpoint. */ - public static class TrustBoundaryResponse extends GenericJson { + /** Represents the JSON response from the regional access boundary endpoint. */ + public static class RegionalAccessBoundaryResponse extends GenericJson { @Key("encodedLocations") private String encodedLocations; @@ -123,7 +147,7 @@ public List getLocations() { } @Override - /** Returns a string representation of the TrustBoundaryResponse. */ + /** Returns a string representation of the RegionalAccessBoundaryResponse. */ public String toString() { return MoreObjects.toStringHelper(this) .add("encodedLocations", encodedLocations) @@ -137,40 +161,52 @@ static void setEnvironmentProviderForTest(@Nullable EnvironmentProvider provider environmentProvider = provider == null ? SystemEnvironmentProvider.getInstance() : provider; } + @VisibleForTesting + static void setClockForTest(Clock testClock) { + clock = testClock; + } + + @VisibleForTesting + static void setMaxRetryElapsedTimeMillisForTest(int millis) { + maxRetryElapsedTimeMillis = millis; + } + /** - * Checks if the trust boundary feature is enabled based on an environment variable. The feature - * is enabled if the environment variable is set to "true" or "1" (case-insensitive). Any other - * value, or if the variable is unset, will result in the feature being disabled. + * 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 trust boundary feature is enabled, false otherwise. + * @return True if the regional access boundary feature is enabled, false otherwise. */ - static boolean isTrustBoundaryEnabled() { - String trustBoundaryEnabled = - environmentProvider.getEnv(GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED_ENV_VAR); - if (trustBoundaryEnabled == null) { + 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 lowercasedTrustBoundaryEnabled = trustBoundaryEnabled.toLowerCase(); - return "true".equals(lowercasedTrustBoundaryEnabled) || "1".equals(trustBoundaryEnabled); + String lowercased = enabled.toLowerCase(); + return "true".equals(lowercased) || "1".equals(enabled); } /** - * Refreshes the trust boundary by making a network call to the trust boundary endpoint. + * 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 trust boundary endpoint. + * @param url The URL of the regional access boundary endpoint. * @param accessToken The access token to authenticate the request. - * @param cachedTrustBoundary An optional previously cached trust boundary, which may be used in + * @param cachedRAB An optional previously cached regional access boundary, which may be used in * the request headers. - * @return A new TrustBoundary object containing the refreshed information. + * @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 TrustBoundary refresh( + static RegionalAccessBoundary refresh( HttpTransportFactory transportFactory, String url, AccessToken accessToken, - @Nullable TrustBoundary cachedTrustBoundary) + @Nullable RegionalAccessBoundary cachedRAB) throws IOException { Preconditions.checkNotNull(accessToken, "The provided access token is null."); if (accessToken.getExpirationTime() != null @@ -182,9 +218,9 @@ static TrustBoundary refresh( HttpRequest request = requestFactory.buildGetRequest(new GenericUrl(url)); request.getHeaders().setAuthorization("Bearer " + accessToken.getTokenValue()); - // Add the cached trust boundary header, if available. - if (cachedTrustBoundary != null) { - request.getHeaders().set(TRUST_BOUNDARY_KEY, cachedTrustBoundary.getEncodedLocations()); + // Add the cached regional access boundary header, if available. + if (cachedRAB != null) { + request.getHeaders().set(HEADER_KEY, cachedRAB.getEncodedLocations()); } // Add retry logic @@ -193,33 +229,40 @@ static TrustBoundary refresh( .setInitialIntervalMillis(OAuth2Utils.INITIAL_RETRY_INTERVAL_MILLIS) .setRandomizationFactor(OAuth2Utils.RETRY_RANDOMIZATION_FACTOR) .setMultiplier(OAuth2Utils.RETRY_MULTIPLIER) + .setMaxElapsedTimeMillis(maxRetryElapsedTimeMillis) .build(); HttpUnsuccessfulResponseHandler unsuccessfulResponseHandler = - new HttpBackOffUnsuccessfulResponseHandler(backoff); + new HttpBackOffUnsuccessfulResponseHandler(backoff) + .setBackOffRequired( + response -> { + int statusCode = response.getStatusCode(); + return (statusCode >= 500 && statusCode < 600) + || statusCode == 403 + || statusCode == 404; + }); request.setUnsuccessfulResponseHandler(unsuccessfulResponseHandler); HttpIOExceptionHandler ioExceptionHandler = new HttpBackOffIOExceptionHandler(backoff); request.setIOExceptionHandler(ioExceptionHandler); - TrustBoundaryResponse json; + RegionalAccessBoundaryResponse json; try { HttpResponse response = request.execute(); String responseString = response.parseAsString(); JsonParser parser = OAuth2Utils.JSON_FACTORY.createJsonParser(responseString); - json = parser.parseAndClose(TrustBoundaryResponse.class); + json = parser.parseAndClose(RegionalAccessBoundaryResponse.class); } catch (IOException e) { - throw new IOException("TrustBoundary: Failure while getting trust boundaries:", 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. In case of NO_OP the lookup endpoint returns - // encodedLocations as '0x0' and locations as null. That is why we only check for - // encodedLocations. + // 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( - "TrustBoundary: Malformed response from lookup endpoint - `encodedLocations` was null."); + "RegionalAccessBoundary: Malformed response from lookup endpoint - `encodedLocations` was null."); } - return new TrustBoundary(encodedLocations, json.getLocations()); + return new RegionalAccessBoundary(encodedLocations, json.getLocations()); } } diff --git a/oauth2_http/java/com/google/auth/oauth2/TrustBoundaryProvider.java b/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundaryProvider.java similarity index 81% rename from oauth2_http/java/com/google/auth/oauth2/TrustBoundaryProvider.java rename to oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundaryProvider.java index 3f034d22f..520360478 100644 --- a/oauth2_http/java/com/google/auth/oauth2/TrustBoundaryProvider.java +++ b/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundaryProvider.java @@ -35,16 +35,16 @@ import java.io.IOException; /** - * An interface for providing trust boundary information. It is used to provide a common interface - * for credentials that support trust boundary checks. + * 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 TrustBoundaryProvider { +interface RegionalAccessBoundaryProvider { /** - * Returns the trust boundary URI. + * Returns the regional access boundary URI. * - * @return The trust boundary URI. + * @return The regional access boundary URI. */ - String getTrustBoundaryUrl() throws IOException; + 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 5307d4d6d..f1aa01bc5 100644 --- a/oauth2_http/java/com/google/auth/oauth2/ServiceAccountCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/ServiceAccountCredentials.java @@ -90,7 +90,7 @@ *

By default uses a JSON Web Token (JWT) to fetch access tokens. */ public class ServiceAccountCredentials extends GoogleCredentials - implements ServiceAccountSigner, IdTokenProvider, JwtProvider, TrustBoundaryProvider { + 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"; @@ -581,11 +581,7 @@ public AccessToken refreshAccessToken() throws IOException { int expiresInSeconds = OAuth2Utils.validateInt32(responseData, "expires_in", PARSE_ERROR_PREFIX); long expiresAtMilliseconds = clock.currentTimeMillis() + expiresInSeconds * 1000L; - AccessToken newAccessToken = new AccessToken(accessToken, new Date(expiresAtMilliseconds)); - - refreshTrustBoundary(newAccessToken, transportFactory); - - return newAccessToken; + return new AccessToken(accessToken, new Date(expiresAtMilliseconds)); } /** @@ -830,7 +826,7 @@ public boolean getUseJwtAccessWithScope() { @InternalApi @Override - public String getTrustBoundaryUrl() throws IOException { + public String getRegionalAccessBoundaryUrl() throws IOException { return String.format( OAuth2Utils.IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_SERVICE_ACCOUNT, getUniverseDomain(), @@ -842,6 +838,11 @@ JwtCredentials getSelfSignedJwtCredentialsWithScope() { return selfSignedJwtCredentialsWithScope; } + @Override + HttpTransportFactory getTransportFactory() { + return transportFactory; + } + @Override public String getAccount() { return getClientEmail(); @@ -1037,6 +1038,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) { @@ -1059,7 +1071,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) { @@ -1128,6 +1149,17 @@ private Map> getRequestMetadataWithSelfSignedJwt(URI uri) } Map> requestMetadata = jwtCredentials.getRequestMetadata(null); + List authHeaders = requestMetadata.get(AuthHttpConstants.AUTHORIZATION); + if (authHeaders != null && !authHeaders.isEmpty()) { + // Extract the token value to trigger a background Regional Access Boundary refresh. + 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); + refreshRegionalAccessBoundaryIfExpired(uri, wrappedToken); + } + } 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 b68b49dc5..58ef558a9 100644 --- a/oauth2_http/javatests/com/google/auth/TestUtils.java +++ b/oauth2_http/javatests/com/google/auth/TestUtils.java @@ -66,8 +66,8 @@ 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 TRUST_BOUNDARY_ENCODED_LOCATION = "0x800000"; - public static final List TRUST_BOUNDARY_LOCATIONS = + 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(); diff --git a/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java index d197bb569..f14c668f3 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java @@ -1401,9 +1401,9 @@ public AwsSecurityCredentials getCredentials(ExternalAccountSupplierContext cont } @Test - public void testRefresh_trustBoundarySuccess() throws IOException { + public void testRefresh_regionalAccessBoundarySuccess() throws IOException, InterruptedException { TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - TrustBoundary.setEnvironmentProviderForTest(environmentProvider); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); environmentProvider.setEnv("GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT", "1"); MockExternalAccountCredentialsTransportFactory transportFactory = @@ -1422,11 +1422,29 @@ public void testRefresh_trustBoundarySuccess() throws IOException { .setSubjectTokenType("subjectTokenType") .build(); - awsCredential.refresh(); + // First call: initiates async refresh. + Map> headers = awsCredential.getRequestMetadata(); + assertNull(headers.get(RegionalAccessBoundary.HEADER_KEY)); - TrustBoundary trustBoundary = awsCredential.getTrustBoundary(); - assertNotNull(trustBoundary); - assertEquals(TestUtils.TRUST_BOUNDARY_ENCODED_LOCATION, trustBoundary.getEncodedLocations()); - TrustBoundary.setEnvironmentProviderForTest(null); + waitForRegionalAccessBoundary(awsCredential); + + // Second call: should have header. + headers = awsCredential.getRequestMetadata(); + assertEquals( + headers.get(RegionalAccessBoundary.HEADER_KEY), + Arrays.asList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION)); + RegionalAccessBoundary.setEnvironmentProviderForTest(null); + } + + 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 e13c9ce93..83e5b7fc3 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java @@ -33,7 +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.TrustBoundary.TRUST_BOUNDARY_KEY; +import static com.google.auth.oauth2.RegionalAccessBoundary.HEADER_KEY; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -132,7 +132,7 @@ public class ComputeEngineCredentialsTest extends BaseSerializationTest { @After public void tearDown() { - TrustBoundary.setEnvironmentProviderForTest(null); + RegionalAccessBoundary.setEnvironmentProviderForTest(null); } @Test @@ -1153,45 +1153,45 @@ public void idTokenWithAudience_503StatusCode() { } @Test - public void refresh_trustBoundarySuccess() throws IOException { + public void refresh_regionalAccessBoundarySuccess() throws IOException, InterruptedException { TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - TrustBoundary.setEnvironmentProviderForTest(environmentProvider); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); environmentProvider.setEnv("GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT", "1"); String defaultAccountEmail = "default@email.com"; MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); - TrustBoundary trustBoundary = - new TrustBoundary( - TestUtils.TRUST_BOUNDARY_ENCODED_LOCATION, TestUtils.TRUST_BOUNDARY_LOCATIONS); - transportFactory.transport.setTrustBoundary(trustBoundary); + RegionalAccessBoundary regionalAccessBoundary = + new RegionalAccessBoundary( + TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION, + TestUtils.REGIONAL_ACCESS_BOUNDARY_LOCATIONS); + transportFactory.transport.setRegionalAccessBoundary(regionalAccessBoundary); transportFactory.transport.setServiceAccountEmail(defaultAccountEmail); ComputeEngineCredentials credentials = ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + // First call: initiates async refresh. Map> headers = credentials.getRequestMetadata(); - assertEquals( - headers.get(TRUST_BOUNDARY_KEY), Arrays.asList(TestUtils.TRUST_BOUNDARY_ENCODED_LOCATION)); - } + assertNull(headers.get(HEADER_KEY)); - @Test - public void refresh_trustBoundaryFails_throwsIOException() throws IOException { - TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - TrustBoundary.setEnvironmentProviderForTest(environmentProvider); - environmentProvider.setEnv("GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT", "1"); + waitForRegionalAccessBoundary(credentials); - String defaultAccountEmail = "default@email.com"; - MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); - transportFactory.transport.setServiceAccountEmail(defaultAccountEmail); - - ComputeEngineCredentials credentials = - ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + // Second call: should have header. + headers = credentials.getRequestMetadata(); + assertEquals( + headers.get(HEADER_KEY), + Arrays.asList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION)); + } - IOException exception = assertThrows(IOException.class, () -> credentials.refresh()); - assertTrue( - "The exception message should explain why the refresh failed.", - exception - .getMessage() - .contains("Failed to refresh trust boundary and no cached value is available.")); + 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 { diff --git a/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentialsTest.java index 525cb20e9..b8930d23b 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentialsTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentialsTest.java @@ -35,17 +35,14 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotEquals; -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; 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; @@ -137,7 +134,7 @@ public void setup() { @After public void tearDown() { - TrustBoundary.setEnvironmentProviderForTest(null); + RegionalAccessBoundary.setEnvironmentProviderForTest(null); } @Test @@ -1225,76 +1222,45 @@ public void toString_expectedFormat() { } @Test - public void testRefresh_trustBoundarySuccess() throws IOException { + public void testRefresh_regionalAccessBoundarySuccess() throws IOException, InterruptedException { TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - TrustBoundary.setEnvironmentProviderForTest(environmentProvider); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); environmentProvider.setEnv("GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT", "1"); ExternalAccountAuthorizedUserCredentials credentials = ExternalAccountAuthorizedUserCredentials.newBuilder() - .setHttpTransportFactory(transportFactory) - .setAudience(AUDIENCE) .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(); - credentials.refresh(); - TrustBoundary trustBoundary = credentials.getTrustBoundary(); - assertNotNull(trustBoundary); - assertEquals(TestUtils.TRUST_BOUNDARY_ENCODED_LOCATION, trustBoundary.getEncodedLocations()); - } - - @Test - public void testRefresh_trustBoundaryFails_incorrectAudience() { - TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - TrustBoundary.setEnvironmentProviderForTest(environmentProvider); - environmentProvider.setEnv("GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT", "1"); + // First call: initiates async refresh. + Map> headers = credentials.getRequestMetadata(); + assertNull(headers.get(RegionalAccessBoundary.HEADER_KEY)); - ExternalAccountAuthorizedUserCredentials credentials = - ExternalAccountAuthorizedUserCredentials.newBuilder() - .setHttpTransportFactory(transportFactory) - .setAudience("audience") - .setClientId(CLIENT_ID) - .setClientSecret(CLIENT_SECRET) - .setRefreshToken(REFRESH_TOKEN) - .setTokenUrl(TOKEN_URL) - .build(); + waitForRegionalAccessBoundary(credentials); - IllegalStateException exception = - assertThrows( - IllegalStateException.class, - () -> { - credentials.refresh(); - }); + // Second call: should have header. + headers = credentials.getRequestMetadata(); assertEquals( - "The provided audience is not in the correct format for a workforce pool. " - + "Refer: https://docs.cloud.google.com/iam/docs/principal-identifiers", - exception.getMessage()); + headers.get(RegionalAccessBoundary.HEADER_KEY), + Arrays.asList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION)); } - @Test - public void serialize() throws IOException, ClassNotFoundException { - 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) - .build(); - - ExternalAccountAuthorizedUserCredentials deserializedCredentials = - serializeAndDeserialize(credentials); - assertEquals(credentials, deserializedCredentials); - assertEquals(credentials.hashCode(), deserializedCredentials.hashCode()); - assertEquals(credentials.toString(), deserializedCredentials.toString()); - assertSame(deserializedCredentials.clock, Clock.SYSTEM); + 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 3245995c9..987753126 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java @@ -32,6 +32,9 @@ package com.google.auth.oauth2; import static com.google.auth.oauth2.MockExternalAccountCredentialsTransport.SERVICE_ACCOUNT_IMPERSONATION_URL; +import static com.google.auth.oauth2.OAuth2Utils.IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_SERVICE_ACCOUNT; +import static com.google.auth.oauth2.OAuth2Utils.IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_WORKFORCE_POOL; +import static com.google.auth.oauth2.OAuth2Utils.IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_WORKLOAD_POOL; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; @@ -51,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.After; import org.junit.Before; import org.junit.Test; @@ -97,7 +95,7 @@ public void setup() { @After public void tearDown() { - TrustBoundary.setEnvironmentProviderForTest(null); + RegionalAccessBoundary.setEnvironmentProviderForTest(null); } @Test @@ -1256,7 +1254,7 @@ public void validateServiceAccountImpersonationUrls_invalidUrls() { } @Test - public void getTrustBoundaryUrl_workload() throws IOException { + public void getRegionalAccessBoundaryUrl_workload() throws IOException { String audience = "//iam.googleapis.com/projects/12345/locations/global/workloadIdentityPools/my-pool/providers/my-provider"; ExternalAccountCredentials credentials = @@ -1268,11 +1266,11 @@ public void getTrustBoundaryUrl_workload() throws IOException { String expectedUrl = "https://iamcredentials.googleapis.com/v1/projects/12345/locations/global/workloadIdentityPools/my-pool/allowedLocations"; - assertEquals(expectedUrl, credentials.getTrustBoundaryUrl()); + assertEquals(expectedUrl, credentials.getRegionalAccessBoundaryUrl()); } @Test - public void getTrustBoundaryUrl_workforce() throws IOException { + public void getRegionalAccessBoundaryUrl_workforce() throws IOException { String audience = "//iam.googleapis.com/locations/global/workforcePools/my-pool/providers/my-provider"; ExternalAccountCredentials credentials = @@ -1285,11 +1283,11 @@ public void getTrustBoundaryUrl_workforce() throws IOException { String expectedUrl = "https://iamcredentials.googleapis.com/v1/locations/global/workforcePools/my-pool/allowedLocations"; - assertEquals(expectedUrl, credentials.getTrustBoundaryUrl()); + assertEquals(expectedUrl, credentials.getRegionalAccessBoundaryUrl()); } @Test - public void getTrustBoundaryUrl_invalidAudience_throws() { + public void getRegionalAccessBoundaryUrl_invalidAudience_throws() { ExternalAccountCredentials credentials = TestExternalAccountCredentials.newBuilder() .setAudience("invalid-audience") @@ -1301,7 +1299,7 @@ public void getTrustBoundaryUrl_invalidAudience_throws() { assertThrows( IllegalStateException.class, () -> { - credentials.getTrustBoundaryUrl(); + credentials.getRegionalAccessBoundaryUrl(); }); assertEquals( @@ -1311,12 +1309,13 @@ public void getTrustBoundaryUrl_invalidAudience_throws() { } @Test - public void refresh_workload_trustBoundarySuccess() throws IOException { + public void refresh_workload_regionalAccessBoundarySuccess() + throws IOException, InterruptedException { String audience = "//iam.googleapis.com/projects/12345/locations/global/workloadIdentityPools/my-pool/providers/my-provider"; TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - TrustBoundary.setEnvironmentProviderForTest(environmentProvider); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); environmentProvider.setEnv("GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT", "1"); ExternalAccountCredentials credentials = @@ -1334,20 +1333,28 @@ public String retrieveSubjectToken() throws IOException { return "dummy-subject-token"; } }; - credentials.refresh(); - TrustBoundary trustBoundary = credentials.getTrustBoundary(); - assertNotNull(trustBoundary); - assertEquals(TestUtils.TRUST_BOUNDARY_ENCODED_LOCATION, trustBoundary.getEncodedLocations()); + // First call: initiates async refresh. + Map> headers = credentials.getRequestMetadata(); + assertNull(headers.get(RegionalAccessBoundary.HEADER_KEY)); + + waitForRegionalAccessBoundary(credentials); + + // Second call: should have header. + headers = credentials.getRequestMetadata(); + assertEquals( + headers.get(RegionalAccessBoundary.HEADER_KEY), + Arrays.asList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION)); } @Test - public void refresh_workforce_trustBoundarySuccess() throws IOException { + public void refresh_workforce_regionalAccessBoundarySuccess() + throws IOException, InterruptedException { String audience = "//iam.googleapis.com/locations/global/workforcePools/my-pool/providers/my-provider"; TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - TrustBoundary.setEnvironmentProviderForTest(environmentProvider); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); environmentProvider.setEnv("GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT", "1"); ExternalAccountCredentials credentials = @@ -1366,23 +1373,59 @@ public String retrieveSubjectToken() throws IOException { } }; - credentials.refresh(); + // First call: initiates async refresh. + Map> headers = credentials.getRequestMetadata(); + assertNull(headers.get(RegionalAccessBoundary.HEADER_KEY)); + + waitForRegionalAccessBoundary(credentials); - TrustBoundary trustBoundary = credentials.getTrustBoundary(); - assertNotNull(trustBoundary); - assertEquals(TestUtils.TRUST_BOUNDARY_ENCODED_LOCATION, trustBoundary.getEncodedLocations()); + // Second call: should have header. + headers = credentials.getRequestMetadata(); + assertEquals( + headers.get(RegionalAccessBoundary.HEADER_KEY), + Arrays.asList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION)); } @Test - public void refresh_impersonated_workload_trustBoundarySuccess() throws IOException { + public void refresh_impersonated_workload_regionalAccessBoundarySuccess() + throws IOException, InterruptedException { + String projectNumber = "12345"; + String poolId = "my-pool"; + String providerId = "my-provider"; String audience = - "//iam.googleapis.com/projects/12345/locations/global/workloadIdentityPools/my-pool/providers/my-provider"; + String.format( + "//iam.googleapis.com/projects/%s/locations/global/workloadIdentityPools/%s/providers/%s", + projectNumber, poolId, providerId); + TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - TrustBoundary.setEnvironmentProviderForTest(environmentProvider); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); environmentProvider.setEnv("GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT", "1"); 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, + GOOGLE_DEFAULT_UNIVERSE, + projectNumber, + poolId); + RegionalAccessBoundary workloadRab = + new RegionalAccessBoundary("workload-encoded", Collections.singletonList("workload-loc")); + 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, + GOOGLE_DEFAULT_UNIVERSE, + saEmail); + RegionalAccessBoundary impersonatedRab = + new RegionalAccessBoundary( + "impersonated-encoded", Collections.singletonList("impersonated-loc")); + 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"); @@ -1401,23 +1444,57 @@ public void refresh_impersonated_workload_trustBoundarySuccess() throws IOExcept .setEnvironmentProvider(environmentProvider) .build(); - credentials.refresh(); + // First call: initiates async refresh. + Map> requestHeaders = credentials.getRequestMetadata(); + assertNull(requestHeaders.get(RegionalAccessBoundary.HEADER_KEY)); + + waitForRegionalAccessBoundary(credentials); - TrustBoundary trustBoundary = credentials.getTrustBoundary(); - assertNotNull(trustBoundary); - assertEquals(TestUtils.TRUST_BOUNDARY_ENCODED_LOCATION, trustBoundary.getEncodedLocations()); + // Second call: should have the IMPERSONATED header, not the workload one. + requestHeaders = credentials.getRequestMetadata(); + assertEquals( + Arrays.asList("impersonated-encoded"), + requestHeaders.get(RegionalAccessBoundary.HEADER_KEY)); } @Test - public void refresh_impersonated_workforce_trustBoundarySuccess() throws IOException { + public void refresh_impersonated_workforce_regionalAccessBoundarySuccess() + throws IOException, InterruptedException { + String poolId = "my-pool"; + String providerId = "my-provider"; String audience = - "//iam.googleapis.com/locations/global/workforcePools/my-pool/providers/my-provider"; + String.format( + "//iam.googleapis.com/locations/global/workforcePools/%s/providers/%s", + poolId, providerId); + TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - TrustBoundary.setEnvironmentProviderForTest(environmentProvider); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); environmentProvider.setEnv("GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT", "1"); 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, + GOOGLE_DEFAULT_UNIVERSE, + poolId); + RegionalAccessBoundary workforceRab = + new RegionalAccessBoundary("workforce-encoded", Collections.singletonList("workforce-loc")); + 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, + GOOGLE_DEFAULT_UNIVERSE, + saEmail); + RegionalAccessBoundary impersonatedRab = + new RegionalAccessBoundary( + "impersonated-encoded", Collections.singletonList("impersonated-loc")); + 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"); @@ -1437,11 +1514,29 @@ public void refresh_impersonated_workforce_trustBoundarySuccess() throws IOExcep .setEnvironmentProvider(environmentProvider) .build(); - credentials.refresh(); + // First call: initiates async refresh. + Map> requestHeaders = credentials.getRequestMetadata(); + assertNull(requestHeaders.get(RegionalAccessBoundary.HEADER_KEY)); - TrustBoundary trustBoundary = credentials.getTrustBoundary(); - assertNotNull(trustBoundary); - assertEquals(TestUtils.TRUST_BOUNDARY_ENCODED_LOCATION, trustBoundary.getEncodedLocations()); + waitForRegionalAccessBoundary(credentials); + + // Second call: should have the IMPERSONATED header, not the workforce one. + requestHeaders = credentials.getRequestMetadata(); + assertEquals( + Arrays.asList("impersonated-encoded"), + requestHeaders.get(RegionalAccessBoundary.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() { diff --git a/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java index edf8b104a..ac0ff54be 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java @@ -31,13 +31,12 @@ package com.google.auth.oauth2; -import static com.google.auth.oauth2.TrustBoundary.TRUST_BOUNDARY_KEY; +import static com.google.auth.oauth2.RegionalAccessBoundary.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.assertThrows; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -54,11 +53,8 @@ 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 org.junit.After; import org.junit.Test; @@ -106,7 +102,9 @@ public class GoogleCredentialsTest extends BaseSerializationTest { @After public void tearDown() { - TrustBoundary.setEnvironmentProviderForTest(null); + RegionalAccessBoundary.setEnvironmentProviderForTest(null); + RegionalAccessBoundary.setClockForTest(Clock.SYSTEM); + RABManager.setClockForTest(Clock.SYSTEM); } @Test @@ -796,6 +794,7 @@ public void serialize() throws IOException, ClassNotFoundException { assertEquals(testCredentials.hashCode(), deserializedCredentials.hashCode()); assertEquals(testCredentials.toString(), deserializedCredentials.toString()); assertSame(deserializedCredentials.clock, Clock.SYSTEM); + assertNotNull(deserializedCredentials.rabManager); } @Test @@ -948,10 +947,10 @@ public void getCredentialInfo_impersonatedServiceAccount() throws IOException { } @Test - public void trustBoundary_shouldNotCallLookupEndpointWhenDisabled() throws IOException { + public void regionalAccessBoundary_shouldNotCallLookupEndpointWhenDisabled() throws IOException { TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - TrustBoundary.setEnvironmentProviderForTest(environmentProvider); - environmentProvider.setEnv(TrustBoundary.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED_ENV_VAR, "false"); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "false"); MockTokenServerTransport transport = new MockTokenServerTransport(); transport.addServiceAccount(SA_CLIENT_EMAIL, ACCESS_TOKEN); @@ -966,21 +965,23 @@ public void trustBoundary_shouldNotCallLookupEndpointWhenDisabled() throws IOExc .build(); credentials.getRequestMetadata(); - assertNull(credentials.getTrustBoundary()); + assertNull(credentials.getRegionalAccessBoundary()); } @Test - public void trustBoundary_shouldFetchAndReturnTrustBoundaryDataSuccessfully() throws IOException { + public void regionalAccessBoundary_shouldFetchAndReturnRegionalAccessBoundaryDataSuccessfully() + throws IOException, InterruptedException { TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - TrustBoundary.setEnvironmentProviderForTest(environmentProvider); - environmentProvider.setEnv(TrustBoundary.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED_ENV_VAR, "true"); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "true"); MockTokenServerTransport transport = new MockTokenServerTransport(); transport.addServiceAccount(SA_CLIENT_EMAIL, ACCESS_TOKEN); - TrustBoundary trustBoundary = - new TrustBoundary( - TestUtils.TRUST_BOUNDARY_ENCODED_LOCATION, Collections.singletonList("us-central1")); - transport.setTrustBoundary(trustBoundary); + RegionalAccessBoundary regionalAccessBoundary = + new RegionalAccessBoundary( + TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION, + Collections.singletonList("us-central1")); + transport.setRegionalAccessBoundary(regionalAccessBoundary); ServiceAccountCredentials credentials = ServiceAccountCredentials.newBuilder() @@ -991,25 +992,36 @@ public void trustBoundary_shouldFetchAndReturnTrustBoundaryDataSuccessfully() th .setScopes(SCOPES) .build(); + // First call: returns no header, initiates async refresh. Map> headers = credentials.getRequestMetadata(); + assertNull(headers.get(HEADER_KEY)); + + waitForRegionalAccessBoundary(credentials); + + // Second call: should have header. + headers = credentials.getRequestMetadata(); assertEquals( - headers.get(TRUST_BOUNDARY_KEY), Arrays.asList(TestUtils.TRUST_BOUNDARY_ENCODED_LOCATION)); + headers.get(HEADER_KEY), + Arrays.asList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION)); } @Test - public void trustBoundary_shouldRetryTrustBoundaryLookupOnFailure() throws IOException { + public void regionalAccessBoundary_shouldRetryRegionalAccessBoundaryLookupOnFailure() + throws IOException, InterruptedException { TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - TrustBoundary.setEnvironmentProviderForTest(environmentProvider); - environmentProvider.setEnv(TrustBoundary.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED_ENV_VAR, "true"); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "true"); // This transport will be used for the trust boundary lookup. // We will configure it to fail on the first attempt. - MockTokenServerTransport trustBoundaryTransport = new MockTokenServerTransport(); - trustBoundaryTransport.addResponseErrorSequence(new IOException("Service Unavailable")); - TrustBoundary trustBoundary = - new TrustBoundary( - TestUtils.TRUST_BOUNDARY_ENCODED_LOCATION, TestUtils.TRUST_BOUNDARY_LOCATIONS); - trustBoundaryTransport.setTrustBoundary(trustBoundary); + 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); + regionalAccessBoundaryTransport.setRegionalAccessBoundary(regionalAccessBoundary); // This transport will be used for the access token refresh. // It will succeed. @@ -1029,7 +1041,7 @@ public void trustBoundary_shouldRetryTrustBoundaryLookupOnFailure() throws IOExc public com.google.api.client.http.LowLevelHttpRequest buildRequest( String method, String url) throws IOException { if (url.endsWith("/allowedLocations")) { - return trustBoundaryTransport.buildRequest(method, url); + return regionalAccessBoundaryTransport.buildRequest(method, url); } return accessTokenTransport.buildRequest(method, url); } @@ -1037,40 +1049,21 @@ public com.google.api.client.http.LowLevelHttpRequest buildRequest( .setScopes(SCOPES) .build(); + credentials.getRequestMetadata(); + waitForRegionalAccessBoundary(credentials); + Map> headers = credentials.getRequestMetadata(); assertEquals( - Arrays.asList(TestUtils.TRUST_BOUNDARY_ENCODED_LOCATION), headers.get(TRUST_BOUNDARY_KEY)); + Arrays.asList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION), + headers.get(HEADER_KEY)); } @Test - public void trustBoundary_refreshShouldReturnNullWhenDefaultDomainIsNotGoogleApis() + public void regionalAccessBoundary_refreshShouldNotThrowWhenNoValidAccessTokenIsPassed() throws IOException { TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - TrustBoundary.setEnvironmentProviderForTest(environmentProvider); - environmentProvider.setEnv(TrustBoundary.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED_ENV_VAR, "true"); - - MockTokenServerTransport transport = new MockTokenServerTransport(); - transport.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) - .setHttpTransportFactory(() -> transport) - .setScopes(SCOPES) - .setUniverseDomain("other.universe") - .build(); - - credentials.refreshAccessToken(); - assertNull(credentials.getTrustBoundary()); - } - - @Test - public void trustBoundary_refreshShouldThrowWhenNoValidAccessTokenIsPassed() throws IOException { - TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - TrustBoundary.setEnvironmentProviderForTest(environmentProvider); - environmentProvider.setEnv(TrustBoundary.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED_ENV_VAR, "true"); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "true"); MockTokenServerTransport transport = new MockTokenServerTransport(); // Return an expired access token. @@ -1086,20 +1079,22 @@ public void trustBoundary_refreshShouldThrowWhenNoValidAccessTokenIsPassed() thr .setScopes(SCOPES) .build(); - IllegalArgumentException exception = - assertThrows(IllegalArgumentException.class, () -> credentials.getRequestMetadata()); - assertEquals("The provided access token is expired.", exception.getMessage()); + // Should not throw, but just fail-open (no header). + Map> headers = credentials.getRequestMetadata(); + assertNull(headers.get(HEADER_KEY)); } @Test - public void trustBoundary_refreshShouldReturnNoOpIfResponseFromLookupIsNoOp() throws IOException { + public void regionalAccessBoundary_cooldownDoublingAndRefresh() + throws IOException, InterruptedException { TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - TrustBoundary.setEnvironmentProviderForTest(environmentProvider); - environmentProvider.setEnv(TrustBoundary.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED_ENV_VAR, "true"); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "true"); MockTokenServerTransport transport = new MockTokenServerTransport(); transport.addServiceAccount(SA_CLIENT_EMAIL, ACCESS_TOKEN); - transport.setTrustBoundary(new TrustBoundary("0x0", Collections.emptyList())); + // Always fail lookup for now. + transport.addResponseErrorSequence(new IOException("Persistent Failure")); ServiceAccountCredentials credentials = ServiceAccountCredentials.newBuilder() @@ -1110,209 +1105,218 @@ public void trustBoundary_refreshShouldReturnNoOpIfResponseFromLookupIsNoOp() th .setScopes(SCOPES) .build(); - credentials.refresh(); + TestClock testClock = new TestClock(); + RABManager.setClockForTest(testClock); + RegionalAccessBoundary.setMaxRetryElapsedTimeMillisForTest(100); - assertTrue(credentials.getTrustBoundary().isNoOp()); - } + // First attempt: triggers lookup, fails, enters 15m cooldown. + credentials.getRequestMetadata(); + waitForCooldownActive(credentials); + assertTrue(credentials.rabManager.isCooldownActive()); + assertEquals(15 * 60 * 1000L, credentials.rabManager.getCurrentCooldownMillis()); - @Test - public void trustBoundary_refreshShouldReturnNoOpAndNotCallLookupEndpointWhenCachedIsNoOp() - throws IOException { - TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - TrustBoundary.setEnvironmentProviderForTest(environmentProvider); - environmentProvider.setEnv(TrustBoundary.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED_ENV_VAR, "true"); + // Second attempt (during cooldown): does not trigger lookup. + credentials.getRequestMetadata(); + assertTrue(credentials.rabManager.isCooldownActive()); - MockTokenServerTransport transport = new MockTokenServerTransport(); - transport.addServiceAccount(SA_CLIENT_EMAIL, ACCESS_TOKEN); - transport.setTrustBoundary(new TrustBoundary("0x0", Collections.emptyList())); + // Fast-forward past 15m cooldown. + testClock.advanceTime(16 * 60 * 1000L); + assertFalse(credentials.rabManager.isCooldownActive()); - 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(); + // Third attempt (cooldown expired): triggers lookup, fails again, cooldown should double. + credentials.getRequestMetadata(); + waitForCooldownActive(credentials); + assertTrue(credentials.rabManager.isCooldownActive()); + assertEquals(30 * 60 * 1000L, credentials.rabManager.getCurrentCooldownMillis()); + + // Fast-forward past 30m cooldown. + testClock.advanceTime(31 * 60 * 1000L); + assertFalse(credentials.rabManager.isCooldownActive()); - // First refresh to cache the no-op trust boundary. - credentials.refresh(); + // Set successful response. + transport.setRegionalAccessBoundary( + new RegionalAccessBoundary("0x123", Collections.emptyList())); - // Set trust boundary to a valid non No-Op value. - transport.setTrustBoundary( - new TrustBoundary( - TestUtils.TRUST_BOUNDARY_ENCODED_LOCATION, TestUtils.TRUST_BOUNDARY_LOCATIONS)); + // Fourth attempt: triggers lookup, succeeds, resets cooldown. + credentials.getRequestMetadata(); + waitForRegionalAccessBoundary(credentials); + assertFalse(credentials.rabManager.isCooldownActive()); + assertEquals("0x123", credentials.getRegionalAccessBoundary().getEncodedLocations()); + assertEquals(15 * 60 * 1000L, credentials.rabManager.getCurrentCooldownMillis()); + } - // Refresh trust boundaries - credentials.refresh(); + @Test + public void regionalAccessBoundary_manualOverride() throws IOException { + RegionalAccessBoundary manualRAB = + new RegionalAccessBoundary("0x999", Collections.singletonList("us-east1")); + GoogleCredentials credentials = + GoogleCredentials.newBuilder() + .setAccessToken(new AccessToken(ACCESS_TOKEN, null)) + .setRegionalAccessBoundary(manualRAB) + .build(); - // Check whether the trust boundaries are still no_op. - assertTrue(credentials.getTrustBoundary().isNoOp()); + Map> headers = credentials.getRequestMetadata(); + assertEquals(Collections.singletonList("0x999"), headers.get(HEADER_KEY)); } @Test - public void trustBoundary_refreshShouldReturnCachedTbIfCallToLookupFails() throws IOException { + public void regionalAccessBoundary_staleRabErrorInitiatesBackgroundLookup() + throws IOException, InterruptedException { TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - TrustBoundary.setEnvironmentProviderForTest(environmentProvider); - environmentProvider.setEnv(TrustBoundary.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED_ENV_VAR, "true"); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "true"); MockTokenServerTransport transport = new MockTokenServerTransport(); - transport.addServiceAccount(SA_CLIENT_EMAIL, ACCESS_TOKEN); - TrustBoundary trustBoundary = - new TrustBoundary( - TestUtils.TRUST_BOUNDARY_ENCODED_LOCATION, TestUtils.TRUST_BOUNDARY_LOCATIONS); - transport.setTrustBoundary(trustBoundary); - - 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(); + // Ensure success response. + transport.setRegionalAccessBoundary( + new RegionalAccessBoundary("new-rab", Collections.singletonList("us-central1"))); - // First refresh to cache the trust boundary. - credentials.refresh(); + GoogleCredentials credentials = createTestCredentials(transport); + // Seed with an "old" RAB. + credentials.setRegionalAccessBoundary( + new RegionalAccessBoundary("old-rab", Collections.singletonList("us-central1"))); - // Set the trust boundary to be returned to null so we get an exception. - transport.setTrustBoundary(null); + // Reactive refresh should clear cache and start a background lookup. + // We pass the token explicitly to avoid triggering a refresh inside getRequestMetadata. + credentials.reactiveRefreshRegionalAccessBoundary( + CALL_URI, new AccessToken(ACCESS_TOKEN, null)); - credentials.refresh(); + // Current cache should be null (cleared). + assertNull(credentials.getRegionalAccessBoundary()); - assertEquals( - TestUtils.TRUST_BOUNDARY_ENCODED_LOCATION, - credentials.getTrustBoundary().getEncodedLocations()); + waitForRegionalAccessBoundary(credentials); + assertEquals("new-rab", credentials.getRegionalAccessBoundary().getEncodedLocations()); + // Verify one lookup was made. + assertEquals(1, transport.getRegionalAccessBoundaryRequestCount()); } @Test - public void trustBoundary_refreshShouldThrowIfCallToLookupFailsAndNoCachedTb() - throws IOException { - TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - TrustBoundary.setEnvironmentProviderForTest(environmentProvider); - environmentProvider.setEnv(TrustBoundary.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED_ENV_VAR, "true"); - - MockTokenServerTransport transport = new MockTokenServerTransport(); - transport.addServiceAccount(SA_CLIENT_EMAIL, ACCESS_TOKEN); - transport.addResponseErrorSequence(new IOException("Service Unavailable")); + public void regionalAccessBoundary_shouldFailOpenWhenRefreshCannotBeStarted() throws IOException { + // Use a simple AccessToken-based credential that won't try to refresh. + GoogleCredentials credentials = GoogleCredentials.create(new AccessToken("some-token", null)); - 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(); - IOException exception = assertThrows(IOException.class, () -> credentials.refresh()); - assertTrue( - exception - .getMessage() - .contains("Failed to refresh trust boundary and no cached value is available.")); + // Should not throw, but just fail-open (no header). + Map> headers = credentials.getRequestMetadata(); + assertNull(headers.get(HEADER_KEY)); } @Test - public void trustBoundary_refreshShouldThrowInCaseOfMalformedResponse() throws IOException { + public void regionalAccessBoundary_deduplicationOfConcurrentRefreshes() + throws IOException, InterruptedException { TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - TrustBoundary.setEnvironmentProviderForTest(environmentProvider); - environmentProvider.setEnv(TrustBoundary.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED_ENV_VAR, "true"); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "true"); MockTokenServerTransport transport = new MockTokenServerTransport(); - transport.addServiceAccount(SA_CLIENT_EMAIL, ACCESS_TOKEN); - // The transport will return a response with no encodedLocations field. - transport.setTrustBoundary(new TrustBoundary(null, Collections.emptyList())); + transport.setRegionalAccessBoundary( + new RegionalAccessBoundary("valid", Collections.singletonList("us-central1"))); + // 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(); + } - 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(); + waitForRegionalAccessBoundary(credentials); - IOException exception = assertThrows(IOException.class, () -> credentials.refresh()); - assertTrue( - exception - .getMessage() - .contains("Failed to refresh trust boundary and no cached value is available.")); + // Only ONE request should have been made to the lookup endpoint. + assertEquals(1, transport.getRegionalAccessBoundaryRequestCount()); } @Test - public void trustBoundary_getRequestHeadersShouldAttachTrustBoundaryHeader() throws IOException { + public void regionalAccessBoundary_shouldSkipRefreshForRegionalEndpoints() throws IOException { TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - TrustBoundary.setEnvironmentProviderForTest(environmentProvider); - environmentProvider.setEnv(TrustBoundary.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED_ENV_VAR, "true"); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "true"); MockTokenServerTransport transport = new MockTokenServerTransport(); - transport.addServiceAccount(SA_CLIENT_EMAIL, ACCESS_TOKEN); - TrustBoundary trustBoundary = - new TrustBoundary( - TestUtils.TRUST_BOUNDARY_ENCODED_LOCATION, Collections.singletonList("us-central1")); - transport.setTrustBoundary(trustBoundary); - - 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(); + GoogleCredentials credentials = createTestCredentials(transport); - Map> headers = credentials.getRequestMetadata(); + URI regionalUri = URI.create("https://storage.us-central1.rep.googleapis.com/v1/b/foo"); + credentials.getRequestMetadata(regionalUri); - assertEquals( - Arrays.asList(TestUtils.TRUST_BOUNDARY_ENCODED_LOCATION), headers.get(TRUST_BOUNDARY_KEY)); + // Should not have triggered any lookup. + assertEquals(0, transport.getRegionalAccessBoundaryRequestCount()); } @Test - public void trustBoundary_getRequestHeadersShouldAttachEmptyStringTbHeaderInCaseOfNoOp() - throws IOException { + public void regionalAccessBoundary_staleHeaderPrevention() + throws IOException, InterruptedException { TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - TrustBoundary.setEnvironmentProviderForTest(environmentProvider); - environmentProvider.setEnv(TrustBoundary.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED_ENV_VAR, "true"); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "true"); MockTokenServerTransport transport = new MockTokenServerTransport(); - transport.addServiceAccount(SA_CLIENT_EMAIL, ACCESS_TOKEN); - transport.setTrustBoundary(new TrustBoundary("0x0", Collections.emptyList())); + // Start with an expired RAB (using 0 as expiration timestamp). + RegionalAccessBoundary expiredRAB = + new RegionalAccessBoundary("expired-rab", Collections.singletonList("us-central1"), 0L); + transport.setRegionalAccessBoundary(expiredRAB); - 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(); + GoogleCredentials credentials = createTestCredentials(transport); + credentials.setRegionalAccessBoundary(expiredRAB); - Map> headers = credentials.getRequestMetadata(); + // Verify it is considered expired. + assertNull(credentials.getRegionalAccessBoundary()); - assertEquals(Arrays.asList(""), headers.get(TRUST_BOUNDARY_KEY)); + // Call should NOT have the stale header. + Map> headers = credentials.getRequestMetadata(); + assertNull(headers.get(HEADER_KEY)); } - @Test - public void trustBoundary_getRequestHeadersShouldNotAttachTbHeaderInCaseOfNonGduUniverse() + private GoogleCredentials createTestCredentials(MockTokenServerTransport transport) throws IOException { - TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - TrustBoundary.setEnvironmentProviderForTest(environmentProvider); - environmentProvider.setEnv(TrustBoundary.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED_ENV_VAR, "true"); - - MockTokenServerTransport transport = new MockTokenServerTransport(); 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"); + } + } - ServiceAccountCredentials credentials = - ServiceAccountCredentials.newBuilder() - .setClientEmail(SA_CLIENT_EMAIL) - .setPrivateKey(OAuth2Utils.privateKeyFromPkcs8(SA_PRIVATE_KEY_PKCS8)) - .setPrivateKeyId(SA_PRIVATE_KEY_ID) - .setHttpTransportFactory(() -> transport) - .setScopes(SCOPES) - .setUniverseDomain("other.universe") - .build(); + private void waitForCooldownActive(GoogleCredentials credentials) throws InterruptedException { + long deadline = System.currentTimeMillis() + 5000; + while (!credentials.rabManager.isCooldownActive() && System.currentTimeMillis() < deadline) { + Thread.sleep(100); + } + if (!credentials.rabManager.isCooldownActive()) { + fail("Timed out waiting for cooldown to become active"); + } + } - Map> headers = credentials.getRequestMetadata(); + private static class TestClock implements Clock { + private final AtomicLong currentTime = new AtomicLong(System.currentTimeMillis()); - assertNull(headers.get(TRUST_BOUNDARY_KEY)); + @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 a48e56c7e..9a2fc6193 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java @@ -1306,9 +1306,9 @@ void setShouldThrowOnGetCertificatePath(boolean shouldThrow) { } @Test - public void testRefresh_trustBoundarySuccess() throws IOException { + public void testRefresh_regionalAccessBoundarySuccess() throws IOException, InterruptedException { TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - TrustBoundary.setEnvironmentProviderForTest(environmentProvider); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); environmentProvider.setEnv("GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT", "1"); MockExternalAccountCredentialsTransportFactory transportFactory = @@ -1325,11 +1325,29 @@ public void testRefresh_trustBoundarySuccess() throws IOException { .setTokenUrl(STS_URL) .build(); - credentials.refresh(); + // First call: initiates async refresh. + Map> headers = credentials.getRequestMetadata(); + assertNull(headers.get(RegionalAccessBoundary.HEADER_KEY)); - TrustBoundary trustBoundary = credentials.getTrustBoundary(); - assertNotNull(trustBoundary); - assertEquals(TestUtils.TRUST_BOUNDARY_ENCODED_LOCATION, trustBoundary.getEncodedLocations()); - TrustBoundary.setEnvironmentProviderForTest(null); + waitForRegionalAccessBoundary(credentials); + + // Second call: should have header. + headers = credentials.getRequestMetadata(); + assertEquals( + headers.get(RegionalAccessBoundary.HEADER_KEY), + Arrays.asList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION)); + RegionalAccessBoundary.setEnvironmentProviderForTest(null); + } + + 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 abe5d4ed7..d8c3fef2b 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java @@ -31,7 +31,7 @@ package com.google.auth.oauth2; -import static com.google.auth.oauth2.TrustBoundary.TRUST_BOUNDARY_KEY; +import static com.google.auth.oauth2.RegionalAccessBoundary.HEADER_KEY; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -156,9 +156,10 @@ 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 TrustBoundary TRUST_BOUNDARY = - new TrustBoundary( - TestUtils.TRUST_BOUNDARY_ENCODED_LOCATION, TestUtils.TRUST_BOUNDARY_LOCATIONS); + public static final RegionalAccessBoundary REGIONAL_ACCESS_BOUNDARY = + new RegionalAccessBoundary( + TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION, + TestUtils.REGIONAL_ACCESS_BOUNDARY_LOCATIONS); private GoogleCredentials sourceCredentials; private MockIAMCredentialsServiceTransportFactory mockTransportFactory; @@ -171,7 +172,7 @@ public void setup() throws IOException { @After public void tearDown() { - TrustBoundary.setEnvironmentProviderForTest(null); + RegionalAccessBoundary.setEnvironmentProviderForTest(null); } static GoogleCredentials getSourceCredentials() throws IOException { @@ -187,7 +188,7 @@ static GoogleCredentials getSourceCredentials() throws IOException { .setHttpTransportFactory(transportFactory) .build(); transportFactory.transport.addServiceAccount(SA_CLIENT_EMAIL, ACCESS_TOKEN); - transportFactory.transport.setTrustBoundary(TRUST_BOUNDARY); + transportFactory.transport.setRegionalAccessBoundary(REGIONAL_ACCESS_BOUNDARY); return sourceCredentials; } @@ -1315,15 +1316,15 @@ public void serialize() throws IOException, ClassNotFoundException { } @Test - public void refresh_trustBoundarySuccess() throws IOException { + public void refresh_regionalAccessBoundarySuccess() throws IOException, InterruptedException { TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - TrustBoundary.setEnvironmentProviderForTest(environmentProvider); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); environmentProvider.setEnv("GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT", "1"); - // Mock trust boundary response - TrustBoundary trustBoundary = TRUST_BOUNDARY; + // Mock regional access boundary response + RegionalAccessBoundary regionalAccessBoundary = REGIONAL_ACCESS_BOUNDARY; - mockTransportFactory.getTransport().setTrustBoundary(trustBoundary); + mockTransportFactory.getTransport().setRegionalAccessBoundary(regionalAccessBoundary); mockTransportFactory.getTransport().setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL); mockTransportFactory.getTransport().setAccessToken(ACCESS_TOKEN); mockTransportFactory.getTransport().setExpireTime(getDefaultExpireTime()); @@ -1340,40 +1341,29 @@ public void refresh_trustBoundarySuccess() throws IOException { VALID_LIFETIME, mockTransportFactory); + // First call: initiates async refresh. Map> headers = targetCredentials.getRequestMetadata(); - assertEquals( - headers.get(TRUST_BOUNDARY_KEY), - Collections.singletonList(TestUtils.TRUST_BOUNDARY_ENCODED_LOCATION)); - } + assertNull(headers.get(HEADER_KEY)); - @Test - public void refresh_trustBoundaryFails_throwsIOException() throws IOException { - TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - TrustBoundary.setEnvironmentProviderForTest(environmentProvider); - environmentProvider.setEnv("GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT", "1"); + waitForRegionalAccessBoundary(targetCredentials); - 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); + // Second call: should have header. + headers = targetCredentials.getRequestMetadata(); + assertEquals( + headers.get(HEADER_KEY), + Collections.singletonList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION)); + } - IOException exception = assertThrows(IOException.class, () -> targetCredentials.refresh()); - assertTrue( - "The exception message should explain why the refresh failed.", - exception - .getMessage() - .contains("Failed to refresh trust boundary and no cached value is available.")); + 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() { diff --git a/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java b/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java index 7be65adf3..33e1998df 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,7 +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 TRUST_BOUNDARY_URL_END = "/allowedLocations"; + private static final String REGIONAL_ACCESS_BOUNDARY_URL_END = "/allowedLocations"; private static final String SUBJECT_TOKEN = "subjectToken"; private static final String TOKEN_TYPE = "Bearer"; @@ -93,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); @@ -198,11 +204,18 @@ public LowLevelHttpResponse execute() throws IOException { if (url.contains(IAM_ENDPOINT)) { - if (url.endsWith(TRUST_BOUNDARY_URL_END)) { + 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); + } GenericJson responseJson = new GenericJson(); responseJson.setFactory(OAuth2Utils.JSON_FACTORY); - responseJson.put("encodedLocations", TestUtils.TRUST_BOUNDARY_ENCODED_LOCATION); - responseJson.put("locations", TestUtils.TRUST_BOUNDARY_LOCATIONS); + responseJson.put("encodedLocations", rab.getEncodedLocations()); + responseJson.put("locations", rab.getLocations()); String content = responseJson.toPrettyString(); return new MockLowLevelHttpResponse() .setContentType(Json.MEDIA_TYPE) diff --git a/oauth2_http/javatests/com/google/auth/oauth2/MockIAMCredentialsServiceTransport.java b/oauth2_http/javatests/com/google/auth/oauth2/MockIAMCredentialsServiceTransport.java index 9994f3040..5346f4fdb 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/MockIAMCredentialsServiceTransport.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/MockIAMCredentialsServiceTransport.java @@ -80,7 +80,7 @@ public ServerResponse(int statusCode, String response, boolean repeatServerRespo private String universeDomain; - private TrustBoundary trustBoundary; + private RegionalAccessBoundary regionalAccessBoundary; private MockLowLevelHttpRequest request; @@ -134,8 +134,8 @@ public void setAccessTokenEndpoint(String accessTokenEndpoint) { this.iamAccessTokenEndpoint = accessTokenEndpoint; } - public void setTrustBoundary(TrustBoundary trustBoundary) { - this.trustBoundary = trustBoundary; + public void setRegionalAccessBoundary(RegionalAccessBoundary regionalAccessBoundary) { + this.regionalAccessBoundary = regionalAccessBoundary; } public MockLowLevelHttpRequest getRequest() { @@ -232,13 +232,13 @@ public LowLevelHttpResponse execute() throws IOException { new MockLowLevelHttpRequest(url) { @Override public LowLevelHttpResponse execute() throws IOException { - if (trustBoundary == null) { + if (regionalAccessBoundary == null) { return new MockLowLevelHttpResponse().setStatusCode(404); } GenericJson responseJson = new GenericJson(); responseJson.setFactory(OAuth2Utils.JSON_FACTORY); - responseJson.put("encodedLocations", trustBoundary.getEncodedLocations()); - responseJson.put("locations", trustBoundary.getLocations()); + responseJson.put("encodedLocations", regionalAccessBoundary.getEncodedLocations()); + responseJson.put("locations", regionalAccessBoundary.getLocations()); String content = responseJson.toPrettyString(); return new MockLowLevelHttpResponse() .setContentType(Json.MEDIA_TYPE) diff --git a/oauth2_http/javatests/com/google/auth/oauth2/MockMetadataServerTransport.java b/oauth2_http/javatests/com/google/auth/oauth2/MockMetadataServerTransport.java index 3e90d0faa..70012330b 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/MockMetadataServerTransport.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/MockMetadataServerTransport.java @@ -73,7 +73,8 @@ public class MockMetadataServerTransport extends MockHttpTransport { private boolean emptyContent; private MockLowLevelHttpRequest request; - private TrustBoundary trustBoundary; + private RegionalAccessBoundary regionalAccessBoundary; + private IOException lookupError; public MockMetadataServerTransport() {} @@ -122,8 +123,12 @@ public void setEmptyContent(boolean emptyContent) { this.emptyContent = emptyContent; } - public void setTrustBoundary(TrustBoundary trustBoundary) { - this.trustBoundary = trustBoundary; + public void setRegionalAccessBoundary(RegionalAccessBoundary regionalAccessBoundary) { + this.regionalAccessBoundary = regionalAccessBoundary; + } + + public void setLookupError(IOException lookupError) { + this.lookupError = lookupError; } public MockLowLevelHttpRequest getRequest() { @@ -147,7 +152,7 @@ public LowLevelHttpRequest buildRequest(String method, String url) throws IOExce } else if (isMtlsConfigRequestUrl(url)) { return getMockRequestForMtlsConfig(url); } else if (isIamLookupUrl(url)) { - return getMockRequestForTrustBoundaryLookup(url); + return getMockRequestForRegionalAccessBoundaryLookup(url); } this.request = new MockLowLevelHttpRequest(url) { @@ -370,17 +375,20 @@ protected boolean isMtlsConfigRequestUrl(String url) { + SecureSessionAgent.S2A_CONFIG_ENDPOINT_POSTFIX); } - private MockLowLevelHttpRequest getMockRequestForTrustBoundaryLookup(String url) { + private MockLowLevelHttpRequest getMockRequestForRegionalAccessBoundaryLookup(String url) { return new MockLowLevelHttpRequest(url) { @Override public LowLevelHttpResponse execute() throws IOException { - if (trustBoundary == null) { + 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", trustBoundary.getEncodedLocations()); - responseJson.put("locations", trustBoundary.getLocations()); + responseJson.put("encodedLocations", regionalAccessBoundary.getEncodedLocations()); + responseJson.put("locations", regionalAccessBoundary.getLocations()); String content = responseJson.toPrettyString(); return new MockLowLevelHttpResponse().setContentType(Json.MEDIA_TYPE).setContent(content); } @@ -388,7 +396,7 @@ public LowLevelHttpResponse execute() throws IOException { } protected boolean isIamLookupUrl(String url) { - // Mocking call to the /allowedLocations endpoint for trust boundary refresh. + // 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. diff --git a/oauth2_http/javatests/com/google/auth/oauth2/MockStsTransport.java b/oauth2_http/javatests/com/google/auth/oauth2/MockStsTransport.java index e3a50d027..8d98d430c 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/MockStsTransport.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/MockStsTransport.java @@ -62,7 +62,7 @@ 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_TRUST_BOUNDARY_PATTERN = + 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"; @@ -103,15 +103,16 @@ public LowLevelHttpRequest buildRequest(final String method, final String url) { public LowLevelHttpResponse execute() throws IOException { // Mocking call to refresh trust boundaries. // The lookup endpoint is located in the IAM server. - Matcher trustBoundaryMatcher = - Pattern.compile(VALID_TRUST_BOUNDARY_PATTERN).matcher(url); - if (trustBoundaryMatcher.matches()) { - // Mocking call to the /allowedLocations endpoint for trust boundary refresh. + 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.TRUST_BOUNDARY_LOCATIONS); - response.put("encodedLocations", TestUtils.TRUST_BOUNDARY_ENCODED_LOCATION); + 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)); diff --git a/oauth2_http/javatests/com/google/auth/oauth2/MockTokenServerTransport.java b/oauth2_http/javatests/com/google/auth/oauth2/MockTokenServerTransport.java index 76ef3f807..42b4396fd 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/MockTokenServerTransport.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/MockTokenServerTransport.java @@ -77,10 +77,25 @@ public class MockTokenServerTransport extends MockHttpTransport { private MockLowLevelHttpRequest request; private ClientAuthenticationType clientAuthenticationType; private PKCEProvider pkceProvider; - private TrustBoundary trustBoundary; + private RegionalAccessBoundary regionalAccessBoundary; + private final Map regionalAccessBoundaries = new HashMap<>(); + private int regionalAccessBoundaryRequestCount = 0; + private int responseDelayMillis = 0; - public void setTrustBoundary(TrustBoundary trustBoundary) { - this.trustBoundary = trustBoundary; + public void setRegionalAccessBoundary(RegionalAccessBoundary regionalAccessBoundary) { + this.regionalAccessBoundary = regionalAccessBoundary; + } + + public void addRegionalAccessBoundary(String url, RegionalAccessBoundary regionalAccessBoundary) { + this.regionalAccessBoundaries.put(url, regionalAccessBoundary); + } + + public int getRegionalAccessBoundaryRequestCount() { + return regionalAccessBoundaryRequestCount; + } + + public void setResponseDelayMillis(int responseDelayMillis) { + this.responseDelayMillis = responseDelayMillis; } public MockTokenServerTransport() {} @@ -327,7 +342,7 @@ public LowLevelHttpResponse execute() throws IOException { }; return request; } else if (urlWithoutQuery.endsWith("/allowedLocations")) { - // Mocking call to the /allowedLocations endpoint for trust boundary refresh. + // 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. @@ -335,13 +350,25 @@ public LowLevelHttpResponse execute() throws IOException { new MockLowLevelHttpRequest(url) { @Override public LowLevelHttpResponse execute() throws IOException { - if (trustBoundary == null) { + regionalAccessBoundaryRequestCount++; + if (responseDelayMillis > 0) { + try { + Thread.sleep(responseDelayMillis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + RegionalAccessBoundary rab = regionalAccessBoundaries.get(url); + if (rab == null) { + rab = regionalAccessBoundary; + } + if (rab == null) { return new MockLowLevelHttpResponse().setStatusCode(404); } GenericJson responseJson = new GenericJson(); responseJson.setFactory(JSON_FACTORY); - responseJson.put("encodedLocations", trustBoundary.getEncodedLocations()); - responseJson.put("locations", trustBoundary.getLocations()); + responseJson.put("encodedLocations", rab.getEncodedLocations()); + responseJson.put("locations", rab.getLocations()); String content = responseJson.toPrettyString(); return new MockLowLevelHttpResponse() .setContentType(Json.MEDIA_TYPE) diff --git a/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java index fa21acc0e..81fc4879a 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java @@ -604,9 +604,9 @@ public void serialize() throws IOException, ClassNotFoundException { } @Test - public void testRefresh_trustBoundarySuccess() throws IOException { + public void testRefresh_regionalAccessBoundarySuccess() throws IOException, InterruptedException { TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - TrustBoundary.setEnvironmentProviderForTest(environmentProvider); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); environmentProvider.setEnv("GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT", "1"); MockExternalAccountCredentialsTransportFactory transportFactory = @@ -624,11 +624,30 @@ public void testRefresh_trustBoundarySuccess() throws IOException { .setExecutableHandler(options -> "pluggableAuthToken") .build(); - credentials.refresh(); - TrustBoundary trustBoundary = credentials.getTrustBoundary(); - assertNotNull(trustBoundary); - assertEquals(TestUtils.TRUST_BOUNDARY_ENCODED_LOCATION, trustBoundary.getEncodedLocations()); - TrustBoundary.setEnvironmentProviderForTest(null); + // First call: initiates async refresh. + Map> headers = credentials.getRequestMetadata(); + assertNull(headers.get(RegionalAccessBoundary.HEADER_KEY)); + + waitForRegionalAccessBoundary(credentials); + + // Second call: should have header. + headers = credentials.getRequestMetadata(); + assertEquals( + headers.get(RegionalAccessBoundary.HEADER_KEY), + Arrays.asList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION)); + RegionalAccessBoundary.setEnvironmentProviderForTest(null); + } + + 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() { diff --git a/oauth2_http/javatests/com/google/auth/oauth2/ServiceAccountCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/ServiceAccountCredentialsTest.java index ca275f1e3..62f7d4012 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/ServiceAccountCredentialsTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/ServiceAccountCredentialsTest.java @@ -31,7 +31,7 @@ package com.google.auth.oauth2; -import static com.google.auth.oauth2.TrustBoundary.TRUST_BOUNDARY_KEY; +import static com.google.auth.oauth2.RegionalAccessBoundary.HEADER_KEY; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -164,7 +164,7 @@ static ServiceAccountCredentials.Builder createDefaultBuilder() throws IOExcepti @After public void tearDown() { - TrustBoundary.setEnvironmentProviderForTest(null); + RegionalAccessBoundary.setEnvironmentProviderForTest(null); } @Test @@ -1810,19 +1810,20 @@ public void createScopes_existingAccessTokenInvalidated() throws IOException { } @Test - public void refresh_trustBoundarySuccess() throws IOException { + public void refresh_regionalAccessBoundarySuccess() throws IOException, InterruptedException { TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - TrustBoundary.setEnvironmentProviderForTest(environmentProvider); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); environmentProvider.setEnv("GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT", "1"); - // Mock trust boundary response - TrustBoundary trustBoundary = - new TrustBoundary( - TestUtils.TRUST_BOUNDARY_ENCODED_LOCATION, TestUtils.TRUST_BOUNDARY_LOCATIONS); + // Mock regional access boundary response + RegionalAccessBoundary regionalAccessBoundary = + new RegionalAccessBoundary( + TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION, + TestUtils.REGIONAL_ACCESS_BOUNDARY_LOCATIONS); MockTokenServerTransport transport = new MockTokenServerTransport(); transport.addServiceAccount(CLIENT_EMAIL, "test-access-token"); - transport.setTrustBoundary(trustBoundary); + transport.setRegionalAccessBoundary(regionalAccessBoundary); ServiceAccountCredentials credentials = ServiceAccountCredentials.newBuilder() @@ -1834,19 +1835,33 @@ public void refresh_trustBoundarySuccess() throws IOException { .setScopes(SCOPES) .build(); + // First call: initiates async refresh. Map> headers = credentials.getRequestMetadata(); + assertNull(headers.get(HEADER_KEY)); + + waitForRegionalAccessBoundary(credentials); + + // Second call: should have header. + headers = credentials.getRequestMetadata(); assertEquals( - headers.get(TRUST_BOUNDARY_KEY), Arrays.asList(TestUtils.TRUST_BOUNDARY_ENCODED_LOCATION)); + headers.get(HEADER_KEY), + Arrays.asList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION)); } @Test - public void refresh_trustBoundaryFails_throwsIOException() throws IOException { + public void refresh_regionalAccessBoundary_selfSignedJWT() + throws IOException, InterruptedException { TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - TrustBoundary.setEnvironmentProviderForTest(environmentProvider); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); environmentProvider.setEnv("GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT", "1"); + RegionalAccessBoundary regionalAccessBoundary = + new RegionalAccessBoundary( + TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION, + TestUtils.REGIONAL_ACCESS_BOUNDARY_LOCATIONS); + MockTokenServerTransport transport = new MockTokenServerTransport(); - transport.addServiceAccount(CLIENT_EMAIL, "test-access-token"); + transport.setRegionalAccessBoundary(regionalAccessBoundary); ServiceAccountCredentials credentials = ServiceAccountCredentials.newBuilder() @@ -1855,14 +1870,30 @@ public void refresh_trustBoundaryFails_throwsIOException() throws IOException { OAuth2Utils.privateKeyFromPkcs8(ServiceAccountCredentialsTest.PRIVATE_KEY_PKCS8)) .setPrivateKeyId("test-key-id") .setHttpTransportFactory(() -> transport) + .setUseJwtAccessWithScope(true) .setScopes(SCOPES) .build(); - IOException exception = assertThrows(IOException.class, () -> credentials.refresh()); - assertTrue( - "The exception message should explain why the refresh failed.", - exception - .getMessage() - .contains("Failed to refresh trust boundary and no cached value is available.")); + + // First call: initiates async refresh using the SSJWT as the token. + credentials.getRequestMetadata(); + + waitForRegionalAccessBoundary(credentials); + + 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"); + } } private void verifyJwtAccess(Map> metadata, String expectedScopeClaim)