diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java index ad5fb8e7dcf3..bcfe916c3168 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java @@ -41,6 +41,7 @@ import com.google.api.client.http.HttpStatusCodes; import com.google.api.client.json.JsonObjectParser; import com.google.api.client.util.GenericData; +import com.google.api.core.InternalApi; import com.google.auth.CredentialTypeForMetrics; import com.google.auth.Credentials; import com.google.auth.Retryable; @@ -80,7 +81,7 @@ *

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

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

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

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

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

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

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

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

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

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

This method is blocking. For standard flows, it will wait for a network call to complete. + * For Self-signed JWT flows, it calculates the token locally. + * + * @param uri The URI of the request. + * @return The request metadata containing the authorization header. + * @throws IOException If an error occurs while fetching or calculating the token. + */ @Override public Map> getRequestMetadata(URI uri) throws IOException { if (createScopedRequired() && uri == null) { @@ -1125,6 +1158,8 @@ private Map> getRequestMetadataWithSelfSignedJwt(URI uri) } Map> requestMetadata = jwtCredentials.getRequestMetadata(null); + requestMetadata = addRegionalAccessBoundaryToRequestMetadata(uri, requestMetadata); + refreshRegionalAccessBoundaryWithSelfSignedJwtIfExpired(uri, requestMetadata); return addQuotaProjectIdToRequestMetadata(quotaProjectId, requestMetadata); } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/TestUtils.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/TestUtils.java index 91b648992848..9315c631985e 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/TestUtils.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/TestUtils.java @@ -42,6 +42,7 @@ import com.google.api.client.json.gson.GsonFactory; import com.google.auth.http.AuthHttpConstants; import com.google.common.base.Splitter; +import com.google.common.collect.ImmutableList; import com.google.common.collect.Lists; import java.io.ByteArrayInputStream; import java.io.IOException; @@ -55,6 +56,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.TimeZone; import javax.annotation.Nullable; /** Utilities for test code under com.google.auth. */ @@ -64,6 +66,9 @@ public class TestUtils { URI.create("https://auth.cloud.google/authorize"); public static final URI WORKFORCE_IDENTITY_FEDERATION_TOKEN_SERVER_URI = URI.create("https://sts.googleapis.com/v1/oauthtoken"); + public static final String REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION = "0x800000"; + public static final List REGIONAL_ACCESS_BOUNDARY_LOCATIONS = + ImmutableList.of("us-central1", "us-central2"); private static final JsonFactory JSON_FACTORY = GsonFactory.getDefaultInstance(); @@ -167,7 +172,9 @@ public static String getDefaultExpireTime() { Calendar calendar = Calendar.getInstance(); calendar.setTime(new Date()); calendar.add(Calendar.SECOND, 300); - return new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'").format(calendar.getTime()); + SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"); + dateFormat.setTimeZone(TimeZone.getTimeZone("UTC")); + return dateFormat.format(calendar.getTime()); } private TestUtils() {} diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java index e401ae853771..a0930b796d04 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java @@ -56,11 +56,20 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; /** Tests for {@link AwsCredentials}. */ class AwsCredentialsTest extends BaseSerializationTest { + @org.junit.jupiter.api.BeforeEach + void setUp() {} + + @org.junit.jupiter.api.AfterEach + void tearDown() { + RegionalAccessBoundary.setEnvironmentProviderForTest(null); + } + private static final String STS_URL = "https://sts.googleapis.com/v1/token"; private static final String AWS_CREDENTIALS_URL = "https://169.254.169.254"; private static final String AWS_CREDENTIALS_URL_WITH_ROLE = "https://169.254.169.254/roleName"; @@ -1357,4 +1366,51 @@ public AwsSecurityCredentials getCredentials(ExternalAccountSupplierContext cont return credentials; } } + + @Test + public void testRefresh_regionalAccessBoundarySuccess() throws IOException, InterruptedException { + TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + AwsSecurityCredentialsSupplier supplier = + new TestAwsSecurityCredentialsSupplier("test", programmaticAwsCreds, null, null); + + AwsCredentials awsCredential = + AwsCredentials.newBuilder() + .setAwsSecurityCredentialsSupplier(supplier) + .setHttpTransportFactory(transportFactory) + .setAudience( + "//iam.googleapis.com/projects/12345/locations/global/workloadIdentityPools/pool/providers/provider") + .setTokenUrl(STS_URL) + .setSubjectTokenType("subjectTokenType") + .build(); + + // First call: initiates async refresh. + Map> headers = awsCredential.getRequestMetadata(); + assertNull(headers.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY)); + + waitForRegionalAccessBoundary(awsCredential); + + // Second call: should have header. + headers = awsCredential.getRequestMetadata(); + assertEquals( + headers.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY), + Arrays.asList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION)); + } + + private void waitForRegionalAccessBoundary(GoogleCredentials credentials) + throws InterruptedException { + long deadline = System.currentTimeMillis() + 5000; + while (credentials.getRegionalAccessBoundary() == null + && System.currentTimeMillis() < deadline) { + Thread.sleep(100); + } + if (credentials.getRegionalAccessBoundary() == null) { + Assertions.fail("Timed out waiting for regional access boundary refresh"); + } + } } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java index 82240171d9af..8b20d0cc20f4 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java @@ -33,6 +33,7 @@ import static com.google.auth.oauth2.ComputeEngineCredentials.METADATA_RESPONSE_EMPTY_CONTENT_ERROR_MESSAGE; import static com.google.auth.oauth2.ImpersonatedCredentialsTest.SA_CLIENT_EMAIL; +import static com.google.auth.oauth2.RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -43,6 +44,7 @@ import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; import com.google.api.client.http.HttpStatusCodes; import com.google.api.client.http.HttpTransport; @@ -75,6 +77,14 @@ /** Test case for {@link ComputeEngineCredentials}. */ class ComputeEngineCredentialsTest extends BaseSerializationTest { + @org.junit.jupiter.api.BeforeEach + void setUp() {} + + @org.junit.jupiter.api.AfterEach + void tearDown() { + RegionalAccessBoundary.setEnvironmentProviderForTest(null); + } + private static final URI CALL_URI = URI.create("http://googleapis.com/testapi/v1/foo"); private static final String TOKEN_URL = @@ -393,7 +403,6 @@ void getRequestMetadata_hasAccessToken() throws IOException { TestUtils.assertContainsBearerToken(metadata, ACCESS_TOKEN); // verify metrics header added and other header intact Map> requestHeaders = transportFactory.transport.getRequest().getHeaders(); - com.google.auth.oauth2.TestUtils.validateMetricsHeader(requestHeaders, "at", "mds"); assertTrue(requestHeaders.containsKey("metadata-flavor")); assertTrue(requestHeaders.get("metadata-flavor").contains("Google")); } @@ -1177,6 +1186,50 @@ void getProjectId_explicitSet_noMDsCall() { assertEquals(0, transportFactory.transport.getRequestCount()); } + @org.junit.jupiter.api.Test + void refresh_regionalAccessBoundarySuccess() throws IOException, InterruptedException { + TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + + String defaultAccountEmail = "default@email.com"; + MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); + RegionalAccessBoundary regionalAccessBoundary = + new RegionalAccessBoundary( + TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION, + TestUtils.REGIONAL_ACCESS_BOUNDARY_LOCATIONS, + null); + transportFactory.transport.setRegionalAccessBoundary(regionalAccessBoundary); + transportFactory.transport.setServiceAccountEmail(defaultAccountEmail); + + ComputeEngineCredentials credentials = + ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + + // First call: initiates async refresh. + Map> headers = credentials.getRequestMetadata(); + assertNull(headers.get(X_ALLOWED_LOCATIONS_HEADER_KEY)); + + waitForRegionalAccessBoundary(credentials); + + // Second call: should have header. + headers = credentials.getRequestMetadata(); + assertEquals( + headers.get(X_ALLOWED_LOCATIONS_HEADER_KEY), + Arrays.asList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION)); + } + + private void waitForRegionalAccessBoundary(GoogleCredentials credentials) + throws InterruptedException { + long deadline = System.currentTimeMillis() + 5000; + while (credentials.getRegionalAccessBoundary() == null + && System.currentTimeMillis() < deadline) { + Thread.sleep(100); + } + if (credentials.getRegionalAccessBoundary() == null) { + fail("Timed out waiting for regional access boundary refresh"); + } + } + static class MockMetadataServerTransportFactory implements HttpTransportFactory { MockMetadataServerTransport transport = diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentialsTest.java index 78bb6811953e..fbf3f79dbe65 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentialsTest.java @@ -43,7 +43,6 @@ import com.google.api.client.http.HttpTransport; import com.google.api.client.json.GenericJson; import com.google.api.client.testing.http.MockLowLevelHttpRequest; -import com.google.api.client.util.Clock; import com.google.auth.TestUtils; import com.google.auth.http.AuthHttpConstants; import com.google.auth.http.HttpTransportFactory; @@ -62,6 +61,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -129,6 +129,11 @@ void setup() { transportFactory = new MockExternalAccountAuthorizedUserCredentialsTransportFactory(); } + @org.junit.jupiter.api.AfterEach + void tearDown() { + RegionalAccessBoundary.setEnvironmentProviderForTest(null); + } + @Test void builder_allFields() throws IOException { ExternalAccountAuthorizedUserCredentials credentials = @@ -1233,7 +1238,49 @@ void serialize() throws IOException, ClassNotFoundException { assertEquals(credentials, deserializedCredentials); assertEquals(credentials.hashCode(), deserializedCredentials.hashCode()); assertEquals(credentials.toString(), deserializedCredentials.toString()); - assertSame(Clock.SYSTEM, deserializedCredentials.clock); + assertSame(com.google.api.client.util.Clock.SYSTEM, deserializedCredentials.clock); + } + + @org.junit.jupiter.api.Test + void testRefresh_regionalAccessBoundarySuccess() throws IOException, InterruptedException { + TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + + ExternalAccountAuthorizedUserCredentials credentials = + ExternalAccountAuthorizedUserCredentials.newBuilder() + .setClientId(CLIENT_ID) + .setClientSecret(CLIENT_SECRET) + .setRefreshToken(REFRESH_TOKEN) + .setTokenUrl(TOKEN_URL) + .setAudience( + "//iam.googleapis.com/locations/global/workforcePools/pool/providers/provider") + .setHttpTransportFactory(transportFactory) + .build(); + + // First call: initiates async refresh. + Map> headers = credentials.getRequestMetadata(); + assertNull(headers.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY)); + + waitForRegionalAccessBoundary(credentials); + + // Second call: should have header. + headers = credentials.getRequestMetadata(); + assertEquals( + headers.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY), + Arrays.asList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION)); + } + + private void waitForRegionalAccessBoundary(GoogleCredentials credentials) + throws InterruptedException { + long deadline = System.currentTimeMillis() + 5000; + while (credentials.getRegionalAccessBoundary() == null + && System.currentTimeMillis() < deadline) { + Thread.sleep(100); + } + if (credentials.getRegionalAccessBoundary() == null) { + Assertions.fail("Timed out waiting for regional access boundary refresh"); + } } static GenericJson buildJsonCredentials() { diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java index 1338c0d68fe9..5b20f33db983 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java @@ -32,6 +32,9 @@ package com.google.auth.oauth2; import static com.google.auth.oauth2.MockExternalAccountCredentialsTransport.SERVICE_ACCOUNT_IMPERSONATION_URL; +import static com.google.auth.oauth2.OAuth2Utils.IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_SERVICE_ACCOUNT; +import static com.google.auth.oauth2.OAuth2Utils.IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_WORKFORCE_POOL; +import static com.google.auth.oauth2.OAuth2Utils.IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_WORKLOAD_POOL; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; @@ -53,12 +56,8 @@ import java.io.IOException; import java.math.BigDecimal; import java.net.URI; -import java.util.Arrays; -import java.util.Date; -import java.util.HashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; +import java.util.*; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -93,6 +92,11 @@ void setup() { transportFactory = new MockExternalAccountCredentialsTransportFactory(); } + @org.junit.jupiter.api.AfterEach + void tearDown() { + RegionalAccessBoundary.setEnvironmentProviderForTest(null); + } + @Test void fromStream_identityPoolCredentials() throws IOException { GenericJson json = buildJsonIdentityPoolCredential(); @@ -1144,7 +1148,7 @@ void serialize() throws IOException, ClassNotFoundException { assertEquals( testCredentials.getServiceAccountImpersonationOptions().getLifetime(), deserializedCredentials.getServiceAccountImpersonationOptions().getLifetime()); - assertSame(Clock.SYSTEM, deserializedCredentials.clock); + assertSame(deserializedCredentials.clock, Clock.SYSTEM); assertEquals( MockExternalAccountCredentialsTransportFactory.class, deserializedCredentials.toBuilder().getHttpTransportFactory().getClass()); @@ -1240,6 +1244,274 @@ void validateServiceAccountImpersonationUrls_invalidUrls() { } } + @Test + public void getRegionalAccessBoundaryUrl_workload() throws IOException { + String audience = + "//iam.googleapis.com/projects/12345/locations/global/workloadIdentityPools/my-pool/providers/my-provider"; + ExternalAccountCredentials credentials = + TestExternalAccountCredentials.newBuilder() + .setAudience(audience) + .setSubjectTokenType("subject_token_type") + .setCredentialSource(new TestCredentialSource(FILE_CREDENTIAL_SOURCE_MAP)) + .build(); + + String expectedUrl = + "https://iamcredentials.googleapis.com/v1/projects/12345/locations/global/workloadIdentityPools/my-pool/allowedLocations"; + assertEquals(expectedUrl, credentials.getRegionalAccessBoundaryUrl()); + } + + @Test + public void getRegionalAccessBoundaryUrl_workforce() throws IOException { + String audience = + "//iam.googleapis.com/locations/global/workforcePools/my-pool/providers/my-provider"; + ExternalAccountCredentials credentials = + TestExternalAccountCredentials.newBuilder() + .setAudience(audience) + .setWorkforcePoolUserProject("12345") + .setSubjectTokenType("subject_token_type") + .setCredentialSource(new TestCredentialSource(FILE_CREDENTIAL_SOURCE_MAP)) + .build(); + + String expectedUrl = + "https://iamcredentials.googleapis.com/v1/locations/global/workforcePools/my-pool/allowedLocations"; + assertEquals(expectedUrl, credentials.getRegionalAccessBoundaryUrl()); + } + + @Test + public void getRegionalAccessBoundaryUrl_invalidAudience_throws() { + ExternalAccountCredentials credentials = + TestExternalAccountCredentials.newBuilder() + .setAudience("invalid-audience") + .setSubjectTokenType("subject_token_type") + .setCredentialSource(new TestCredentialSource(FILE_CREDENTIAL_SOURCE_MAP)) + .build(); + + IllegalStateException exception = + assertThrows( + IllegalStateException.class, + () -> { + credentials.getRegionalAccessBoundaryUrl(); + }); + + assertEquals( + "The provided audience is not in a valid format for either a workload identity pool or a workforce pool. " + + "Refer: https://docs.cloud.google.com/iam/docs/principal-identifiers", + exception.getMessage()); + } + + @Test + public void refresh_workload_regionalAccessBoundarySuccess() + throws IOException, InterruptedException { + TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + String audience = + "//iam.googleapis.com/projects/12345/locations/global/workloadIdentityPools/my-pool/providers/my-provider"; + + ExternalAccountCredentials credentials = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(transportFactory) + .setAudience(audience) + .setSubjectTokenType("subject_token_type") + .setTokenUrl(STS_URL) + .setCredentialSource(new TestCredentialSource(FILE_CREDENTIAL_SOURCE_MAP))) { + @Override + public String retrieveSubjectToken() throws IOException { + // This override isolates the test from the filesystem. + return "dummy-subject-token"; + } + }; + + // First call: initiates async refresh. + Map> headers = credentials.getRequestMetadata(); + assertNull(headers.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY)); + + waitForRegionalAccessBoundary(credentials); + + // Second call: should have header. + headers = credentials.getRequestMetadata(); + assertEquals( + headers.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY), + Arrays.asList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION)); + } + + @Test + public void refresh_workforce_regionalAccessBoundarySuccess() + throws IOException, InterruptedException { + TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + String audience = + "//iam.googleapis.com/locations/global/workforcePools/my-pool/providers/my-provider"; + + ExternalAccountCredentials credentials = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(transportFactory) + .setAudience(audience) + .setWorkforcePoolUserProject("12345") + .setSubjectTokenType("subject_token_type") + .setTokenUrl(STS_URL) + .setCredentialSource(new TestCredentialSource(FILE_CREDENTIAL_SOURCE_MAP))) { + @Override + public String retrieveSubjectToken() throws IOException { + return "dummy-subject-token"; + } + }; + + // First call: initiates async refresh. + Map> headers = credentials.getRequestMetadata(); + assertNull(headers.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY)); + + waitForRegionalAccessBoundary(credentials); + + // Second call: should have header. + headers = credentials.getRequestMetadata(); + assertEquals( + headers.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY), + Arrays.asList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION)); + } + + @Test + public void refresh_impersonated_workload_regionalAccessBoundarySuccess() + throws IOException, InterruptedException { + TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + String projectNumber = "12345"; + String poolId = "my-pool"; + String providerId = "my-provider"; + String audience = + String.format( + "//iam.googleapis.com/projects/%s/locations/global/workloadIdentityPools/%s/providers/%s", + projectNumber, poolId, providerId); + + transportFactory.transport.setExpireTime(TestUtils.getDefaultExpireTime()); + + // 1. Setup distinct RABs for workload and impersonated identities. + String workloadRabUrl = + String.format( + IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_WORKLOAD_POOL, projectNumber, poolId); + RegionalAccessBoundary workloadRab = + new RegionalAccessBoundary( + "workload-encoded", Collections.singletonList("workload-loc"), null); + transportFactory.transport.addRegionalAccessBoundary(workloadRabUrl, workloadRab); + + String saEmail = + ImpersonatedCredentials.extractTargetPrincipal(SERVICE_ACCOUNT_IMPERSONATION_URL); + String impersonatedRabUrl = + String.format(IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_SERVICE_ACCOUNT, saEmail); + RegionalAccessBoundary impersonatedRab = + new RegionalAccessBoundary( + "impersonated-encoded", Collections.singletonList("impersonated-loc"), null); + transportFactory.transport.addRegionalAccessBoundary(impersonatedRabUrl, impersonatedRab); + + // Use a URL-based source that the mock transport can handle, to avoid file IO. + Map urlCredentialSourceMap = new HashMap<>(); + urlCredentialSourceMap.put("url", "https://www.metadata.google.com"); + Map headers = new HashMap<>(); + headers.put("Metadata-Flavor", "Google"); + urlCredentialSourceMap.put("headers", headers); + + ExternalAccountCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(transportFactory) + .setAudience(audience) + .setSubjectTokenType("subject_token_type") + .setTokenUrl(STS_URL) + .setServiceAccountImpersonationUrl(SERVICE_ACCOUNT_IMPERSONATION_URL) + .setCredentialSource(new IdentityPoolCredentialSource(urlCredentialSourceMap)) + .build(); + + // First call: initiates async refresh. + Map> requestHeaders = credentials.getRequestMetadata(); + assertNull(requestHeaders.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY)); + + waitForRegionalAccessBoundary(credentials); + + // Second call: should have the IMPERSONATED header, not the workload one. + requestHeaders = credentials.getRequestMetadata(); + assertEquals( + Arrays.asList("impersonated-encoded"), + requestHeaders.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY)); + } + + @Test + public void refresh_impersonated_workforce_regionalAccessBoundarySuccess() + throws IOException, InterruptedException { + TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + String poolId = "my-pool"; + String providerId = "my-provider"; + String audience = + String.format( + "//iam.googleapis.com/locations/global/workforcePools/%s/providers/%s", + poolId, providerId); + + transportFactory.transport.setExpireTime(TestUtils.getDefaultExpireTime()); + + // 1. Setup distinct RABs for workforce and impersonated identities. + String workforceRabUrl = + String.format(IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_WORKFORCE_POOL, poolId); + RegionalAccessBoundary workforceRab = + new RegionalAccessBoundary( + "workforce-encoded", Collections.singletonList("workforce-loc"), null); + transportFactory.transport.addRegionalAccessBoundary(workforceRabUrl, workforceRab); + + String saEmail = + ImpersonatedCredentials.extractTargetPrincipal(SERVICE_ACCOUNT_IMPERSONATION_URL); + String impersonatedRabUrl = + String.format(IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_SERVICE_ACCOUNT, saEmail); + RegionalAccessBoundary impersonatedRab = + new RegionalAccessBoundary( + "impersonated-encoded", Collections.singletonList("impersonated-loc"), null); + transportFactory.transport.addRegionalAccessBoundary(impersonatedRabUrl, impersonatedRab); + + // Use a URL-based source that the mock transport can handle, to avoid file IO. + Map urlCredentialSourceMap = new HashMap<>(); + urlCredentialSourceMap.put("url", "https://www.metadata.google.com"); + Map headers = new HashMap<>(); + headers.put("Metadata-Flavor", "Google"); + urlCredentialSourceMap.put("headers", headers); + + ExternalAccountCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(transportFactory) + .setAudience(audience) + .setWorkforcePoolUserProject("12345") + .setSubjectTokenType("subject_token_type") + .setTokenUrl(STS_URL) + .setServiceAccountImpersonationUrl(SERVICE_ACCOUNT_IMPERSONATION_URL) + .setCredentialSource(new IdentityPoolCredentialSource(urlCredentialSourceMap)) + .build(); + + // First call: initiates async refresh. + Map> requestHeaders = credentials.getRequestMetadata(); + assertNull(requestHeaders.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY)); + + waitForRegionalAccessBoundary(credentials); + + // Second call: should have the IMPERSONATED header, not the workforce one. + requestHeaders = credentials.getRequestMetadata(); + assertEquals( + Arrays.asList("impersonated-encoded"), + requestHeaders.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY)); + } + + private void waitForRegionalAccessBoundary(GoogleCredentials credentials) + throws InterruptedException { + long deadline = System.currentTimeMillis() + 5000; + while (credentials.getRegionalAccessBoundary() == null + && System.currentTimeMillis() < deadline) { + Thread.sleep(100); + } + if (credentials.getRegionalAccessBoundary() == null) { + Assertions.fail("Timed out waiting for regional access boundary refresh"); + } + } + private GenericJson buildJsonIdentityPoolCredential() { GenericJson json = new GenericJson(); json.put( diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java index 74aa9fae9ccd..dd64a07d4a1f 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java @@ -31,6 +31,8 @@ package com.google.auth.oauth2; +import static com.google.auth.oauth2.RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY; +import static org.junit.Assert.fail; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; @@ -44,6 +46,7 @@ import com.google.api.client.json.GenericJson; import com.google.api.client.util.Clock; import com.google.auth.Credentials; +import com.google.auth.RequestMetadataCallback; import com.google.auth.TestUtils; import com.google.auth.http.HttpTransportFactory; import com.google.auth.oauth2.ExternalAccountAuthorizedUserCredentialsTest.MockExternalAccountAuthorizedUserCredentialsTransportFactory; @@ -58,7 +61,10 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; +import javax.annotation.Nullable; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; /** Test case for {@link GoogleCredentials}. */ @@ -99,6 +105,14 @@ class GoogleCredentialsTest extends BaseSerializationTest { private static final String GOOGLE_DEFAULT_UNIVERSE = "googleapis.com"; private static final String TPC_UNIVERSE = "foo.bar"; + @org.junit.jupiter.api.BeforeEach + void setUp() {} + + @org.junit.jupiter.api.AfterEach + void tearDown() { + RegionalAccessBoundary.setEnvironmentProviderForTest(null); + } + @Test void getApplicationDefault_nullTransport_throws() { assertThrows(NullPointerException.class, () -> GoogleCredentials.getApplicationDefault(null)); @@ -838,6 +852,57 @@ void serialize() throws IOException, ClassNotFoundException { assertEquals(testCredentials.hashCode(), deserializedCredentials.hashCode()); assertEquals(testCredentials.toString(), deserializedCredentials.toString()); assertSame(Clock.SYSTEM, deserializedCredentials.clock); + assertSame(deserializedCredentials.clock, Clock.SYSTEM); + assertNotNull(deserializedCredentials.regionalAccessBoundaryManager); + } + + @Test + public void serialize_removesStaleRabHeaders() throws Exception { + TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + + MockTokenServerTransportFactory transportFactory = new MockTokenServerTransportFactory(); + RegionalAccessBoundary rab = + new RegionalAccessBoundary( + "test-encoded", + Collections.singletonList("test-loc"), + System.currentTimeMillis(), + null); + transportFactory.transport.setRegionalAccessBoundary(rab); + transportFactory.transport.addServiceAccount(SA_CLIENT_EMAIL, ACCESS_TOKEN); + + GoogleCredentials credentials = + new ServiceAccountCredentials.Builder() + .setClientEmail(SA_CLIENT_EMAIL) + .setPrivateKey(OAuth2Utils.privateKeyFromPkcs8(SA_PRIVATE_KEY_PKCS8)) + .setPrivateKeyId(SA_PRIVATE_KEY_ID) + .setHttpTransportFactory(transportFactory) + .setScopes(SCOPES) + .build(); + + // 1. Trigger request metadata to start async RAB refresh + credentials.getRequestMetadata(URI.create("https://foo.com")); + + // Wait for the RAB to be fetched and cached + waitForRegionalAccessBoundary(credentials); + + // 2. Verify the live credential has the RAB header + Map> metadata = credentials.getRequestMetadata(); + assertEquals( + Collections.singletonList("test-encoded"), + metadata.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY)); + + // 3. Serialize and deserialize. + GoogleCredentials deserialized = serializeAndDeserialize(credentials); + + // 4. Verify. + // The manager is transient, so it should be empty. + assertNull(deserialized.getRegionalAccessBoundary()); + + // The metadata should NOT contain the RAB header anymore, preventing stale headers. + Map> deserializedMetadata = deserialized.getRequestMetadata(); + assertNull(deserializedMetadata.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY)); } @Test @@ -977,4 +1042,349 @@ void getCredentialInfo_impersonatedServiceAccount() throws IOException { assertEquals( ImpersonatedCredentialsTest.IMPERSONATED_CLIENT_EMAIL, credentialInfo.get("Principal")); } + + @Test + public void regionalAccessBoundary_shouldFetchAndReturnRegionalAccessBoundaryDataSuccessfully() + throws IOException, InterruptedException { + TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + MockTokenServerTransport transport = new MockTokenServerTransport(); + transport.addServiceAccount(SA_CLIENT_EMAIL, ACCESS_TOKEN); + RegionalAccessBoundary regionalAccessBoundary = + new RegionalAccessBoundary( + TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION, + Collections.singletonList("us-central1"), + null); + transport.setRegionalAccessBoundary(regionalAccessBoundary); + + ServiceAccountCredentials credentials = + ServiceAccountCredentials.newBuilder() + .setClientEmail(SA_CLIENT_EMAIL) + .setPrivateKey(OAuth2Utils.privateKeyFromPkcs8(SA_PRIVATE_KEY_PKCS8)) + .setPrivateKeyId(SA_PRIVATE_KEY_ID) + .setHttpTransportFactory(() -> transport) + .setScopes(SCOPES) + .build(); + + // First call: returns no header, initiates async refresh. + Map> headers = credentials.getRequestMetadata(); + assertNull(headers.get(X_ALLOWED_LOCATIONS_HEADER_KEY)); + + waitForRegionalAccessBoundary(credentials); + + // Second call: should have header. + headers = credentials.getRequestMetadata(); + assertEquals( + headers.get(X_ALLOWED_LOCATIONS_HEADER_KEY), + Arrays.asList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION)); + } + + @Test + public void regionalAccessBoundary_shouldRetryRegionalAccessBoundaryLookupOnFailure() + throws IOException, InterruptedException { + TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + + // This transport will be used for the regional access boundary lookup. + // We will configure it to fail on the first attempt. + MockTokenServerTransport regionalAccessBoundaryTransport = new MockTokenServerTransport(); + regionalAccessBoundaryTransport.addResponseErrorSequence( + new IOException("Service Unavailable")); + RegionalAccessBoundary regionalAccessBoundary = + new RegionalAccessBoundary( + TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION, + TestUtils.REGIONAL_ACCESS_BOUNDARY_LOCATIONS, + null); + regionalAccessBoundaryTransport.setRegionalAccessBoundary(regionalAccessBoundary); + + // This transport will be used for the access token refresh. + // It will succeed. + MockTokenServerTransport accessTokenTransport = new MockTokenServerTransport(); + accessTokenTransport.addServiceAccount(SA_CLIENT_EMAIL, ACCESS_TOKEN); + + ServiceAccountCredentials credentials = + ServiceAccountCredentials.newBuilder() + .setClientEmail(SA_CLIENT_EMAIL) + .setPrivateKey(OAuth2Utils.privateKeyFromPkcs8(SA_PRIVATE_KEY_PKCS8)) + .setPrivateKeyId(SA_PRIVATE_KEY_ID) + // Use a custom transport factory that returns the correct transport for each endpoint. + .setHttpTransportFactory( + () -> + new com.google.api.client.testing.http.MockHttpTransport() { + @Override + public com.google.api.client.http.LowLevelHttpRequest buildRequest( + String method, String url) throws IOException { + if (url.endsWith("/allowedLocations")) { + return regionalAccessBoundaryTransport.buildRequest(method, url); + } + return accessTokenTransport.buildRequest(method, url); + } + }) + .setScopes(SCOPES) + .build(); + + credentials.getRequestMetadata(); + waitForRegionalAccessBoundary(credentials); + + Map> headers = credentials.getRequestMetadata(); + assertEquals( + Arrays.asList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION), + headers.get(X_ALLOWED_LOCATIONS_HEADER_KEY)); + } + + @Test + public void regionalAccessBoundary_refreshShouldNotThrowWhenNoValidAccessTokenIsPassed() + throws IOException { + TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + MockTokenServerTransport transport = new MockTokenServerTransport(); + // Return an expired access token. + transport.addServiceAccount(SA_CLIENT_EMAIL, "expired-token"); + transport.setExpiresInSeconds(-1); + + ServiceAccountCredentials credentials = + ServiceAccountCredentials.newBuilder() + .setClientEmail(SA_CLIENT_EMAIL) + .setPrivateKey(OAuth2Utils.privateKeyFromPkcs8(SA_PRIVATE_KEY_PKCS8)) + .setPrivateKeyId(SA_PRIVATE_KEY_ID) + .setHttpTransportFactory(() -> transport) + .setScopes(SCOPES) + .build(); + + // Should not throw, but just fail-open (no header). + Map> headers = credentials.getRequestMetadata(); + assertNull(headers.get(X_ALLOWED_LOCATIONS_HEADER_KEY)); + } + + @Test + public void regionalAccessBoundary_cooldownDoublingAndRefresh() + throws IOException, InterruptedException { + TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + MockTokenServerTransport transport = new MockTokenServerTransport(); + transport.addServiceAccount(SA_CLIENT_EMAIL, ACCESS_TOKEN); + // Always fail lookup for now. + transport.addResponseErrorSequence(new IOException("Persistent Failure")); + + ServiceAccountCredentials credentials = + ServiceAccountCredentials.newBuilder() + .setClientEmail(SA_CLIENT_EMAIL) + .setPrivateKey(OAuth2Utils.privateKeyFromPkcs8(SA_PRIVATE_KEY_PKCS8)) + .setPrivateKeyId(SA_PRIVATE_KEY_ID) + .setHttpTransportFactory(() -> transport) + .setScopes(SCOPES) + .build(); + + TestClock testClock = new TestClock(); + credentials.clock = testClock; + credentials.regionalAccessBoundaryManager = new RegionalAccessBoundaryManager(testClock, 100); + + // First attempt: triggers lookup, fails, enters 15m cooldown. + credentials.getRequestMetadata(); + waitForCooldownActive(credentials); + assertTrue(credentials.regionalAccessBoundaryManager.isCooldownActive()); + assertEquals( + 15 * 60 * 1000L, credentials.regionalAccessBoundaryManager.getCurrentCooldownMillis()); + + // Second attempt (during cooldown): does not trigger lookup. + credentials.getRequestMetadata(); + assertTrue(credentials.regionalAccessBoundaryManager.isCooldownActive()); + + // Fast-forward past 15m cooldown. + testClock.advanceTime(16 * 60 * 1000L); + assertFalse(credentials.regionalAccessBoundaryManager.isCooldownActive()); + + // Third attempt (cooldown expired): triggers lookup, fails again, cooldown should double. + credentials.getRequestMetadata(); + waitForCooldownActive(credentials); + assertTrue(credentials.regionalAccessBoundaryManager.isCooldownActive()); + assertEquals( + 30 * 60 * 1000L, credentials.regionalAccessBoundaryManager.getCurrentCooldownMillis()); + + // Fast-forward past 30m cooldown. + testClock.advanceTime(31 * 60 * 1000L); + assertFalse(credentials.regionalAccessBoundaryManager.isCooldownActive()); + + // Set successful response. + transport.setRegionalAccessBoundary( + new RegionalAccessBoundary("0x123", Collections.emptyList(), null)); + + // Fourth attempt: triggers lookup, succeeds, resets cooldown. + credentials.getRequestMetadata(); + waitForRegionalAccessBoundary(credentials); + assertFalse(credentials.regionalAccessBoundaryManager.isCooldownActive()); + assertEquals("0x123", credentials.getRegionalAccessBoundary().getEncodedLocations()); + assertEquals( + 15 * 60 * 1000L, credentials.regionalAccessBoundaryManager.getCurrentCooldownMillis()); + } + + @Test + public void regionalAccessBoundary_shouldFailOpenWhenRefreshCannotBeStarted() throws IOException { + TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + // Use a simple AccessToken-based credential that won't try to refresh. + GoogleCredentials credentials = GoogleCredentials.create(new AccessToken("some-token", null)); + + // Should not throw, but just fail-open (no header). + Map> headers = credentials.getRequestMetadata(); + assertNull(headers.get(X_ALLOWED_LOCATIONS_HEADER_KEY)); + } + + @Test + public void regionalAccessBoundary_deduplicationOfConcurrentRefreshes() + throws IOException, InterruptedException { + TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + MockTokenServerTransport transport = new MockTokenServerTransport(); + transport.setRegionalAccessBoundary( + new RegionalAccessBoundary("valid", Collections.singletonList("us-central1"), null)); + // Add delay to lookup to ensure threads overlap. + transport.setResponseDelayMillis(500); + + GoogleCredentials credentials = createTestCredentials(transport); + + // Fire multiple concurrent requests. + for (int i = 0; i < 10; i++) { + new Thread( + () -> { + try { + credentials.getRequestMetadata(); + } catch (IOException e) { + } + }) + .start(); + } + + waitForRegionalAccessBoundary(credentials); + + // Only ONE request should have been made to the lookup endpoint. + assertEquals(1, transport.getRegionalAccessBoundaryRequestCount()); + } + + @Test + public void regionalAccessBoundary_shouldSkipRefreshForRegionalEndpoints() throws IOException { + TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + MockTokenServerTransport transport = new MockTokenServerTransport(); + GoogleCredentials credentials = createTestCredentials(transport); + + URI regionalUri = URI.create("https://storage.us-central1.rep.googleapis.com/v1/b/foo"); + credentials.getRequestMetadata(regionalUri); + + // Should not have triggered any lookup. + assertEquals(0, transport.getRegionalAccessBoundaryRequestCount()); + } + + @Test + public void getRequestMetadata_ignoresRabRefreshException() throws IOException { + GoogleCredentials credentials = + new GoogleCredentials() { + @Override + public AccessToken refreshAccessToken() throws IOException { + return new AccessToken("token", null); + } + + @Override + void refreshRegionalAccessBoundaryIfExpired( + @Nullable URI uri, @Nullable AccessToken token) throws IOException { + throw new IOException("Simulated RAB failure"); + } + }; + + // This should not throw the IOException from refreshRegionalAccessBoundaryIfExpired + Map> metadata = + credentials.getRequestMetadata(URI.create("https://foo.com")); + assertTrue(metadata.containsKey("Authorization")); + } + + @Test + public void getRequestMetadataAsync_ignoresRabRefreshException() throws IOException { + GoogleCredentials credentials = + new GoogleCredentials() { + @Override + public AccessToken refreshAccessToken() throws IOException { + return new AccessToken("token", null); + } + + @Override + void refreshRegionalAccessBoundaryIfExpired( + @Nullable URI uri, @Nullable AccessToken token) throws IOException { + throw new IOException("Simulated RAB failure"); + } + }; + + java.util.concurrent.atomic.AtomicBoolean success = + new java.util.concurrent.atomic.AtomicBoolean(false); + credentials.getRequestMetadata( + URI.create("https://foo.com"), + Runnable::run, + new RequestMetadataCallback() { + @Override + public void onSuccess(Map> metadata) { + success.set(true); + } + + @Override + public void onFailure(Throwable exception) { + fail("Should not have failed"); + } + }); + + assertTrue(success.get()); + } + + private GoogleCredentials createTestCredentials(MockTokenServerTransport transport) + throws IOException { + transport.addServiceAccount(SA_CLIENT_EMAIL, ACCESS_TOKEN); + return new ServiceAccountCredentials.Builder() + .setClientEmail(SA_CLIENT_EMAIL) + .setPrivateKey(OAuth2Utils.privateKeyFromPkcs8(SA_PRIVATE_KEY_PKCS8)) + .setPrivateKeyId(SA_PRIVATE_KEY_ID) + .setHttpTransportFactory(() -> transport) + .setScopes(SCOPES) + .build(); + } + + private void waitForRegionalAccessBoundary(GoogleCredentials credentials) + throws InterruptedException { + long deadline = System.currentTimeMillis() + 5000; + while (credentials.getRegionalAccessBoundary() == null + && System.currentTimeMillis() < deadline) { + Thread.sleep(100); + } + if (credentials.getRegionalAccessBoundary() == null) { + Assertions.fail("Timed out waiting for regional access boundary refresh"); + } + } + + private void waitForCooldownActive(GoogleCredentials credentials) throws InterruptedException { + long deadline = System.currentTimeMillis() + 5000; + while (!credentials.regionalAccessBoundaryManager.isCooldownActive() + && System.currentTimeMillis() < deadline) { + Thread.sleep(100); + } + if (!credentials.regionalAccessBoundaryManager.isCooldownActive()) { + Assertions.fail("Timed out waiting for cooldown to become active"); + } + } + + private static class TestClock implements Clock { + private final AtomicLong currentTime = new AtomicLong(System.currentTimeMillis()); + + @Override + public long currentTimeMillis() { + return currentTime.get(); + } + + public void advanceTime(long millis) { + currentTime.addAndGet(millis); + } + } } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java index 674d523e5090..399bf7246c9a 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java @@ -37,9 +37,11 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; import com.google.api.client.http.HttpTransport; import com.google.api.client.json.GenericJson; @@ -75,6 +77,14 @@ class IdentityPoolCredentialsTest extends BaseSerializationTest { private static final IdentityPoolSubjectTokenSupplier testProvider = (ExternalAccountSupplierContext context) -> "testSubjectToken"; + @org.junit.jupiter.api.BeforeEach + void setUp() {} + + @org.junit.jupiter.api.AfterEach + void tearDown() { + RegionalAccessBoundary.setEnvironmentProviderForTest(null); + } + @Test void createdScoped_clonedCredentialWithAddedScopes() { IdentityPoolCredentials credentials = @@ -1299,4 +1309,49 @@ void setShouldThrowOnGetKeyStore(boolean shouldThrow) { this.shouldThrowOnGetKeyStore = shouldThrow; } } + + @Test + public void testRefresh_regionalAccessBoundarySuccess() throws IOException, InterruptedException { + TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + HttpTransportFactory testingHttpTransportFactory = transportFactory; + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setHttpTransportFactory(testingHttpTransportFactory) + .setAudience( + "//iam.googleapis.com/projects/12345/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl(STS_URL) + .build(); + + // First call: initiates async refresh. + Map> headers = credentials.getRequestMetadata(); + assertNull(headers.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY)); + + waitForRegionalAccessBoundary(credentials); + + // Second call: should have header. + headers = credentials.getRequestMetadata(); + assertEquals( + headers.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY), + Arrays.asList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION)); + } + + private void waitForRegionalAccessBoundary(GoogleCredentials credentials) + throws InterruptedException { + long deadline = System.currentTimeMillis() + 5000; + while (credentials.getRegionalAccessBoundary() == null + && System.currentTimeMillis() < deadline) { + Thread.sleep(100); + } + if (credentials.getRegionalAccessBoundary() == null) { + fail("Timed out waiting for regional access boundary refresh"); + } + } } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java index 044aa0ce6755..fc3c2e9c783e 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java @@ -31,6 +31,7 @@ package com.google.auth.oauth2; +import static com.google.auth.oauth2.RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -40,6 +41,7 @@ import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -69,6 +71,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Calendar; +import java.util.Collections; import java.util.Date; import java.util.List; import java.util.Map; @@ -145,6 +148,11 @@ class ImpersonatedCredentialsTest extends BaseSerializationTest { private static final String REFRESH_TOKEN = "dasdfasdffa4ffdfadgyjirasdfadsft"; public static final List DELEGATES = Arrays.asList("sa1@developer.gserviceaccount.com", "sa2@developer.gserviceaccount.com"); + public static final RegionalAccessBoundary REGIONAL_ACCESS_BOUNDARY = + new RegionalAccessBoundary( + TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION, + TestUtils.REGIONAL_ACCESS_BOUNDARY_LOCATIONS, + null); private GoogleCredentials sourceCredentials; private MockIAMCredentialsServiceTransportFactory mockTransportFactory; @@ -155,6 +163,11 @@ void setup() throws IOException { mockTransportFactory = new MockIAMCredentialsServiceTransportFactory(); } + @org.junit.After + public void tearDown() { + RegionalAccessBoundary.setEnvironmentProviderForTest(null); + } + static GoogleCredentials getSourceCredentials() throws IOException { MockTokenServerTransportFactory transportFactory = new MockTokenServerTransportFactory(); PrivateKey privateKey = OAuth2Utils.privateKeyFromPkcs8(SA_PRIVATE_KEY_PKCS8); @@ -168,6 +181,7 @@ static GoogleCredentials getSourceCredentials() throws IOException { .setHttpTransportFactory(transportFactory) .build(); transportFactory.transport.addServiceAccount(SA_CLIENT_EMAIL, ACCESS_TOKEN); + transportFactory.transport.setRegionalAccessBoundary(REGIONAL_ACCESS_BOUNDARY); return sourceCredentials; } @@ -1260,6 +1274,56 @@ void refreshAccessToken_afterSerialization_success() throws IOException, ClassNo assertEquals(ACCESS_TOKEN, token.getTokenValue()); } + @Test + void refresh_regionalAccessBoundarySuccess() throws IOException, InterruptedException { + TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + // Mock regional access boundary response + RegionalAccessBoundary regionalAccessBoundary = REGIONAL_ACCESS_BOUNDARY; + + mockTransportFactory.getTransport().setRegionalAccessBoundary(regionalAccessBoundary); + mockTransportFactory.getTransport().setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL); + mockTransportFactory.getTransport().setAccessToken(ACCESS_TOKEN); + mockTransportFactory.getTransport().setExpireTime(getDefaultExpireTime()); + mockTransportFactory + .getTransport() + .addStatusCodeAndMessage(HttpStatusCodes.STATUS_CODE_OK, "", true); + + ImpersonatedCredentials targetCredentials = + ImpersonatedCredentials.create( + sourceCredentials, + IMPERSONATED_CLIENT_EMAIL, + null, + IMMUTABLE_SCOPES_LIST, + VALID_LIFETIME, + mockTransportFactory); + + // First call: initiates async refresh. + Map> headers = targetCredentials.getRequestMetadata(); + assertNull(headers.get(X_ALLOWED_LOCATIONS_HEADER_KEY)); + + waitForRegionalAccessBoundary(targetCredentials); + + // Second call: should have header. + headers = targetCredentials.getRequestMetadata(); + assertEquals( + headers.get(X_ALLOWED_LOCATIONS_HEADER_KEY), + Collections.singletonList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION)); + } + + private void waitForRegionalAccessBoundary(GoogleCredentials credentials) + throws InterruptedException { + long deadline = System.currentTimeMillis() + 5000; + while (credentials.getRegionalAccessBoundary() == null + && System.currentTimeMillis() < deadline) { + Thread.sleep(100); + } + if (credentials.getRegionalAccessBoundary() == null) { + fail("Timed out waiting for regional access boundary refresh"); + } + } + public static String getDefaultExpireTime() { return Instant.now().plusSeconds(VALID_LIFETIME).truncatedTo(ChronoUnit.SECONDS).toString(); } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java index 524a312ce0c1..68e9c8edf393 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java @@ -94,12 +94,21 @@ static void setup() { LoggingUtils.setEnvironmentProvider(testEnvironmentProvider); } + @org.junit.jupiter.api.BeforeEach + void setUp() {} + + @org.junit.jupiter.api.AfterEach + void tearDown() { + RegionalAccessBoundary.setEnvironmentProviderForTest(null); + } + @Test void userCredentials_getRequestMetadata_fromRefreshToken_hasAccessToken() throws IOException { TestAppender testAppender = setupTestLogger(UserCredentials.class); MockTokenServerTransportFactory transportFactory = new MockTokenServerTransportFactory(); transportFactory.transport.addClient(CLIENT_ID, CLIENT_SECRET); transportFactory.transport.addRefreshToken(REFRESH_TOKEN, ACCESS_TOKEN); + UserCredentials userCredentials = UserCredentials.newBuilder() .setClientId(CLIENT_ID) @@ -212,6 +221,7 @@ void serviceAccountCredentials_idTokenWithAudience_iamFlow_targetAudienceMatches transportFactory.getTransport().setTargetPrincipal(CLIENT_EMAIL); transportFactory.getTransport().setIdToken(DEFAULT_ID_TOKEN); transportFactory.getTransport().addStatusCodeAndMessage(HttpStatusCodes.STATUS_CODE_OK, ""); + ServiceAccountCredentials credentials = createDefaultBuilder() .setScopes(SCOPES) diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java index 7719b08d2e7b..c53eda5b2bd5 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java @@ -50,6 +50,7 @@ import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Queue; @@ -68,6 +69,7 @@ public class MockExternalAccountCredentialsTransport extends MockHttpTransport { private static final String AWS_IMDSV2_SESSION_TOKEN_URL = "https://169.254.169.254/imdsv2"; private static final String METADATA_SERVER_URL = "https://www.metadata.google.com"; private static final String STS_URL = "https://sts.googleapis.com/v1/token"; + private static final String REGIONAL_ACCESS_BOUNDARY_URL_END = "/allowedLocations"; private static final String SUBJECT_TOKEN = "subjectToken"; private static final String TOKEN_TYPE = "Bearer"; @@ -92,6 +94,11 @@ public class MockExternalAccountCredentialsTransport extends MockHttpTransport { private String expireTime; private String metadataServerContentType; private String stsContent; + private final Map regionalAccessBoundaries = new HashMap<>(); + + public void addRegionalAccessBoundary(String url, RegionalAccessBoundary regionalAccessBoundary) { + this.regionalAccessBoundaries.put(url, regionalAccessBoundary); + } public void addResponseErrorSequence(IOException... errors) { Collections.addAll(responseErrorSequence, errors); @@ -196,6 +203,26 @@ public LowLevelHttpResponse execute() throws IOException { } if (url.contains(IAM_ENDPOINT)) { + + if (url.endsWith(REGIONAL_ACCESS_BOUNDARY_URL_END)) { + RegionalAccessBoundary rab = regionalAccessBoundaries.get(url); + if (rab == null) { + rab = + new RegionalAccessBoundary( + TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION, + TestUtils.REGIONAL_ACCESS_BOUNDARY_LOCATIONS, + null); + } + GenericJson responseJson = new GenericJson(); + responseJson.setFactory(OAuth2Utils.JSON_FACTORY); + responseJson.put("encodedLocations", rab.getEncodedLocations()); + responseJson.put("locations", rab.getLocations()); + String content = responseJson.toPrettyString(); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(content); + } + GenericJson query = OAuth2Utils.JSON_FACTORY .createJsonParser(getContentAsString()) @@ -220,7 +247,9 @@ public LowLevelHttpResponse execute() throws IOException { } }; - this.requests.add(request); + if (url == null || !url.contains("allowedLocations")) { + this.requests.add(request); + } return request; } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockIAMCredentialsServiceTransport.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockIAMCredentialsServiceTransport.java index cbd57d115afe..5346f4fdba3d 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockIAMCredentialsServiceTransport.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockIAMCredentialsServiceTransport.java @@ -80,6 +80,8 @@ public ServerResponse(int statusCode, String response, boolean repeatServerRespo private String universeDomain; + private RegionalAccessBoundary regionalAccessBoundary; + private MockLowLevelHttpRequest request; MockIAMCredentialsServiceTransport(String universeDomain) { @@ -132,6 +134,10 @@ public void setAccessTokenEndpoint(String accessTokenEndpoint) { this.iamAccessTokenEndpoint = accessTokenEndpoint; } + public void setRegionalAccessBoundary(RegionalAccessBoundary regionalAccessBoundary) { + this.regionalAccessBoundary = regionalAccessBoundary; + } + public MockLowLevelHttpRequest getRequest() { return request; } @@ -221,6 +227,25 @@ public LowLevelHttpResponse execute() throws IOException { .setContent(tokenContent); } }; + } else if (url.endsWith("/allowedLocations")) { + request = + new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() throws IOException { + if (regionalAccessBoundary == null) { + return new MockLowLevelHttpResponse().setStatusCode(404); + } + GenericJson responseJson = new GenericJson(); + responseJson.setFactory(OAuth2Utils.JSON_FACTORY); + responseJson.put("encodedLocations", regionalAccessBoundary.getEncodedLocations()); + responseJson.put("locations", regionalAccessBoundary.getLocations()); + String content = responseJson.toPrettyString(); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(content); + } + }; + return request; } else { return super.buildRequest(method, url); } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockMetadataServerTransport.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockMetadataServerTransport.java index 1b218b73ef45..92b24d60fd53 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockMetadataServerTransport.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockMetadataServerTransport.java @@ -72,6 +72,9 @@ public class MockMetadataServerTransport extends MockHttpTransport { private boolean emptyContent; private MockLowLevelHttpRequest request; + private RegionalAccessBoundary regionalAccessBoundary; + private IOException lookupError; + public MockMetadataServerTransport() {} public MockMetadataServerTransport(String accessToken) { @@ -119,6 +122,14 @@ public void setEmptyContent(boolean emptyContent) { this.emptyContent = emptyContent; } + public void setRegionalAccessBoundary(RegionalAccessBoundary regionalAccessBoundary) { + this.regionalAccessBoundary = regionalAccessBoundary; + } + + public void setLookupError(IOException lookupError) { + this.lookupError = lookupError; + } + public MockLowLevelHttpRequest getRequest() { return request; } @@ -139,6 +150,8 @@ public LowLevelHttpRequest buildRequest(String method, String url) throws IOExce return this.request; } else if (isMtlsConfigRequestUrl(url)) { return getMockRequestForMtlsConfig(url); + } else if (isIamLookupUrl(url)) { + return getMockRequestForRegionalAccessBoundaryLookup(url); } this.request = new MockLowLevelHttpRequest(url) { @@ -213,7 +226,7 @@ public LowLevelHttpResponse execute() throws IOException { refreshContents.put( "access_token", scopesToAccessToken.get("[" + urlParsed.get(1) + "]")); } - refreshContents.put("expires_in", 3600000); + refreshContents.put("expires_in", 3600); refreshContents.put("token_type", "Bearer"); String refreshText = refreshContents.toPrettyString(); @@ -346,4 +359,32 @@ protected boolean isMtlsConfigRequestUrl(String url) { ComputeEngineCredentials.getMetadataServerUrl() + SecureSessionAgent.S2A_CONFIG_ENDPOINT_POSTFIX); } + + private MockLowLevelHttpRequest getMockRequestForRegionalAccessBoundaryLookup(String url) { + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() throws IOException { + if (lookupError != null) { + throw lookupError; + } + if (regionalAccessBoundary == null) { + return new MockLowLevelHttpResponse().setStatusCode(404); + } + GenericJson responseJson = new GenericJson(); + responseJson.setFactory(OAuth2Utils.JSON_FACTORY); + responseJson.put("encodedLocations", regionalAccessBoundary.getEncodedLocations()); + responseJson.put("locations", regionalAccessBoundary.getLocations()); + String content = responseJson.toPrettyString(); + return new MockLowLevelHttpResponse().setContentType(Json.MEDIA_TYPE).setContent(content); + } + }; + } + + protected boolean isIamLookupUrl(String url) { + // Mocking call to the /allowedLocations endpoint for regional access boundary refresh. + // For testing convenience, this mock transport handles + // the /allowedLocations endpoint. The actual server for this endpoint + // will be the IAM Credentials API. + return url.endsWith("/allowedLocations"); + } } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockStsTransport.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockStsTransport.java index cdb0a068e2d0..24566a0e5ca3 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockStsTransport.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockStsTransport.java @@ -62,6 +62,8 @@ public final class MockStsTransport extends MockHttpTransport { private static final String ISSUED_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"; private static final String VALID_STS_PATTERN = "https:\\/\\/sts.[a-z-_\\.]+\\/v1\\/(token|oauthtoken)"; + private static final String VALID_REGIONAL_ACCESS_BOUNDARY_PATTERN = + "https:\\/\\/iam.[a-z-_\\.]+\\/v1\\/.*\\/allowedLocations"; private static final String ACCESS_TOKEN = "accessToken"; private static final String TOKEN_TYPE = "Bearer"; private static final Long EXPIRES_IN = 3600L; @@ -99,6 +101,23 @@ public LowLevelHttpRequest buildRequest(final String method, final String url) { new MockLowLevelHttpRequest(url) { @Override public LowLevelHttpResponse execute() throws IOException { + // Mocking call to refresh regional access boundaries. + // The lookup endpoint is located in the IAM server. + Matcher regionalAccessBoundaryMatcher = + Pattern.compile(VALID_REGIONAL_ACCESS_BOUNDARY_PATTERN).matcher(url); + if (regionalAccessBoundaryMatcher.matches()) { + // Mocking call to the /allowedLocations endpoint for regional access boundary + // refresh. + // For testing convenience, this mock transport handles + // the /allowedLocations endpoint. + GenericJson response = new GenericJson(); + response.put("locations", TestUtils.REGIONAL_ACCESS_BOUNDARY_LOCATIONS); + response.put("encodedLocations", TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(OAuth2Utils.JSON_FACTORY.toString(response)); + } + // Environment version is prefixed by "aws". e.g. "aws1". Matcher matcher = Pattern.compile(VALID_STS_PATTERN).matcher(url); if (!matcher.matches()) { diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockTokenServerTransport.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockTokenServerTransport.java index 5a6cd2e5d1a8..62f31e256d24 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockTokenServerTransport.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockTokenServerTransport.java @@ -76,6 +76,21 @@ public class MockTokenServerTransport extends MockHttpTransport { private int expiresInSeconds = 3600; private MockLowLevelHttpRequest request; private PKCEProvider pkceProvider; + private RegionalAccessBoundary regionalAccessBoundary; + private int regionalAccessBoundaryRequestCount = 0; + private int responseDelayMillis = 0; + + public void setRegionalAccessBoundary(RegionalAccessBoundary regionalAccessBoundary) { + this.regionalAccessBoundary = regionalAccessBoundary; + } + + public int getRegionalAccessBoundaryRequestCount() { + return regionalAccessBoundaryRequestCount; + } + + public void setResponseDelayMillis(int responseDelayMillis) { + this.responseDelayMillis = responseDelayMillis; + } public MockTokenServerTransport() {} @@ -171,6 +186,40 @@ public LowLevelHttpRequest buildRequest(String method, String url) throws IOExce int questionMarkPos = url.indexOf('?'); final String urlWithoutQuery = (questionMarkPos > 0) ? url.substring(0, questionMarkPos) : url; + if (urlWithoutQuery.endsWith("/allowedLocations")) { + // Mocking call to the /allowedLocations endpoint for regional access boundary refresh. + // For testing convenience, this mock transport handles + // the /allowedLocations endpoint. The actual server for this endpoint + // will be the IAM Credentials API. + request = + new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() throws IOException { + regionalAccessBoundaryRequestCount++; + if (responseDelayMillis > 0) { + try { + Thread.sleep(responseDelayMillis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + RegionalAccessBoundary rab = regionalAccessBoundary; + if (rab == null) { + return new MockLowLevelHttpResponse().setStatusCode(404); + } + GenericJson responseJson = new GenericJson(); + responseJson.setFactory(JSON_FACTORY); + responseJson.put("encodedLocations", rab.getEncodedLocations()); + responseJson.put("locations", rab.getLocations()); + String content = responseJson.toPrettyString(); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(content); + } + }; + return request; + } + if (!responseSequence.isEmpty()) { request = new MockLowLevelHttpRequest(url) { diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java index 094b21f9dbb2..adc945dd72ea 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java @@ -36,6 +36,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.fail; import com.google.api.client.http.HttpTransport; import com.google.api.client.json.GenericJson; @@ -56,6 +57,12 @@ /** Tests for {@link PluggableAuthCredentials}. */ class PluggableAuthCredentialsTest extends BaseSerializationTest { + + @org.junit.jupiter.api.AfterEach + void tearDown() { + RegionalAccessBoundary.setEnvironmentProviderForTest(null); + } + // The default timeout for waiting for the executable to finish (30 seconds). private static final int DEFAULT_EXECUTABLE_TIMEOUT_MS = 30 * 1000; // The minimum timeout for waiting for the executable to finish (5 seconds). @@ -601,6 +608,52 @@ void serialize() { assertThrows(NotSerializableException.class, () -> serializeAndDeserialize(testCredentials)); } + @Test + public void testRefresh_regionalAccessBoundarySuccess() throws IOException, InterruptedException { + TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + transportFactory.transport.setExpireTime(TestUtils.getDefaultExpireTime()); + + PluggableAuthCredentials credentials = + PluggableAuthCredentials.newBuilder() + .setHttpTransportFactory(transportFactory) + .setAudience( + "//iam.googleapis.com/projects/12345/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl(transportFactory.transport.getStsUrl()) + .setCredentialSource(buildCredentialSource()) + .setExecutableHandler(options -> "pluggableAuthToken") + .build(); + + // First call: initiates async refresh. + Map> headers = credentials.getRequestMetadata(); + assertNull(headers.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY)); + + waitForRegionalAccessBoundary(credentials); + + // Second call: should have header. + headers = credentials.getRequestMetadata(); + assertEquals( + headers.get(RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY), + Arrays.asList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION)); + } + + private void waitForRegionalAccessBoundary(GoogleCredentials credentials) + throws InterruptedException { + long deadline = System.currentTimeMillis() + 5000; + while (credentials.getRegionalAccessBoundary() == null + && System.currentTimeMillis() < deadline) { + Thread.sleep(100); + } + if (credentials.getRegionalAccessBoundary() == null) { + fail("Timed out waiting for regional access boundary refresh"); + } + } + private static PluggableAuthCredentialSource buildCredentialSource() { return buildCredentialSource("command", null, null); } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/RegionalAccessBoundaryTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/RegionalAccessBoundaryTest.java new file mode 100644 index 000000000000..7c7ccd690ce2 --- /dev/null +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/RegionalAccessBoundaryTest.java @@ -0,0 +1,220 @@ +/* + * Copyright 2026, Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.oauth2; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import com.google.api.client.testing.http.MockHttpTransport; +import com.google.api.client.testing.http.MockLowLevelHttpResponse; +import com.google.api.client.util.Clock; +import com.google.auth.http.HttpTransportFactory; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.util.Collections; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class RegionalAccessBoundaryTest { + + private static final long TTL = RegionalAccessBoundary.TTL_MILLIS; + private static final long REFRESH_THRESHOLD = RegionalAccessBoundary.REFRESH_THRESHOLD_MILLIS; + + private TestClock testClock; + + @Before + public void setUp() { + testClock = new TestClock(); + } + + @After + public void tearDown() {} + + @Test + public void testIsExpired() { + long now = testClock.currentTimeMillis(); + RegionalAccessBoundary rab = + new RegionalAccessBoundary("encoded", Collections.singletonList("loc"), now, testClock); + + assertFalse(rab.isExpired()); + + testClock.set(now + TTL - 1); + assertFalse(rab.isExpired()); + + testClock.set(now + TTL + 1); + assertTrue(rab.isExpired()); + } + + @Test + public void testShouldRefresh() { + long now = testClock.currentTimeMillis(); + RegionalAccessBoundary rab = + new RegionalAccessBoundary("encoded", Collections.singletonList("loc"), now, testClock); + + // Initial state: fresh + assertFalse(rab.shouldRefresh()); + + // Just before threshold + testClock.set(now + TTL - REFRESH_THRESHOLD - 1); + assertFalse(rab.shouldRefresh()); + + // At threshold + testClock.set(now + TTL - REFRESH_THRESHOLD + 1); + assertTrue(rab.shouldRefresh()); + + // Still not expired + assertFalse(rab.isExpired()); + } + + @Test + public void testSerialization() throws Exception { + long now = testClock.currentTimeMillis(); + RegionalAccessBoundary rab = + new RegionalAccessBoundary("encoded", Collections.singletonList("loc"), now, testClock); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(baos); + oos.writeObject(rab); + oos.close(); + + ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray()); + ObjectInputStream ois = new ObjectInputStream(bais); + RegionalAccessBoundary deserializedRab = (RegionalAccessBoundary) ois.readObject(); + ois.close(); + + assertEquals("encoded", deserializedRab.getEncodedLocations()); + assertEquals(1, deserializedRab.getLocations().size()); + assertEquals("loc", deserializedRab.getLocations().get(0)); + // The transient clock field should be restored to Clock.SYSTEM upon deserialization, + // thereby avoiding a NullPointerException when checking expiration. + assertFalse(deserializedRab.isExpired()); + } + + @Test + public void testManagerTriggersRefreshInGracePeriod() throws InterruptedException { + final String url = + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/default:allowedLocations"; + final AccessToken token = + new AccessToken( + "token", new java.util.Date(System.currentTimeMillis() + 10 * 3600000L)); // + + // Mock transport to return a new RAB + final String newEncoded = "new-encoded"; + MockHttpTransport transport = + new MockHttpTransport.Builder() + .setLowLevelHttpResponse( + new MockLowLevelHttpResponse() + .setContentType("application/json") + .setContent( + "{\"encodedLocations\": \"" + + newEncoded + + "\", \"locations\": [\"new-loc\"]}")) + .build(); + HttpTransportFactory transportFactory = () -> transport; + RegionalAccessBoundaryProvider provider = () -> url; + + RegionalAccessBoundaryManager manager = new RegionalAccessBoundaryManager(testClock); + + // 1. Let's first get a RAB into the cache + manager.triggerAsyncRefresh(transportFactory, provider, token); + + // Wait for it to be cached + int retries = 0; + while (manager.getCachedRAB() == null && retries < 50) { + Thread.sleep(50); + retries++; + } + assertEquals(newEncoded, manager.getCachedRAB().getEncodedLocations()); + + // 2. Advance clock to grace period + testClock.set(testClock.currentTimeMillis() + TTL - REFRESH_THRESHOLD + 1000); + + assertTrue(manager.getCachedRAB().shouldRefresh()); + assertFalse(manager.getCachedRAB().isExpired()); + + // 3. Prepare mock for SECOND refresh + final String newerEncoded = "newer-encoded"; + MockHttpTransport transport2 = + new MockHttpTransport.Builder() + .setLowLevelHttpResponse( + new MockLowLevelHttpResponse() + .setContentType("application/json") + .setContent( + "{\"encodedLocations\": \"" + + newerEncoded + + "\", \"locations\": [\"newer-loc\"]}")) + .build(); + HttpTransportFactory transportFactory2 = () -> transport2; + + // 4. Trigger refresh - should start because we are in grace period + manager.triggerAsyncRefresh(transportFactory2, provider, token); + + // 5. Wait for background refresh to complete + // We expect the cached RAB to eventually change to newerEncoded + retries = 0; + RegionalAccessBoundary resultRab = null; + while (retries < 100) { + resultRab = manager.getCachedRAB(); + if (resultRab != null && newerEncoded.equals(resultRab.getEncodedLocations())) { + break; + } + Thread.sleep(50); + retries++; + } + + assertTrue( + "Refresh should have completed and updated the cache within 5 seconds", + resultRab != null && newerEncoded.equals(resultRab.getEncodedLocations())); + assertEquals(newerEncoded, resultRab.getEncodedLocations()); + } + + private static class TestClock implements Clock { + private final AtomicLong currentTime = new AtomicLong(System.currentTimeMillis()); + + @Override + public long currentTimeMillis() { + return currentTime.get(); + } + + public void set(long millis) { + currentTime.set(millis); + } + } +} diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ServiceAccountCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ServiceAccountCredentialsTest.java index ed26a0af3c6f..1ac38f957c6e 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ServiceAccountCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ServiceAccountCredentialsTest.java @@ -31,6 +31,7 @@ package com.google.auth.oauth2; +import static com.google.auth.oauth2.RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -155,6 +156,14 @@ static ServiceAccountCredentials.Builder createDefaultBuilder() throws IOExcepti return createDefaultBuilderWithKey(privateKey); } + @org.junit.jupiter.api.BeforeEach + void setUp() {} + + @org.junit.jupiter.api.AfterEach + void tearDown() { + RegionalAccessBoundary.setEnvironmentProviderForTest(null); + } + @Test void setLifetime() throws IOException { ServiceAccountCredentials.Builder builder = createDefaultBuilder(); @@ -1762,7 +1771,101 @@ void createScopes_existingAccessTokenInvalidated() throws IOException { assertNull(newAccessToken); } - private void verifyJwtAccess(Map> metadata, String expectedScopeClaim) + @Test + public void refresh_regionalAccessBoundarySuccess() throws IOException, InterruptedException { + TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + // Mock regional access boundary response + RegionalAccessBoundary regionalAccessBoundary = + new RegionalAccessBoundary( + TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION, + TestUtils.REGIONAL_ACCESS_BOUNDARY_LOCATIONS, + null); + + MockTokenServerTransport transport = new MockTokenServerTransport(); + transport.addServiceAccount(CLIENT_EMAIL, "test-access-token"); + transport.setRegionalAccessBoundary(regionalAccessBoundary); + + ServiceAccountCredentials credentials = + ServiceAccountCredentials.newBuilder() + .setClientEmail(CLIENT_EMAIL) + .setPrivateKey( + OAuth2Utils.privateKeyFromPkcs8(ServiceAccountCredentialsTest.PRIVATE_KEY_PKCS8)) + .setPrivateKeyId("test-key-id") + .setHttpTransportFactory(() -> transport) + .setScopes(SCOPES) + .build(); + + // First call: initiates async refresh. + Map> headers = credentials.getRequestMetadata(); + assertNull(headers.get(X_ALLOWED_LOCATIONS_HEADER_KEY)); + + waitForRegionalAccessBoundary(credentials); + + // Second call: should have header. + headers = credentials.getRequestMetadata(); + assertEquals( + headers.get(X_ALLOWED_LOCATIONS_HEADER_KEY), + Arrays.asList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION)); + } + + @Test + public void refresh_regionalAccessBoundary_selfSignedJWT() + throws IOException, InterruptedException { + TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider(); + RegionalAccessBoundary.setEnvironmentProviderForTest(environmentProvider); + environmentProvider.setEnv(RegionalAccessBoundary.ENABLE_EXPERIMENT_ENV_VAR, "1"); + RegionalAccessBoundary regionalAccessBoundary = + new RegionalAccessBoundary( + TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION, + TestUtils.REGIONAL_ACCESS_BOUNDARY_LOCATIONS, + null); + + MockTokenServerTransport transport = new MockTokenServerTransport(); + transport.setRegionalAccessBoundary(regionalAccessBoundary); + + ServiceAccountCredentials credentials = + ServiceAccountCredentials.newBuilder() + .setClientEmail(CLIENT_EMAIL) + .setPrivateKey( + OAuth2Utils.privateKeyFromPkcs8(ServiceAccountCredentialsTest.PRIVATE_KEY_PKCS8)) + .setPrivateKeyId("test-key-id") + .setHttpTransportFactory(() -> transport) + .setUseJwtAccessWithScope(true) + .setScopes(SCOPES) + .build(); + + // First call: initiates async refresh using the SSJWT as the token. + Map> headers = credentials.getRequestMetadata(); + assertNull(headers.get(X_ALLOWED_LOCATIONS_HEADER_KEY)); + + waitForRegionalAccessBoundary(credentials); + + // Second call: should have header. + headers = credentials.getRequestMetadata(); + assertEquals( + headers.get(X_ALLOWED_LOCATIONS_HEADER_KEY), + Arrays.asList(TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION)); + + assertEquals( + TestUtils.REGIONAL_ACCESS_BOUNDARY_ENCODED_LOCATION, + credentials.getRegionalAccessBoundary().getEncodedLocations()); + } + + private void waitForRegionalAccessBoundary(GoogleCredentials credentials) + throws InterruptedException { + long deadline = System.currentTimeMillis() + 5000; + while (credentials.getRegionalAccessBoundary() == null + && System.currentTimeMillis() < deadline) { + Thread.sleep(100); + } + if (credentials.getRegionalAccessBoundary() == null) { + fail("Timed out waiting for regional access boundary refresh"); + } + } + + void verifyJwtAccess(Map> metadata, String expectedScopeClaim) throws IOException { assertNotNull(metadata); List authorizations = metadata.get(AuthHttpConstants.AUTHORIZATION); diff --git a/google-auth-library-java/samples/snippets/pom.xml b/google-auth-library-java/samples/snippets/pom.xml index 5b721797222a..941191a80ee0 100644 --- a/google-auth-library-java/samples/snippets/pom.xml +++ b/google-auth-library-java/samples/snippets/pom.xml @@ -80,4 +80,3 @@ - diff --git a/java-dataplex/grpc-google-cloud-dataplex-v1/src/main/java/com/google/cloud/dataplex/v1/ContentServiceGrpc.java b/java-dataplex/grpc-google-cloud-dataplex-v1/src/main/java/com/google/cloud/dataplex/v1/ContentServiceGrpc.java index a087ae2f0103..7d657f076cf9 100644 --- a/java-dataplex/grpc-google-cloud-dataplex-v1/src/main/java/com/google/cloud/dataplex/v1/ContentServiceGrpc.java +++ b/java-dataplex/grpc-google-cloud-dataplex-v1/src/main/java/com/google/cloud/dataplex/v1/ContentServiceGrpc.java @@ -15,7 +15,6 @@ */ package com.google.cloud.dataplex.v1; - /** * *