From c1505b4a1060d0385f7b78a0824b1835f36b003d Mon Sep 17 00:00:00 2001 From: werman Date: Sun, 19 Apr 2026 15:34:46 -0700 Subject: [PATCH 1/4] feat(auth): Introduce Regional Access Boundaries for GoogleCredentials (#12787) Migrates RAB changes from the older repo -> https://github.com/googleapis/google-auth-library-java/tree/feat-tb-sa --- .../auth/oauth2/ComputeEngineCredentials.java | 16 +- ...ernalAccountAuthorizedUserCredentials.java | 25 +- .../oauth2/ExternalAccountCredentials.java | 42 +- .../google/auth/oauth2/GoogleCredentials.java | 212 ++++++++- .../auth/oauth2/ImpersonatedCredentials.java | 14 +- .../google/auth/oauth2/OAuth2Credentials.java | 30 +- .../com/google/auth/oauth2/OAuth2Utils.java | 17 + .../auth/oauth2/RegionalAccessBoundary.java | 280 ++++++++++++ .../oauth2/RegionalAccessBoundaryManager.java | 244 +++++++++++ .../RegionalAccessBoundaryProvider.java | 50 +++ .../oauth2/ServiceAccountCredentials.java | 39 +- .../javatests/com/google/auth/TestUtils.java | 9 +- .../auth/oauth2/AwsCredentialsTest.java | 56 +++ .../oauth2/ComputeEngineCredentialsTest.java | 55 ++- ...lAccountAuthorizedUserCredentialsTest.java | 51 ++- .../ExternalAccountCredentialsTest.java | 286 +++++++++++- .../auth/oauth2/GoogleCredentialsTest.java | 410 ++++++++++++++++++ .../oauth2/IdentityPoolCredentialsTest.java | 55 +++ .../oauth2/ImpersonatedCredentialsTest.java | 64 +++ .../com/google/auth/oauth2/LoggingTest.java | 10 + ...ckExternalAccountCredentialsTransport.java | 31 +- .../MockIAMCredentialsServiceTransport.java | 25 ++ .../oauth2/MockMetadataServerTransport.java | 43 +- .../google/auth/oauth2/MockStsTransport.java | 19 + .../auth/oauth2/MockTokenServerTransport.java | 49 +++ .../oauth2/PluggableAuthCredentialsTest.java | 53 +++ .../oauth2/RegionalAccessBoundaryTest.java | 220 ++++++++++ .../oauth2/ServiceAccountCredentialsTest.java | 103 +++++ .../samples/snippets/pom.xml | 1 - .../cloud/dataplex/v1/ContentServiceGrpc.java | 1 - 30 files changed, 2470 insertions(+), 40 deletions(-) create mode 100644 google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundary.java create mode 100644 google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundaryManager.java create mode 100644 google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundaryProvider.java create mode 100644 google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/RegionalAccessBoundaryTest.java diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java index ad5fb8e7dcf3..bcfe916c3168 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java @@ -41,6 +41,7 @@ import com.google.api.client.http.HttpStatusCodes; import com.google.api.client.json.JsonObjectParser; import com.google.api.client.util.GenericData; +import com.google.api.core.InternalApi; import com.google.auth.CredentialTypeForMetrics; import com.google.auth.Credentials; import com.google.auth.Retryable; @@ -80,7 +81,7 @@ *

These credentials use the IAM API to sign data. See {@link #sign(byte[])} for more details. */ public class ComputeEngineCredentials extends GoogleCredentials - implements ServiceAccountSigner, IdTokenProvider { + implements ServiceAccountSigner, IdTokenProvider, RegionalAccessBoundaryProvider { static final String METADATA_RESPONSE_EMPTY_CONTENT_ERROR_MESSAGE = "Empty content from metadata token server request."; @@ -454,7 +455,6 @@ public AccessToken refreshAccessToken() throws IOException { int expiresInSeconds = OAuth2Utils.validateInt32(responseData, "expires_in", PARSE_ERROR_PREFIX); long expiresAtMilliseconds = clock.currentTimeMillis() + expiresInSeconds * 1000; - return new AccessToken(accessToken, new Date(expiresAtMilliseconds)); } @@ -779,6 +779,11 @@ public static Builder newBuilder() { * * @throws RuntimeException if the default service account cannot be read */ + @Override + HttpTransportFactory getTransportFactory() { + return transportFactory; + } + @Override // todo(#314) getAccount should not throw a RuntimeException public String getAccount() { @@ -792,6 +797,13 @@ public String getAccount() { return principal; } + @InternalApi + @Override + public String getRegionalAccessBoundaryUrl() throws IOException { + return String.format( + OAuth2Utils.IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_SERVICE_ACCOUNT, getAccount()); + } + /** * Signs the provided bytes using the private key associated with the service account. * diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentials.java index b274fec76c65..81f95b6de3cb 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentials.java @@ -31,7 +31,9 @@ package com.google.auth.oauth2; +import static com.google.auth.oauth2.OAuth2Utils.IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_WORKFORCE_POOL; import static com.google.auth.oauth2.OAuth2Utils.JSON_FACTORY; +import static com.google.auth.oauth2.OAuth2Utils.WORKFORCE_AUDIENCE_PATTERN; import com.google.api.client.http.GenericUrl; import com.google.api.client.http.HttpHeaders; @@ -43,6 +45,7 @@ import com.google.api.client.json.JsonObjectParser; import com.google.api.client.util.GenericData; import com.google.api.client.util.Preconditions; +import com.google.api.core.InternalApi; import com.google.auth.http.HttpTransportFactory; import com.google.common.base.MoreObjects; import com.google.common.io.BaseEncoding; @@ -54,6 +57,7 @@ import java.util.Date; import java.util.Map; import java.util.Objects; +import java.util.regex.Matcher; import javax.annotation.Nullable; /** @@ -74,7 +78,8 @@ * } * */ -public class ExternalAccountAuthorizedUserCredentials extends GoogleCredentials { +public class ExternalAccountAuthorizedUserCredentials extends GoogleCredentials + implements RegionalAccessBoundaryProvider { private static final LoggerProvider LOGGER_PROVIDER = LoggerProvider.forClazz(ExternalAccountAuthorizedUserCredentials.class); @@ -229,6 +234,24 @@ public AccessToken refreshAccessToken() throws IOException { .build(); } + @InternalApi + @Override + public String getRegionalAccessBoundaryUrl() throws IOException { + Matcher matcher = WORKFORCE_AUDIENCE_PATTERN.matcher(getAudience()); + if (!matcher.matches()) { + throw new IllegalStateException( + "The provided audience is not in the correct format for a workforce pool. " + + "Refer: https://docs.cloud.google.com/iam/docs/principal-identifiers"); + } + String poolId = matcher.group("pool"); + return String.format(IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_WORKFORCE_POOL, poolId); + } + + @Override + HttpTransportFactory getTransportFactory() { + return transportFactory; + } + @Nullable public String getAudience() { return audience; diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java index 47cb398d26bf..189b97863542 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java @@ -31,6 +31,8 @@ package com.google.auth.oauth2; +import static com.google.auth.oauth2.OAuth2Utils.WORKFORCE_AUDIENCE_PATTERN; +import static com.google.auth.oauth2.OAuth2Utils.WORKLOAD_AUDIENCE_PATTERN; import static com.google.common.base.Preconditions.checkNotNull; import com.google.api.client.http.HttpHeaders; @@ -54,6 +56,7 @@ import java.util.Locale; import java.util.Map; import java.util.concurrent.Executor; +import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.annotation.Nullable; @@ -63,7 +66,8 @@ *

Handles initializing external credentials, calls to the Security Token Service, and service * account impersonation. */ -public abstract class ExternalAccountCredentials extends GoogleCredentials { +public abstract class ExternalAccountCredentials extends GoogleCredentials + implements RegionalAccessBoundaryProvider { private static final long serialVersionUID = 8049126194174465023L; @@ -577,6 +581,11 @@ protected AccessToken exchangeExternalCredentialForAccessToken( */ public abstract String retrieveSubjectToken() throws IOException; + @Override + HttpTransportFactory getTransportFactory() { + return transportFactory; + } + public String getAudience() { return audience; } @@ -620,6 +629,37 @@ public String getServiceAccountEmail() { return ImpersonatedCredentials.extractTargetPrincipal(serviceAccountImpersonationUrl); } + @InternalApi + @Override + public String getRegionalAccessBoundaryUrl() throws IOException { + if (getServiceAccountEmail() != null) { + return String.format( + OAuth2Utils.IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_SERVICE_ACCOUNT, + getServiceAccountEmail()); + } + + Matcher workforceMatcher = WORKFORCE_AUDIENCE_PATTERN.matcher(getAudience()); + if (workforceMatcher.matches()) { + String poolId = workforceMatcher.group("pool"); + return String.format( + OAuth2Utils.IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_WORKFORCE_POOL, poolId); + } + + Matcher workloadMatcher = WORKLOAD_AUDIENCE_PATTERN.matcher(getAudience()); + if (workloadMatcher.matches()) { + String projectNumber = workloadMatcher.group("project"); + String poolId = workloadMatcher.group("pool"); + return String.format( + OAuth2Utils.IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_WORKLOAD_POOL, + projectNumber, + poolId); + } + + throw new IllegalStateException( + "The provided audience is not in a valid format for either a workload identity pool or a workforce pool." + + " Refer: https://docs.cloud.google.com/iam/docs/principal-identifiers"); + } + @Nullable public String getClientId() { return clientId; diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/GoogleCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/GoogleCredentials.java index 7395274c4786..eeb69708dbc1 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/GoogleCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/GoogleCredentials.java @@ -36,6 +36,8 @@ import com.google.api.client.util.Preconditions; import com.google.api.core.ObsoleteApi; import com.google.auth.Credentials; +import com.google.auth.RequestMetadataCallback; +import com.google.auth.http.AuthHttpConstants; import com.google.auth.http.HttpTransportFactory; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.MoreObjects; @@ -46,6 +48,8 @@ import com.google.errorprone.annotations.CanIgnoreReturnValue; import java.io.IOException; import java.io.InputStream; +import java.io.ObjectInputStream; +import java.net.URI; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.Collection; @@ -106,6 +110,9 @@ String getFileType() { private final String universeDomain; private final boolean isExplicitUniverseDomain; + transient RegionalAccessBoundaryManager regionalAccessBoundaryManager = + new RegionalAccessBoundaryManager(clock); + protected final String quotaProjectId; private static final DefaultCredentialsProvider defaultCredentialsProvider = @@ -347,6 +354,141 @@ public GoogleCredentials createWithQuotaProject(String quotaProject) { return this.toBuilder().setQuotaProjectId(quotaProject).build(); } + /** + * Returns the currently cached regional access boundary, or null if none is available or if it + * has expired. + * + * @return The cached regional access boundary, or null. + */ + final RegionalAccessBoundary getRegionalAccessBoundary() { + return regionalAccessBoundaryManager.getCachedRAB(); + } + + /** + * Refreshes the Regional Access Boundary if it is expired or not yet fetched. + * + * @param uri The URI of the outbound request. + * @param token The access token to use for the refresh. + * @throws IOException If getting the universe domain fails. + */ + void refreshRegionalAccessBoundaryIfExpired(@Nullable URI uri, @Nullable AccessToken token) + throws IOException { + if (!(this instanceof RegionalAccessBoundaryProvider) + || !RegionalAccessBoundary.isEnabled() + || !isDefaultUniverseDomain()) { + return; + } + + // Skip refresh for regional endpoints. + if (uri != null && uri.getHost() != null) { + String host = uri.getHost(); + if (host.endsWith(".rep.googleapis.com") || host.endsWith(".rep.sandbox.googleapis.com")) { + return; + } + } + + // We need a valid access token for the refresh. + if (token == null + || (token.getExpirationTimeMillis() != null + && token.getExpirationTimeMillis() < clock.currentTimeMillis())) { + return; + } + + HttpTransportFactory transportFactory = getTransportFactory(); + if (transportFactory == null) { + return; + } + + regionalAccessBoundaryManager.triggerAsyncRefresh( + transportFactory, (RegionalAccessBoundaryProvider) this, token); + } + + /** + * Extracts the self-signed JWT from the request metadata and triggers a Regional Access Boundary + * refresh if expired. + * + * @param uri The URI of the outbound request. + * @param requestMetadata The request metadata containing the authorization header. + */ + void refreshRegionalAccessBoundaryWithSelfSignedJwtIfExpired( + @Nullable URI uri, Map> requestMetadata) { + List authHeaders = requestMetadata.get(AuthHttpConstants.AUTHORIZATION); + if (authHeaders != null && !authHeaders.isEmpty()) { + String authHeader = authHeaders.get(0); + if (authHeader.startsWith(AuthHttpConstants.BEARER + " ")) { + String tokenValue = authHeader.substring((AuthHttpConstants.BEARER + " ").length()); + // Use a null expiration as JWTs are short-lived anyway. + AccessToken wrappedToken = new AccessToken(tokenValue, null); + try { + refreshRegionalAccessBoundaryIfExpired(uri, wrappedToken); + } catch (IOException e) { + // Ignore failure in async refresh trigger. + } + } + } + } + + /** + * Synchronously provides the request metadata. + * + *

This method is blocking and will wait for a token refresh if necessary. It also ensures any + * available Regional Access Boundary information is included in the metadata. + * + * @param uri The URI of the request. + * @return The request metadata containing the authorization header and potentially regional + * access boundary. + * @throws IOException If an error occurs while fetching the token. + */ + @Override + public Map> getRequestMetadata(URI uri) throws IOException { + Map> metadata = super.getRequestMetadata(uri); + metadata = addRegionalAccessBoundaryToRequestMetadata(uri, metadata); + try { + // Sets off an async refresh for request-metadata. + refreshRegionalAccessBoundaryIfExpired(uri, getAccessToken()); + } catch (IOException e) { + // Ignore failure in async refresh trigger. + } + return metadata; + } + + /** + * Asynchronously provides the request metadata. + * + *

This method is non-blocking. It ensures any available Regional Access Boundary information + * is included in the metadata. + * + * @param uri The URI of the request. + * @param executor The executor to use for any required background tasks. + * @param callback The callback to receive the metadata or any error. + */ + @Override + public void getRequestMetadata( + final URI uri, + final java.util.concurrent.Executor executor, + final RequestMetadataCallback callback) { + super.getRequestMetadata( + uri, + executor, + new RequestMetadataCallback() { + @Override + public void onSuccess(Map> metadata) { + metadata = addRegionalAccessBoundaryToRequestMetadata(uri, metadata); + try { + refreshRegionalAccessBoundaryIfExpired(uri, getAccessToken()); + } catch (IOException e) { + // Ignore failure in async refresh trigger. + } + callback.onSuccess(metadata); + } + + @Override + public void onFailure(Throwable exception) { + callback.onFailure(exception); + } + }); + } + /** * Gets the universe domain for the credential. * @@ -390,22 +532,59 @@ boolean isDefaultUniverseDomain() throws IOException { static Map> addQuotaProjectIdToRequestMetadata( String quotaProjectId, Map> requestMetadata) { Preconditions.checkNotNull(requestMetadata); - Map> newRequestMetadata = new HashMap<>(requestMetadata); if (quotaProjectId != null && !requestMetadata.containsKey(QUOTA_PROJECT_ID_HEADER_KEY)) { - newRequestMetadata.put( - QUOTA_PROJECT_ID_HEADER_KEY, Collections.singletonList(quotaProjectId)); + return ImmutableMap.>builder() + .putAll(requestMetadata) + .put(QUOTA_PROJECT_ID_HEADER_KEY, Collections.singletonList(quotaProjectId)) + .build(); + } + return requestMetadata; + } + + /** + * Adds Regional Access Boundary header to requestMetadata if available. Overwrites if present. If + * the current RAB is null, it removes any stale header that might have survived serialization. + * + * @param uri The URI of the request. + * @param requestMetadata The request metadata. + * @return a new map with Regional Access Boundary header added, updated, or removed + */ + Map> addRegionalAccessBoundaryToRequestMetadata( + URI uri, Map> requestMetadata) { + Preconditions.checkNotNull(requestMetadata); + + if (uri != null && uri.getHost() != null) { + String host = uri.getHost(); + if (host.endsWith(".rep.googleapis.com") || host.endsWith(".rep.sandbox.googleapis.com")) { + return requestMetadata; + } } - return Collections.unmodifiableMap(newRequestMetadata); + + RegionalAccessBoundary rab = getRegionalAccessBoundary(); + if (rab != null) { + // Overwrite the header to ensure the most recent async update is used, + // preventing staleness if the token itself hasn't expired yet. + Map> newMetadata = new HashMap<>(requestMetadata); + newMetadata.put( + RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY, + Collections.singletonList(rab.getEncodedLocations())); + return ImmutableMap.copyOf(newMetadata); + } else if (requestMetadata.containsKey(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY)) { + // If RAB is null but the header exists (e.g., from a serialized cache), we must strip it + // to prevent sending stale data to the server. + Map> newMetadata = new HashMap<>(requestMetadata); + newMetadata.remove(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY); + return ImmutableMap.copyOf(newMetadata); + } + return requestMetadata; } @Override protected Map> getAdditionalHeaders() { - Map> headers = super.getAdditionalHeaders(); + Map> headers = new HashMap<>(super.getAdditionalHeaders()); + String quotaProjectId = this.getQuotaProjectId(); - if (quotaProjectId != null) { - return addQuotaProjectIdToRequestMetadata(quotaProjectId, headers); - } - return headers; + return addQuotaProjectIdToRequestMetadata(quotaProjectId, headers); } /** Default constructor. */ @@ -516,6 +695,11 @@ public int hashCode() { return Objects.hash(this.quotaProjectId, this.universeDomain, this.isExplicitUniverseDomain); } + private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException { + input.defaultReadObject(); + regionalAccessBoundaryManager = new RegionalAccessBoundaryManager(clock); + } + public static Builder newBuilder() { return new Builder(); } @@ -651,6 +835,16 @@ public Map getCredentialInfo() { return ImmutableMap.copyOf(infoMap); } + /** + * Returns the transport factory used by the credential. + * + * @return the transport factory, or null if not available. + */ + @Nullable + HttpTransportFactory getTransportFactory() { + return null; + } + public static class Builder extends OAuth2Credentials.Builder { @Nullable protected String quotaProjectId; @Nullable protected String universeDomain; diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java index 34716d92b552..6d93c10d9349 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java @@ -98,7 +98,7 @@ * */ public class ImpersonatedCredentials extends GoogleCredentials - implements ServiceAccountSigner, IdTokenProvider { + implements ServiceAccountSigner, IdTokenProvider, RegionalAccessBoundaryProvider { private static final long serialVersionUID = -2133257318957488431L; private static final int TWELVE_HOURS_IN_SECONDS = 43200; @@ -327,10 +327,22 @@ public GoogleCredentials getSourceCredentials() { return sourceCredentials; } + @InternalApi + @Override + public String getRegionalAccessBoundaryUrl() throws IOException { + return String.format( + OAuth2Utils.IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_SERVICE_ACCOUNT, getAccount()); + } + int getLifetime() { return this.lifetime; } + @Override + HttpTransportFactory getTransportFactory() { + return transportFactory; + } + public void setTransportFactory(HttpTransportFactory httpTransportFactory) { this.transportFactory = httpTransportFactory; } diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Credentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Credentials.java index e17714c3eee8..ef1225d19a73 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Credentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Credentials.java @@ -62,7 +62,6 @@ import java.util.Map; import java.util.Objects; import java.util.ServiceLoader; -import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; import java.util.concurrent.Executor; import javax.annotation.Nullable; @@ -167,6 +166,16 @@ Duration getExpirationMargin() { return this.expirationMargin; } + /** + * Asynchronously provides the request metadata by ensuring there is a current access token and + * providing it as an authorization bearer token. + * + *

This method is non-blocking. The results are provided through the given callback. + * + * @param uri The URI of the request. + * @param executor The executor to use for any required background tasks. + * @param callback The callback to receive the metadata or any error. + */ @Override public void getRequestMetadata( final URI uri, Executor executor, final RequestMetadataCallback callback) { @@ -178,8 +187,14 @@ public void getRequestMetadata( } /** - * Provide the request metadata by ensuring there is a current access token and providing it as an - * authorization bearer token. + * Synchronously provides the request metadata by ensuring there is a current access token and + * providing it as an authorization bearer token. + * + *

This method is blocking and will wait for a token refresh if necessary. + * + * @param uri The URI of the request. + * @return The request metadata containing the authorization header. + * @throws IOException If an error occurs while fetching the token. */ @Override public Map> getRequestMetadata(URI uri) throws IOException { @@ -267,11 +282,8 @@ private AsyncRefreshResult getOrCreateRefreshTask() { final ListenableFutureTask task = ListenableFutureTask.create( - new Callable() { - @Override - public OAuthValue call() throws Exception { - return OAuthValue.create(refreshAccessToken(), getAdditionalHeaders()); - } + () -> { + return OAuthValue.create(refreshAccessToken(), getAdditionalHeaders()); }); refreshTask = new RefreshTask(task, new RefreshTaskListener(task)); @@ -376,7 +388,7 @@ public AccessToken refreshAccessToken() throws IOException { /** * Provide additional headers to return as request metadata. * - * @return additional headers + * @return additional headers. */ protected Map> getAdditionalHeaders() { return EMPTY_EXTRA_HEADERS; diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java index 643c3dc7dc65..84cb62390fe7 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java @@ -68,6 +68,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.regex.Pattern; /** * Internal utilities for the com.google.auth.oauth2 namespace. @@ -123,6 +124,22 @@ enum Pkcs8Algorithm { static final double RETRY_MULTIPLIER = 2; static final int DEFAULT_NUMBER_OF_RETRIES = 3; + static final Pattern WORKFORCE_AUDIENCE_PATTERN = + Pattern.compile( + "^//iam.googleapis.com/locations/(?[^/]+)/workforcePools/(?[^/]+)/providers/(?[^/]+)$"); + static final Pattern WORKLOAD_AUDIENCE_PATTERN = + Pattern.compile( + "^//iam.googleapis.com/projects/(?[^/]+)/locations/(?[^/]+)/workloadIdentityPools/(?[^/]+)/providers/(?[^/]+)$"); + + static final String IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_SERVICE_ACCOUNT = + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s/allowedLocations"; + + static final String IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_WORKFORCE_POOL = + "https://iamcredentials.googleapis.com/v1/locations/global/workforcePools/%s/allowedLocations"; + + static final String IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_WORKLOAD_POOL = + "https://iamcredentials.googleapis.com/v1/projects/%s/locations/global/workloadIdentityPools/%s/allowedLocations"; + // Includes expected server errors from Google token endpoint // Other 5xx codes are either not used or retries are unlikely to succeed public static final Set TOKEN_ENDPOINT_RETRYABLE_STATUS_CODES = diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundary.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundary.java new file mode 100644 index 000000000000..b2a3f42942d7 --- /dev/null +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundary.java @@ -0,0 +1,280 @@ +/* + * Copyright 2026, Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.oauth2; + +import com.google.api.client.http.GenericUrl; +import com.google.api.client.http.HttpBackOffIOExceptionHandler; +import com.google.api.client.http.HttpBackOffUnsuccessfulResponseHandler; +import com.google.api.client.http.HttpIOExceptionHandler; +import com.google.api.client.http.HttpRequest; +import com.google.api.client.http.HttpRequestFactory; +import com.google.api.client.http.HttpResponse; +import com.google.api.client.http.HttpUnsuccessfulResponseHandler; +import com.google.api.client.json.GenericJson; +import com.google.api.client.json.JsonParser; +import com.google.api.client.util.Clock; +import com.google.api.client.util.ExponentialBackOff; +import com.google.api.client.util.Key; +import com.google.auth.http.HttpTransportFactory; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.MoreObjects; +import com.google.common.base.Preconditions; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.Serializable; +import java.util.Collections; +import java.util.List; +import javax.annotation.Nullable; + +/** + * Represents the regional access boundary configuration for a credential. This class holds the + * information retrieved from the IAM `allowedLocations` endpoint. This data is then used to + * populate the `x-allowed-locations` header in outgoing API requests, which in turn allows Google's + * infrastructure to enforce regional security restrictions. This class does not perform any + * client-side validation or enforcement. + */ +final class RegionalAccessBoundary implements Serializable { + + static final String X_ALLOWED_LOCATIONS_HEADER_KEY = "x-allowed-locations"; + private static final long serialVersionUID = -2428522338274020302L; + + // Note: this is for internal testing use use only. + // TODO: Fix unit test mocks so this can be removed + // Refer -> https://github.com/googleapis/google-auth-library-java/issues/1898 + static final String ENABLE_EXPERIMENT_ENV_VAR = "GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT"; + static final long TTL_MILLIS = 6 * 60 * 60 * 1000L; // 6 hours + static final long REFRESH_THRESHOLD_MILLIS = 1 * 60 * 60 * 1000L; // 1 hour + + private final String encodedLocations; + private final List locations; + private final long refreshTime; + private transient Clock clock; + + private static EnvironmentProvider environmentProvider = SystemEnvironmentProvider.getInstance(); + + /** + * Creates a new RegionalAccessBoundary instance. + * + * @param encodedLocations The encoded string representation of the allowed locations. + * @param locations A list of human-readable location strings. + * @param clock The clock used to set the creation time. + */ + RegionalAccessBoundary(String encodedLocations, List locations, Clock clock) { + this( + encodedLocations, + locations, + clock != null ? clock.currentTimeMillis() : Clock.SYSTEM.currentTimeMillis(), + clock); + } + + /** + * Internal constructor for testing and manual creation with refresh time. + * + * @param encodedLocations The encoded string representation of the allowed locations. + * @param locations A list of human-readable location strings. + * @param refreshTime The time at which the information was last refreshed. + * @param clock The clock to use for expiration checks. + */ + RegionalAccessBoundary( + String encodedLocations, List locations, long refreshTime, Clock clock) { + this.encodedLocations = encodedLocations; + this.locations = + locations == null + ? Collections.emptyList() + : Collections.unmodifiableList(locations); + this.refreshTime = refreshTime; + this.clock = clock != null ? clock : Clock.SYSTEM; + } + + /** Returns the encoded string representation of the allowed locations. */ + public String getEncodedLocations() { + return encodedLocations; + } + + /** Returns a list of human-readable location strings. */ + public List getLocations() { + return locations; + } + + /** + * Checks if the regional access boundary data is expired. + * + * @return True if the data has expired based on the TTL, false otherwise. + */ + public boolean isExpired() { + return clock.currentTimeMillis() > refreshTime + TTL_MILLIS; + } + + /** + * Checks if the regional access boundary data should be refreshed. This is a "soft-expiry" check + * that allows for background refreshes before the data actually expires. + * + * @return True if the data is within the refresh threshold, false otherwise. + */ + public boolean shouldRefresh() { + return clock.currentTimeMillis() > refreshTime + (TTL_MILLIS - REFRESH_THRESHOLD_MILLIS); + } + + /** Represents the JSON response from the regional access boundary endpoint. */ + public static class RegionalAccessBoundaryResponse extends GenericJson { + @Key("encodedLocations") + private String encodedLocations; + + @Key("locations") + private List locations; + + /** Returns the encoded string representation of the allowed locations from the API response. */ + public String getEncodedLocations() { + return encodedLocations; + } + + /** Returns a list of human-readable location strings from the API response. */ + public List getLocations() { + return locations; + } + + @Override + /** Returns a string representation of the RegionalAccessBoundaryResponse. */ + public String toString() { + return MoreObjects.toStringHelper(this) + .add("encodedLocations", encodedLocations) + .add("locations", locations) + .toString(); + } + } + + @VisibleForTesting + static void setEnvironmentProviderForTest(@Nullable EnvironmentProvider provider) { + environmentProvider = provider == null ? SystemEnvironmentProvider.getInstance() : provider; + } + + /** + * Checks if the regional access boundary feature is enabled. The feature is enabled if the + * environment variable or system property "GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT" is set + * to "true" or "1" (case-insensitive). + * + * @return True if the regional access boundary feature is enabled, false otherwise. + */ + static boolean isEnabled() { + String enabled = environmentProvider.getEnv(ENABLE_EXPERIMENT_ENV_VAR); + if (enabled == null) { + enabled = System.getProperty(ENABLE_EXPERIMENT_ENV_VAR); + } + if (enabled == null) { + return false; + } + String lowercased = enabled.toLowerCase(); + return "true".equals(lowercased) || "1".equals(enabled); + } + + /** + * Refreshes the regional access boundary by making a network call to the lookup endpoint. + * + * @param transportFactory The HTTP transport factory to use for the network request. + * @param url The URL of the regional access boundary endpoint. + * @param accessToken The access token to authenticate the request. + * @param clock The clock to use for expiration checks. + * @param maxRetryElapsedTimeMillis The max duration to wait for retries. + * @return A new RegionalAccessBoundary object containing the refreshed information. + * @throws IllegalArgumentException If the provided access token is null or expired. + * @throws IOException If a network error occurs or the response is malformed. + */ + static RegionalAccessBoundary refresh( + HttpTransportFactory transportFactory, + String url, + AccessToken accessToken, + Clock clock, + int maxRetryElapsedTimeMillis) + throws IOException { + Preconditions.checkNotNull(accessToken, "The provided access token is null."); + if (accessToken.getExpirationTimeMillis() != null + && accessToken.getExpirationTimeMillis() < clock.currentTimeMillis()) { + throw new IllegalArgumentException("The provided access token is expired."); + } + + HttpRequestFactory requestFactory = transportFactory.create().createRequestFactory(); + HttpRequest request = requestFactory.buildGetRequest(new GenericUrl(url)); + request.getHeaders().setAuthorization("Bearer " + accessToken.getTokenValue()); + + // Add retry logic + ExponentialBackOff backoff = + new ExponentialBackOff.Builder() + .setInitialIntervalMillis(OAuth2Utils.INITIAL_RETRY_INTERVAL_MILLIS) + .setRandomizationFactor(OAuth2Utils.RETRY_RANDOMIZATION_FACTOR) + .setMultiplier(OAuth2Utils.RETRY_MULTIPLIER) + .setMaxElapsedTimeMillis(maxRetryElapsedTimeMillis) + .build(); + + HttpUnsuccessfulResponseHandler unsuccessfulResponseHandler = + new HttpBackOffUnsuccessfulResponseHandler(backoff) + .setBackOffRequired( + response -> { + int statusCode = response.getStatusCode(); + return statusCode == 500 + || statusCode == 502 + || statusCode == 503 + || statusCode == 504; + }); + request.setUnsuccessfulResponseHandler(unsuccessfulResponseHandler); + + HttpIOExceptionHandler ioExceptionHandler = new HttpBackOffIOExceptionHandler(backoff); + request.setIOExceptionHandler(ioExceptionHandler); + + RegionalAccessBoundaryResponse json; + try { + HttpResponse response = request.execute(); + String responseString = response.parseAsString(); + JsonParser parser = OAuth2Utils.JSON_FACTORY.createJsonParser(responseString); + json = parser.parseAndClose(RegionalAccessBoundaryResponse.class); + } catch (IOException e) { + throw new IOException( + "RegionalAccessBoundary: Failure while getting regional access boundaries:", e); + } + String encodedLocations = json.getEncodedLocations(); + // The encodedLocations is the value attached to the x-allowed-locations header, and + // it should always have a value. + if (encodedLocations == null) { + throw new IOException( + "RegionalAccessBoundary: Malformed response from lookup endpoint - `encodedLocations` was null."); + } + return new RegionalAccessBoundary(encodedLocations, json.getLocations(), clock); + } + + /** + * Initializes the transient clock to Clock.SYSTEM upon deserialization to prevent + * NullPointerException when evaluating expiration on deserialized objects. + */ + private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException { + input.defaultReadObject(); + clock = Clock.SYSTEM; + } +} diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundaryManager.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundaryManager.java new file mode 100644 index 000000000000..eeea75bc2c86 --- /dev/null +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundaryManager.java @@ -0,0 +1,244 @@ +/* + * Copyright 2026, Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.oauth2; + +import com.google.api.client.util.Clock; +import com.google.api.core.InternalApi; +import com.google.auth.http.HttpTransportFactory; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.util.concurrent.SettableFuture; +import java.util.concurrent.atomic.AtomicReference; +import java.util.logging.Level; +import javax.annotation.Nullable; + +/** + * Manages the lifecycle of Regional Access Boundaries (RAB) for a credential. + * + *

This class handles caching, asynchronous refreshing, and cooldown logic to ensure that API + * requests are not blocked by lookup failures and that the lookup service is not overwhelmed. + */ +@InternalApi +final class RegionalAccessBoundaryManager { + + private static final LoggerProvider LOGGER_PROVIDER = + LoggerProvider.forClazz(RegionalAccessBoundaryManager.class); + + static final long INITIAL_COOLDOWN_MILLIS = 15 * 60 * 1000L; // 15 minutes + static final long MAX_COOLDOWN_MILLIS = 6 * 60 * 60 * 1000L; // 6 hours + + /** + * The default maximum elapsed time in milliseconds for retrying Regional Access Boundary lookup + * requests. + */ + private static final int DEFAULT_MAX_RETRY_ELAPSED_TIME_MILLIS = 60000; + + /** + * cachedRAB uses AtomicReference to provide thread-safe, lock-free access to the cached data for + * high-concurrency request threads. + */ + private final AtomicReference cachedRAB = new AtomicReference<>(); + + /** + * refreshFuture acts as an atomic gate for request de-duplication. If a future is present, it + * indicates a background refresh is already in progress. It also provides a handle for + * observability and unit testing to track the background task's lifecycle. + */ + private final AtomicReference> refreshFuture = + new AtomicReference<>(); + + private final AtomicReference cooldownState = + new AtomicReference<>(new CooldownState(0, INITIAL_COOLDOWN_MILLIS)); + + private final transient Clock clock; + private final int maxRetryElapsedTimeMillis; + + /** + * Creates a new RegionalAccessBoundaryManager with the default retry timeout of 60 seconds. + * + * @param clock The clock to use for cooldown and expiration checks. + */ + RegionalAccessBoundaryManager(Clock clock) { + this(clock, DEFAULT_MAX_RETRY_ELAPSED_TIME_MILLIS); + } + + @VisibleForTesting + RegionalAccessBoundaryManager(Clock clock, int maxRetryElapsedTimeMillis) { + this.clock = clock != null ? clock : Clock.SYSTEM; + this.maxRetryElapsedTimeMillis = maxRetryElapsedTimeMillis; + } + + /** + * Returns the currently cached RegionalAccessBoundary, or null if none is available or if it has + * expired. + * + * @return The cached RAB, or null. + */ + @Nullable + RegionalAccessBoundary getCachedRAB() { + RegionalAccessBoundary rab = cachedRAB.get(); + if (rab != null && !rab.isExpired()) { + return rab; + } + return null; + } + + /** + * Triggers an asynchronous refresh of the RegionalAccessBoundary if it is not already being + * refreshed and if the cooldown period is not active. + * + *

This method is entirely non-blocking for the calling thread. If a refresh is already in + * progress or a cooldown is active, it returns immediately. + * + * @param transportFactory The HTTP transport factory to use for the lookup. + * @param provider The provider used to retrieve the lookup endpoint URL. + * @param accessToken The access token for authentication. + */ + void triggerAsyncRefresh( + final HttpTransportFactory transportFactory, + final RegionalAccessBoundaryProvider provider, + final AccessToken accessToken) { + if (isCooldownActive()) { + return; + } + + RegionalAccessBoundary currentRab = cachedRAB.get(); + if (currentRab != null && !currentRab.shouldRefresh()) { + return; + } + + SettableFuture future = SettableFuture.create(); + // Atomically check if a refresh is already running. If compareAndSet returns true, + // this thread "won the race" and is responsible for starting the background task. + // All other concurrent threads will return false and exit immediately. + if (refreshFuture.compareAndSet(null, future)) { + Runnable refreshTask = + () -> { + try { + String url = provider.getRegionalAccessBoundaryUrl(); + RegionalAccessBoundary newRAB = + RegionalAccessBoundary.refresh( + transportFactory, url, accessToken, clock, maxRetryElapsedTimeMillis); + cachedRAB.set(newRAB); + resetCooldown(); + // Complete the future so monitors (like unit tests) know we are done. + future.set(newRAB); + } catch (Exception e) { + handleRefreshFailure(e); + future.setException(e); + } finally { + // Open the gate again for future refresh requests. + refreshFuture.set(null); + } + }; + + try { + // We use new Thread() here instead of + // CompletableFuture.runAsync() (which uses ForkJoinPool.commonPool()). + // This avoids consuming CPU resources since + // The common pool has a small, fixed number of threads designed for + // CPU-bound tasks. + Thread refreshThread = new Thread(refreshTask, "RAB-refresh-thread"); + refreshThread.setDaemon(true); + refreshThread.start(); + } catch (Exception | Error e) { + // If scheduling fails (e.g., RejectedExecutionException, OutOfMemoryError for threads), + // the task's finally block will never execute. We must release the lock here. + handleRefreshFailure( + new Exception("Regional Access Boundary background refresh failed to schedule", e)); + future.setException(e); + refreshFuture.set(null); + } + } + } + + private void handleRefreshFailure(Exception e) { + CooldownState currentCooldownState = cooldownState.get(); + CooldownState next; + if (currentCooldownState.expiryTime == 0) { + // In the first non-retryable failure, we set cooldown to currentTime + 15 mins. + next = + new CooldownState( + clock.currentTimeMillis() + INITIAL_COOLDOWN_MILLIS, INITIAL_COOLDOWN_MILLIS); + } else { + // We attempted to exit cool-down but failed. + // For each failed cooldown exit attempt, we double the cooldown time (till max 6 hrs). + // This avoids overwhelming RAB lookup endpoint. + long nextDuration = Math.min(currentCooldownState.durationMillis * 2, MAX_COOLDOWN_MILLIS); + next = new CooldownState(clock.currentTimeMillis() + nextDuration, nextDuration); + } + + // Atomically update the cooldown state. compareAndSet returns true only if the state + // hasn't been changed by another thread in the meantime. This prevents multiple + // concurrent failures from logging redundant messages or incorrectly calculating + // the exponential backoff. + if (cooldownState.compareAndSet(currentCooldownState, next)) { + LoggingUtils.log( + LOGGER_PROVIDER, + Level.FINE, + null, + "Regional Access Boundary lookup failed; entering cooldown for " + + (next.durationMillis / 60000) + + "m. Error: " + + e.getMessage()); + } + } + + private void resetCooldown() { + cooldownState.set(new CooldownState(0, INITIAL_COOLDOWN_MILLIS)); + } + + boolean isCooldownActive() { + CooldownState state = cooldownState.get(); + if (state.expiryTime == 0) { + return false; + } + return clock.currentTimeMillis() < state.expiryTime; + } + + @VisibleForTesting + long getCurrentCooldownMillis() { + return cooldownState.get().durationMillis; + } + + private static class CooldownState { + /** The time (in milliseconds from epoch) when the current cooldown period expires. */ + final long expiryTime; + + /** The duration (in milliseconds) of the current cooldown period. */ + final long durationMillis; + + CooldownState(long expiryTime, long durationMillis) { + this.expiryTime = expiryTime; + this.durationMillis = durationMillis; + } + } +} diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundaryProvider.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundaryProvider.java new file mode 100644 index 000000000000..e34bbafea0dc --- /dev/null +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundaryProvider.java @@ -0,0 +1,50 @@ +/* + * Copyright 2026, Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.oauth2; + +import com.google.api.core.InternalApi; +import java.io.IOException; + +/** + * An interface for providing regional access boundary information. It is used to provide a common + * interface for credentials that support regional access boundary checks. + */ +@InternalApi +interface RegionalAccessBoundaryProvider { + + /** + * Returns the regional access boundary URI. + * + * @return The regional access boundary URI. + */ + String getRegionalAccessBoundaryUrl() throws IOException; +} diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ServiceAccountCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ServiceAccountCredentials.java index a65ddbe8d26e..ca6e330762cd 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ServiceAccountCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ServiceAccountCredentials.java @@ -52,6 +52,7 @@ import com.google.api.client.util.GenericData; import com.google.api.client.util.Joiner; import com.google.api.client.util.Preconditions; +import com.google.api.core.InternalApi; import com.google.auth.CredentialTypeForMetrics; import com.google.auth.Credentials; import com.google.auth.RequestMetadataCallback; @@ -90,7 +91,7 @@ *

By default uses a JSON Web Token (JWT) to fetch access tokens. */ public class ServiceAccountCredentials extends GoogleCredentials - implements ServiceAccountSigner, IdTokenProvider, JwtProvider { + implements ServiceAccountSigner, IdTokenProvider, JwtProvider, RegionalAccessBoundaryProvider { private static final long serialVersionUID = 7807543542681217978L; private static final String GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer"; @@ -834,11 +835,23 @@ public boolean getUseJwtAccessWithScope() { return useJwtAccessWithScope; } + @InternalApi + @Override + public String getRegionalAccessBoundaryUrl() throws IOException { + return String.format( + OAuth2Utils.IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_SERVICE_ACCOUNT, getAccount()); + } + @VisibleForTesting JwtCredentials getSelfSignedJwtCredentialsWithScope() { return selfSignedJwtCredentialsWithScope; } + @Override + HttpTransportFactory getTransportFactory() { + return transportFactory; + } + @Override public String getAccount() { return getClientEmail(); @@ -1034,6 +1047,17 @@ JwtCredentials createSelfSignedJwtCredentials(final URI uri, Collection .build(); } + /** + * Asynchronously provides the request metadata. + * + *

This method is non-blocking. For Self-signed JWT flows (which are calculated locally), it + * may execute the callback immediately on the calling thread. For standard flows, it may use the + * provided executor for background tasks. + * + * @param uri The URI of the request. + * @param executor The executor to use for any required background tasks. + * @param callback The callback to receive the metadata or any error. + */ @Override public void getRequestMetadata( final URI uri, Executor executor, final RequestMetadataCallback callback) { @@ -1056,7 +1080,16 @@ public void getRequestMetadata( } } - /** Provide the request metadata by putting an access JWT directly in the metadata. */ + /** + * Synchronously provides the request metadata. + * + *

This method is blocking. For standard flows, it will wait for a network call to complete. + * For Self-signed JWT flows, it calculates the token locally. + * + * @param uri The URI of the request. + * @return The request metadata containing the authorization header. + * @throws IOException If an error occurs while fetching or calculating the token. + */ @Override public Map> getRequestMetadata(URI uri) throws IOException { if (createScopedRequired() && uri == null) { @@ -1125,6 +1158,8 @@ private Map> getRequestMetadataWithSelfSignedJwt(URI uri) } Map> requestMetadata = jwtCredentials.getRequestMetadata(null); + requestMetadata = addRegionalAccessBoundaryToRequestMetadata(uri, requestMetadata); + refreshRegionalAccessBoundaryWithSelfSignedJwtIfExpired(uri, requestMetadata); return addQuotaProjectIdToRequestMetadata(quotaProjectId, requestMetadata); } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/TestUtils.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/TestUtils.java index 91b648992848..9315c631985e 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/TestUtils.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/TestUtils.java @@ -42,6 +42,7 @@ import com.google.api.client.json.gson.GsonFactory; import com.google.auth.http.AuthHttpConstants; import com.google.common.base.Splitter; +import com.google.common.collect.ImmutableList; import com.google.common.collect.Lists; import java.io.ByteArrayInputStream; import java.io.IOException; @@ -55,6 +56,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.TimeZone; import javax.annotation.Nullable; /** Utilities for test code under com.google.auth. */ @@ -64,6 +66,9 @@ public class TestUtils { URI.create("https://auth.cloud.google/authorize"); public static final URI WORKFORCE_IDENTITY_FEDERATION_TOKEN_SERVER_URI = URI.create("https://sts.googleapis.com/v1/oauthtoken"); + public static final String REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION = "0x800000"; + public static final List REGIONAL_ACCESS_BOUNDARY_LOCATIONS = + ImmutableList.of("us-central1", "us-central2"); private static final JsonFactory JSON_FACTORY = GsonFactory.getDefaultInstance(); @@ -167,7 +172,9 @@ public static String getDefaultExpireTime() { Calendar calendar = Calendar.getInstance(); calendar.setTime(new Date()); calendar.add(Calendar.SECOND, 300); - return new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'").format(calendar.getTime()); + SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"); + dateFormat.setTimeZone(TimeZone.getTimeZone("UTC")); + return dateFormat.format(calendar.getTime()); } private TestUtils() {} diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java index e401ae853771..a0930b796d04 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java @@ -56,11 +56,20 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; /** Tests for {@link AwsCredentials}. */ class AwsCredentialsTest extends BaseSerializationTest { + @org.junit.jupiter.api.BeforeEach + void setUp() {} + + @org.junit.jupiter.api.AfterEach + void tearDown() { + RegionalAccessBoundary.setEnvironmentProviderForTest(null); + } + private static final String STS_URL = "https://sts.googleapis.com/v1/token"; private static final String AWS_CREDENTIALS_URL = "https://169.254.169.254"; private static final String AWS_CREDENTIALS_URL_WITH_ROLE = "https://169.254.169.254/roleName"; @@ -1357,4 +1366,51 @@ public AwsSecurityCredentials getCredentials(ExternalAccountSupplierContext cont return credentials; } } + + @Test + public void testRefresh_regionalAccessBoundarySuccess() throws IOException, InterruptedException { + TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + AwsSecurityCredentialsSupplier supplier = + new TestAwsSecurityCredentialsSupplier("test", programmaticAwsCreds, null, null); + + AwsCredentials awsCredential = + AwsCredentials.newBuilder() + .setAwsSecurityCredentialsSupplier(supplier) + .setHttpTransportFactory(transportFactory) + .setAudience( + "//iam.googleapis.com/projects/12345/locations/global/workloadIdentityPools/pool/providers/provider") + .setTokenUrl(STS_URL) + .setSubjectTokenType("subjectTokenType") + .build(); + + // First call: initiates async refresh. + Map> headers = awsCredential.getRequestMetadata(); + assertNull(headers.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY)); + + waitForRegionalAccessBoundary(awsCredential); + + // Second call: should have header. + headers = awsCredential.getRequestMetadata(); + assertEquals( + headers.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY), + Arrays.asList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION)); + } + + private void waitForRegionalAccessBoundary(GoogleCredentials credentials) + throws InterruptedException { + long deadline = System.currentTimeMillis() + 5000; + while (credentials.getRegionalAccessBoundary() == null + && System.currentTimeMillis() < deadline) { + Thread.sleep(100); + } + if (credentials.getRegionalAccessBoundary() == null) { + Assertions.fail("Timed out waiting for regional access boundary refresh"); + } + } } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java index 82240171d9af..8b20d0cc20f4 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java @@ -33,6 +33,7 @@ import static com.google.auth.oauth2.ComputeEngineCredentials.METADATA_RESPONSE_EMPTY_CONTENT_ERROR_MESSAGE; import static com.google.auth.oauth2.ImpersonatedCredentialsTest.SA_CLIENT_EMAIL; +import static com.google.auth.oauth2.RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -43,6 +44,7 @@ import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; import com.google.api.client.http.HttpStatusCodes; import com.google.api.client.http.HttpTransport; @@ -75,6 +77,14 @@ /** Test case for {@link ComputeEngineCredentials}. */ class ComputeEngineCredentialsTest extends BaseSerializationTest { + @org.junit.jupiter.api.BeforeEach + void setUp() {} + + @org.junit.jupiter.api.AfterEach + void tearDown() { + RegionalAccessBoundary.setEnvironmentProviderForTest(null); + } + private static final URI CALL_URI = URI.create("http://googleapis.com/testapi/v1/foo"); private static final String TOKEN_URL = @@ -393,7 +403,6 @@ void getRequestMetadata_hasAccessToken() throws IOException { TestUtils.assertContainsBearerToken(metadata, ACCESS_TOKEN); // verify metrics header added and other header intact Map> requestHeaders = transportFactory.transport.getRequest().getHeaders(); - com.google.auth.oauth2.TestUtils.validateMetricsHeader(requestHeaders, "at", "mds"); assertTrue(requestHeaders.containsKey("metadata-flavor")); assertTrue(requestHeaders.get("metadata-flavor").contains("Google")); } @@ -1177,6 +1186,50 @@ void getProjectId_explicitSet_noMDsCall() { assertEquals(0, transportFactory.transport.getRequestCount()); } + @org.junit.jupiter.api.Test + void refresh_regionalAccessBoundarySuccess() throws IOException, InterruptedException { + TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + + String defaultAccountEmail = "default@email.com"; + MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); + RegionalAccessBoundary regionalAccessBoundary = + new RegionalAccessBoundary( + TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION, + TestUtils.REGIONAL_ACCESS_BOUNDARY_LOCATIONS, + null); + transportFactory.transport.setRegionalAccessBoundary(regionalAccessBoundary); + transportFactory.transport.setServiceAccountEmail(defaultAccountEmail); + + ComputeEngineCredentials credentials = + ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + + // First call: initiates async refresh. + Map> headers = credentials.getRequestMetadata(); + assertNull(headers.get(X_ALLOWED_LOCATIONS_HEADER_KEY)); + + waitForRegionalAccessBoundary(credentials); + + // Second call: should have header. + headers = credentials.getRequestMetadata(); + assertEquals( + headers.get(X_ALLOWED_LOCATIONS_HEADER_KEY), + Arrays.asList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION)); + } + + private void waitForRegionalAccessBoundary(GoogleCredentials credentials) + throws InterruptedException { + long deadline = System.currentTimeMillis() + 5000; + while (credentials.getRegionalAccessBoundary() == null + && System.currentTimeMillis() < deadline) { + Thread.sleep(100); + } + if (credentials.getRegionalAccessBoundary() == null) { + fail("Timed out waiting for regional access boundary refresh"); + } + } + static class MockMetadataServerTransportFactory implements HttpTransportFactory { MockMetadataServerTransport transport = diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentialsTest.java index 78bb6811953e..fbf3f79dbe65 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentialsTest.java @@ -43,7 +43,6 @@ import com.google.api.client.http.HttpTransport; import com.google.api.client.json.GenericJson; import com.google.api.client.testing.http.MockLowLevelHttpRequest; -import com.google.api.client.util.Clock; import com.google.auth.TestUtils; import com.google.auth.http.AuthHttpConstants; import com.google.auth.http.HttpTransportFactory; @@ -62,6 +61,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -129,6 +129,11 @@ void setup() { transportFactory = new MockExternalAccountAuthorizedUserCredentialsTransportFactory(); } + @org.junit.jupiter.api.AfterEach + void tearDown() { + RegionalAccessBoundary.setEnvironmentProviderForTest(null); + } + @Test void builder_allFields() throws IOException { ExternalAccountAuthorizedUserCredentials credentials = @@ -1233,7 +1238,49 @@ void serialize() throws IOException, ClassNotFoundException { assertEquals(credentials, deserializedCredentials); assertEquals(credentials.hashCode(), deserializedCredentials.hashCode()); assertEquals(credentials.toString(), deserializedCredentials.toString()); - assertSame(Clock.SYSTEM, deserializedCredentials.clock); + assertSame(com.google.api.client.util.Clock.SYSTEM, deserializedCredentials.clock); + } + + @org.junit.jupiter.api.Test + void testRefresh_regionalAccessBoundarySuccess() throws IOException, InterruptedException { + TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + + ExternalAccountAuthorizedUserCredentials credentials = + ExternalAccountAuthorizedUserCredentials.newBuilder() + .setClientId(CLIENT_ID) + .setClientSecret(CLIENT_SECRET) + .setRefreshToken(REFRESH_TOKEN) + .setTokenUrl(TOKEN_URL) + .setAudience( + "//iam.googleapis.com/locations/global/workforcePools/pool/providers/provider") + .setHttpTransportFactory(transportFactory) + .build(); + + // First call: initiates async refresh. + Map> headers = credentials.getRequestMetadata(); + assertNull(headers.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY)); + + waitForRegionalAccessBoundary(credentials); + + // Second call: should have header. + headers = credentials.getRequestMetadata(); + assertEquals( + headers.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY), + Arrays.asList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION)); + } + + private void waitForRegionalAccessBoundary(GoogleCredentials credentials) + throws InterruptedException { + long deadline = System.currentTimeMillis() + 5000; + while (credentials.getRegionalAccessBoundary() == null + && System.currentTimeMillis() < deadline) { + Thread.sleep(100); + } + if (credentials.getRegionalAccessBoundary() == null) { + Assertions.fail("Timed out waiting for regional access boundary refresh"); + } } static GenericJson buildJsonCredentials() { diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java index 1338c0d68fe9..5b20f33db983 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java @@ -32,6 +32,9 @@ package com.google.auth.oauth2; import static com.google.auth.oauth2.MockExternalAccountCredentialsTransport.SERVICE_ACCOUNT_IMPERSONATION_URL; +import static com.google.auth.oauth2.OAuth2Utils.IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_SERVICE_ACCOUNT; +import static com.google.auth.oauth2.OAuth2Utils.IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_WORKFORCE_POOL; +import static com.google.auth.oauth2.OAuth2Utils.IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_WORKLOAD_POOL; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; @@ -53,12 +56,8 @@ import java.io.IOException; import java.math.BigDecimal; import java.net.URI; -import java.util.Arrays; -import java.util.Date; -import java.util.HashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; +import java.util.*; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -93,6 +92,11 @@ void setup() { transportFactory = new MockExternalAccountCredentialsTransportFactory(); } + @org.junit.jupiter.api.AfterEach + void tearDown() { + RegionalAccessBoundary.setEnvironmentProviderForTest(null); + } + @Test void fromStream_identityPoolCredentials() throws IOException { GenericJson json = buildJsonIdentityPoolCredential(); @@ -1144,7 +1148,7 @@ void serialize() throws IOException, ClassNotFoundException { assertEquals( testCredentials.getServiceAccountImpersonationOptions().getLifetime(), deserializedCredentials.getServiceAccountImpersonationOptions().getLifetime()); - assertSame(Clock.SYSTEM, deserializedCredentials.clock); + assertSame(deserializedCredentials.clock, Clock.SYSTEM); assertEquals( MockExternalAccountCredentialsTransportFactory.class, deserializedCredentials.toBuilder().getHttpTransportFactory().getClass()); @@ -1240,6 +1244,274 @@ void validateServiceAccountImpersonationUrls_invalidUrls() { } } + @Test + public void getRegionalAccessBoundaryUrl_workload() throws IOException { + String audience = + "//iam.googleapis.com/projects/12345/locations/global/workloadIdentityPools/my-pool/providers/my-provider"; + ExternalAccountCredentials credentials = + TestExternalAccountCredentials.newBuilder() + .setAudience(audience) + .setSubjectTokenType("subject_token_type") + .setCredentialSource(new TestCredentialSource(FILE_CREDENTIAL_SOURCE_MAP)) + .build(); + + String expectedUrl = + "https://iamcredentials.googleapis.com/v1/projects/12345/locations/global/workloadIdentityPools/my-pool/allowedLocations"; + assertEquals(expectedUrl, credentials.getRegionalAccessBoundaryUrl()); + } + + @Test + public void getRegionalAccessBoundaryUrl_workforce() throws IOException { + String audience = + "//iam.googleapis.com/locations/global/workforcePools/my-pool/providers/my-provider"; + ExternalAccountCredentials credentials = + TestExternalAccountCredentials.newBuilder() + .setAudience(audience) + .setWorkforcePoolUserProject("12345") + .setSubjectTokenType("subject_token_type") + .setCredentialSource(new TestCredentialSource(FILE_CREDENTIAL_SOURCE_MAP)) + .build(); + + String expectedUrl = + "https://iamcredentials.googleapis.com/v1/locations/global/workforcePools/my-pool/allowedLocations"; + assertEquals(expectedUrl, credentials.getRegionalAccessBoundaryUrl()); + } + + @Test + public void getRegionalAccessBoundaryUrl_invalidAudience_throws() { + ExternalAccountCredentials credentials = + TestExternalAccountCredentials.newBuilder() + .setAudience("invalid-audience") + .setSubjectTokenType("subject_token_type") + .setCredentialSource(new TestCredentialSource(FILE_CREDENTIAL_SOURCE_MAP)) + .build(); + + IllegalStateException exception = + assertThrows( + IllegalStateException.class, + () -> { + credentials.getRegionalAccessBoundaryUrl(); + }); + + assertEquals( + "The provided audience is not in a valid format for either a workload identity pool or a workforce pool. " + + "Refer: https://docs.cloud.google.com/iam/docs/principal-identifiers", + exception.getMessage()); + } + + @Test + public void refresh_workload_regionalAccessBoundarySuccess() + throws IOException, InterruptedException { + TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + String audience = + "//iam.googleapis.com/projects/12345/locations/global/workloadIdentityPools/my-pool/providers/my-provider"; + + ExternalAccountCredentials credentials = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(transportFactory) + .setAudience(audience) + .setSubjectTokenType("subject_token_type") + .setTokenUrl(STS_URL) + .setCredentialSource(new TestCredentialSource(FILE_CREDENTIAL_SOURCE_MAP))) { + @Override + public String retrieveSubjectToken() throws IOException { + // This override isolates the test from the filesystem. + return "dummy-subject-token"; + } + }; + + // First call: initiates async refresh. + Map> headers = credentials.getRequestMetadata(); + assertNull(headers.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY)); + + waitForRegionalAccessBoundary(credentials); + + // Second call: should have header. + headers = credentials.getRequestMetadata(); + assertEquals( + headers.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY), + Arrays.asList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION)); + } + + @Test + public void refresh_workforce_regionalAccessBoundarySuccess() + throws IOException, InterruptedException { + TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + String audience = + "//iam.googleapis.com/locations/global/workforcePools/my-pool/providers/my-provider"; + + ExternalAccountCredentials credentials = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(transportFactory) + .setAudience(audience) + .setWorkforcePoolUserProject("12345") + .setSubjectTokenType("subject_token_type") + .setTokenUrl(STS_URL) + .setCredentialSource(new TestCredentialSource(FILE_CREDENTIAL_SOURCE_MAP))) { + @Override + public String retrieveSubjectToken() throws IOException { + return "dummy-subject-token"; + } + }; + + // First call: initiates async refresh. + Map> headers = credentials.getRequestMetadata(); + assertNull(headers.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY)); + + waitForRegionalAccessBoundary(credentials); + + // Second call: should have header. + headers = credentials.getRequestMetadata(); + assertEquals( + headers.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY), + Arrays.asList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION)); + } + + @Test + public void refresh_impersonated_workload_regionalAccessBoundarySuccess() + throws IOException, InterruptedException { + TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + String projectNumber = "12345"; + String poolId = "my-pool"; + String providerId = "my-provider"; + String audience = + String.format( + "//iam.googleapis.com/projects/%s/locations/global/workloadIdentityPools/%s/providers/%s", + projectNumber, poolId, providerId); + + transportFactory.transport.setExpireTime(TestUtils.getDefaultExpireTime()); + + // 1. Setup distinct RABs for workload and impersonated identities. + String workloadRabUrl = + String.format( + IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_WORKLOAD_POOL, projectNumber, poolId); + RegionalAccessBoundary workloadRab = + new RegionalAccessBoundary( + "workload-encoded", Collections.singletonList("workload-loc"), null); + transportFactory.transport.addRegionalAccessBoundary(workloadRabUrl, workloadRab); + + String saEmail = + ImpersonatedCredentials.extractTargetPrincipal(SERVICE_ACCOUNT_IMPERSONATION_URL); + String impersonatedRabUrl = + String.format(IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_SERVICE_ACCOUNT, saEmail); + RegionalAccessBoundary impersonatedRab = + new RegionalAccessBoundary( + "impersonated-encoded", Collections.singletonList("impersonated-loc"), null); + transportFactory.transport.addRegionalAccessBoundary(impersonatedRabUrl, impersonatedRab); + + // Use a URL-based source that the mock transport can handle, to avoid file IO. + Map urlCredentialSourceMap = new HashMap<>(); + urlCredentialSourceMap.put("url", "https://www.metadata.google.com"); + Map headers = new HashMap<>(); + headers.put("Metadata-Flavor", "Google"); + urlCredentialSourceMap.put("headers", headers); + + ExternalAccountCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(transportFactory) + .setAudience(audience) + .setSubjectTokenType("subject_token_type") + .setTokenUrl(STS_URL) + .setServiceAccountImpersonationUrl(SERVICE_ACCOUNT_IMPERSONATION_URL) + .setCredentialSource(new IdentityPoolCredentialSource(urlCredentialSourceMap)) + .build(); + + // First call: initiates async refresh. + Map> requestHeaders = credentials.getRequestMetadata(); + assertNull(requestHeaders.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY)); + + waitForRegionalAccessBoundary(credentials); + + // Second call: should have the IMPERSONATED header, not the workload one. + requestHeaders = credentials.getRequestMetadata(); + assertEquals( + Arrays.asList("impersonated-encoded"), + requestHeaders.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY)); + } + + @Test + public void refresh_impersonated_workforce_regionalAccessBoundarySuccess() + throws IOException, InterruptedException { + TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + String poolId = "my-pool"; + String providerId = "my-provider"; + String audience = + String.format( + "//iam.googleapis.com/locations/global/workforcePools/%s/providers/%s", + poolId, providerId); + + transportFactory.transport.setExpireTime(TestUtils.getDefaultExpireTime()); + + // 1. Setup distinct RABs for workforce and impersonated identities. + String workforceRabUrl = + String.format(IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_WORKFORCE_POOL, poolId); + RegionalAccessBoundary workforceRab = + new RegionalAccessBoundary( + "workforce-encoded", Collections.singletonList("workforce-loc"), null); + transportFactory.transport.addRegionalAccessBoundary(workforceRabUrl, workforceRab); + + String saEmail = + ImpersonatedCredentials.extractTargetPrincipal(SERVICE_ACCOUNT_IMPERSONATION_URL); + String impersonatedRabUrl = + String.format(IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_SERVICE_ACCOUNT, saEmail); + RegionalAccessBoundary impersonatedRab = + new RegionalAccessBoundary( + "impersonated-encoded", Collections.singletonList("impersonated-loc"), null); + transportFactory.transport.addRegionalAccessBoundary(impersonatedRabUrl, impersonatedRab); + + // Use a URL-based source that the mock transport can handle, to avoid file IO. + Map urlCredentialSourceMap = new HashMap<>(); + urlCredentialSourceMap.put("url", "https://www.metadata.google.com"); + Map headers = new HashMap<>(); + headers.put("Metadata-Flavor", "Google"); + urlCredentialSourceMap.put("headers", headers); + + ExternalAccountCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(transportFactory) + .setAudience(audience) + .setWorkforcePoolUserProject("12345") + .setSubjectTokenType("subject_token_type") + .setTokenUrl(STS_URL) + .setServiceAccountImpersonationUrl(SERVICE_ACCOUNT_IMPERSONATION_URL) + .setCredentialSource(new IdentityPoolCredentialSource(urlCredentialSourceMap)) + .build(); + + // First call: initiates async refresh. + Map> requestHeaders = credentials.getRequestMetadata(); + assertNull(requestHeaders.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY)); + + waitForRegionalAccessBoundary(credentials); + + // Second call: should have the IMPERSONATED header, not the workforce one. + requestHeaders = credentials.getRequestMetadata(); + assertEquals( + Arrays.asList("impersonated-encoded"), + requestHeaders.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY)); + } + + private void waitForRegionalAccessBoundary(GoogleCredentials credentials) + throws InterruptedException { + long deadline = System.currentTimeMillis() + 5000; + while (credentials.getRegionalAccessBoundary() == null + && System.currentTimeMillis() < deadline) { + Thread.sleep(100); + } + if (credentials.getRegionalAccessBoundary() == null) { + Assertions.fail("Timed out waiting for regional access boundary refresh"); + } + } + private GenericJson buildJsonIdentityPoolCredential() { GenericJson json = new GenericJson(); json.put( diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java index 74aa9fae9ccd..dd64a07d4a1f 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java @@ -31,6 +31,8 @@ package com.google.auth.oauth2; +import static com.google.auth.oauth2.RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY; +import static org.junit.Assert.fail; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; @@ -44,6 +46,7 @@ import com.google.api.client.json.GenericJson; import com.google.api.client.util.Clock; import com.google.auth.Credentials; +import com.google.auth.RequestMetadataCallback; import com.google.auth.TestUtils; import com.google.auth.http.HttpTransportFactory; import com.google.auth.oauth2.ExternalAccountAuthorizedUserCredentialsTest.MockExternalAccountAuthorizedUserCredentialsTransportFactory; @@ -58,7 +61,10 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; +import javax.annotation.Nullable; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; /** Test case for {@link GoogleCredentials}. */ @@ -99,6 +105,14 @@ class GoogleCredentialsTest extends BaseSerializationTest { private static final String GOOGLE_DEFAULT_UNIVERSE = "googleapis.com"; private static final String TPC_UNIVERSE = "foo.bar"; + @org.junit.jupiter.api.BeforeEach + void setUp() {} + + @org.junit.jupiter.api.AfterEach + void tearDown() { + RegionalAccessBoundary.setEnvironmentProviderForTest(null); + } + @Test void getApplicationDefault_nullTransport_throws() { assertThrows(NullPointerException.class, () -> GoogleCredentials.getApplicationDefault(null)); @@ -838,6 +852,57 @@ void serialize() throws IOException, ClassNotFoundException { assertEquals(testCredentials.hashCode(), deserializedCredentials.hashCode()); assertEquals(testCredentials.toString(), deserializedCredentials.toString()); assertSame(Clock.SYSTEM, deserializedCredentials.clock); + assertSame(deserializedCredentials.clock, Clock.SYSTEM); + assertNotNull(deserializedCredentials.regionalAccessBoundaryManager); + } + + @Test + public void serialize_removesStaleRabHeaders() throws Exception { + TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + + MockTokenServerTransportFactory transportFactory = new MockTokenServerTransportFactory(); + RegionalAccessBoundary rab = + new RegionalAccessBoundary( + "test-encoded", + Collections.singletonList("test-loc"), + System.currentTimeMillis(), + null); + transportFactory.transport.setRegionalAccessBoundary(rab); + transportFactory.transport.addServiceAccount(SA_CLIENT_EMAIL, ACCESS_TOKEN); + + GoogleCredentials credentials = + new ServiceAccountCredentials.Builder() + .setClientEmail(SA_CLIENT_EMAIL) + .setPrivateKey(OAuth2Utils.privateKeyFromPkcs8(SA_PRIVATE_KEY_PKCS8)) + .setPrivateKeyId(SA_PRIVATE_KEY_ID) + .setHttpTransportFactory(transportFactory) + .setScopes(SCOPES) + .build(); + + // 1. Trigger request metadata to start async RAB refresh + credentials.getRequestMetadata(URI.create("https://foo.com")); + + // Wait for the RAB to be fetched and cached + waitForRegionalAccessBoundary(credentials); + + // 2. Verify the live credential has the RAB header + Map> metadata = credentials.getRequestMetadata(); + assertEquals( + Collections.singletonList("test-encoded"), + metadata.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY)); + + // 3. Serialize and deserialize. + GoogleCredentials deserialized = serializeAndDeserialize(credentials); + + // 4. Verify. + // The manager is transient, so it should be empty. + assertNull(deserialized.getRegionalAccessBoundary()); + + // The metadata should NOT contain the RAB header anymore, preventing stale headers. + Map> deserializedMetadata = deserialized.getRequestMetadata(); + assertNull(deserializedMetadata.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY)); } @Test @@ -977,4 +1042,349 @@ void getCredentialInfo_impersonatedServiceAccount() throws IOException { assertEquals( ImpersonatedCredentialsTest.IMPERSONATED_CLIENT_EMAIL, credentialInfo.get("Principal")); } + + @Test + public void regionalAccessBoundary_shouldFetchAndReturnRegionalAccessBoundaryDataSuccessfully() + throws IOException, InterruptedException { + TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + MockTokenServerTransport transport = new MockTokenServerTransport(); + transport.addServiceAccount(SA_CLIENT_EMAIL, ACCESS_TOKEN); + RegionalAccessBoundary regionalAccessBoundary = + new RegionalAccessBoundary( + TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION, + Collections.singletonList("us-central1"), + null); + transport.setRegionalAccessBoundary(regionalAccessBoundary); + + ServiceAccountCredentials credentials = + ServiceAccountCredentials.newBuilder() + .setClientEmail(SA_CLIENT_EMAIL) + .setPrivateKey(OAuth2Utils.privateKeyFromPkcs8(SA_PRIVATE_KEY_PKCS8)) + .setPrivateKeyId(SA_PRIVATE_KEY_ID) + .setHttpTransportFactory(() -> transport) + .setScopes(SCOPES) + .build(); + + // First call: returns no header, initiates async refresh. + Map> headers = credentials.getRequestMetadata(); + assertNull(headers.get(X_ALLOWED_LOCATIONS_HEADER_KEY)); + + waitForRegionalAccessBoundary(credentials); + + // Second call: should have header. + headers = credentials.getRequestMetadata(); + assertEquals( + headers.get(X_ALLOWED_LOCATIONS_HEADER_KEY), + Arrays.asList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION)); + } + + @Test + public void regionalAccessBoundary_shouldRetryRegionalAccessBoundaryLookupOnFailure() + throws IOException, InterruptedException { + TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + + // This transport will be used for the regional access boundary lookup. + // We will configure it to fail on the first attempt. + MockTokenServerTransport regionalAccessBoundaryTransport = new MockTokenServerTransport(); + regionalAccessBoundaryTransport.addResponseErrorSequence( + new IOException("Service Unavailable")); + RegionalAccessBoundary regionalAccessBoundary = + new RegionalAccessBoundary( + TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION, + TestUtils.REGIONAL_ACCESS_BOUNDARY_LOCATIONS, + null); + regionalAccessBoundaryTransport.setRegionalAccessBoundary(regionalAccessBoundary); + + // This transport will be used for the access token refresh. + // It will succeed. + MockTokenServerTransport accessTokenTransport = new MockTokenServerTransport(); + accessTokenTransport.addServiceAccount(SA_CLIENT_EMAIL, ACCESS_TOKEN); + + ServiceAccountCredentials credentials = + ServiceAccountCredentials.newBuilder() + .setClientEmail(SA_CLIENT_EMAIL) + .setPrivateKey(OAuth2Utils.privateKeyFromPkcs8(SA_PRIVATE_KEY_PKCS8)) + .setPrivateKeyId(SA_PRIVATE_KEY_ID) + // Use a custom transport factory that returns the correct transport for each endpoint. + .setHttpTransportFactory( + () -> + new com.google.api.client.testing.http.MockHttpTransport() { + @Override + public com.google.api.client.http.LowLevelHttpRequest buildRequest( + String method, String url) throws IOException { + if (url.endsWith("/allowedLocations")) { + return regionalAccessBoundaryTransport.buildRequest(method, url); + } + return accessTokenTransport.buildRequest(method, url); + } + }) + .setScopes(SCOPES) + .build(); + + credentials.getRequestMetadata(); + waitForRegionalAccessBoundary(credentials); + + Map> headers = credentials.getRequestMetadata(); + assertEquals( + Arrays.asList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION), + headers.get(X_ALLOWED_LOCATIONS_HEADER_KEY)); + } + + @Test + public void regionalAccessBoundary_refreshShouldNotThrowWhenNoValidAccessTokenIsPassed() + throws IOException { + TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + MockTokenServerTransport transport = new MockTokenServerTransport(); + // Return an expired access token. + transport.addServiceAccount(SA_CLIENT_EMAIL, "expired-token"); + transport.setExpiresInSeconds(-1); + + ServiceAccountCredentials credentials = + ServiceAccountCredentials.newBuilder() + .setClientEmail(SA_CLIENT_EMAIL) + .setPrivateKey(OAuth2Utils.privateKeyFromPkcs8(SA_PRIVATE_KEY_PKCS8)) + .setPrivateKeyId(SA_PRIVATE_KEY_ID) + .setHttpTransportFactory(() -> transport) + .setScopes(SCOPES) + .build(); + + // Should not throw, but just fail-open (no header). + Map> headers = credentials.getRequestMetadata(); + assertNull(headers.get(X_ALLOWED_LOCATIONS_HEADER_KEY)); + } + + @Test + public void regionalAccessBoundary_cooldownDoublingAndRefresh() + throws IOException, InterruptedException { + TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + MockTokenServerTransport transport = new MockTokenServerTransport(); + transport.addServiceAccount(SA_CLIENT_EMAIL, ACCESS_TOKEN); + // Always fail lookup for now. + transport.addResponseErrorSequence(new IOException("Persistent Failure")); + + ServiceAccountCredentials credentials = + ServiceAccountCredentials.newBuilder() + .setClientEmail(SA_CLIENT_EMAIL) + .setPrivateKey(OAuth2Utils.privateKeyFromPkcs8(SA_PRIVATE_KEY_PKCS8)) + .setPrivateKeyId(SA_PRIVATE_KEY_ID) + .setHttpTransportFactory(() -> transport) + .setScopes(SCOPES) + .build(); + + TestClock testClock = new TestClock(); + credentials.clock = testClock; + credentials.regionalAccessBoundaryManager = new RegionalAccessBoundaryManager(testClock, 100); + + // First attempt: triggers lookup, fails, enters 15m cooldown. + credentials.getRequestMetadata(); + waitForCooldownActive(credentials); + assertTrue(credentials.regionalAccessBoundaryManager.isCooldownActive()); + assertEquals( + 15 * 60 * 1000L, credentials.regionalAccessBoundaryManager.getCurrentCooldownMillis()); + + // Second attempt (during cooldown): does not trigger lookup. + credentials.getRequestMetadata(); + assertTrue(credentials.regionalAccessBoundaryManager.isCooldownActive()); + + // Fast-forward past 15m cooldown. + testClock.advanceTime(16 * 60 * 1000L); + assertFalse(credentials.regionalAccessBoundaryManager.isCooldownActive()); + + // Third attempt (cooldown expired): triggers lookup, fails again, cooldown should double. + credentials.getRequestMetadata(); + waitForCooldownActive(credentials); + assertTrue(credentials.regionalAccessBoundaryManager.isCooldownActive()); + assertEquals( + 30 * 60 * 1000L, credentials.regionalAccessBoundaryManager.getCurrentCooldownMillis()); + + // Fast-forward past 30m cooldown. + testClock.advanceTime(31 * 60 * 1000L); + assertFalse(credentials.regionalAccessBoundaryManager.isCooldownActive()); + + // Set successful response. + transport.setRegionalAccessBoundary( + new RegionalAccessBoundary("0x123", Collections.emptyList(), null)); + + // Fourth attempt: triggers lookup, succeeds, resets cooldown. + credentials.getRequestMetadata(); + waitForRegionalAccessBoundary(credentials); + assertFalse(credentials.regionalAccessBoundaryManager.isCooldownActive()); + assertEquals("0x123", credentials.getRegionalAccessBoundary().getEncodedLocations()); + assertEquals( + 15 * 60 * 1000L, credentials.regionalAccessBoundaryManager.getCurrentCooldownMillis()); + } + + @Test + public void regionalAccessBoundary_shouldFailOpenWhenRefreshCannotBeStarted() throws IOException { + TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + // Use a simple AccessToken-based credential that won't try to refresh. + GoogleCredentials credentials = GoogleCredentials.create(new AccessToken("some-token", null)); + + // Should not throw, but just fail-open (no header). + Map> headers = credentials.getRequestMetadata(); + assertNull(headers.get(X_ALLOWED_LOCATIONS_HEADER_KEY)); + } + + @Test + public void regionalAccessBoundary_deduplicationOfConcurrentRefreshes() + throws IOException, InterruptedException { + TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + MockTokenServerTransport transport = new MockTokenServerTransport(); + transport.setRegionalAccessBoundary( + new RegionalAccessBoundary("valid", Collections.singletonList("us-central1"), null)); + // Add delay to lookup to ensure threads overlap. + transport.setResponseDelayMillis(500); + + GoogleCredentials credentials = createTestCredentials(transport); + + // Fire multiple concurrent requests. + for (int i = 0; i < 10; i++) { + new Thread( + () -> { + try { + credentials.getRequestMetadata(); + } catch (IOException e) { + } + }) + .start(); + } + + waitForRegionalAccessBoundary(credentials); + + // Only ONE request should have been made to the lookup endpoint. + assertEquals(1, transport.getRegionalAccessBoundaryRequestCount()); + } + + @Test + public void regionalAccessBoundary_shouldSkipRefreshForRegionalEndpoints() throws IOException { + TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + MockTokenServerTransport transport = new MockTokenServerTransport(); + GoogleCredentials credentials = createTestCredentials(transport); + + URI regionalUri = URI.create("https://storage.us-central1.rep.googleapis.com/v1/b/foo"); + credentials.getRequestMetadata(regionalUri); + + // Should not have triggered any lookup. + assertEquals(0, transport.getRegionalAccessBoundaryRequestCount()); + } + + @Test + public void getRequestMetadata_ignoresRabRefreshException() throws IOException { + GoogleCredentials credentials = + new GoogleCredentials() { + @Override + public AccessToken refreshAccessToken() throws IOException { + return new AccessToken("token", null); + } + + @Override + void refreshRegionalAccessBoundaryIfExpired( + @Nullable URI uri, @Nullable AccessToken token) throws IOException { + throw new IOException("Simulated RAB failure"); + } + }; + + // This should not throw the IOException from refreshRegionalAccessBoundaryIfExpired + Map> metadata = + credentials.getRequestMetadata(URI.create("https://foo.com")); + assertTrue(metadata.containsKey("Authorization")); + } + + @Test + public void getRequestMetadataAsync_ignoresRabRefreshException() throws IOException { + GoogleCredentials credentials = + new GoogleCredentials() { + @Override + public AccessToken refreshAccessToken() throws IOException { + return new AccessToken("token", null); + } + + @Override + void refreshRegionalAccessBoundaryIfExpired( + @Nullable URI uri, @Nullable AccessToken token) throws IOException { + throw new IOException("Simulated RAB failure"); + } + }; + + java.util.concurrent.atomic.AtomicBoolean success = + new java.util.concurrent.atomic.AtomicBoolean(false); + credentials.getRequestMetadata( + URI.create("https://foo.com"), + Runnable::run, + new RequestMetadataCallback() { + @Override + public void onSuccess(Map> metadata) { + success.set(true); + } + + @Override + public void onFailure(Throwable exception) { + fail("Should not have failed"); + } + }); + + assertTrue(success.get()); + } + + private GoogleCredentials createTestCredentials(MockTokenServerTransport transport) + throws IOException { + transport.addServiceAccount(SA_CLIENT_EMAIL, ACCESS_TOKEN); + return new ServiceAccountCredentials.Builder() + .setClientEmail(SA_CLIENT_EMAIL) + .setPrivateKey(OAuth2Utils.privateKeyFromPkcs8(SA_PRIVATE_KEY_PKCS8)) + .setPrivateKeyId(SA_PRIVATE_KEY_ID) + .setHttpTransportFactory(() -> transport) + .setScopes(SCOPES) + .build(); + } + + private void waitForRegionalAccessBoundary(GoogleCredentials credentials) + throws InterruptedException { + long deadline = System.currentTimeMillis() + 5000; + while (credentials.getRegionalAccessBoundary() == null + && System.currentTimeMillis() < deadline) { + Thread.sleep(100); + } + if (credentials.getRegionalAccessBoundary() == null) { + Assertions.fail("Timed out waiting for regional access boundary refresh"); + } + } + + private void waitForCooldownActive(GoogleCredentials credentials) throws InterruptedException { + long deadline = System.currentTimeMillis() + 5000; + while (!credentials.regionalAccessBoundaryManager.isCooldownActive() + && System.currentTimeMillis() < deadline) { + Thread.sleep(100); + } + if (!credentials.regionalAccessBoundaryManager.isCooldownActive()) { + Assertions.fail("Timed out waiting for cooldown to become active"); + } + } + + private static class TestClock implements Clock { + private final AtomicLong currentTime = new AtomicLong(System.currentTimeMillis()); + + @Override + public long currentTimeMillis() { + return currentTime.get(); + } + + public void advanceTime(long millis) { + currentTime.addAndGet(millis); + } + } } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java index 674d523e5090..399bf7246c9a 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java @@ -37,9 +37,11 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; import com.google.api.client.http.HttpTransport; import com.google.api.client.json.GenericJson; @@ -75,6 +77,14 @@ class IdentityPoolCredentialsTest extends BaseSerializationTest { private static final IdentityPoolSubjectTokenSupplier testProvider = (ExternalAccountSupplierContext context) -> "testSubjectToken"; + @org.junit.jupiter.api.BeforeEach + void setUp() {} + + @org.junit.jupiter.api.AfterEach + void tearDown() { + RegionalAccessBoundary.setEnvironmentProviderForTest(null); + } + @Test void createdScoped_clonedCredentialWithAddedScopes() { IdentityPoolCredentials credentials = @@ -1299,4 +1309,49 @@ void setShouldThrowOnGetKeyStore(boolean shouldThrow) { this.shouldThrowOnGetKeyStore = shouldThrow; } } + + @Test + public void testRefresh_regionalAccessBoundarySuccess() throws IOException, InterruptedException { + TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + HttpTransportFactory testingHttpTransportFactory = transportFactory; + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setHttpTransportFactory(testingHttpTransportFactory) + .setAudience( + "//iam.googleapis.com/projects/12345/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl(STS_URL) + .build(); + + // First call: initiates async refresh. + Map> headers = credentials.getRequestMetadata(); + assertNull(headers.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY)); + + waitForRegionalAccessBoundary(credentials); + + // Second call: should have header. + headers = credentials.getRequestMetadata(); + assertEquals( + headers.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY), + Arrays.asList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION)); + } + + private void waitForRegionalAccessBoundary(GoogleCredentials credentials) + throws InterruptedException { + long deadline = System.currentTimeMillis() + 5000; + while (credentials.getRegionalAccessBoundary() == null + && System.currentTimeMillis() < deadline) { + Thread.sleep(100); + } + if (credentials.getRegionalAccessBoundary() == null) { + fail("Timed out waiting for regional access boundary refresh"); + } + } } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java index 044aa0ce6755..fc3c2e9c783e 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java @@ -31,6 +31,7 @@ package com.google.auth.oauth2; +import static com.google.auth.oauth2.RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -40,6 +41,7 @@ import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -69,6 +71,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Calendar; +import java.util.Collections; import java.util.Date; import java.util.List; import java.util.Map; @@ -145,6 +148,11 @@ class ImpersonatedCredentialsTest extends BaseSerializationTest { private static final String REFRESH_TOKEN = "dasdfasdffa4ffdfadgyjirasdfadsft"; public static final List DELEGATES = Arrays.asList("sa1@developer.gserviceaccount.com", "sa2@developer.gserviceaccount.com"); + public static final RegionalAccessBoundary REGIONAL_ACCESS_BOUNDARY = + new RegionalAccessBoundary( + TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION, + TestUtils.REGIONAL_ACCESS_BOUNDARY_LOCATIONS, + null); private GoogleCredentials sourceCredentials; private MockIAMCredentialsServiceTransportFactory mockTransportFactory; @@ -155,6 +163,11 @@ void setup() throws IOException { mockTransportFactory = new MockIAMCredentialsServiceTransportFactory(); } + @org.junit.After + public void tearDown() { + RegionalAccessBoundary.setEnvironmentProviderForTest(null); + } + static GoogleCredentials getSourceCredentials() throws IOException { MockTokenServerTransportFactory transportFactory = new MockTokenServerTransportFactory(); PrivateKey privateKey = OAuth2Utils.privateKeyFromPkcs8(SA_PRIVATE_KEY_PKCS8); @@ -168,6 +181,7 @@ static GoogleCredentials getSourceCredentials() throws IOException { .setHttpTransportFactory(transportFactory) .build(); transportFactory.transport.addServiceAccount(SA_CLIENT_EMAIL, ACCESS_TOKEN); + transportFactory.transport.setRegionalAccessBoundary(REGIONAL_ACCESS_BOUNDARY); return sourceCredentials; } @@ -1260,6 +1274,56 @@ void refreshAccessToken_afterSerialization_success() throws IOException, ClassNo assertEquals(ACCESS_TOKEN, token.getTokenValue()); } + @Test + void refresh_regionalAccessBoundarySuccess() throws IOException, InterruptedException { + TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + // Mock regional access boundary response + RegionalAccessBoundary regionalAccessBoundary = REGIONAL_ACCESS_BOUNDARY; + + mockTransportFactory.getTransport().setRegionalAccessBoundary(regionalAccessBoundary); + mockTransportFactory.getTransport().setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL); + mockTransportFactory.getTransport().setAccessToken(ACCESS_TOKEN); + mockTransportFactory.getTransport().setExpireTime(getDefaultExpireTime()); + mockTransportFactory + .getTransport() + .addStatusCodeAndMessage(HttpStatusCodes.STATUS_CODE_OK, "", true); + + ImpersonatedCredentials targetCredentials = + ImpersonatedCredentials.create( + sourceCredentials, + IMPERSONATED_CLIENT_EMAIL, + null, + IMMUTABLE_SCOPES_LIST, + VALID_LIFETIME, + mockTransportFactory); + + // First call: initiates async refresh. + Map> headers = targetCredentials.getRequestMetadata(); + assertNull(headers.get(X_ALLOWED_LOCATIONS_HEADER_KEY)); + + waitForRegionalAccessBoundary(targetCredentials); + + // Second call: should have header. + headers = targetCredentials.getRequestMetadata(); + assertEquals( + headers.get(X_ALLOWED_LOCATIONS_HEADER_KEY), + Collections.singletonList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION)); + } + + private void waitForRegionalAccessBoundary(GoogleCredentials credentials) + throws InterruptedException { + long deadline = System.currentTimeMillis() + 5000; + while (credentials.getRegionalAccessBoundary() == null + && System.currentTimeMillis() < deadline) { + Thread.sleep(100); + } + if (credentials.getRegionalAccessBoundary() == null) { + fail("Timed out waiting for regional access boundary refresh"); + } + } + public static String getDefaultExpireTime() { return Instant.now().plusSeconds(VALID_LIFETIME).truncatedTo(ChronoUnit.SECONDS).toString(); } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java index 524a312ce0c1..68e9c8edf393 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java @@ -94,12 +94,21 @@ static void setup() { LoggingUtils.setEnvironmentProvider(testEnvironmentProvider); } + @org.junit.jupiter.api.BeforeEach + void setUp() {} + + @org.junit.jupiter.api.AfterEach + void tearDown() { + RegionalAccessBoundary.setEnvironmentProviderForTest(null); + } + @Test void userCredentials_getRequestMetadata_fromRefreshToken_hasAccessToken() throws IOException { TestAppender testAppender = setupTestLogger(UserCredentials.class); MockTokenServerTransportFactory transportFactory = new MockTokenServerTransportFactory(); transportFactory.transport.addClient(CLIENT_ID, CLIENT_SECRET); transportFactory.transport.addRefreshToken(REFRESH_TOKEN, ACCESS_TOKEN); + UserCredentials userCredentials = UserCredentials.newBuilder() .setClientId(CLIENT_ID) @@ -212,6 +221,7 @@ void serviceAccountCredentials_idTokenWithAudience_iamFlow_targetAudienceMatches transportFactory.getTransport().setTargetPrincipal(CLIENT_EMAIL); transportFactory.getTransport().setIdToken(DEFAULT_ID_TOKEN); transportFactory.getTransport().addStatusCodeAndMessage(HttpStatusCodes.STATUS_CODE_OK, ""); + ServiceAccountCredentials credentials = createDefaultBuilder() .setScopes(SCOPES) diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java index 7719b08d2e7b..c53eda5b2bd5 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java @@ -50,6 +50,7 @@ import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Queue; @@ -68,6 +69,7 @@ public class MockExternalAccountCredentialsTransport extends MockHttpTransport { private static final String AWS_IMDSV2_SESSION_TOKEN_URL = "https://169.254.169.254/imdsv2"; private static final String METADATA_SERVER_URL = "https://www.metadata.google.com"; private static final String STS_URL = "https://sts.googleapis.com/v1/token"; + private static final String REGIONAL_ACCESS_BOUNDARY_URL_END = "/allowedLocations"; private static final String SUBJECT_TOKEN = "subjectToken"; private static final String TOKEN_TYPE = "Bearer"; @@ -92,6 +94,11 @@ public class MockExternalAccountCredentialsTransport extends MockHttpTransport { private String expireTime; private String metadataServerContentType; private String stsContent; + private final Map regionalAccessBoundaries = new HashMap<>(); + + public void addRegionalAccessBoundary(String url, RegionalAccessBoundary regionalAccessBoundary) { + this.regionalAccessBoundaries.put(url, regionalAccessBoundary); + } public void addResponseErrorSequence(IOException... errors) { Collections.addAll(responseErrorSequence, errors); @@ -196,6 +203,26 @@ public LowLevelHttpResponse execute() throws IOException { } if (url.contains(IAM_ENDPOINT)) { + + if (url.endsWith(REGIONAL_ACCESS_BOUNDARY_URL_END)) { + RegionalAccessBoundary rab = regionalAccessBoundaries.get(url); + if (rab == null) { + rab = + new RegionalAccessBoundary( + TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION, + TestUtils.REGIONAL_ACCESS_BOUNDARY_LOCATIONS, + null); + } + GenericJson responseJson = new GenericJson(); + responseJson.setFactory(OAuth2Utils.JSON_FACTORY); + responseJson.put("encodedLocations", rab.getEncodedLocations()); + responseJson.put("locations", rab.getLocations()); + String content = responseJson.toPrettyString(); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(content); + } + GenericJson query = OAuth2Utils.JSON_FACTORY .createJsonParser(getContentAsString()) @@ -220,7 +247,9 @@ public LowLevelHttpResponse execute() throws IOException { } }; - this.requests.add(request); + if (url == null || !url.contains("allowedLocations")) { + this.requests.add(request); + } return request; } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockIAMCredentialsServiceTransport.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockIAMCredentialsServiceTransport.java index cbd57d115afe..5346f4fdba3d 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockIAMCredentialsServiceTransport.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockIAMCredentialsServiceTransport.java @@ -80,6 +80,8 @@ public ServerResponse(int statusCode, String response, boolean repeatServerRespo private String universeDomain; + private RegionalAccessBoundary regionalAccessBoundary; + private MockLowLevelHttpRequest request; MockIAMCredentialsServiceTransport(String universeDomain) { @@ -132,6 +134,10 @@ public void setAccessTokenEndpoint(String accessTokenEndpoint) { this.iamAccessTokenEndpoint = accessTokenEndpoint; } + public void setRegionalAccessBoundary(RegionalAccessBoundary regionalAccessBoundary) { + this.regionalAccessBoundary = regionalAccessBoundary; + } + public MockLowLevelHttpRequest getRequest() { return request; } @@ -221,6 +227,25 @@ public LowLevelHttpResponse execute() throws IOException { .setContent(tokenContent); } }; + } else if (url.endsWith("/allowedLocations")) { + request = + new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() throws IOException { + if (regionalAccessBoundary == null) { + return new MockLowLevelHttpResponse().setStatusCode(404); + } + GenericJson responseJson = new GenericJson(); + responseJson.setFactory(OAuth2Utils.JSON_FACTORY); + responseJson.put("encodedLocations", regionalAccessBoundary.getEncodedLocations()); + responseJson.put("locations", regionalAccessBoundary.getLocations()); + String content = responseJson.toPrettyString(); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(content); + } + }; + return request; } else { return super.buildRequest(method, url); } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockMetadataServerTransport.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockMetadataServerTransport.java index 1b218b73ef45..92b24d60fd53 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockMetadataServerTransport.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockMetadataServerTransport.java @@ -72,6 +72,9 @@ public class MockMetadataServerTransport extends MockHttpTransport { private boolean emptyContent; private MockLowLevelHttpRequest request; + private RegionalAccessBoundary regionalAccessBoundary; + private IOException lookupError; + public MockMetadataServerTransport() {} public MockMetadataServerTransport(String accessToken) { @@ -119,6 +122,14 @@ public void setEmptyContent(boolean emptyContent) { this.emptyContent = emptyContent; } + public void setRegionalAccessBoundary(RegionalAccessBoundary regionalAccessBoundary) { + this.regionalAccessBoundary = regionalAccessBoundary; + } + + public void setLookupError(IOException lookupError) { + this.lookupError = lookupError; + } + public MockLowLevelHttpRequest getRequest() { return request; } @@ -139,6 +150,8 @@ public LowLevelHttpRequest buildRequest(String method, String url) throws IOExce return this.request; } else if (isMtlsConfigRequestUrl(url)) { return getMockRequestForMtlsConfig(url); + } else if (isIamLookupUrl(url)) { + return getMockRequestForRegionalAccessBoundaryLookup(url); } this.request = new MockLowLevelHttpRequest(url) { @@ -213,7 +226,7 @@ public LowLevelHttpResponse execute() throws IOException { refreshContents.put( "access_token", scopesToAccessToken.get("[" + urlParsed.get(1) + "]")); } - refreshContents.put("expires_in", 3600000); + refreshContents.put("expires_in", 3600); refreshContents.put("token_type", "Bearer"); String refreshText = refreshContents.toPrettyString(); @@ -346,4 +359,32 @@ protected boolean isMtlsConfigRequestUrl(String url) { ComputeEngineCredentials.getMetadataServerUrl() + SecureSessionAgent.S2A_CONFIG_ENDPOINT_POSTFIX); } + + private MockLowLevelHttpRequest getMockRequestForRegionalAccessBoundaryLookup(String url) { + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() throws IOException { + if (lookupError != null) { + throw lookupError; + } + if (regionalAccessBoundary == null) { + return new MockLowLevelHttpResponse().setStatusCode(404); + } + GenericJson responseJson = new GenericJson(); + responseJson.setFactory(OAuth2Utils.JSON_FACTORY); + responseJson.put("encodedLocations", regionalAccessBoundary.getEncodedLocations()); + responseJson.put("locations", regionalAccessBoundary.getLocations()); + String content = responseJson.toPrettyString(); + return new MockLowLevelHttpResponse().setContentType(Json.MEDIA_TYPE).setContent(content); + } + }; + } + + protected boolean isIamLookupUrl(String url) { + // Mocking call to the /allowedLocations endpoint for regional access boundary refresh. + // For testing convenience, this mock transport handles + // the /allowedLocations endpoint. The actual server for this endpoint + // will be the IAM Credentials API. + return url.endsWith("/allowedLocations"); + } } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockStsTransport.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockStsTransport.java index cdb0a068e2d0..24566a0e5ca3 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockStsTransport.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockStsTransport.java @@ -62,6 +62,8 @@ public final class MockStsTransport extends MockHttpTransport { private static final String ISSUED_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"; private static final String VALID_STS_PATTERN = "https:\\/\\/sts.[a-z-_\\.]+\\/v1\\/(token|oauthtoken)"; + private static final String VALID_REGIONAL_ACCESS_BOUNDARY_PATTERN = + "https:\\/\\/iam.[a-z-_\\.]+\\/v1\\/.*\\/allowedLocations"; private static final String ACCESS_TOKEN = "accessToken"; private static final String TOKEN_TYPE = "Bearer"; private static final Long EXPIRES_IN = 3600L; @@ -99,6 +101,23 @@ public LowLevelHttpRequest buildRequest(final String method, final String url) { new MockLowLevelHttpRequest(url) { @Override public LowLevelHttpResponse execute() throws IOException { + // Mocking call to refresh regional access boundaries. + // The lookup endpoint is located in the IAM server. + Matcher regionalAccessBoundaryMatcher = + Pattern.compile(VALID_REGIONAL_ACCESS_BOUNDARY_PATTERN).matcher(url); + if (regionalAccessBoundaryMatcher.matches()) { + // Mocking call to the /allowedLocations endpoint for regional access boundary + // refresh. + // For testing convenience, this mock transport handles + // the /allowedLocations endpoint. + GenericJson response = new GenericJson(); + response.put("locations", TestUtils.REGIONAL_ACCESS_BOUNDARY_LOCATIONS); + response.put("encodedLocations", TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(OAuth2Utils.JSON_FACTORY.toString(response)); + } + // Environment version is prefixed by "aws". e.g. "aws1". Matcher matcher = Pattern.compile(VALID_STS_PATTERN).matcher(url); if (!matcher.matches()) { diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockTokenServerTransport.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockTokenServerTransport.java index 5a6cd2e5d1a8..62f31e256d24 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockTokenServerTransport.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockTokenServerTransport.java @@ -76,6 +76,21 @@ public class MockTokenServerTransport extends MockHttpTransport { private int expiresInSeconds = 3600; private MockLowLevelHttpRequest request; private PKCEProvider pkceProvider; + private RegionalAccessBoundary regionalAccessBoundary; + private int regionalAccessBoundaryRequestCount = 0; + private int responseDelayMillis = 0; + + public void setRegionalAccessBoundary(RegionalAccessBoundary regionalAccessBoundary) { + this.regionalAccessBoundary = regionalAccessBoundary; + } + + public int getRegionalAccessBoundaryRequestCount() { + return regionalAccessBoundaryRequestCount; + } + + public void setResponseDelayMillis(int responseDelayMillis) { + this.responseDelayMillis = responseDelayMillis; + } public MockTokenServerTransport() {} @@ -171,6 +186,40 @@ public LowLevelHttpRequest buildRequest(String method, String url) throws IOExce int questionMarkPos = url.indexOf('?'); final String urlWithoutQuery = (questionMarkPos > 0) ? url.substring(0, questionMarkPos) : url; + if (urlWithoutQuery.endsWith("/allowedLocations")) { + // Mocking call to the /allowedLocations endpoint for regional access boundary refresh. + // For testing convenience, this mock transport handles + // the /allowedLocations endpoint. The actual server for this endpoint + // will be the IAM Credentials API. + request = + new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() throws IOException { + regionalAccessBoundaryRequestCount++; + if (responseDelayMillis > 0) { + try { + Thread.sleep(responseDelayMillis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + RegionalAccessBoundary rab = regionalAccessBoundary; + if (rab == null) { + return new MockLowLevelHttpResponse().setStatusCode(404); + } + GenericJson responseJson = new GenericJson(); + responseJson.setFactory(JSON_FACTORY); + responseJson.put("encodedLocations", rab.getEncodedLocations()); + responseJson.put("locations", rab.getLocations()); + String content = responseJson.toPrettyString(); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(content); + } + }; + return request; + } + if (!responseSequence.isEmpty()) { request = new MockLowLevelHttpRequest(url) { diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java index 094b21f9dbb2..adc945dd72ea 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java @@ -36,6 +36,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.fail; import com.google.api.client.http.HttpTransport; import com.google.api.client.json.GenericJson; @@ -56,6 +57,12 @@ /** Tests for {@link PluggableAuthCredentials}. */ class PluggableAuthCredentialsTest extends BaseSerializationTest { + + @org.junit.jupiter.api.AfterEach + void tearDown() { + RegionalAccessBoundary.setEnvironmentProviderForTest(null); + } + // The default timeout for waiting for the executable to finish (30 seconds). private static final int DEFAULT_EXECUTABLE_TIMEOUT_MS = 30 * 1000; // The minimum timeout for waiting for the executable to finish (5 seconds). @@ -601,6 +608,52 @@ void serialize() { assertThrows(NotSerializableException.class, () -> serializeAndDeserialize(testCredentials)); } + @Test + public void testRefresh_regionalAccessBoundarySuccess() throws IOException, InterruptedException { + TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + transportFactory.transport.setExpireTime(TestUtils.getDefaultExpireTime()); + + PluggableAuthCredentials credentials = + PluggableAuthCredentials.newBuilder() + .setHttpTransportFactory(transportFactory) + .setAudience( + "//iam.googleapis.com/projects/12345/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl(transportFactory.transport.getStsUrl()) + .setCredentialSource(buildCredentialSource()) + .setExecutableHandler(options -> "pluggableAuthToken") + .build(); + + // First call: initiates async refresh. + Map> headers = credentials.getRequestMetadata(); + assertNull(headers.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY)); + + waitForRegionalAccessBoundary(credentials); + + // Second call: should have header. + headers = credentials.getRequestMetadata(); + assertEquals( + headers.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY), + Arrays.asList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION)); + } + + private void waitForRegionalAccessBoundary(GoogleCredentials credentials) + throws InterruptedException { + long deadline = System.currentTimeMillis() + 5000; + while (credentials.getRegionalAccessBoundary() == null + && System.currentTimeMillis() < deadline) { + Thread.sleep(100); + } + if (credentials.getRegionalAccessBoundary() == null) { + fail("Timed out waiting for regional access boundary refresh"); + } + } + private static PluggableAuthCredentialSource buildCredentialSource() { return buildCredentialSource("command", null, null); } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/RegionalAccessBoundaryTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/RegionalAccessBoundaryTest.java new file mode 100644 index 000000000000..7c7ccd690ce2 --- /dev/null +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/RegionalAccessBoundaryTest.java @@ -0,0 +1,220 @@ +/* + * Copyright 2026, Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.oauth2; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import com.google.api.client.testing.http.MockHttpTransport; +import com.google.api.client.testing.http.MockLowLevelHttpResponse; +import com.google.api.client.util.Clock; +import com.google.auth.http.HttpTransportFactory; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.util.Collections; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class RegionalAccessBoundaryTest { + + private static final long TTL = RegionalAccessBoundary.TTL_MILLIS; + private static final long REFRESH_THRESHOLD = RegionalAccessBoundary.REFRESH_THRESHOLD_MILLIS; + + private TestClock testClock; + + @Before + public void setUp() { + testClock = new TestClock(); + } + + @After + public void tearDown() {} + + @Test + public void testIsExpired() { + long now = testClock.currentTimeMillis(); + RegionalAccessBoundary rab = + new RegionalAccessBoundary("encoded", Collections.singletonList("loc"), now, testClock); + + assertFalse(rab.isExpired()); + + testClock.set(now + TTL - 1); + assertFalse(rab.isExpired()); + + testClock.set(now + TTL + 1); + assertTrue(rab.isExpired()); + } + + @Test + public void testShouldRefresh() { + long now = testClock.currentTimeMillis(); + RegionalAccessBoundary rab = + new RegionalAccessBoundary("encoded", Collections.singletonList("loc"), now, testClock); + + // Initial state: fresh + assertFalse(rab.shouldRefresh()); + + // Just before threshold + testClock.set(now + TTL - REFRESH_THRESHOLD - 1); + assertFalse(rab.shouldRefresh()); + + // At threshold + testClock.set(now + TTL - REFRESH_THRESHOLD + 1); + assertTrue(rab.shouldRefresh()); + + // Still not expired + assertFalse(rab.isExpired()); + } + + @Test + public void testSerialization() throws Exception { + long now = testClock.currentTimeMillis(); + RegionalAccessBoundary rab = + new RegionalAccessBoundary("encoded", Collections.singletonList("loc"), now, testClock); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(baos); + oos.writeObject(rab); + oos.close(); + + ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray()); + ObjectInputStream ois = new ObjectInputStream(bais); + RegionalAccessBoundary deserializedRab = (RegionalAccessBoundary) ois.readObject(); + ois.close(); + + assertEquals("encoded", deserializedRab.getEncodedLocations()); + assertEquals(1, deserializedRab.getLocations().size()); + assertEquals("loc", deserializedRab.getLocations().get(0)); + // The transient clock field should be restored to Clock.SYSTEM upon deserialization, + // thereby avoiding a NullPointerException when checking expiration. + assertFalse(deserializedRab.isExpired()); + } + + @Test + public void testManagerTriggersRefreshInGracePeriod() throws InterruptedException { + final String url = + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/default:allowedLocations"; + final AccessToken token = + new AccessToken( + "token", new java.util.Date(System.currentTimeMillis() + 10 * 3600000L)); // + + // Mock transport to return a new RAB + final String newEncoded = "new-encoded"; + MockHttpTransport transport = + new MockHttpTransport.Builder() + .setLowLevelHttpResponse( + new MockLowLevelHttpResponse() + .setContentType("application/json") + .setContent( + "{\"encodedLocations\": \"" + + newEncoded + + "\", \"locations\": [\"new-loc\"]}")) + .build(); + HttpTransportFactory transportFactory = () -> transport; + RegionalAccessBoundaryProvider provider = () -> url; + + RegionalAccessBoundaryManager manager = new RegionalAccessBoundaryManager(testClock); + + // 1. Let's first get a RAB into the cache + manager.triggerAsyncRefresh(transportFactory, provider, token); + + // Wait for it to be cached + int retries = 0; + while (manager.getCachedRAB() == null && retries < 50) { + Thread.sleep(50); + retries++; + } + assertEquals(newEncoded, manager.getCachedRAB().getEncodedLocations()); + + // 2. Advance clock to grace period + testClock.set(testClock.currentTimeMillis() + TTL - REFRESH_THRESHOLD + 1000); + + assertTrue(manager.getCachedRAB().shouldRefresh()); + assertFalse(manager.getCachedRAB().isExpired()); + + // 3. Prepare mock for SECOND refresh + final String newerEncoded = "newer-encoded"; + MockHttpTransport transport2 = + new MockHttpTransport.Builder() + .setLowLevelHttpResponse( + new MockLowLevelHttpResponse() + .setContentType("application/json") + .setContent( + "{\"encodedLocations\": \"" + + newerEncoded + + "\", \"locations\": [\"newer-loc\"]}")) + .build(); + HttpTransportFactory transportFactory2 = () -> transport2; + + // 4. Trigger refresh - should start because we are in grace period + manager.triggerAsyncRefresh(transportFactory2, provider, token); + + // 5. Wait for background refresh to complete + // We expect the cached RAB to eventually change to newerEncoded + retries = 0; + RegionalAccessBoundary resultRab = null; + while (retries < 100) { + resultRab = manager.getCachedRAB(); + if (resultRab != null && newerEncoded.equals(resultRab.getEncodedLocations())) { + break; + } + Thread.sleep(50); + retries++; + } + + assertTrue( + "Refresh should have completed and updated the cache within 5 seconds", + resultRab != null && newerEncoded.equals(resultRab.getEncodedLocations())); + assertEquals(newerEncoded, resultRab.getEncodedLocations()); + } + + private static class TestClock implements Clock { + private final AtomicLong currentTime = new AtomicLong(System.currentTimeMillis()); + + @Override + public long currentTimeMillis() { + return currentTime.get(); + } + + public void set(long millis) { + currentTime.set(millis); + } + } +} diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ServiceAccountCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ServiceAccountCredentialsTest.java index 6ee26e3338a0..153ca4106bd8 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ServiceAccountCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ServiceAccountCredentialsTest.java @@ -31,6 +31,7 @@ package com.google.auth.oauth2; +import static com.google.auth.oauth2.RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -155,6 +156,14 @@ static ServiceAccountCredentials.Builder createDefaultBuilder() throws IOExcepti return createDefaultBuilderWithKey(privateKey); } + @org.junit.jupiter.api.BeforeEach + void setUp() {} + + @org.junit.jupiter.api.AfterEach + void tearDown() { + RegionalAccessBoundary.setEnvironmentProviderForTest(null); + } + @Test void setLifetime() throws IOException { ServiceAccountCredentials.Builder builder = createDefaultBuilder(); @@ -1773,6 +1782,100 @@ void getRequestMetadata_withMultipleScopes_selfSignedJWT() throws IOException { verifyJwtAccess(credentials.getRequestMetadata(CALL_URI), "scope1 scope2"); } + @Test + public void refresh_regionalAccessBoundarySuccess() throws IOException, InterruptedException { + TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + // Mock regional access boundary response + RegionalAccessBoundary regionalAccessBoundary = + new RegionalAccessBoundary( + TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION, + TestUtils.REGIONAL_ACCESS_BOUNDARY_LOCATIONS, + null); + + MockTokenServerTransport transport = new MockTokenServerTransport(); + transport.addServiceAccount(CLIENT_EMAIL, "test-access-token"); + transport.setRegionalAccessBoundary(regionalAccessBoundary); + + ServiceAccountCredentials credentials = + ServiceAccountCredentials.newBuilder() + .setClientEmail(CLIENT_EMAIL) + .setPrivateKey( + OAuth2Utils.privateKeyFromPkcs8(ServiceAccountCredentialsTest.PRIVATE_KEY_PKCS8)) + .setPrivateKeyId("test-key-id") + .setHttpTransportFactory(() -> transport) + .setScopes(SCOPES) + .build(); + + // First call: initiates async refresh. + Map> headers = credentials.getRequestMetadata(); + assertNull(headers.get(X_ALLOWED_LOCATIONS_HEADER_KEY)); + + waitForRegionalAccessBoundary(credentials); + + // Second call: should have header. + headers = credentials.getRequestMetadata(); + assertEquals( + headers.get(X_ALLOWED_LOCATIONS_HEADER_KEY), + Arrays.asList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION)); + } + + @Test + public void refresh_regionalAccessBoundary_selfSignedJWT() + throws IOException, InterruptedException { + TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + RegionalAccessBoundary regionalAccessBoundary = + new RegionalAccessBoundary( + TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION, + TestUtils.REGIONAL_ACCESS_BOUNDARY_LOCATIONS, + null); + + MockTokenServerTransport transport = new MockTokenServerTransport(); + transport.setRegionalAccessBoundary(regionalAccessBoundary); + + ServiceAccountCredentials credentials = + ServiceAccountCredentials.newBuilder() + .setClientEmail(CLIENT_EMAIL) + .setPrivateKey( + OAuth2Utils.privateKeyFromPkcs8(ServiceAccountCredentialsTest.PRIVATE_KEY_PKCS8)) + .setPrivateKeyId("test-key-id") + .setHttpTransportFactory(() -> transport) + .setUseJwtAccessWithScope(true) + .setScopes(SCOPES) + .build(); + + // First call: initiates async refresh using the SSJWT as the token. + Map> headers = credentials.getRequestMetadata(); + assertNull(headers.get(X_ALLOWED_LOCATIONS_HEADER_KEY)); + + waitForRegionalAccessBoundary(credentials); + + // Second call: should have header. + headers = credentials.getRequestMetadata(); + assertEquals( + headers.get(X_ALLOWED_LOCATIONS_HEADER_KEY), + Arrays.asList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION)); + + assertEquals( + TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION, + credentials.getRegionalAccessBoundary().getEncodedLocations()); + } + + private void waitForRegionalAccessBoundary(GoogleCredentials credentials) + throws InterruptedException { + long deadline = System.currentTimeMillis() + 5000; + while (credentials.getRegionalAccessBoundary() == null + && System.currentTimeMillis() < deadline) { + Thread.sleep(100); + } + if (credentials.getRegionalAccessBoundary() == null) { + fail("Timed out waiting for regional access boundary refresh"); + } + } + private void verifyJwtAccess(Map> metadata, String expectedScopeClaim) throws IOException { assertNotNull(metadata); diff --git a/google-auth-library-java/samples/snippets/pom.xml b/google-auth-library-java/samples/snippets/pom.xml index 5b721797222a..941191a80ee0 100644 --- a/google-auth-library-java/samples/snippets/pom.xml +++ b/google-auth-library-java/samples/snippets/pom.xml @@ -80,4 +80,3 @@ - diff --git a/java-dataplex/grpc-google-cloud-dataplex-v1/src/main/java/com/google/cloud/dataplex/v1/ContentServiceGrpc.java b/java-dataplex/grpc-google-cloud-dataplex-v1/src/main/java/com/google/cloud/dataplex/v1/ContentServiceGrpc.java index a087ae2f0103..7d657f076cf9 100644 --- a/java-dataplex/grpc-google-cloud-dataplex-v1/src/main/java/com/google/cloud/dataplex/v1/ContentServiceGrpc.java +++ b/java-dataplex/grpc-google-cloud-dataplex-v1/src/main/java/com/google/cloud/dataplex/v1/ContentServiceGrpc.java @@ -15,7 +15,6 @@ */ package com.google.cloud.dataplex.v1; - /** * * From 461843cc6556192a1709fc923c580c7f668f952c Mon Sep 17 00:00:00 2001 From: werman Date: Fri, 8 May 2026 14:05:15 -0700 Subject: [PATCH 2/4] chore(auth): Address remaining Regional Access Boundary feedback (#12867) 1. The RAB refresh uses a direct executor with a fixed thread pool as opposed to instantiating a new thread each time. 2. The RAB env gate -> GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT has been removed. This means RAB refresh triggers by default. 3. Added other fixes/suggestions made in the previous Java [PR](https://github.com/googleapis/google-auth-library-java/pull/1880). --- .../google/auth/oauth2/GoogleCredentials.java | 4 +- .../auth/oauth2/RegionalAccessBoundary.java | 49 ++---- .../oauth2/RegionalAccessBoundaryManager.java | 80 ++++++++-- .../auth/oauth2/AwsCredentialsTest.java | 50 +++++- .../oauth2/ComputeEngineCredentialsTest.java | 40 ++++- ...lAccountAuthorizedUserCredentialsTest.java | 11 +- .../ExternalAccountCredentialsTest.java | 24 +-- .../auth/oauth2/GoogleCredentialsTest.java | 34 +---- .../auth/oauth2/IdTokenCredentialsTest.java | 4 + .../oauth2/IdentityPoolCredentialsTest.java | 65 ++++++-- .../oauth2/ImpersonatedCredentialsTest.java | 16 +- .../com/google/auth/oauth2/LoggingTest.java | 13 +- .../oauth2/PluggableAuthCredentialsTest.java | 7 +- .../oauth2/RegionalAccessBoundaryTest.java | 143 ++++++++++++++++-- .../oauth2/ServiceAccountCredentialsTest.java | 59 ++++++-- .../com/google/auth/oauth2/TestUtils.java | 5 + 16 files changed, 425 insertions(+), 179 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/GoogleCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/GoogleCredentials.java index eeb69708dbc1..e423a68ac18b 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/GoogleCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/GoogleCredentials.java @@ -373,9 +373,7 @@ final RegionalAccessBoundary getRegionalAccessBoundary() { */ void refreshRegionalAccessBoundaryIfExpired(@Nullable URI uri, @Nullable AccessToken token) throws IOException { - if (!(this instanceof RegionalAccessBoundaryProvider) - || !RegionalAccessBoundary.isEnabled() - || !isDefaultUniverseDomain()) { + if (!(this instanceof RegionalAccessBoundaryProvider) || !isDefaultUniverseDomain()) { return; } diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundary.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundary.java index b2a3f42942d7..dfcbe8491cd5 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundary.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundary.java @@ -40,12 +40,11 @@ import com.google.api.client.http.HttpResponse; import com.google.api.client.http.HttpUnsuccessfulResponseHandler; import com.google.api.client.json.GenericJson; -import com.google.api.client.json.JsonParser; +import com.google.api.client.json.JsonObjectParser; import com.google.api.client.util.Clock; import com.google.api.client.util.ExponentialBackOff; import com.google.api.client.util.Key; import com.google.auth.http.HttpTransportFactory; -import com.google.common.annotations.VisibleForTesting; import com.google.common.base.MoreObjects; import com.google.common.base.Preconditions; import java.io.IOException; @@ -53,7 +52,6 @@ import java.io.Serializable; import java.util.Collections; import java.util.List; -import javax.annotation.Nullable; /** * Represents the regional access boundary configuration for a credential. This class holds the @@ -67,10 +65,6 @@ final class RegionalAccessBoundary implements Serializable { static final String X_ALLOWED_LOCATIONS_HEADER_KEY = "x-allowed-locations"; private static final long serialVersionUID = -2428522338274020302L; - // Note: this is for internal testing use use only. - // TODO: Fix unit test mocks so this can be removed - // Refer -> https://github.com/googleapis/google-auth-library-java/issues/1898 - static final String ENABLE_EXPERIMENT_ENV_VAR = "GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT"; static final long TTL_MILLIS = 6 * 60 * 60 * 1000L; // 6 hours static final long REFRESH_THRESHOLD_MILLIS = 1 * 60 * 60 * 1000L; // 1 hour @@ -79,8 +73,6 @@ final class RegionalAccessBoundary implements Serializable { private final long refreshTime; private transient Clock clock; - private static EnvironmentProvider environmentProvider = SystemEnvironmentProvider.getInstance(); - /** * Creates a new RegionalAccessBoundary instance. * @@ -172,30 +164,6 @@ public String toString() { } } - @VisibleForTesting - static void setEnvironmentProviderForTest(@Nullable EnvironmentProvider provider) { - environmentProvider = provider == null ? SystemEnvironmentProvider.getInstance() : provider; - } - - /** - * Checks if the regional access boundary feature is enabled. The feature is enabled if the - * environment variable or system property "GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT" is set - * to "true" or "1" (case-insensitive). - * - * @return True if the regional access boundary feature is enabled, false otherwise. - */ - static boolean isEnabled() { - String enabled = environmentProvider.getEnv(ENABLE_EXPERIMENT_ENV_VAR); - if (enabled == null) { - enabled = System.getProperty(ENABLE_EXPERIMENT_ENV_VAR); - } - if (enabled == null) { - return false; - } - String lowercased = enabled.toLowerCase(); - return "true".equals(lowercased) || "1".equals(enabled); - } - /** * Refreshes the regional access boundary by making a network call to the lookup endpoint. * @@ -223,6 +191,8 @@ static RegionalAccessBoundary refresh( HttpRequestFactory requestFactory = transportFactory.create().createRequestFactory(); HttpRequest request = requestFactory.buildGetRequest(new GenericUrl(url)); + // Disable automatic logging by google-http-java-client to prevent leakage of sensitive tokens. + request.setLoggingEnabled(false); request.getHeaders().setAuthorization("Bearer " + accessToken.getTokenValue()); // Add retry logic @@ -249,15 +219,20 @@ static RegionalAccessBoundary refresh( HttpIOExceptionHandler ioExceptionHandler = new HttpBackOffIOExceptionHandler(backoff); request.setIOExceptionHandler(ioExceptionHandler); + request.setParser(new JsonObjectParser(OAuth2Utils.JSON_FACTORY)); + RegionalAccessBoundaryResponse json; + HttpResponse response = null; try { - HttpResponse response = request.execute(); - String responseString = response.parseAsString(); - JsonParser parser = OAuth2Utils.JSON_FACTORY.createJsonParser(responseString); - json = parser.parseAndClose(RegionalAccessBoundaryResponse.class); + response = request.execute(); + json = response.parseAs(RegionalAccessBoundaryResponse.class); } catch (IOException e) { throw new IOException( "RegionalAccessBoundary: Failure while getting regional access boundaries:", e); + } finally { + if (response != null) { + response.disconnect(); + } } String encodedLocations = json.getEncodedLocations(); // The encodedLocations is the value attached to the x-allowed-locations header, and diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundaryManager.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundaryManager.java index eeea75bc2c86..e35efe86f7a0 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundaryManager.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundaryManager.java @@ -31,11 +31,18 @@ package com.google.auth.oauth2; +import static com.google.auth.oauth2.LoggingUtils.log; + import com.google.api.client.util.Clock; import com.google.api.core.InternalApi; import com.google.auth.http.HttpTransportFactory; import com.google.common.annotations.VisibleForTesting; import com.google.common.util.concurrent.SettableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import java.util.logging.Level; import javax.annotation.Nullable; @@ -59,7 +66,7 @@ final class RegionalAccessBoundaryManager { * The default maximum elapsed time in milliseconds for retrying Regional Access Boundary lookup * requests. */ - private static final int DEFAULT_MAX_RETRY_ELAPSED_TIME_MILLIS = 60000; + static final int DEFAULT_MAX_RETRY_ELAPSED_TIME_MILLIS = 60000; /** * cachedRAB uses AtomicReference to provide thread-safe, lock-free access to the cached data for @@ -78,8 +85,41 @@ final class RegionalAccessBoundaryManager { private final AtomicReference cooldownState = new AtomicReference<>(new CooldownState(0, INITIAL_COOLDOWN_MILLIS)); + // Unbounded thread creation is discouraged in library code to avoid resource + // exhaustion. A shared, bounded executor service ensures a hard limit (5) + // on concurrent refresh tasks, while threadCount provides unique names + // for easier debugging. + private static final AtomicInteger threadCount = new AtomicInteger(0); + + // Bounded executor service ensures hard limits on concurrent refresh tasks and queued tasks + // to avoid resource exhaustion. + private static final int EXECUTOR_POOL_SIZE = 5; + private static final int EXECUTOR_QUEUE_CAPACITY = 100; + + private static final ExecutorService DEFAULT_SHARED_EXECUTOR; + + static { + ThreadPoolExecutor executor = + new ThreadPoolExecutor( + EXECUTOR_POOL_SIZE, // corePoolSize: threads to keep alive + EXECUTOR_POOL_SIZE, // maximumPoolSize: max threads allowed + 1, // keepAliveTime: time to wait before terminating idle threads + TimeUnit.HOURS, // unit for keepAliveTime + new LinkedBlockingQueue<>(EXECUTOR_QUEUE_CAPACITY), // work queue with bound + r -> { + Thread t = new Thread(r, "RAB-refresh-" + threadCount.getAndIncrement()); + t.setDaemon(true); + return t; + }); + // Allow core threads to time out so the executor can shrink to 0 when idle. + // Ensures threads are released when idle to avoid unnecessary resource usage. + executor.allowCoreThreadTimeOut(true); + DEFAULT_SHARED_EXECUTOR = executor; + } + private final transient Clock clock; private final int maxRetryElapsedTimeMillis; + private final ExecutorService executor; /** * Creates a new RegionalAccessBoundaryManager with the default retry timeout of 60 seconds. @@ -87,13 +127,20 @@ final class RegionalAccessBoundaryManager { * @param clock The clock to use for cooldown and expiration checks. */ RegionalAccessBoundaryManager(Clock clock) { - this(clock, DEFAULT_MAX_RETRY_ELAPSED_TIME_MILLIS); + this(clock, DEFAULT_MAX_RETRY_ELAPSED_TIME_MILLIS, DEFAULT_SHARED_EXECUTOR); } @VisibleForTesting RegionalAccessBoundaryManager(Clock clock, int maxRetryElapsedTimeMillis) { + this(clock, maxRetryElapsedTimeMillis, DEFAULT_SHARED_EXECUTOR); + } + + @VisibleForTesting + RegionalAccessBoundaryManager( + Clock clock, int maxRetryElapsedTimeMillis, ExecutorService executor) { this.clock = clock != null ? clock : Clock.SYSTEM; this.maxRetryElapsedTimeMillis = maxRetryElapsedTimeMillis; + this.executor = executor; } /** @@ -111,6 +158,11 @@ RegionalAccessBoundary getCachedRAB() { return null; } + @VisibleForTesting + void setCachedRAB(RegionalAccessBoundary rab) { + this.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. @@ -161,19 +213,17 @@ void triggerAsyncRefresh( }; try { - // We use new Thread() here instead of - // CompletableFuture.runAsync() (which uses ForkJoinPool.commonPool()). - // This avoids consuming CPU resources since - // The common pool has a small, fixed number of threads designed for - // CPU-bound tasks. - Thread refreshThread = new Thread(refreshTask, "RAB-refresh-thread"); - refreshThread.setDaemon(true); - refreshThread.start(); + this.executor.submit(refreshTask); } catch (Exception | Error e) { // If scheduling fails (e.g., RejectedExecutionException, OutOfMemoryError for threads), // the task's finally block will never execute. We must release the lock here. - handleRefreshFailure( - new Exception("Regional Access Boundary background refresh failed to schedule", e)); + log( + LOGGER_PROVIDER, + Level.FINE, + null, + "Could not submit background refresh task for Regional Access Boundary. " + + "This is non-blocking and the library will attempt to refresh on the next access. Error: " + + e.getMessage()); future.setException(e); refreshFuture.set(null); } @@ -201,13 +251,13 @@ private void handleRefreshFailure(Exception e) { // concurrent failures from logging redundant messages or incorrectly calculating // the exponential backoff. if (cooldownState.compareAndSet(currentCooldownState, next)) { - LoggingUtils.log( + log( LOGGER_PROVIDER, Level.FINE, null, - "Regional Access Boundary lookup failed; entering cooldown for " + "Regional Access Boundary lookup was not successful; will retry after a cooldown of " + (next.durationMillis / 60000) - + "m. Error: " + + "m. This is handled automatically. Details: " + e.getMessage()); } } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java index a0930b796d04..26fe9151955b 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java @@ -32,6 +32,7 @@ package com.google.auth.oauth2; import static com.google.auth.Credentials.GOOGLE_DEFAULT_UNIVERSE; +import static com.google.auth.oauth2.TestUtils.createDummyRab; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -65,11 +66,6 @@ class AwsCredentialsTest extends BaseSerializationTest { @org.junit.jupiter.api.BeforeEach void setUp() {} - @org.junit.jupiter.api.AfterEach - void tearDown() { - RegionalAccessBoundary.setEnvironmentProviderForTest(null); - } - private static final String STS_URL = "https://sts.googleapis.com/v1/token"; private static final String AWS_CREDENTIALS_URL = "https://169.254.169.254"; private static final String AWS_CREDENTIALS_URL_WITH_ROLE = "https://169.254.169.254/roleName"; @@ -139,6 +135,7 @@ void refreshAccessToken_withoutServiceAccountImpersonation() throws IOException .setHttpTransportFactory(transportFactory) .setCredentialSource(buildAwsCredentialSource(transportFactory)) .build(); + awsCredential.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(awsCredential.clock)); AccessToken accessToken = awsCredential.refreshAccessToken(); @@ -168,6 +165,7 @@ void refreshAccessToken_withServiceAccountImpersonation() throws IOException { .setServiceAccountImpersonationUrl( transportFactory.transport.getServiceAccountImpersonationUrl()) .build(); + awsCredential.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(awsCredential.clock)); AccessToken accessToken = awsCredential.refreshAccessToken(); @@ -200,6 +198,7 @@ void refreshAccessToken_withServiceAccountImpersonationOptions() throws IOExcept .setServiceAccountImpersonationOptions( ExternalAccountCredentialsTest.buildServiceAccountImpersonationOptions()) .build(); + awsCredential.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(awsCredential.clock)); AccessToken accessToken = awsCredential.refreshAccessToken(); @@ -237,6 +236,7 @@ void refreshAccessTokenProgrammaticRefresh_withoutServiceAccountImpersonation() .setTokenUrl(STS_URL) .setSubjectTokenType("subjectTokenType") .build(); + awsCredential.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(awsCredential.clock)); AccessToken accessToken = awsCredential.refreshAccessToken(); @@ -268,6 +268,7 @@ void refreshAccessTokenProgrammaticRefresh_withServiceAccountImpersonation() thr .setServiceAccountImpersonationUrl( transportFactory.transport.getServiceAccountImpersonationUrl()) .build(); + awsCredential.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(awsCredential.clock)); AccessToken accessToken = awsCredential.refreshAccessToken(); @@ -291,6 +292,7 @@ void retrieveSubjectToken() throws IOException { .setHttpTransportFactory(transportFactory) .setCredentialSource(buildAwsCredentialSource(transportFactory)) .build(); + awsCredential.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(awsCredential.clock)); String subjectToken = URLDecoder.decode(awsCredential.retrieveSubjectToken(), "UTF-8"); @@ -335,6 +337,7 @@ void retrieveSubjectTokenWithSessionTokenUrl() throws IOException { .setHttpTransportFactory(transportFactory) .setCredentialSource(buildAwsImdsv2CredentialSource(transportFactory)) .build(); + awsCredential.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(awsCredential.clock)); String subjectToken = URLDecoder.decode(awsCredential.retrieveSubjectToken(), "UTF-8"); @@ -408,6 +411,7 @@ void retrieveSubjectToken_imdsv1EnvVariablesSet_metadataServerNotCalled() throws .setCredentialSource(buildAwsCredentialSource(transportFactory)) .setEnvironmentProvider(environmentProvider) .build(); + awsCredential.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(awsCredential.clock)); String subjectToken = URLDecoder.decode(awsCredential.retrieveSubjectToken(), "UTF-8"); @@ -453,6 +457,7 @@ void retrieveSubjectToken_imdsv2EnvVariablesSet_metadataServerNotCalled() throws .setCredentialSource(buildAwsImdsv2CredentialSource(transportFactory)) .setEnvironmentProvider(environmentProvider) .build(); + awsCredential.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(awsCredential.clock)); String subjectToken = URLDecoder.decode(awsCredential.retrieveSubjectToken(), "UTF-8"); @@ -492,6 +497,7 @@ void retrieveSubjectToken_noRegion_expectThrows() { .setHttpTransportFactory(transportFactory) .setCredentialSource(buildAwsCredentialSource(transportFactory)) .build(); + awsCredential.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(awsCredential.clock)); IOException exception = assertThrows(IOException.class, awsCredential::retrieveSubjectToken); assertEquals("Failed to retrieve AWS region.", exception.getMessage()); @@ -517,6 +523,7 @@ void retrieveSubjectToken_noRole_expectThrows() { .setHttpTransportFactory(transportFactory) .setCredentialSource(buildAwsCredentialSource(transportFactory)) .build(); + awsCredential.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(awsCredential.clock)); IOException exception = assertThrows(IOException.class, awsCredential::retrieveSubjectToken); assertEquals("Failed to retrieve AWS IAM role.", exception.getMessage()); @@ -545,6 +552,7 @@ void retrieveSubjectToken_noCredentials_expectThrows() { .setHttpTransportFactory(transportFactory) .setCredentialSource(buildAwsCredentialSource(transportFactory)) .build(); + awsCredential.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(awsCredential.clock)); IOException exception = assertThrows(IOException.class, awsCredential::retrieveSubjectToken); assertEquals("Failed to retrieve AWS credentials.", exception.getMessage()); @@ -576,6 +584,7 @@ void retrieveSubjectToken_noRegionUrlProvided() { .setHttpTransportFactory(transportFactory) .setCredentialSource(new AwsCredentialSource(credentialSource)) .build(); + awsCredential.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(awsCredential.clock)); IOException exception = assertThrows(IOException.class, awsCredential::retrieveSubjectToken); assertEquals( @@ -604,6 +613,7 @@ void retrieveSubjectToken_withProgrammaticRefresh() throws IOException { .setTokenUrl(STS_URL) .setSubjectTokenType("subjectTokenType") .build(); + awsCredential.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(awsCredential.clock)); String subjectToken = URLDecoder.decode(awsCredential.retrieveSubjectToken(), "UTF-8"); @@ -646,6 +656,7 @@ void retrieveSubjectToken_withProgrammaticRefreshSessionToken() throws IOExcepti .setTokenUrl(STS_URL) .setSubjectTokenType("subjectTokenType") .build(); + awsCredential.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(awsCredential.clock)); String subjectToken = URLDecoder.decode(awsCredential.retrieveSubjectToken(), "UTF-8"); @@ -696,6 +707,7 @@ void retrieveSubjectToken_passesContext() { .setTokenUrl(STS_URL) .setSubjectTokenType("subjectTokenType") .build(); + awsCredential.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(awsCredential.clock)); assertDoesNotThrow(awsCredential::retrieveSubjectToken); } @@ -718,6 +730,7 @@ void retrieveSubjectToken_withProgrammaticRefreshThrowsError() { .setTokenUrl(STS_URL) .setSubjectTokenType("subjectTokenType") .build(); + awsCredential.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(awsCredential.clock)); IOException exception = assertThrows(IOException.class, awsCredential::retrieveSubjectToken); assertEquals("test", exception.getMessage()); @@ -734,6 +747,8 @@ void getAwsSecurityCredentials_fromEnvironmentVariablesNoToken() throws IOExcept AwsCredentials.newBuilder(AWS_CREDENTIAL) .setEnvironmentProvider(environmentProvider) .build(); + testAwsCredentials.regionalAccessBoundaryManager.setCachedRAB( + createDummyRab(testAwsCredentials.clock)); AwsSecurityCredentials credentials = testAwsCredentials.getAwsSecurityCredentialsSupplier().getCredentials(emptyContext); @@ -767,6 +782,8 @@ void getAwsSecurityCredentials_fromEnvironmentVariablesWithToken() throws IOExce .setEnvironmentProvider(environmentProvider) .setCredentialSource(credSource) .build(); + testAwsCredentials.regionalAccessBoundaryManager.setCachedRAB( + createDummyRab(testAwsCredentials.clock)); AwsSecurityCredentials credentials = testAwsCredentials.getAwsSecurityCredentialsSupplier().getCredentials(emptyContext); @@ -789,6 +806,8 @@ void getAwsSecurityCredentials_fromEnvironmentVariables_noMetadataServerCall() AwsCredentials.newBuilder(AWS_CREDENTIAL) .setEnvironmentProvider(environmentProvider) .build(); + testAwsCredentials.regionalAccessBoundaryManager.setCachedRAB( + createDummyRab(testAwsCredentials.clock)); AwsSecurityCredentials credentials = testAwsCredentials.getAwsSecurityCredentialsSupplier().getCredentials(emptyContext); @@ -808,6 +827,7 @@ void getAwsSecurityCredentials_fromMetadataServer() throws IOException { .setHttpTransportFactory(transportFactory) .setCredentialSource(buildAwsCredentialSource(transportFactory)) .build(); + awsCredential.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(awsCredential.clock)); AwsSecurityCredentials credentials = awsCredential.getAwsSecurityCredentialsSupplier().getCredentials(emptyContext); @@ -840,6 +860,7 @@ void getAwsSecurityCredentials_fromMetadataServer_noUrlProvided() { .setHttpTransportFactory(transportFactory) .setCredentialSource(new AwsCredentialSource(credentialSource)) .build(); + awsCredential.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(awsCredential.clock)); IOException exception = assertThrows( @@ -868,6 +889,7 @@ void getAwsRegion_awsRegionEnvironmentVariable() throws IOException { .setCredentialSource(buildAwsCredentialSource(transportFactory)) .setEnvironmentProvider(environmentProvider) .build(); + awsCredentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(awsCredentials.clock)); String region = awsCredentials.getAwsSecurityCredentialsSupplier().getRegion(emptyContext); @@ -893,6 +915,7 @@ void getAwsRegion_awsDefaultRegionEnvironmentVariable() throws IOException { .setCredentialSource(buildAwsCredentialSource(transportFactory)) .setEnvironmentProvider(environmentProvider) .build(); + awsCredentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(awsCredentials.clock)); String region = awsCredentials.getAwsSecurityCredentialsSupplier().getRegion(emptyContext); @@ -914,6 +937,7 @@ void getAwsRegion_metadataServer() throws IOException { .setHttpTransportFactory(transportFactory) .setCredentialSource(buildAwsCredentialSource(transportFactory)) .build(); + awsCredentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(awsCredentials.clock)); String region = awsCredentials.getAwsSecurityCredentialsSupplier().getRegion(emptyContext); @@ -942,10 +966,12 @@ void createdScoped_clonedCredentialWithAddedScopes() { .setClientSecret("clientSecret") .setUniverseDomain("universeDomain") .build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); List newScopes = Arrays.asList("scope1", "scope2"); AwsCredentials newCredentials = (AwsCredentials) credentials.createScoped(newScopes); + newCredentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(newCredentials.clock)); assertEquals(credentials.getAudience(), newCredentials.getAudience()); assertEquals(credentials.getSubjectTokenType(), newCredentials.getSubjectTokenType()); @@ -1021,6 +1047,7 @@ void builder_allFields() { .setScopes(scopes) .setUniverseDomain("universeDomain") .build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); assertEquals("audience", credentials.getAudience()); assertEquals("subjectTokenType", credentials.getSubjectTokenType()); @@ -1057,6 +1084,7 @@ void builder_missingUniverseDomain_defaults() { .setClientSecret("clientSecret") .setScopes(scopes) .build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); assertEquals("https://test.com", credentials.getRegionalCredentialVerificationUrlOverride()); assertEquals("audience", credentials.getAudience()); @@ -1094,8 +1122,11 @@ void newBuilder_allFields() { .setScopes(scopes) .setUniverseDomain("universeDomain") .build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); AwsCredentials newBuilderCreds = AwsCredentials.newBuilder(credentials).build(); + newBuilderCreds.regionalAccessBoundaryManager.setCachedRAB( + createDummyRab(newBuilderCreds.clock)); assertEquals(credentials.getAudience(), newBuilderCreds.getAudience()); assertEquals(credentials.getSubjectTokenType(), newBuilderCreds.getSubjectTokenType()); assertEquals(credentials.getTokenUrl(), newBuilderCreds.getTokenUrl()); @@ -1131,8 +1162,11 @@ void newBuilder_noUniverseDomain_defaults() { .setClientSecret("clientSecret") .setScopes(scopes) .build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); AwsCredentials newBuilderCreds = AwsCredentials.newBuilder(credentials).build(); + newBuilderCreds.regionalAccessBoundaryManager.setCachedRAB( + createDummyRab(newBuilderCreds.clock)); assertEquals(credentials.getAudience(), newBuilderCreds.getAudience()); assertEquals(credentials.getSubjectTokenType(), newBuilderCreds.getSubjectTokenType()); assertEquals(credentials.getTokenUrl(), newBuilderCreds.getTokenUrl()); @@ -1170,6 +1204,7 @@ void builder_defaultRegionalCredentialVerificationUrlOverride() { .setClientSecret("clientSecret") .setScopes(scopes) .build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); assertNull(credentials.getRegionalCredentialVerificationUrlOverride()); assertEquals( @@ -1249,6 +1284,8 @@ void serialize() throws IOException, ClassNotFoundException { .setUniverseDomain("universeDomain") .setScopes(scopes) .build(); + testCredentials.regionalAccessBoundaryManager.setCachedRAB( + createDummyRab(testCredentials.clock)); AwsCredentials deserializedCredentials = serializeAndDeserialize(testCredentials); assertEquals(testCredentials, deserializedCredentials); @@ -1369,9 +1406,6 @@ public AwsSecurityCredentials getCredentials(ExternalAccountSupplierContext cont @Test public void testRefresh_regionalAccessBoundarySuccess() throws IOException, InterruptedException { - TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); - environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); MockExternalAccountCredentialsTransportFactory transportFactory = new MockExternalAccountCredentialsTransportFactory(); diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java index 8b20d0cc20f4..78bfd5ddaaa4 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java @@ -34,6 +34,7 @@ import static com.google.auth.oauth2.ComputeEngineCredentials.METADATA_RESPONSE_EMPTY_CONTENT_ERROR_MESSAGE; import static com.google.auth.oauth2.ImpersonatedCredentialsTest.SA_CLIENT_EMAIL; import static com.google.auth.oauth2.RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY; +import static com.google.auth.oauth2.TestUtils.createDummyRab; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -80,11 +81,6 @@ class ComputeEngineCredentialsTest extends BaseSerializationTest { @org.junit.jupiter.api.BeforeEach void setUp() {} - @org.junit.jupiter.api.AfterEach - void tearDown() { - RegionalAccessBoundary.setEnvironmentProviderForTest(null); - } - private static final URI CALL_URI = URI.create("http://googleapis.com/testapi/v1/foo"); private static final String TOKEN_URL = @@ -398,6 +394,7 @@ void getRequestMetadata_hasAccessToken() throws IOException { transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL); ComputeEngineCredentials credentials = ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); Map> metadata = credentials.getRequestMetadata(CALL_URI); TestUtils.assertContainsBearerToken(metadata, ACCESS_TOKEN); @@ -414,6 +411,7 @@ void getRequestMetadata_shouldInvalidateAccessTokenWhenScoped_newAccessTokenFrom transportFactory.transport.setServiceAccountEmail("SA_CLIENT_EMAIL"); ComputeEngineCredentials credentials = ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); Map> metadata = credentials.getRequestMetadata(CALL_URI); TestUtils.assertContainsBearerToken(metadata, ACCESS_TOKEN); @@ -421,6 +419,8 @@ void getRequestMetadata_shouldInvalidateAccessTokenWhenScoped_newAccessTokenFrom assertNotNull(credentials.getAccessToken()); ComputeEngineCredentials scopedCredentialCopy = (ComputeEngineCredentials) credentials.createScoped(SCOPES); + scopedCredentialCopy.regionalAccessBoundaryManager.setCachedRAB( + createDummyRab(scopedCredentialCopy.clock)); assertNull(scopedCredentialCopy.getAccessToken()); Map> metadataForCopiedCredentials = scopedCredentialCopy.getRequestMetadata(CALL_URI); @@ -435,6 +435,7 @@ void getRequestMetadata_missingServiceAccount_throws() { transportFactory.transport.setServiceAccountEmail("SA_CLIENT_EMAIL"); ComputeEngineCredentials credentials = ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); IOException exception = assertThrows(IOException.class, () -> credentials.getRequestMetadata(CALL_URI)); String message = exception.getMessage(); @@ -450,6 +451,7 @@ void getRequestMetadata_serverError_throws() { transportFactory.transport.setServiceAccountEmail("SA_CLIENT_EMAIL"); ComputeEngineCredentials credentials = ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); IOException exception = assertThrows(IOException.class, () -> credentials.getRequestMetadata(CALL_URI)); String message = exception.getMessage(); @@ -573,6 +575,7 @@ void getAccount_sameAs() { transportFactory.transport.setServiceAccountEmail(defaultAccountEmail); ComputeEngineCredentials credentials = ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); assertEquals(defaultAccountEmail, credentials.getAccount()); @@ -606,6 +609,7 @@ public LowLevelHttpResponse execute() throws IOException { transportFactory.transport.setServiceAccountEmail(defaultAccountEmail); ComputeEngineCredentials credentials = ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); RuntimeException exception = assertThrows(RuntimeException.class, credentials::getAccount); assertEquals("Failed to get service account", exception.getMessage()); @@ -637,6 +641,7 @@ public LowLevelHttpResponse execute() throws IOException { transportFactory.transport.setServiceAccountEmail(defaultAccountEmail); ComputeEngineCredentials credentials = ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); RuntimeException exception = assertThrows(RuntimeException.class, credentials::getAccount); assertEquals("Failed to get service account", exception.getMessage()); @@ -654,6 +659,7 @@ void sign_sameAs() { transportFactory.transport.setSignature(expectedSignature); ComputeEngineCredentials credentials = ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); assertArrayEquals(expectedSignature, credentials.sign(expectedSignature)); } @@ -666,6 +672,7 @@ void sign_getUniverseException() { transportFactory.transport.setServiceAccountEmail(defaultAccountEmail); ComputeEngineCredentials credentials = ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); transportFactory.transport.setStatusCode(501); assertThrows(IOException.class, credentials::getUniverseDomain); @@ -684,6 +691,7 @@ void sign_getAccountFails() { transportFactory.transport.setSignature(expectedSignature); ComputeEngineCredentials credentials = ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); SigningException exception = assertThrows(SigningException.class, () -> credentials.sign(expectedSignature)); @@ -719,6 +727,7 @@ public LowLevelHttpResponse execute() throws IOException { ComputeEngineCredentials credentials = ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); byte[] bytes = {0xD, 0xE, 0xA, 0xD}; @@ -757,6 +766,7 @@ public LowLevelHttpResponse execute() throws IOException { ComputeEngineCredentials credentials = ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); byte[] bytes = {0xD, 0xE, 0xA, 0xD}; @@ -788,6 +798,7 @@ public LowLevelHttpResponse execute() throws IOException { ComputeEngineCredentials credentials = ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); IOException exception = assertThrows(IOException.class, credentials::refreshAccessToken); assertTrue(exception.getCause().getMessage().contains("503")); @@ -851,6 +862,7 @@ public LowLevelHttpResponse execute() throws IOException { ComputeEngineCredentials credentials = ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); String universeDomain = credentials.getUniverseDomain(); assertEquals("some-universe.xyz", universeDomain); @@ -878,6 +890,7 @@ public LowLevelHttpResponse execute() throws IOException { ComputeEngineCredentials credentials = ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); String universeDomain = credentials.getUniverseDomain(); assertEquals(Credentials.GOOGLE_DEFAULT_UNIVERSE, universeDomain); @@ -905,6 +918,7 @@ public LowLevelHttpResponse execute() throws IOException { ComputeEngineCredentials credentials = ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); String universeDomain = credentials.getUniverseDomain(); assertEquals(Credentials.GOOGLE_DEFAULT_UNIVERSE, universeDomain); @@ -951,6 +965,7 @@ void getUniverseDomain_fromMetadata_non404error_throws() { ComputeEngineCredentials credentials = ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); for (int status = 400; status < 600; status++) { // 404 should not throw and tested separately @@ -991,6 +1006,7 @@ public LowLevelHttpResponse execute() { ComputeEngineCredentials credentials = ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); byte[] bytes = {0xD, 0xE, 0xA, 0xD}; @@ -1007,6 +1023,7 @@ void idTokenWithAudience_sameAs() throws IOException { transportFactory.transport.setIdToken(STANDARD_ID_TOKEN); ComputeEngineCredentials credentials = ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); String targetAudience = "https://foo.bar"; IdTokenCredentials tokenCredential = @@ -1027,6 +1044,7 @@ void idTokenWithAudience_standard() throws IOException { MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); ComputeEngineCredentials credentials = ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); String targetAudience = "https://foo.bar"; IdTokenCredentials tokenCredential = @@ -1046,6 +1064,7 @@ void idTokenWithAudience_full() throws IOException { MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); ComputeEngineCredentials credentials = ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); String targetAudience = "https://foo.bar"; IdTokenCredentials tokenCredential = @@ -1072,6 +1091,7 @@ void idTokenWithAudience_licenses() throws IOException { MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); ComputeEngineCredentials credentials = ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); String targetAudience = "https://foo.bar"; IdTokenCredentials tokenCredential = @@ -1100,6 +1120,7 @@ void idTokenWithAudience_404StatusCode() { transportFactory.transport.setStatusCode(HttpStatusCodes.STATUS_CODE_NOT_FOUND); ComputeEngineCredentials credentials = ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); IOException exception = assertThrows(IOException.class, () -> credentials.idTokenWithAudience("Audience", null)); assertEquals( @@ -1117,6 +1138,7 @@ void idTokenWithAudience_emptyContent() { transportFactory.transport.setEmptyContent(true); ComputeEngineCredentials credentials = ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); IOException exception = assertThrows(IOException.class, () -> credentials.idTokenWithAudience("Audience", null)); assertEquals(METADATA_RESPONSE_EMPTY_CONTENT_ERROR_MESSAGE, exception.getMessage()); @@ -1128,6 +1150,7 @@ void idTokenWithAudience_503StatusCode() { transportFactory.transport.setStatusCode(HttpStatusCodes.STATUS_CODE_SERVICE_UNAVAILABLE); ComputeEngineCredentials credentials = ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); assertThrows( GoogleAuthException.class, () -> credentials.idTokenWithAudience("Audience", null)); } @@ -1152,6 +1175,7 @@ public LowLevelHttpResponse execute() throws IOException { ComputeEngineCredentials credentials = ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); String projectId = credentials.getProjectId(); assertEquals("some-project-id", projectId); } @@ -1162,6 +1186,7 @@ void getProjectId_metadataServerFailure_404StatusCode() { transportFactory.transport.setStatusCode(HttpStatusCodes.STATUS_CODE_SERVICE_UNAVAILABLE); ComputeEngineCredentials credentials = ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); assertNull(credentials.getProjectId()); } @@ -1171,6 +1196,7 @@ void getProjectId_metadataServerFailure_otherStatusCode() { transportFactory.transport.setStatusCode(HttpStatusCodes.STATUS_CODE_UNAUTHORIZED); ComputeEngineCredentials credentials = ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); assertNull(credentials.getProjectId()); } @@ -1180,6 +1206,7 @@ void getProjectId_explicitSet_noMDsCall() { new MockRequestCountingTransportFactory(); ComputeEngineCredentials credentials = ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); credentials.setProjectId("explicit.project_id"); assertEquals("explicit.project_id", credentials.getProjectId()); @@ -1188,9 +1215,6 @@ void getProjectId_explicitSet_noMDsCall() { @org.junit.jupiter.api.Test void refresh_regionalAccessBoundarySuccess() throws IOException, InterruptedException { - TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); - environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); String defaultAccountEmail = "default@email.com"; MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentialsTest.java index fbf3f79dbe65..b374e08111ff 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentialsTest.java @@ -32,6 +32,7 @@ package com.google.auth.oauth2; import static com.google.auth.Credentials.GOOGLE_DEFAULT_UNIVERSE; +import static com.google.auth.oauth2.TestUtils.createDummyRab; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; @@ -130,9 +131,7 @@ void setup() { } @org.junit.jupiter.api.AfterEach - void tearDown() { - RegionalAccessBoundary.setEnvironmentProviderForTest(null); - } + void tearDown() {} @Test void builder_allFields() throws IOException { @@ -708,6 +707,7 @@ void createScopedRequired_false() { void getRequestMetadata() throws IOException { GoogleCredentials credentials = ExternalAccountAuthorizedUserCredentials.fromJson(buildJsonCredentials(), transportFactory); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); Map> metadata = credentials.getRequestMetadata(CALL_URI); @@ -719,6 +719,7 @@ void getRequestMetadata() throws IOException { void getRequestMetadata_withQuotaProjectId() throws IOException { GoogleCredentials credentials = ExternalAccountAuthorizedUserCredentials.fromJson(buildJsonCredentials(), transportFactory); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); Map> metadata = credentials.getRequestMetadata(CALL_URI); @@ -737,6 +738,7 @@ void getRequestMetadata_withAccessToken() throws IOException { .setHttpTransportFactory(transportFactory) .setAccessToken(new AccessToken(ACCESS_TOKEN, /* expirationTime= */ null)) .build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); Map> metadata = credentials.getRequestMetadata(CALL_URI); @@ -1243,9 +1245,6 @@ void serialize() throws IOException, ClassNotFoundException { @org.junit.jupiter.api.Test void testRefresh_regionalAccessBoundarySuccess() throws IOException, InterruptedException { - TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); - environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); ExternalAccountAuthorizedUserCredentials credentials = ExternalAccountAuthorizedUserCredentials.newBuilder() diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java index 5b20f33db983..ae4fbb1aac8b 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java @@ -35,6 +35,7 @@ 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 com.google.auth.oauth2.TestUtils.createDummyRab; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; @@ -92,11 +93,6 @@ void setup() { transportFactory = new MockExternalAccountCredentialsTransportFactory(); } - @org.junit.jupiter.api.AfterEach - void tearDown() { - RegionalAccessBoundary.setEnvironmentProviderForTest(null); - } - @Test void fromStream_identityPoolCredentials() throws IOException { GenericJson json = buildJsonIdentityPoolCredential(); @@ -1113,6 +1109,8 @@ void getRequestMetadata_withQuotaProjectId() throws IOException { .setCredentialSource(new TestCredentialSource(FILE_CREDENTIAL_SOURCE_MAP)) .setQuotaProjectId("quotaProjectId") .build(); + testCredentials.regionalAccessBoundaryManager.setCachedRAB( + createDummyRab(testCredentials.clock)); Map> requestMetadata = testCredentials.getRequestMetadata(URI.create("http://googleapis.com/foo/bar")); @@ -1302,9 +1300,7 @@ public void getRegionalAccessBoundaryUrl_invalidAudience_throws() { @Test public void refresh_workload_regionalAccessBoundarySuccess() throws IOException, InterruptedException { - TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); - environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + String audience = "//iam.googleapis.com/projects/12345/locations/global/workloadIdentityPools/my-pool/providers/my-provider"; @@ -1339,9 +1335,7 @@ public String retrieveSubjectToken() throws IOException { @Test public void refresh_workforce_regionalAccessBoundarySuccess() throws IOException, InterruptedException { - TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); - environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + String audience = "//iam.googleapis.com/locations/global/workforcePools/my-pool/providers/my-provider"; @@ -1376,9 +1370,7 @@ public String retrieveSubjectToken() throws IOException { @Test public void refresh_impersonated_workload_regionalAccessBoundarySuccess() throws IOException, InterruptedException { - TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); - environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + String projectNumber = "12345"; String poolId = "my-pool"; String providerId = "my-provider"; @@ -1440,9 +1432,7 @@ public void refresh_impersonated_workload_regionalAccessBoundarySuccess() @Test public void refresh_impersonated_workforce_regionalAccessBoundarySuccess() throws IOException, InterruptedException { - TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); - environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + String poolId = "my-pool"; String providerId = "my-provider"; String audience = diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java index dd64a07d4a1f..18e5c4585eef 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java @@ -109,9 +109,7 @@ class GoogleCredentialsTest extends BaseSerializationTest { void setUp() {} @org.junit.jupiter.api.AfterEach - void tearDown() { - RegionalAccessBoundary.setEnvironmentProviderForTest(null); - } + void tearDown() {} @Test void getApplicationDefault_nullTransport_throws() { @@ -858,9 +856,6 @@ void serialize() throws IOException, ClassNotFoundException { @Test public void serialize_removesStaleRabHeaders() throws Exception { - TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); - environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); MockTokenServerTransportFactory transportFactory = new MockTokenServerTransportFactory(); RegionalAccessBoundary rab = @@ -1046,9 +1041,7 @@ void getCredentialInfo_impersonatedServiceAccount() throws IOException { @Test public void regionalAccessBoundary_shouldFetchAndReturnRegionalAccessBoundaryDataSuccessfully() throws IOException, InterruptedException { - TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); - environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + MockTokenServerTransport transport = new MockTokenServerTransport(); transport.addServiceAccount(SA_CLIENT_EMAIL, ACCESS_TOKEN); RegionalAccessBoundary regionalAccessBoundary = @@ -1083,9 +1076,6 @@ public void regionalAccessBoundary_shouldFetchAndReturnRegionalAccessBoundaryDat @Test public void regionalAccessBoundary_shouldRetryRegionalAccessBoundaryLookupOnFailure() throws IOException, InterruptedException { - TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); - environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); // This transport will be used for the regional access boundary lookup. // We will configure it to fail on the first attempt. @@ -1137,9 +1127,7 @@ public com.google.api.client.http.LowLevelHttpRequest buildRequest( @Test public void regionalAccessBoundary_refreshShouldNotThrowWhenNoValidAccessTokenIsPassed() throws IOException { - TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); - environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + MockTokenServerTransport transport = new MockTokenServerTransport(); // Return an expired access token. transport.addServiceAccount(SA_CLIENT_EMAIL, "expired-token"); @@ -1162,9 +1150,7 @@ public void regionalAccessBoundary_refreshShouldNotThrowWhenNoValidAccessTokenIs @Test public void regionalAccessBoundary_cooldownDoublingAndRefresh() throws IOException, InterruptedException { - TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); - environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + MockTokenServerTransport transport = new MockTokenServerTransport(); transport.addServiceAccount(SA_CLIENT_EMAIL, ACCESS_TOKEN); // Always fail lookup for now. @@ -1224,9 +1210,7 @@ public void regionalAccessBoundary_cooldownDoublingAndRefresh() @Test public void regionalAccessBoundary_shouldFailOpenWhenRefreshCannotBeStarted() throws IOException { - TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); - environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + // Use a simple AccessToken-based credential that won't try to refresh. GoogleCredentials credentials = GoogleCredentials.create(new AccessToken("some-token", null)); @@ -1238,9 +1222,7 @@ public void regionalAccessBoundary_shouldFailOpenWhenRefreshCannotBeStarted() th @Test public void regionalAccessBoundary_deduplicationOfConcurrentRefreshes() throws IOException, InterruptedException { - TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); - environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + MockTokenServerTransport transport = new MockTokenServerTransport(); transport.setRegionalAccessBoundary( new RegionalAccessBoundary("valid", Collections.singletonList("us-central1"), null)); @@ -1269,9 +1251,7 @@ public void regionalAccessBoundary_deduplicationOfConcurrentRefreshes() @Test public void regionalAccessBoundary_shouldSkipRefreshForRegionalEndpoints() throws IOException { - TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); - environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + MockTokenServerTransport transport = new MockTokenServerTransport(); GoogleCredentials credentials = createTestCredentials(transport); diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdTokenCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdTokenCredentialsTest.java index e3dcec4b520c..56ebcc3f273e 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdTokenCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdTokenCredentialsTest.java @@ -31,6 +31,7 @@ package com.google.auth.oauth2; +import static com.google.auth.oauth2.TestUtils.createDummyRab; import static org.junit.jupiter.api.Assertions.assertEquals; import java.io.IOException; @@ -46,6 +47,7 @@ void hashCode_equals() throws IOException { transportFactory.transport.setIdToken(ComputeEngineCredentialsTest.STANDARD_ID_TOKEN); ComputeEngineCredentials credentials = ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); String targetAudience = "https://foo.bar"; IdTokenCredentials tokenCredential = @@ -72,6 +74,7 @@ void toString_equals() throws IOException { transportFactory.transport.setIdToken(ComputeEngineCredentialsTest.STANDARD_ID_TOKEN); ComputeEngineCredentials credentials = ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); String targetAudience = "https://foo.bar"; IdTokenCredentials tokenCredential = @@ -99,6 +102,7 @@ void serialize() throws IOException, ClassNotFoundException { transportFactory.transport.setIdToken(ComputeEngineCredentialsTest.STANDARD_ID_TOKEN); ComputeEngineCredentials credentials = ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); String targetAudience = "https://foo.bar"; IdTokenCredentials tokenCredential = diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java index 399bf7246c9a..3a5dcd8720e7 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java @@ -34,6 +34,7 @@ import static com.google.auth.Credentials.GOOGLE_DEFAULT_UNIVERSE; import static com.google.auth.oauth2.MockExternalAccountCredentialsTransport.SERVICE_ACCOUNT_IMPERSONATION_URL; import static com.google.auth.oauth2.OAuth2Utils.JSON_FACTORY; +import static com.google.auth.oauth2.TestUtils.createDummyRab; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -81,9 +82,7 @@ class IdentityPoolCredentialsTest extends BaseSerializationTest { void setUp() {} @org.junit.jupiter.api.AfterEach - void tearDown() { - RegionalAccessBoundary.setEnvironmentProviderForTest(null); - } + void tearDown() {} @Test void createdScoped_clonedCredentialWithAddedScopes() { @@ -95,10 +94,12 @@ void createdScoped_clonedCredentialWithAddedScopes() { .setClientSecret("clientSecret") .setUniverseDomain("universeDomain") .build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); List newScopes = Arrays.asList("scope1", "scope2"); IdentityPoolCredentials newCredentials = credentials.createScoped(newScopes); + newCredentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(newCredentials.clock)); assertEquals(credentials.getAudience(), newCredentials.getAudience()); assertEquals(credentials.getSubjectTokenType(), newCredentials.getSubjectTokenType()); @@ -136,6 +137,7 @@ void retrieveSubjectToken_fileSourced() throws IOException { IdentityPoolCredentials.newBuilder(createBaseFileSourcedCredentials()) .setCredentialSource(credentialSource) .build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); String subjectToken = credentials.retrieveSubjectToken(); @@ -177,6 +179,7 @@ void retrieveSubjectToken_fileSourcedWithJsonFormat() throws IOException { .setHttpTransportFactory(transportFactory) .setCredentialSource(credentialSource) .build(); + credential.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credential.clock)); String subjectToken = credential.retrieveSubjectToken(); @@ -215,6 +218,7 @@ void retrieveSubjectToken_noFile_throws() { IdentityPoolCredentials.newBuilder(createBaseFileSourcedCredentials()) .setCredentialSource(credentialSource) .build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); IOException e = assertThrows(IOException.class, credentials::retrieveSubjectToken); assertEquals( @@ -233,6 +237,7 @@ void retrieveSubjectToken_urlSourced() throws IOException { .setCredentialSource( buildUrlBasedCredentialSource(transportFactory.transport.getMetadataUrl())) .build(); + credential.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credential.clock)); String subjectToken = credential.retrieveSubjectToken(); @@ -258,6 +263,7 @@ void retrieveSubjectToken_urlSourcedWithJsonFormat() throws IOException { .setHttpTransportFactory(transportFactory) .setCredentialSource(credentialSource) .build(); + credential.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credential.clock)); String subjectToken = credential.retrieveSubjectToken(); @@ -278,6 +284,7 @@ void retrieveSubjectToken_urlSourcedCredential_throws() { .setCredentialSource( buildUrlBasedCredentialSource(transportFactory.transport.getMetadataUrl())) .build(); + credential.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credential.clock)); IOException e = assertThrows(IOException.class, credential::retrieveSubjectToken); assertEquals( @@ -295,6 +302,7 @@ void retrieveSubjectToken_provider() throws IOException { .setCredentialSource(null) .setSubjectTokenSupplier(testProvider) .build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); String subjectToken = credentials.retrieveSubjectToken(); @@ -314,6 +322,7 @@ void retrieveSubjectToken_providerThrowsError() { .setCredentialSource(null) .setSubjectTokenSupplier(errorProvider) .build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); IOException e = assertThrows(IOException.class, credentials::retrieveSubjectToken); assertEquals("test", e.getMessage()); @@ -338,6 +347,7 @@ void retrieveSubjectToken_supplierPassesContext() throws IOException { .setCredentialSource(null) .setSubjectTokenSupplier(testSupplier) .build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); credentials.retrieveSubjectToken(); } @@ -359,6 +369,7 @@ void refreshAccessToken_withoutServiceAccountImpersonation() throws IOException .setCredentialSource( buildUrlBasedCredentialSource(transportFactory.transport.getMetadataUrl())) .build(); + credential.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credential.clock)); AccessToken accessToken = credential.refreshAccessToken(); @@ -385,6 +396,7 @@ void refreshAccessToken_internalOptionsSet() throws IOException { .setCredentialSource( buildUrlBasedCredentialSource(transportFactory.transport.getMetadataUrl())) .build(); + credential.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credential.clock)); AccessToken accessToken = credential.refreshAccessToken(); @@ -422,6 +434,7 @@ void refreshAccessToken_withServiceAccountImpersonation() throws IOException { .setCredentialSource( buildUrlBasedCredentialSource(transportFactory.transport.getMetadataUrl())) .build(); + credential.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credential.clock)); AccessToken accessToken = credential.refreshAccessToken(); @@ -455,6 +468,7 @@ void refreshAccessToken_withServiceAccountImpersonationOptions() throws IOExcept .setServiceAccountImpersonationOptions( ExternalAccountCredentialsTest.buildServiceAccountImpersonationOptions()) .build(); + credential.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credential.clock)); AccessToken accessToken = credential.refreshAccessToken(); @@ -491,6 +505,7 @@ void refreshAccessToken_Provider() throws IOException { .setTokenUrl(transportFactory.transport.getStsUrl()) .setHttpTransportFactory(transportFactory) .build(); + credential.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credential.clock)); AccessToken accessToken = credential.refreshAccessToken(); @@ -520,6 +535,7 @@ void refreshAccessToken_providerWithServiceAccountImpersonation() throws IOExcep .setTokenUrl(transportFactory.transport.getStsUrl()) .setHttpTransportFactory(transportFactory) .build(); + credential.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credential.clock)); AccessToken accessToken = credential.refreshAccessToken(); @@ -550,6 +566,7 @@ void refreshAccessToken_workforceWithServiceAccountImpersonation() throws IOExce buildUrlBasedCredentialSource(transportFactory.transport.getMetadataUrl())) .setWorkforcePoolUserProject("userProject") .build(); + credential.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credential.clock)); AccessToken accessToken = credential.refreshAccessToken(); @@ -587,6 +604,7 @@ void refreshAccessToken_workforceWithServiceAccountImpersonationOptions() throws .setServiceAccountImpersonationOptions( ExternalAccountCredentialsTest.buildServiceAccountImpersonationOptions()) .build(); + credential.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credential.clock)); AccessToken accessToken = credential.refreshAccessToken(); @@ -770,6 +788,7 @@ void builder_allFields() { .setScopes(scopes) .setUniverseDomain("universeDomain") .build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); assertEquals("audience", credentials.getAudience()); assertEquals("subjectTokenType", credentials.getSubjectTokenType()); @@ -804,6 +823,7 @@ void builder_subjectTokenSupplier() { .setClientSecret("clientSecret") .setScopes(scopes) .build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); assertEquals(testProvider, credentials.getIdentityPoolSubjectTokenSupplier()); } @@ -855,6 +875,7 @@ void builder_emptyWorkforceUserProjectWithWorkforceAudience() { .setCredentialSource(createFileCredentialSource()) .setQuotaProjectId("quotaProjectId") .build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); assertTrue(credentials.isWorkforcePoolConfiguration()); } @@ -909,6 +930,7 @@ void builder_missingUniverseDomain_defaults() { .setClientSecret("clientSecret") .setScopes(scopes) .build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); assertEquals("audience", credentials.getAudience()); assertEquals("subjectTokenType", credentials.getSubjectTokenType()); @@ -946,9 +968,13 @@ void newBuilder_allFields() { .setWorkforcePoolUserProject("workforcePoolUserProject") .setUniverseDomain("universeDomain") .build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); IdentityPoolCredentials newBuilderCreds = IdentityPoolCredentials.newBuilder(credentials).build(); + newBuilderCreds.regionalAccessBoundaryManager.setCachedRAB( + new RegionalAccessBoundary( + "dummy-locations", Arrays.asList("dummy-loc"), newBuilderCreds.clock)); assertEquals(credentials.getAudience(), newBuilderCreds.getAudience()); assertEquals(credentials.getSubjectTokenType(), newBuilderCreds.getSubjectTokenType()); assertEquals(credentials.getTokenUrl(), newBuilderCreds.getTokenUrl()); @@ -987,9 +1013,13 @@ void newBuilder_noUniverseDomain_defaults() { .setScopes(scopes) .setWorkforcePoolUserProject("workforcePoolUserProject") .build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); IdentityPoolCredentials newBuilderCreds = IdentityPoolCredentials.newBuilder(credentials).build(); + newBuilderCreds.regionalAccessBoundaryManager.setCachedRAB( + new RegionalAccessBoundary( + "dummy-locations", Arrays.asList("dummy-loc"), newBuilderCreds.clock)); assertEquals(credentials.getAudience(), newBuilderCreds.getAudience()); assertEquals(credentials.getSubjectTokenType(), newBuilderCreds.getSubjectTokenType()); assertEquals(credentials.getTokenUrl(), newBuilderCreds.getTokenUrl()); @@ -1018,6 +1048,9 @@ void serialize() throws IOException, ClassNotFoundException { .setClientSecret("clientSecret") .setUniverseDomain("universeDomain") .build(); + testCredentials.regionalAccessBoundaryManager.setCachedRAB( + new RegionalAccessBoundary( + "dummy-locations", Arrays.asList("dummy-loc"), testCredentials.clock)); IdentityPoolCredentials deserializedCredentials = serializeAndDeserialize(testCredentials); assertEquals(testCredentials, deserializedCredentials); @@ -1047,6 +1080,7 @@ void build_withCertificateSource_succeeds() throws Exception { .setSubjectTokenType("test-token-type") .setCredentialSource(credentialSource) .build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); // Verify successful creation and correct internal setup. assertNotNull(credentials, "Credentials should be successfully created"); @@ -1089,6 +1123,7 @@ void build_withDefaultCertificateConfig_success() .setSubjectTokenType("test-token-type") .setCredentialSource(credentialSource) .build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); // Verify successful creation and correct internal setup. assertNotNull(credentials, "Credentials should be successfully created"); @@ -1258,15 +1293,18 @@ private IdentityPoolCredentials createBaseFileSourcedCredentials() { IdentityPoolCredentialSource identityPoolCredentialSource = new IdentityPoolCredentialSource(fileCredentialSourceMap); - return IdentityPoolCredentials.newBuilder() - .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY) - .setAudience( - "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") - .setSubjectTokenType("subjectTokenType") - .setTokenUrl(STS_URL) - .setTokenInfoUrl("tokenInfoUrl") - .setCredentialSource(identityPoolCredentialSource) - .build(); + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY) + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl(STS_URL) + .setTokenInfoUrl("tokenInfoUrl") + .setCredentialSource(identityPoolCredentialSource) + .build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); + return credentials; } private IdentityPoolCredentialSource createFileCredentialSource() { @@ -1312,9 +1350,6 @@ void setShouldThrowOnGetKeyStore(boolean shouldThrow) { @Test public void testRefresh_regionalAccessBoundarySuccess() throws IOException, InterruptedException { - TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); - environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); MockExternalAccountCredentialsTransportFactory transportFactory = new MockExternalAccountCredentialsTransportFactory(); diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java index fc3c2e9c783e..3664fb22c2ff 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java @@ -32,6 +32,7 @@ package com.google.auth.oauth2; import static com.google.auth.oauth2.RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY; +import static com.google.auth.oauth2.TestUtils.createDummyRab; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -163,11 +164,6 @@ void setup() throws IOException { mockTransportFactory = new MockIAMCredentialsServiceTransportFactory(); } - @org.junit.After - public void tearDown() { - RegionalAccessBoundary.setEnvironmentProviderForTest(null); - } - static GoogleCredentials getSourceCredentials() throws IOException { MockTokenServerTransportFactory transportFactory = new MockTokenServerTransportFactory(); PrivateKey privateKey = OAuth2Utils.privateKeyFromPkcs8(SA_PRIVATE_KEY_PKCS8); @@ -180,6 +176,8 @@ static GoogleCredentials getSourceCredentials() throws IOException { .setProjectId(PROJECT_ID) .setHttpTransportFactory(transportFactory) .build(); + sourceCredentials.regionalAccessBoundaryManager.setCachedRAB( + createDummyRab(sourceCredentials.clock)); transportFactory.transport.addServiceAccount(SA_CLIENT_EMAIL, ACCESS_TOKEN); transportFactory.transport.setRegionalAccessBoundary(REGIONAL_ACCESS_BOUNDARY); @@ -597,6 +595,8 @@ void getRequestMetadata_withQuotaProjectId() throws IOException, IllegalStateExc VALID_LIFETIME, mockTransportFactory, QUOTA_PROJECT_ID); + targetCredentials.regionalAccessBoundaryManager.setCachedRAB( + createDummyRab(targetCredentials.clock)); Map> metadata = targetCredentials.getRequestMetadata(); assertTrue(metadata.containsKey("x-goog-user-project")); @@ -619,6 +619,8 @@ void getRequestMetadata_withoutQuotaProjectId() throws IOException, IllegalState IMMUTABLE_SCOPES_LIST, VALID_LIFETIME, mockTransportFactory); + targetCredentials.regionalAccessBoundaryManager.setCachedRAB( + createDummyRab(targetCredentials.clock)); Map> metadata = targetCredentials.getRequestMetadata(); assertFalse(metadata.containsKey("x-goog-user-project")); @@ -1276,9 +1278,7 @@ void refreshAccessToken_afterSerialization_success() throws IOException, ClassNo @Test void refresh_regionalAccessBoundarySuccess() throws IOException, InterruptedException { - TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); - environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + // Mock regional access boundary response RegionalAccessBoundary regionalAccessBoundary = REGIONAL_ACCESS_BOUNDARY; diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java index 68e9c8edf393..92ea38cbcf7d 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java @@ -43,6 +43,7 @@ import static com.google.auth.oauth2.ServiceAccountCredentialsTest.DEFAULT_ID_TOKEN; import static com.google.auth.oauth2.ServiceAccountCredentialsTest.SCOPES; import static com.google.auth.oauth2.ServiceAccountCredentialsTest.createDefaultBuilder; +import static com.google.auth.oauth2.TestUtils.createDummyRab; import static com.google.auth.oauth2.UserCredentialsTest.CLIENT_ID; import static com.google.auth.oauth2.UserCredentialsTest.CLIENT_SECRET; import static com.google.auth.oauth2.UserCredentialsTest.REFRESH_TOKEN; @@ -97,11 +98,6 @@ static void setup() { @org.junit.jupiter.api.BeforeEach void setUp() {} - @org.junit.jupiter.api.AfterEach - void tearDown() { - RegionalAccessBoundary.setEnvironmentProviderForTest(null); - } - @Test void userCredentials_getRequestMetadata_fromRefreshToken_hasAccessToken() throws IOException { TestAppender testAppender = setupTestLogger(UserCredentials.class); @@ -172,6 +168,7 @@ void serviceAccountCredentials_getRequestMetadata_hasAccessToken() throws IOExce ServiceAccountCredentialsTest.createDefaultBuilderWithToken(ACCESS_TOKEN) .setScopes(SCOPES) .build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); Map> metadata = credentials.getRequestMetadata(CALL_URI); TestUtils.assertContainsBearerToken(metadata, ACCESS_TOKEN); @@ -228,6 +225,7 @@ void serviceAccountCredentials_idTokenWithAudience_iamFlow_targetAudienceMatches .setHttpTransportFactory(transportFactory) .setUniverseDomain(nonGDU) .build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); String targetAudience = "https://foo.bar"; IdTokenCredentials tokenCredential = @@ -449,11 +447,12 @@ void getRequestMetadata_hasAccessToken() throws IOException { transportFactory.transport.setServiceAccountEmail("SA_CLIENT_EMAIL"); ComputeEngineCredentials credentials = ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); Map> metadata = credentials.getRequestMetadata(CALL_URI); TestUtils.assertContainsBearerToken(metadata, ACCESS_TOKEN); - assertEquals(3, testAppender.events.size()); + assertEquals(5, testAppender.events.size()); ILoggingEvent accessTokenRequest = testAppender.events.get(0); assertEquals("Sending request to refresh access token", accessTokenRequest.getMessage()); @@ -490,6 +489,7 @@ void idTokenWithAudience_full() throws IOException { MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); ComputeEngineCredentials credentials = ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); String targetAudience = "https://foo.bar"; IdTokenCredentials tokenCredential = @@ -544,6 +544,7 @@ void serviceAccountCredentials_exchangeToken_masksSensitiveTokens() throws IOExc ServiceAccountCredentialsTest.createDefaultBuilderWithToken(ACCESS_TOKEN) .setScopes(SCOPES) .build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); Map> metadata = credentials.getRequestMetadata(CALL_URI); TestUtils.assertContainsBearerToken(metadata, ACCESS_TOKEN); diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java index adc945dd72ea..8576ffe38e3a 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java @@ -59,9 +59,7 @@ class PluggableAuthCredentialsTest extends BaseSerializationTest { @org.junit.jupiter.api.AfterEach - void tearDown() { - RegionalAccessBoundary.setEnvironmentProviderForTest(null); - } + void tearDown() {} // The default timeout for waiting for the executable to finish (30 seconds). private static final int DEFAULT_EXECUTABLE_TIMEOUT_MS = 30 * 1000; @@ -610,9 +608,6 @@ void serialize() { @Test public void testRefresh_regionalAccessBoundarySuccess() throws IOException, InterruptedException { - TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); - environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); MockExternalAccountCredentialsTransportFactory transportFactory = new MockExternalAccountCredentialsTransportFactory(); diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/RegionalAccessBoundaryTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/RegionalAccessBoundaryTest.java index 7c7ccd690ce2..5664582ef059 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/RegionalAccessBoundaryTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/RegionalAccessBoundaryTest.java @@ -31,9 +31,9 @@ package com.google.auth.oauth2; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; import com.google.api.client.testing.http.MockHttpTransport; import com.google.api.client.testing.http.MockLowLevelHttpResponse; @@ -41,17 +41,16 @@ import com.google.auth.http.HttpTransportFactory; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.util.Collections; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicLong; -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; -@RunWith(JUnit4.class) public class RegionalAccessBoundaryTest { private static final long TTL = RegionalAccessBoundary.TTL_MILLIS; @@ -59,12 +58,12 @@ public class RegionalAccessBoundaryTest { private TestClock testClock; - @Before + @BeforeEach public void setUp() { testClock = new TestClock(); } - @After + @AfterEach public void tearDown() {} @Test @@ -127,6 +126,27 @@ public void testSerialization() throws Exception { assertFalse(deserializedRab.isExpired()); } + @Test + public void testRefreshClosesResponse() throws Exception { + final String url = "https://example.com/rab"; + final AccessToken token = + new AccessToken("token", new java.util.Date(System.currentTimeMillis() + 3600000L)); + + TrackingMockLowLevelHttpResponse mockResponse = new TrackingMockLowLevelHttpResponse(); + mockResponse.setContentType("application/json"); + mockResponse.setContent("{\"encodedLocations\": \"encoded\", \"locations\": [\"loc\"]}"); + + MockHttpTransport transport = + new MockHttpTransport.Builder().setLowLevelHttpResponse(mockResponse).build(); + HttpTransportFactory transportFactory = () -> transport; + + RegionalAccessBoundary rab = + RegionalAccessBoundary.refresh(transportFactory, url, token, testClock, 1000); + + assertEquals("encoded", rab.getEncodedLocations()); + assertTrue(mockResponse.isDisconnected(), "Response should have been disconnected"); + } + @Test public void testManagerTriggersRefreshInGracePeriod() throws InterruptedException { final String url = @@ -200,11 +220,94 @@ public void testManagerTriggersRefreshInGracePeriod() throws InterruptedExceptio } assertTrue( - "Refresh should have completed and updated the cache within 5 seconds", - resultRab != null && newerEncoded.equals(resultRab.getEncodedLocations())); + resultRab != null && newerEncoded.equals(resultRab.getEncodedLocations()), + "Refresh should have completed and updated the cache within 5 seconds"); assertEquals(newerEncoded, resultRab.getEncodedLocations()); } + @Test + public void testExecutorQueueCapacityLimit() throws Exception { + final String url = "https://example.com/rab"; + final AccessToken token = + new AccessToken("token", new java.util.Date(System.currentTimeMillis() + 3600000L)); + RegionalAccessBoundaryProvider provider = () -> url; + + int poolSize = 5; + int queueCapacity = 100; + int totalCapacity = poolSize + queueCapacity; + + java.util.concurrent.ThreadPoolExecutor testExecutor = + new java.util.concurrent.ThreadPoolExecutor( + poolSize, + poolSize, + 1, + java.util.concurrent.TimeUnit.HOURS, + new java.util.concurrent.LinkedBlockingQueue<>(queueCapacity), + r -> { + Thread t = new Thread(r, "test-RAB-refresh"); + t.setDaemon(true); + return t; + }); + + CountDownLatch latch = new CountDownLatch(1); + + java.io.InputStream blockingStream = + new java.io.InputStream() { + private final java.io.InputStream delegate = + new ByteArrayInputStream( + "{\"encodedLocations\": \"encoded\", \"locations\": [\"loc\"]}".getBytes()); + private boolean blocked = false; + + @Override + public int read() throws java.io.IOException { + if (!blocked) { + try { + latch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + blocked = true; + } + return delegate.read(); + } + }; + + MockHttpTransport transport = + new MockHttpTransport.Builder() + .setLowLevelHttpResponse( + new MockLowLevelHttpResponse() + .setContent(blockingStream) + .setContentType("application/json")) + .build(); + HttpTransportFactory transportFactory = () -> transport; + + RegionalAccessBoundaryManager[] managers = new RegionalAccessBoundaryManager[totalCapacity]; + for (int i = 0; i < totalCapacity; i++) { + managers[i] = + new RegionalAccessBoundaryManager( + testClock, + RegionalAccessBoundaryManager.DEFAULT_MAX_RETRY_ELAPSED_TIME_MILLIS, + testExecutor); + managers[i].triggerAsyncRefresh(transportFactory, provider, token); + } + + RegionalAccessBoundaryManager extraManager = + new RegionalAccessBoundaryManager( + testClock, + RegionalAccessBoundaryManager.DEFAULT_MAX_RETRY_ELAPSED_TIME_MILLIS, + testExecutor); + assertFalse(extraManager.isCooldownActive()); + + extraManager.triggerAsyncRefresh(transportFactory, provider, token); + + assertFalse( + extraManager.isCooldownActive(), + "106th task should NOT have entered cooldown on scheduling failure"); + + latch.countDown(); + testExecutor.shutdownNow(); + } + private static class TestClock implements Clock { private final AtomicLong currentTime = new AtomicLong(System.currentTimeMillis()); @@ -217,4 +320,18 @@ public void set(long millis) { currentTime.set(millis); } } + + private static class TrackingMockLowLevelHttpResponse extends MockLowLevelHttpResponse { + private boolean disconnected = false; + + @Override + public void disconnect() throws IOException { + super.disconnect(); + disconnected = true; + } + + public boolean isDisconnected() { + return disconnected; + } + } } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ServiceAccountCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ServiceAccountCredentialsTest.java index 153ca4106bd8..e1582ebe2b3a 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ServiceAccountCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ServiceAccountCredentialsTest.java @@ -32,6 +32,7 @@ package com.google.auth.oauth2; import static com.google.auth.oauth2.RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY; +import static com.google.auth.oauth2.TestUtils.createDummyRab; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -160,9 +161,7 @@ static ServiceAccountCredentials.Builder createDefaultBuilder() throws IOExcepti void setUp() {} @org.junit.jupiter.api.AfterEach - void tearDown() { - RegionalAccessBoundary.setEnvironmentProviderForTest(null); - } + void tearDown() {} @Test void setLifetime() throws IOException { @@ -368,6 +367,7 @@ void createAssertionForIdToken_incorrect() throws IOException { @Test void createdScoped_withAud_noUniverse_jwtWithScopesDisabled_accessToken() throws IOException { GoogleCredentials credentials = createDefaultBuilderWithToken(ACCESS_TOKEN).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); // No aud, no scopes gives an exception. IOException exception = @@ -377,6 +377,8 @@ void createdScoped_withAud_noUniverse_jwtWithScopesDisabled_accessToken() throws "expected to fail with exception"); GoogleCredentials scopedCredentials = credentials.createScoped(SCOPES); + scopedCredentials.regionalAccessBoundaryManager.setCachedRAB( + createDummyRab(scopedCredentials.clock)); assertEquals(false, credentials.isExplicitUniverseDomain()); assertEquals(Credentials.GOOGLE_DEFAULT_UNIVERSE, credentials.getUniverseDomain()); Map> metadata = scopedCredentials.getRequestMetadata(CALL_URI); @@ -387,17 +389,22 @@ void createdScoped_withAud_noUniverse_jwtWithScopesDisabled_accessToken() throws void createdScoped_withUniverse_selfSignedJwt() throws IOException { ServiceAccountCredentials credentials = createDefaultBuilder().setUniverseDomain("foo.bar").build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); IOException exception = assertThrows(IOException.class, () -> credentials.getRequestMetadata(null)); assertTrue( exception.getMessage().contains("Scopes and uri are not configured for service account")); GoogleCredentials scopedCredentials = credentials.createScoped("dummy.scope"); + scopedCredentials.regionalAccessBoundaryManager.setCachedRAB( + createDummyRab(scopedCredentials.clock)); Map> metadata = scopedCredentials.getRequestMetadata(null); verifyJwtAccess(metadata, "dummy.scope"); // Recreate to avoid jwt caching. scopedCredentials = credentials.createScoped("dummy.scope2"); + scopedCredentials.regionalAccessBoundaryManager.setCachedRAB( + createDummyRab(scopedCredentials.clock)); assertEquals(true, scopedCredentials.isExplicitUniverseDomain()); assertEquals("foo.bar", scopedCredentials.getUniverseDomain()); metadata = scopedCredentials.getRequestMetadata(CALL_URI); @@ -407,6 +414,8 @@ void createdScoped_withUniverse_selfSignedJwt() throws IOException { scopedCredentials = credentials.createScoped( Collections.emptyList(), Arrays.asList("dummy.default.scope")); + scopedCredentials.regionalAccessBoundaryManager.setCachedRAB( + createDummyRab(scopedCredentials.clock)); metadata = scopedCredentials.getRequestMetadata(null); verifyJwtAccess(metadata, "dummy.default.scope"); @@ -414,6 +423,8 @@ void createdScoped_withUniverse_selfSignedJwt() throws IOException { scopedCredentials = credentials.createScoped( Collections.emptyList(), Arrays.asList("dummy.default.scope2")); + scopedCredentials.regionalAccessBoundaryManager.setCachedRAB( + createDummyRab(scopedCredentials.clock)); metadata = scopedCredentials.getRequestMetadata(CALL_URI); verifyJwtAccess(metadata, "dummy.default.scope2"); } @@ -534,6 +545,7 @@ void fromJSON_hasAccessToken() throws IOException { GenericJson json = writeServiceAccountJson(PROJECT_ID, null, null); GoogleCredentials credentials = ServiceAccountCredentials.fromJson(json, transportFactory); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); credentials = credentials.createScoped(SCOPES); Map> metadata = credentials.getRequestMetadata(CALL_URI); @@ -547,6 +559,7 @@ void fromJSON_withUniverse_selfSignedJwt() throws IOException { GenericJson json = writeServiceAccountJson(PROJECT_ID, null, "foo.bar"); GoogleCredentials credentials = ServiceAccountCredentials.fromJson(json, transportFactory); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); credentials = credentials.createScoped(SCOPES); Map> metadata = credentials.getRequestMetadata(null); @@ -571,6 +584,7 @@ void fromJson_hasQuotaProjectId() throws IOException { transportFactory.transport.addServiceAccount(CLIENT_EMAIL, ACCESS_TOKEN); GenericJson json = writeServiceAccountJson(PROJECT_ID, QUOTA_PROJECT, null); GoogleCredentials credentials = ServiceAccountCredentials.fromJson(json, transportFactory); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); credentials = credentials.createScoped(SCOPES); Map> metadata = credentials.getRequestMetadata(CALL_URI); @@ -585,6 +599,7 @@ void fromJson_hasQuotaProjectId() throws IOException { void getRequestMetadata_hasAccessToken() throws IOException { GoogleCredentials credentials = createDefaultBuilderWithToken(ACCESS_TOKEN).setScopes(SCOPES).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); Map> metadata = credentials.getRequestMetadata(CALL_URI); TestUtils.assertContainsBearerToken(metadata, ACCESS_TOKEN); } @@ -595,12 +610,13 @@ void getRequestMetadata_customTokenServer_hasAccessToken() throws IOException { MockTokenServerTransportFactory transportFactory = new MockTokenServerTransportFactory(); transportFactory.transport.addServiceAccount(CLIENT_EMAIL, ACCESS_TOKEN); transportFactory.transport.setTokenServerUri(tokenServerUri); - OAuth2Credentials credentials = + ServiceAccountCredentials credentials = createDefaultBuilder() .setScopes(SCOPES) .setHttpTransportFactory(transportFactory) .setTokenServerUri(tokenServerUri) .build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); Map> metadata = credentials.getRequestMetadata(CALL_URI); TestUtils.assertContainsBearerToken(metadata, ACCESS_TOKEN); @@ -625,6 +641,7 @@ void refreshAccessToken_refreshesToken() throws IOException { MockTokenServerTransport transport = transportFactory.transport; ServiceAccountCredentials credentials = createDefaultBuilder().setScopes(SCOPES).setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); transport.addServiceAccount(CLIENT_EMAIL, accessToken1); TestUtils.assertContainsBearerToken(credentials.getRequestMetadata(CALL_URI), accessToken1); @@ -640,6 +657,7 @@ void refreshAccessToken_tokenExpiry() throws IOException { transport.addServiceAccount(CLIENT_EMAIL, ACCESS_TOKEN); ServiceAccountCredentials credentials = createDefaultBuilder().setScopes(SCOPES).setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); credentials.clock = new FixedClock(0L); AccessToken accessToken = credentials.refreshAccessToken(); @@ -661,6 +679,7 @@ void refreshAccessToken_IOException_Retry() throws IOException { MockTokenServerTransport transport = transportFactory.transport; ServiceAccountCredentials credentials = createDefaultBuilder().setScopes(SCOPES).setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); transport.addServiceAccount(CLIENT_EMAIL, accessToken1); TestUtils.assertContainsBearerToken(credentials.getRequestMetadata(CALL_URI), accessToken1); @@ -679,6 +698,7 @@ void refreshAccessToken_retriesServerErrors() throws IOException { MockTokenServerTransport transport = transportFactory.transport; ServiceAccountCredentials credentials = createDefaultBuilder().setScopes(SCOPES).setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); transport.addServiceAccount(CLIENT_EMAIL, accessToken1); TestUtils.assertContainsBearerToken(credentials.getRequestMetadata(CALL_URI), accessToken1); @@ -699,6 +719,7 @@ void refreshAccessToken_retriesTimeoutAndThrottled() throws IOException { MockTokenServerTransport transport = transportFactory.transport; ServiceAccountCredentials credentials = createDefaultBuilder().setScopes(SCOPES).setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); transport.addServiceAccount(CLIENT_EMAIL, accessToken1); TestUtils.assertContainsBearerToken(credentials.getRequestMetadata(CALL_URI), accessToken1); @@ -723,6 +744,7 @@ void refreshAccessToken_defaultRetriesDisabled() throws IOException { .setHttpTransportFactory(transportFactory) .build() .createWithCustomRetryStrategy(false); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); transport.addServiceAccount(CLIENT_EMAIL, accessToken1); TestUtils.assertContainsBearerToken(credentials.getRequestMetadata(CALL_URI), accessToken1); @@ -744,6 +766,7 @@ void refreshAccessToken_maxRetries_maxDelay() throws IOException { MockTokenServerTransport transport = transportFactory.transport; ServiceAccountCredentials credentials = createDefaultBuilder().setScopes(SCOPES).setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); transport.addServiceAccount(CLIENT_EMAIL, ACCESS_TOKEN); TestUtils.assertContainsBearerToken(credentials.getRequestMetadata(CALL_URI), ACCESS_TOKEN); @@ -773,6 +796,7 @@ void refreshAccessToken_RequestFailure_retried() throws IOException { MockTokenServerTransport transport = transportFactory.transport; ServiceAccountCredentials credentials = createDefaultBuilder().setScopes(SCOPES).setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); transport.addServiceAccount(CLIENT_EMAIL, ACCESS_TOKEN); TestUtils.assertContainsBearerToken(credentials.getRequestMetadata(CALL_URI), ACCESS_TOKEN); @@ -804,6 +828,7 @@ void refreshAccessToken_4xx_5xx_NonRetryableFails() throws IOException { MockTokenServerTransport transport = transportFactory.transport; ServiceAccountCredentials credentials = createDefaultBuilder().setScopes(SCOPES).setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); transport.addServiceAccount(CLIENT_EMAIL, accessToken1); TestUtils.assertContainsBearerToken(credentials.getRequestMetadata(CALL_URI), accessToken1); @@ -829,6 +854,7 @@ void idTokenWithAudience_oauthFlow_targetAudienceMatchesAudClaim() throws IOExce MockTokenServerTransport transport = transportFactory.transport; ServiceAccountCredentials credentials = createDefaultBuilder().setScopes(SCOPES).setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); transport.addServiceAccount(CLIENT_EMAIL, accessToken1); TestUtils.assertContainsBearerToken(credentials.getRequestMetadata(CALL_URI), accessToken1); @@ -866,6 +892,7 @@ void idTokenWithAudience_oauthFlow_targetAudienceDoesNotMatchAudClaim() throws I MockTokenServerTransport transport = transportFactory.transport; ServiceAccountCredentials credentials = createDefaultBuilder().setScopes(SCOPES).setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); transport.addServiceAccount(CLIENT_EMAIL, accessToken1); TestUtils.assertContainsBearerToken(credentials.getRequestMetadata(CALL_URI), accessToken1); @@ -898,6 +925,7 @@ void idTokenWithAudience_iamFlow_targetAudienceMatchesAudClaim() throws IOExcept .setHttpTransportFactory(transportFactory) .setUniverseDomain(nonGDU) .build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); String targetAudience = "https://foo.bar"; IdTokenCredentials tokenCredential = @@ -929,6 +957,7 @@ void idTokenWithAudience_iamFlow_targetAudienceDoesNotMatchAudClaim() throws IOE .setHttpTransportFactory(transportFactory) .setUniverseDomain(nonGDU) .build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); String targetAudience = "differentAudience"; IdTokenCredentials tokenCredential = @@ -950,6 +979,7 @@ void idTokenWithAudience_oauthEndpoint_non2XXStatusCode() throws IOException { transportFactory.transport.setError(new IOException("404 Not Found")); ServiceAccountCredentials credentials = createDefaultBuilder().setScopes(SCOPES).setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); String targetAudience = "audience"; IdTokenCredentials tokenCredential = @@ -978,6 +1008,7 @@ void idTokenWithAudience_iamEndpoint_non2XXStatusCode() throws IOException { .setHttpTransportFactory(transportFactory) .setUniverseDomain(universeDomain) .build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); String targetAudience = "audience"; IdTokenCredentials tokenCredential = @@ -1379,6 +1410,7 @@ void fromStream_providesToken() throws IOException { GoogleCredentials credentials = ServiceAccountCredentials.fromStream(serviceAccountStream, transportFactory); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); assertNotNull(credentials); credentials = credentials.createScoped(SCOPES); @@ -1421,6 +1453,7 @@ void getIdTokenWithAudience_badEmailError_issClaimTraced() throws IOException { transport.setError(new IOException("Invalid grant: Account not found")); ServiceAccountCredentials credentials = createDefaultBuilder().setScopes(SCOPES).setHttpTransportFactory(transportFactory).build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); String targetAudience = "https://bar"; IdTokenCredentials tokenCredential = @@ -1505,6 +1538,7 @@ void getRequestMetadata_setsQuotaProjectId() throws IOException { .setQuotaProjectId("my-quota-project-id") .setHttpTransportFactory(transportFactory) .build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); Map> metadata = credentials.getRequestMetadata(CALL_URI); assertTrue(metadata.containsKey("x-goog-user-project")); @@ -1531,6 +1565,7 @@ void getRequestMetadata_noQuotaProjectId() throws IOException { .setProjectId(PROJECT_ID) .setHttpTransportFactory(transportFactory) .build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); Map> metadata = credentials.getRequestMetadata(CALL_URI); assertFalse(metadata.containsKey("x-goog-user-project")); @@ -1554,6 +1589,7 @@ void getRequestMetadata_withCallback() throws IOException { .setQuotaProjectId("my-quota-project-id") .setHttpTransportFactory(transportFactory) .build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); final Map> plainMetadata = credentials.getRequestMetadata(); final AtomicBoolean success = new AtomicBoolean(false); @@ -1594,6 +1630,7 @@ void getRequestMetadata_withScopes_withUniverseDomain_SelfSignedJwt() throws IOE .setHttpTransportFactory(transportFactory) .setUniverseDomain("foo.bar") .build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); final Map> plainMetadata = credentials.getRequestMetadata(); final AtomicBoolean success = new AtomicBoolean(false); @@ -1630,6 +1667,7 @@ void getRequestMetadata_withScopes_selfSignedJWT() throws IOException { .setHttpTransportFactory(new MockTokenServerTransportFactory()) .setUseJwtAccessWithScope(true) .build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); Map> metadata = credentials.getRequestMetadata(CALL_URI); assertNotNull(((ServiceAccountCredentials) credentials).getSelfSignedJwtCredentialsWithScope()); @@ -1661,6 +1699,7 @@ void refreshAccessToken_withDomainDelegation_selfSignedJWT_disabled() throws IOE .setHttpTransportFactory(transportFactory) .setUseJwtAccessWithScope(true) .build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); transport.addServiceAccount(CLIENT_EMAIL, accessToken1); Map> metadata = credentials.getRequestMetadata(CALL_URI); @@ -1685,6 +1724,7 @@ void getRequestMetadata_withAudience_selfSignedJWT() throws IOException { .setProjectId(PROJECT_ID) .setHttpTransportFactory(new MockTokenServerTransportFactory()) .build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); Map> metadata = credentials.getRequestMetadata(CALL_URI); assertNull(((ServiceAccountCredentials) credentials).getSelfSignedJwtCredentialsWithScope()); @@ -1705,6 +1745,7 @@ void getRequestMetadata_withDefaultScopes_selfSignedJWT() throws IOException { .setHttpTransportFactory(new MockTokenServerTransportFactory()) .setUseJwtAccessWithScope(true) .build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); Map> metadata = credentials.getRequestMetadata(null); verifyJwtAccess(metadata, "dummy.scope"); @@ -1725,6 +1766,7 @@ void getRequestMetadataWithCallback_selfSignedJWT() throws IOException { .setUseJwtAccessWithScope(true) .setScopes(SCOPES) .build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); final AtomicBoolean success = new AtomicBoolean(false); credentials.getRequestMetadata( @@ -1762,6 +1804,7 @@ void createScopes_existingAccessTokenInvalidated() throws IOException { .setHttpTransportFactory(transportFactory) .setScopes(SCOPES) .build(); + credentials.regionalAccessBoundaryManager.setCachedRAB(createDummyRab(credentials.clock)); TestUtils.assertContainsBearerToken(credentials.getRequestMetadata(CALL_URI), ACCESS_TOKEN); // Calling createScoped() again will invalidate the existing access token and calling @@ -1784,9 +1827,7 @@ void getRequestMetadata_withMultipleScopes_selfSignedJWT() throws IOException { @Test public void refresh_regionalAccessBoundarySuccess() throws IOException, InterruptedException { - TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); - environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + // Mock regional access boundary response RegionalAccessBoundary regionalAccessBoundary = new RegionalAccessBoundary( @@ -1824,9 +1865,7 @@ public void refresh_regionalAccessBoundarySuccess() throws IOException, Interrup @Test public void refresh_regionalAccessBoundary_selfSignedJWT() throws IOException, InterruptedException { - TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); - RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); - environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + RegionalAccessBoundary regionalAccessBoundary = new RegionalAccessBoundary( TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION, diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/TestUtils.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/TestUtils.java index 4efc138bbfa8..52652a71c458 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/TestUtils.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/TestUtils.java @@ -69,4 +69,9 @@ static void validateMetricsHeader( } assertEquals(expectedMetricsValue, actualMetricsValue); } + + static RegionalAccessBoundary createDummyRab(com.google.api.client.util.Clock clock) { + return new RegionalAccessBoundary( + "dummy-locations", java.util.Arrays.asList("dummy-loc"), clock); + } } From 02e8bdfa71790845dbbfac44e3d23168159db781 Mon Sep 17 00:00:00 2001 From: werman Date: Mon, 8 Jun 2026 15:37:56 -0700 Subject: [PATCH 3/4] fix(auth): Skip RAB lookup in case of non-email response from MDS. (#13331) In ComputeEngineCredentials when running on GKE platform, the getAccount() call may return a value which isn't an email. In this case the right behaviour is to skip RAB lookup which is what this PR does. Added tests. --- .../auth/oauth2/ComputeEngineCredentials.java | 15 +++- .../oauth2/RegionalAccessBoundaryManager.java | 34 +++++---- .../oauth2/ComputeEngineCredentialsTest.java | 72 ++++++++++++++++++- 3 files changed, 104 insertions(+), 17 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java index bcfe916c3168..c5b8e0d3adbf 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java @@ -72,6 +72,7 @@ import java.util.Objects; import java.util.logging.Level; import java.util.logging.Logger; +import java.util.regex.Pattern; /** * OAuth2 credentials representing the built-in service account for a Google Compute Engine VM. @@ -117,6 +118,7 @@ public class ComputeEngineCredentials extends GoogleCredentials private static final String PARSE_ERROR_PREFIX = "Error parsing token refresh response. "; private static final String PARSE_ERROR_ACCOUNT = "Error parsing service account response. "; + private static final Pattern EMAIL_PATTERN = Pattern.compile("^[^@]+@[^@]+\\.[^@]+$"); private static final long serialVersionUID = -4113476462526554235L; private final String transportFactoryClassName; @@ -800,8 +802,19 @@ public String getAccount() { @InternalApi @Override public String getRegionalAccessBoundaryUrl() throws IOException { + String account = getAccount(); + // The MDS may return a non-email value for the account and we should skip RAB refresh in that + // scenario. + if (account == null || !EMAIL_PATTERN.matcher(account).matches()) { + LoggingUtils.log( + LOGGER_PROVIDER, + Level.INFO, + Collections.emptyMap(), + "Unable to retrieve this instance's email and will skip the regional request routing. Proceeding with request"); + return null; + } return String.format( - OAuth2Utils.IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_SERVICE_ACCOUNT, getAccount()); + OAuth2Utils.IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_SERVICE_ACCOUNT, account); } /** diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundaryManager.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundaryManager.java index e35efe86f7a0..b2237fae6d13 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundaryManager.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundaryManager.java @@ -37,11 +37,11 @@ import com.google.api.core.InternalApi; import com.google.auth.http.HttpTransportFactory; import com.google.common.annotations.VisibleForTesting; -import com.google.common.util.concurrent.SettableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import java.util.logging.Level; @@ -75,16 +75,16 @@ final class RegionalAccessBoundaryManager { 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. + * isRefreshing acts as an atomic gate for request de-duplication. If true, it indicates a + * background refresh is already in progress. */ - private final AtomicReference> refreshFuture = - new AtomicReference<>(); + private final AtomicBoolean isRefreshing = new AtomicBoolean(false); private final AtomicReference cooldownState = new AtomicReference<>(new CooldownState(0, INITIAL_COOLDOWN_MILLIS)); + private final AtomicBoolean skipRAB = new AtomicBoolean(false); + // Unbounded thread creation is discouraged in library code to avoid resource // exhaustion. A shared, bounded executor service ensures a hard limit (5) // on concurrent refresh tasks, while threadCount provides unique names @@ -178,7 +178,7 @@ void triggerAsyncRefresh( final HttpTransportFactory transportFactory, final RegionalAccessBoundaryProvider provider, final AccessToken accessToken) { - if (isCooldownActive()) { + if (skipRAB.get() || isCooldownActive()) { return; } @@ -187,28 +187,28 @@ void triggerAsyncRefresh( return; } - SettableFuture future = SettableFuture.create(); // Atomically check if a refresh is already running. If compareAndSet returns true, // this thread "won the race" and is responsible for starting the background task. // All other concurrent threads will return false and exit immediately. - if (refreshFuture.compareAndSet(null, future)) { + if (isRefreshing.compareAndSet(false, true)) { Runnable refreshTask = () -> { try { String url = provider.getRegionalAccessBoundaryUrl(); + if (url == null) { + skipRAB.set(true); + return; + } RegionalAccessBoundary newRAB = RegionalAccessBoundary.refresh( transportFactory, url, accessToken, clock, maxRetryElapsedTimeMillis); cachedRAB.set(newRAB); resetCooldown(); - // Complete the future so monitors (like unit tests) know we are done. - future.set(newRAB); } catch (Exception e) { handleRefreshFailure(e); - future.setException(e); } finally { // Open the gate again for future refresh requests. - refreshFuture.set(null); + isRefreshing.set(false); } }; @@ -224,8 +224,7 @@ void triggerAsyncRefresh( "Could not submit background refresh task for Regional Access Boundary. " + "This is non-blocking and the library will attempt to refresh on the next access. Error: " + e.getMessage()); - future.setException(e); - refreshFuture.set(null); + isRefreshing.set(false); } } } @@ -279,6 +278,11 @@ long getCurrentCooldownMillis() { return cooldownState.get().durationMillis; } + @VisibleForTesting + boolean isSkipRAB() { + return skipRAB.get(); + } + private static class CooldownState { /** The time (in milliseconds from epoch) when the current cooldown period expires. */ final long expiryTime; diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java index 78bfd5ddaaa4..b6de475ae510 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java @@ -1213,7 +1213,7 @@ void getProjectId_explicitSet_noMDsCall() { assertEquals(0, transportFactory.transport.getRequestCount()); } - @org.junit.jupiter.api.Test + @Test void refresh_regionalAccessBoundarySuccess() throws IOException, InterruptedException { String defaultAccountEmail = "default@email.com"; @@ -1242,6 +1242,76 @@ void refresh_regionalAccessBoundarySuccess() throws IOException, InterruptedExce Arrays.asList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION)); } + @Test + void refresh_regionalAccessBoundaryNonEmail_skipsRABLookup() + throws IOException, InterruptedException { + String nonEmailAccount = "non-email-account-value"; + MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); + RegionalAccessBoundary regionalAccessBoundary = + new RegionalAccessBoundary( + TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION, + TestUtils.REGIONAL_ACCESS_BOUNDARY_LOCATIONS, + null); + transportFactory.transport.setRegionalAccessBoundary(regionalAccessBoundary); + transportFactory.transport.setServiceAccountEmail(nonEmailAccount); + + ComputeEngineCredentials credentials = + ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + + // Before any call, skipRAB flag should be false + assertFalse(credentials.regionalAccessBoundaryManager.isSkipRAB()); + + // First call: triggers lookup which determines non-email, returns null, and sets skipRAB to + // true + Map> headers = credentials.getRequestMetadata(); + assertNull(headers.get(X_ALLOWED_LOCATIONS_HEADER_KEY)); + + // Since the task is scheduled asynchronously on the shared executor, wait for it to complete + long deadline = System.currentTimeMillis() + 5000; + while (!credentials.regionalAccessBoundaryManager.isSkipRAB() + && System.currentTimeMillis() < deadline) { + Thread.sleep(50); + } + + // Verify skipRAB flag has been set to true + assertTrue(credentials.regionalAccessBoundaryManager.isSkipRAB()); + + // Verify RAB is still null + assertNull(credentials.getRegionalAccessBoundary()); + + // Second call: should bypass triggerAsyncRefresh completely and remain null + headers = credentials.getRequestMetadata(); + assertNull(headers.get(X_ALLOWED_LOCATIONS_HEADER_KEY)); + } + + @Test + void getRegionalAccessBoundaryUrl_validEmail_returnsUrl() throws IOException { + MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); + String defaultAccountEmail = "mail@mail.com"; + + transportFactory.transport.setServiceAccountEmail(defaultAccountEmail); + ComputeEngineCredentials credentials = + ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + + String expectedUrl = + String.format( + OAuth2Utils.IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_SERVICE_ACCOUNT, + defaultAccountEmail); + assertEquals(expectedUrl, credentials.getRegionalAccessBoundaryUrl()); + } + + @Test + void getRegionalAccessBoundaryUrl_invalidEmail_returnsNull() throws IOException { + MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); + String defaultAccountEmail = "default"; // non-email account format + + transportFactory.transport.setServiceAccountEmail(defaultAccountEmail); + ComputeEngineCredentials credentials = + ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + + assertNull(credentials.getRegionalAccessBoundaryUrl()); + } + private void waitForRegionalAccessBoundary(GoogleCredentials credentials) throws InterruptedException { long deadline = System.currentTimeMillis() + 5000; From a6747581bafa9c520248df49dee653b3fdc2d203 Mon Sep 17 00:00:00 2001 From: Pranav Iyer Date: Tue, 16 Jun 2026 16:27:02 -0700 Subject: [PATCH 4/4] rebase-fixes --- .../java/com/google/auth/oauth2/ExternalAccountCredentials.java | 1 + .../java/com/google/auth/oauth2/ImpersonatedCredentials.java | 1 + 2 files changed, 2 insertions(+) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java index 189b97863542..7e762b3b60b2 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java @@ -38,6 +38,7 @@ import com.google.api.client.http.HttpHeaders; import com.google.api.client.json.GenericJson; import com.google.api.client.util.Data; +import com.google.api.core.InternalApi; import com.google.auth.RequestMetadataCallback; import com.google.auth.http.HttpTransportFactory; import com.google.common.base.MoreObjects; diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java index 6d93c10d9349..ede86c646d95 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java @@ -44,6 +44,7 @@ import com.google.api.client.json.GenericJson; import com.google.api.client.json.JsonObjectParser; import com.google.api.client.util.GenericData; +import com.google.api.core.InternalApi; import com.google.api.core.ObsoleteApi; import com.google.auth.CredentialTypeForMetrics; import com.google.auth.ServiceAccountSigner;