diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsHttpTransportFactory.java b/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsHttpTransportFactory.java index fef033350a7a..d5a992189167 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsHttpTransportFactory.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsHttpTransportFactory.java @@ -31,6 +31,7 @@ package com.google.auth.mtls; +import com.google.api.client.http.HttpTransport; import com.google.api.client.http.javanet.NetHttpTransport; import com.google.api.core.InternalApi; import com.google.auth.http.HttpTransportFactory; @@ -62,7 +63,7 @@ public MtlsHttpTransportFactory(KeyStore mtlsKeyStore) { } @Override - public NetHttpTransport create() { + public HttpTransport create() { try { // Build the mTLS transport using the provided KeyStore. return new NetHttpTransport.Builder().trustCertificates(null, mtlsKeyStore, "").build(); diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsUtils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsUtils.java index 0d34cf271986..c8cb0083a83a 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsUtils.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsUtils.java @@ -31,14 +31,19 @@ package com.google.auth.mtls; import com.google.api.core.InternalApi; +import com.google.auth.http.HttpTransportFactory; import com.google.auth.oauth2.EnvironmentProvider; +import com.google.auth.oauth2.OAuth2Utils; import com.google.auth.oauth2.PropertyProvider; import com.google.common.base.Strings; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; +import java.security.KeyStore; import java.util.Locale; +import java.util.logging.Logger; +import javax.annotation.Nullable; /** * Utility class for mTLS related operations. @@ -47,10 +52,27 @@ */ @InternalApi public class MtlsUtils { + private static final Logger LOGGER = Logger.getLogger(MtlsUtils.class.getName()); + static final String CERTIFICATE_CONFIGURATION_ENV_VARIABLE = "GOOGLE_API_CERTIFICATE_CONFIG"; static final String WELL_KNOWN_CERTIFICATE_CONFIG_FILE = "certificate_config.json"; static final String CLOUDSDK_CONFIG_DIRECTORY = "gcloud"; + /** + * The policy determining when to use mutual TLS (mTLS) endpoints. + * + *

See AIP-4114 for the specification on mTLS + * endpoint usage. + */ + public enum MtlsEndpointUsagePolicy { + /** Always use the mTLS endpoint, and fail if client certificates are not configured. */ + ALWAYS, + /** Never use the mTLS endpoint. */ + NEVER, + /** Use the mTLS endpoint if client certificates are configured (auto-discovery). */ + AUTO + } + private MtlsUtils() { // Prevent instantiation for Utility class } @@ -76,39 +98,36 @@ public static String getCertificatePath( } /** - * Resolves and loads the workload certificate configuration. + * Resolves and parses the workload certificate configuration. * - *

The configuration file is resolved in the following order of precedence: 1. The provided - * certConfigPathOverride (if not null). 2. The path specified by the - * GOOGLE_API_CERTIFICATE_CONFIG environment variable. 3. The well-known certificate configuration - * file in the gcloud config directory. + *

This locates the certificate configuration file via {@link #resolveCertificateConfigFile} + * and parses its contents into a {@link WorkloadCertificateConfiguration}. * * @param envProvider the environment provider to use for resolving environment variables * @param propProvider the property provider to use for resolving system properties * @param certConfigPathOverride optional override path for the configuration file * @return the loaded WorkloadCertificateConfiguration - * @throws IOException if the configuration file cannot be found, read, or parsed + * @throws IOException if the configuration file cannot be resolved, read, or parsed */ static WorkloadCertificateConfiguration getWorkloadCertificateConfiguration( EnvironmentProvider envProvider, PropertyProvider propProvider, String certConfigPathOverride) throws IOException { - File certConfig; - if (certConfigPathOverride != null) { - certConfig = new File(certConfigPathOverride); - } else { - String envCredentialsPath = envProvider.getEnv(CERTIFICATE_CONFIGURATION_ENV_VARIABLE); - if (!Strings.isNullOrEmpty(envCredentialsPath)) { - certConfig = new File(envCredentialsPath); - } else { - certConfig = getWellKnownCertificateConfigFile(envProvider, propProvider); + File certConfig = + resolveCertificateConfigFile(envProvider, propProvider, certConfigPathOverride); + if (certConfig == null) { + try { + File wellKnownConfig = getWellKnownCertificateConfigFile(envProvider, propProvider); + throw new CertificateSourceUnavailableException( + "Certificate configuration file does not exist or is not a file: " + + wellKnownConfig.getAbsolutePath()); + } catch (IOException e) { + if (e instanceof CertificateSourceUnavailableException) { + throw (CertificateSourceUnavailableException) e; + } + throw new CertificateSourceUnavailableException( + "Failed to get well-known certificate config file path", e); } } - - if (!certConfig.isFile()) { - throw new CertificateSourceUnavailableException( - "Certificate configuration file does not exist or is not a file: " - + certConfig.getAbsolutePath()); - } try (InputStream certConfigStream = new FileInputStream(certConfig)) { return WorkloadCertificateConfiguration.fromCertificateConfigurationStream(certConfigStream); } @@ -137,4 +156,195 @@ private static File getWellKnownCertificateConfigFile( } return new File(cloudConfigPath, WELL_KNOWN_CERTIFICATE_CONFIG_FILE); } + + /** + * Centralized helper method to determine if mutual TLS (mTLS) can be enabled. + * + * @param envProvider the environment provider to use for resolving environment variables + * @param propProvider the property provider to use for resolving system properties + * @param certConfigPathOverride optional override path for the configuration file + * @return true if mTLS should be enabled, false otherwise + * @throws IOException if the configuration file is present but contains missing or malformed + * files + */ + public static boolean canBeEnabled( + EnvironmentProvider envProvider, PropertyProvider propProvider, String certConfigPathOverride) + throws IOException { + + // Check if client certificate usage is allowed + String useClientCertificate = envProvider.getEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE"); + if ("false".equalsIgnoreCase(useClientCertificate)) { + return false; + } + + MtlsEndpointUsagePolicy policy = getMtlsEndpointUsagePolicy(envProvider); + if (policy == MtlsEndpointUsagePolicy.NEVER) { + return false; + } + + File certConfigFile = + resolveCertificateConfigFile(envProvider, propProvider, certConfigPathOverride); + return certConfigFile != null; + } + + /** + * Returns whether the mutual TLS (mTLS) endpoint should be used. + * + * @param envProvider the environment provider to use for resolving environment variables + * @param propProvider the property provider to use for resolving system properties + * @param certConfigPathOverride optional override path for the configuration file + * @return true if the mTLS endpoint should be used, false otherwise + */ + public static boolean shouldMtlsEndpointBeUsed( + EnvironmentProvider envProvider, + PropertyProvider propProvider, + String certConfigPathOverride) { + MtlsEndpointUsagePolicy policy = getMtlsEndpointUsagePolicy(envProvider); + if (policy == MtlsEndpointUsagePolicy.ALWAYS) { + return true; + } + if (policy == MtlsEndpointUsagePolicy.NEVER) { + return false; + } + // policy is AUTO: use mTLS endpoint if client certificate can be enabled + try { + return canBeEnabled(envProvider, propProvider, certConfigPathOverride); + } catch (IOException e) { + return false; + } + } + + /** + * Resolves the mutual TLS (mTLS) certificate configuration file. + * + *

The configuration file is resolved in the following order of precedence: + * + *

    + *
  1. The developer-provided {@code certConfigPathOverride} (if not null). + *
  2. The path specified by the {@code GOOGLE_API_CERTIFICATE_CONFIG} environment variable. + *
  3. The well-known automatic gcloud workload identity provisioning location (via {@link + * #getWellKnownCertificateConfigFile}). + *
+ * + *

If an explicit configuration file is specified (via override or environment variable) and it + * is missing or invalid, an exception is thrown. If no explicit file is specified and the default + * well-known file is missing, {@code null} is returned. + * + * @param envProvider the environment provider to use for resolving environment variables + * @param propProvider the property provider to use for resolving system properties + * @param certConfigPathOverride optional override path for the configuration file + * @return the resolved File object, or null if no configuration was found + * @throws IOException if an explicit configuration file is missing or malformed + */ + @Nullable + static File resolveCertificateConfigFile( + EnvironmentProvider envProvider, PropertyProvider propProvider, String certConfigPathOverride) + throws IOException { + // 1. Check explicit developer override + if (certConfigPathOverride != null) { + File certConfigFile = new File(certConfigPathOverride); + if (!certConfigFile.isFile()) { + throw new CertificateSourceUnavailableException( + "Certificate configuration file does not exist or is not a file: " + + certConfigFile.getAbsolutePath()); + } + return certConfigFile; + } + + // 2. Check explicit environment variable + String envPath = envProvider.getEnv(CERTIFICATE_CONFIGURATION_ENV_VARIABLE); + if (!Strings.isNullOrEmpty(envPath)) { + File certConfigFile = new File(envPath); + if (!certConfigFile.isFile()) { + throw new CertificateSourceUnavailableException( + "Certificate configuration file does not exist or is not a file: " + + certConfigFile.getAbsolutePath()); + } + return certConfigFile; + } + + // 3. Check optional well-known automatic provisioning location + try { + File wellKnownConfig = getWellKnownCertificateConfigFile(envProvider, propProvider); + if (wellKnownConfig.isFile()) { + return wellKnownConfig; + } + } catch (IOException e) { + LOGGER.info( + "Could not get the mutual TLS (mTLS) client certificate configuration. The library will fall back to making standard non-mTLS requests."); + } + + return null; + } + + /** + * Returns the current mutual TLS endpoint usage policy. + * + * @param envProvider the environment provider to use for resolving environment variables + * @return the MtlsEndpointUsagePolicy enum value + */ + public static MtlsEndpointUsagePolicy getMtlsEndpointUsagePolicy( + EnvironmentProvider envProvider) { + String mtlsEndpointUsagePolicy = envProvider.getEnv("GOOGLE_API_USE_MTLS_ENDPOINT"); + if ("never".equalsIgnoreCase(mtlsEndpointUsagePolicy)) { + return MtlsEndpointUsagePolicy.NEVER; + } else if ("always".equalsIgnoreCase(mtlsEndpointUsagePolicy)) { + return MtlsEndpointUsagePolicy.ALWAYS; + } + return MtlsEndpointUsagePolicy.AUTO; + } + + /** + * Prepares and upgrades the HTTP transport factory for mutual TLS (mTLS) if applicable. + * + * @param baseTransportFactory the base HTTP transport factory to upgrade + * @param envProvider the environment provider to use for resolving environment variables + * @param propProvider the property provider to use for resolving system properties + * @param certConfigPathOverride optional override path for the configuration file + * @return the mTLS-configured HTTP transport factory, or the base factory if mTLS is not enabled + * @throws IOException if mTLS is required/enabled but certificate initialization fails or an + * incompatible transport factory was provided + */ + public static HttpTransportFactory prepareTransportFactoryIfMtlsEnabled( + HttpTransportFactory baseTransportFactory, + EnvironmentProvider envProvider, + PropertyProvider propProvider, + String certConfigPathOverride) + throws IOException { + + if (baseTransportFactory == null) { + return null; + } + + if (baseTransportFactory instanceof MtlsHttpTransportFactory) { + // A custom MtlsHttpTransportFactory was already pre-configured by the user. + // Keep using it as-is without re-initializing. + return baseTransportFactory; + } + + if (!canBeEnabled(envProvider, propProvider, certConfigPathOverride)) { + return baseTransportFactory; + } + + if (baseTransportFactory != OAuth2Utils.HTTP_TRANSPORT_FACTORY) { + // A user configured HttpTransportFactory was explicitly injected. + // Trust the developer's custom factory and return it as-is. + return baseTransportFactory; + } + + try { + // This is the default HttpTransportFactory assigned by credentials. + // Automatically discover and load client certificates to construct an mTLS factory. + X509Provider x509Provider = + new X509Provider(envProvider, propProvider, certConfigPathOverride); + KeyStore mtlsKeyStore = x509Provider.getKeyStore(); + return new MtlsHttpTransportFactory(mtlsKeyStore); + } catch (Exception e) { + LOGGER.warning( + "mTLS transport factory initialization failed, falling back to non-mTLS transport: " + + e.getMessage()); + // Graceful fallback to standard transport if mTLS initialization fails + return baseTransportFactory; + } + } } diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/X509Provider.java b/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/X509Provider.java index c87398940538..8e4d6969a1e1 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/X509Provider.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/X509Provider.java @@ -100,7 +100,7 @@ public X509Provider() { * *

* @@ -111,33 +111,27 @@ public X509Provider() { */ @Override public KeyStore getKeyStore() throws CertificateSourceUnavailableException, IOException { - WorkloadCertificateConfiguration workloadCertConfig = - MtlsUtils.getWorkloadCertificateConfiguration( - envProvider, propProvider, certConfigPathOverride); - - // Read the certificate and private key file paths into streams. - try (InputStream certStream = new FileInputStream(new File(workloadCertConfig.getCertPath())); - InputStream privateKeyStream = - new FileInputStream(new File(workloadCertConfig.getPrivateKeyPath())); - SequenceInputStream certAndPrivateKeyStream = - new SequenceInputStream(certStream, privateKeyStream)) { + try { + // Attempt to load from resolved Config File + WorkloadCertificateConfiguration workloadCertConfig = + MtlsUtils.getWorkloadCertificateConfiguration( + envProvider, propProvider, certConfigPathOverride); - // Build a key store using the combined stream. - return SecurityUtils.createMtlsKeyStore(certAndPrivateKeyStream); + try (InputStream certStream = + new FileInputStream(new File(workloadCertConfig.getCertPath())); + InputStream privateKeyStream = + new FileInputStream(new File(workloadCertConfig.getPrivateKeyPath())); + SequenceInputStream certAndPrivateKeyStream = + new SequenceInputStream(certStream, privateKeyStream)) { + return SecurityUtils.createMtlsKeyStore(certAndPrivateKeyStream); + } } catch (CertificateSourceUnavailableException e) { - // Throw the CertificateSourceUnavailableException without wrapping. throw e; } catch (Exception e) { - // Wrap all other exception types to an IOException. - throw new IOException("X509Provider: Unexpected IOException:", e); + throw new IOException("X509Provider: Unexpected error loading from config file:", e); } } - /** - * Returns true if the X509 mTLS provider is available. - * - * @throws IOException if a general I/O error occurs while determining availability. - */ @Override public boolean isAvailable() throws IOException { try { 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 e564c32c97c6..f69762d066a6 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 @@ -56,8 +56,10 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.concurrent.Executor; import javax.annotation.Nullable; /** Base type for credentials for authorizing calls to Google APIs using OAuth2. */ @@ -379,7 +381,7 @@ void refreshRegionalAccessBoundaryIfExpired(@Nullable URI uri, @Nullable AccessT // Skip refresh for regional endpoints. if (uri != null && uri.getHost() != null) { - String host = uri.getHost().toLowerCase(java.util.Locale.US); + String host = uri.getHost().toLowerCase(Locale.US); if (host.endsWith(".rep.googleapis.com") || host.endsWith(".rep.sandbox.googleapis.com")) { return; } @@ -388,7 +390,7 @@ void refreshRegionalAccessBoundaryIfExpired(@Nullable URI uri, @Nullable AccessT // We need a valid access token for the refresh. if (token == null || (token.getExpirationTimeMillis() != null - && token.getExpirationTimeMillis() < clock.currentTimeMillis())) { + && token.getExpirationTimeMillis() <= clock.currentTimeMillis() + 180_000L)) { return; } @@ -398,7 +400,11 @@ void refreshRegionalAccessBoundaryIfExpired(@Nullable URI uri, @Nullable AccessT } regionalAccessBoundaryManager.triggerAsyncRefresh( - transportFactory, (RegionalAccessBoundaryProvider) this, token); + transportFactory, + (RegionalAccessBoundaryProvider) this, + token, + getEnvironmentProvider(), + getPropertyProvider()); } /** @@ -462,9 +468,7 @@ public Map> getRequestMetadata(URI uri) throws IOException */ @Override public void getRequestMetadata( - final URI uri, - final java.util.concurrent.Executor executor, - final RequestMetadataCallback callback) { + final URI uri, final Executor executor, final RequestMetadataCallback callback) { super.getRequestMetadata( uri, executor, @@ -552,7 +556,7 @@ Map> addRegionalAccessBoundaryToRequestMetadata( Preconditions.checkNotNull(requestMetadata); if (uri != null && uri.getHost() != null) { - String host = uri.getHost().toLowerCase(java.util.Locale.US); + String host = uri.getHost().toLowerCase(Locale.US); if (host.endsWith(".rep.googleapis.com") || host.endsWith(".rep.sandbox.googleapis.com")) { return requestMetadata; } @@ -843,6 +847,14 @@ HttpTransportFactory getTransportFactory() { return null; } + EnvironmentProvider getEnvironmentProvider() { + return SystemEnvironmentProvider.getInstance(); + } + + PropertyProvider getPropertyProvider() { + return SystemPropertyProvider.getInstance(); + } + 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/RegionalAccessBoundary.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundary.java index 444b35ef0328..678183192047 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundary.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundary.java @@ -185,8 +185,8 @@ static RegionalAccessBoundary refresh( 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."); + && accessToken.getExpirationTimeMillis() <= clock.currentTimeMillis() + 180_000L) { + throw new IOException("The provided access token is expired or expiring within skew buffer."); } HttpRequestFactory requestFactory = transportFactory.create().createRequestFactory(); diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundaryManager.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundaryManager.java index ee5d9511d755..db64deaf8d56 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundaryManager.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundaryManager.java @@ -36,7 +36,9 @@ import com.google.api.client.util.Clock; import com.google.api.core.InternalApi; import com.google.auth.http.HttpTransportFactory; +import com.google.auth.mtls.MtlsUtils; import com.google.common.annotations.VisibleForTesting; +import java.io.IOException; import java.util.concurrent.ExecutorService; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; @@ -68,6 +70,9 @@ final class RegionalAccessBoundaryManager { */ static final int DEFAULT_MAX_RETRY_ELAPSED_TIME_MILLIS = 60000; + static final String IAM_ENDPOINT = "iamcredentials.googleapis.com"; + static final String MTLS_IAM_ENDPOINT = "iamcredentials.mtls.googleapis.com"; + /** * cachedRAB uses AtomicReference to provide thread-safe, lock-free access to the cached data for * high-concurrency request threads. @@ -177,7 +182,9 @@ void setCachedRAB(RegionalAccessBoundary rab) { void triggerAsyncRefresh( final HttpTransportFactory transportFactory, final RegionalAccessBoundaryProvider provider, - final AccessToken accessToken) { + final AccessToken accessToken, + final EnvironmentProvider envProvider, + final PropertyProvider propProvider) { if (skipRAB.get() || isCooldownActive()) { return; } @@ -191,6 +198,11 @@ void triggerAsyncRefresh( // this thread "won the race" and is responsible for starting the background task. // All other concurrent threads will return false and exit immediately. if (isRefreshing.compareAndSet(false, true)) { + currentRab = cachedRAB.get(); + if (currentRab != null && !currentRab.shouldRefresh()) { + isRefreshing.set(false); + return; + } Runnable refreshTask = () -> { try { @@ -199,9 +211,15 @@ void triggerAsyncRefresh( skipRAB.set(true); return; } + HttpTransportFactory upgradedTransportFactory = + MtlsUtils.prepareTransportFactoryIfMtlsEnabled( + transportFactory, envProvider, propProvider, null); + if (MtlsUtils.shouldMtlsEndpointBeUsed(envProvider, propProvider, null)) { + url = url.replace(IAM_ENDPOINT, MTLS_IAM_ENDPOINT); + } RegionalAccessBoundary newRAB = RegionalAccessBoundary.refresh( - transportFactory, url, accessToken, clock, maxRetryElapsedTimeMillis); + upgradedTransportFactory, url, accessToken, clock, maxRetryElapsedTimeMillis); cachedRAB.set(newRAB); resetCooldown(); } catch (Throwable e) { @@ -217,6 +235,8 @@ void triggerAsyncRefresh( } 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 IOException("Failed to submit background refresh task: " + e.getMessage(), e)); log( LOGGER_PROVIDER, Level.FINE, diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsUtilsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsUtilsTest.java index f3fdf05a4c32..34e6891a26df 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsUtilsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsUtilsTest.java @@ -33,7 +33,9 @@ import static org.junit.jupiter.api.Assertions.*; +import com.google.auth.http.HttpTransportFactory; import com.google.auth.oauth2.EnvironmentProvider; +import com.google.auth.oauth2.OAuth2Utils; import com.google.auth.oauth2.PropertyProvider; import java.io.File; import java.io.IOException; @@ -243,4 +245,353 @@ public String getProperty(String name, String def) { assertEquals("APPDATA environment variable is not set on Windows.", exception.getMessage()); } + + // If client certificate usage is explicitly disabled, canBeEnabled should return false. + @Test + void canBeEnabled_allowanceExplicitFalse_returnsFalse() throws IOException { + EnvironmentProvider envProvider = + new EnvironmentProvider() { + @Override + public String getEnv(String name) { + if ("GOOGLE_API_USE_CLIENT_CERTIFICATE".equals(name)) { + return "false"; + } + return null; + } + }; + PropertyProvider propProvider = (name, def) -> def; + + assertFalse(MtlsUtils.canBeEnabled(envProvider, propProvider, null)); + } + + // If client certificate usage is explicitly enabled and a valid configuration is present, + // canBeEnabled should return true. + @Test + void canBeEnabled_allowanceExplicitTrue_withConfig_returnsTrue() throws IOException { + EnvironmentProvider envProvider = + new EnvironmentProvider() { + @Override + public String getEnv(String name) { + if ("GOOGLE_API_USE_CLIENT_CERTIFICATE".equals(name)) { + return "true"; + } + if ("GOOGLE_API_CERTIFICATE_CONFIG".equals(name)) { + return "testresources/mtls/certificate_config.json"; + } + return null; + } + }; + PropertyProvider propProvider = (name, def) -> def; + + assertTrue(MtlsUtils.canBeEnabled(envProvider, propProvider, null)); + } + + // If client certificate usage is unset but a valid configuration is present, mTLS should be + // enabled by default (returns true). + @Test + void canBeEnabled_allowanceUnset_withConfig_returnsTrue() throws IOException { + EnvironmentProvider envProvider = + new EnvironmentProvider() { + @Override + public String getEnv(String name) { + if ("GOOGLE_API_CERTIFICATE_CONFIG".equals(name)) { + return "testresources/mtls/certificate_config.json"; + } + return null; + } + }; + PropertyProvider propProvider = (name, def) -> def; + + assertTrue(MtlsUtils.canBeEnabled(envProvider, propProvider, null)); + } + + // If the GOOGLE_API_CERTIFICATE_CONFIG environment variable points to a non-existent file, + // canBeEnabled should throw an IOException. + @Test + void canBeEnabled_envVarConfigMissingFile_throwsIOException() throws IOException { + Path nonExistentConfig = tempDir.resolve("non_existent.json"); + EnvironmentProvider envProvider = + new EnvironmentProvider() { + @Override + public String getEnv(String name) { + if ("GOOGLE_API_CERTIFICATE_CONFIG".equals(name)) { + return nonExistentConfig.toString(); + } + return null; + } + }; + PropertyProvider propProvider = (name, def) -> def; + + assertThrows(IOException.class, () -> MtlsUtils.canBeEnabled(envProvider, propProvider, null)); + } + + // If the well-known gcloud certificate configuration file exists, canBeEnabled should return + // true. + @Test + void canBeEnabled_wellKnownConfigExists_returnsTrue() throws IOException { + Path gcloudDir = tempDir.resolve(".config/gcloud"); + Files.createDirectories(gcloudDir); + Path configFile = gcloudDir.resolve("certificate_config.json"); + Path certFile = tempDir.resolve("cert.pem"); + Path keyFile = tempDir.resolve("key.pem"); + Files.createFile(certFile); + Files.createFile(keyFile); + Files.write(configFile, createJsonConfigString(certFile, keyFile).getBytes()); + + EnvironmentProvider envProvider = name -> null; + PropertyProvider propProvider = + new PropertyProvider() { + @Override + public String getProperty(String name, String def) { + if ("os.name".equals(name)) return "Linux"; + if ("user.home".equals(name)) return tempDir.toString(); + return def; + } + }; + + assertTrue(MtlsUtils.canBeEnabled(envProvider, propProvider, null)); + } + + @Test + void canBeEnabled_alwaysPolicy_clientCertDisabled_returnsFalse() throws IOException { + EnvironmentProvider envProvider = + name -> { + if ("GOOGLE_API_USE_CLIENT_CERTIFICATE".equals(name)) { + return "false"; + } + if ("GOOGLE_API_USE_MTLS_ENDPOINT".equals(name)) { + return "always"; + } + return null; + }; + PropertyProvider propProvider = (name, def) -> def; + + assertFalse(MtlsUtils.canBeEnabled(envProvider, propProvider, null)); + assertTrue(MtlsUtils.shouldMtlsEndpointBeUsed(envProvider, propProvider, null)); + } + + @Test + void getMtlsEndpointUsagePolicy_never() { + EnvironmentProvider envProvider = + name -> "GOOGLE_API_USE_MTLS_ENDPOINT".equals(name) ? "never" : null; + assertEquals( + MtlsUtils.MtlsEndpointUsagePolicy.NEVER, MtlsUtils.getMtlsEndpointUsagePolicy(envProvider)); + } + + @Test + void getMtlsEndpointUsagePolicy_always() { + EnvironmentProvider envProvider = + name -> "GOOGLE_API_USE_MTLS_ENDPOINT".equals(name) ? "always" : null; + assertEquals( + MtlsUtils.MtlsEndpointUsagePolicy.ALWAYS, + MtlsUtils.getMtlsEndpointUsagePolicy(envProvider)); + } + + @Test + void getMtlsEndpointUsagePolicy_auto() { + EnvironmentProvider envProvider = name -> null; + assertEquals( + MtlsUtils.MtlsEndpointUsagePolicy.AUTO, MtlsUtils.getMtlsEndpointUsagePolicy(envProvider)); + } + + @Test + void canBeEnabled_policyNever_returnsFalse() throws IOException { + EnvironmentProvider envProvider = + new EnvironmentProvider() { + @Override + public String getEnv(String name) { + if ("GOOGLE_API_CERTIFICATE_CONFIG".equals(name)) { + return "testresources/mtls/certificate_config.json"; + } + if ("GOOGLE_API_USE_MTLS_ENDPOINT".equals(name)) { + return "never"; + } + return null; + } + }; + PropertyProvider propProvider = (name, def) -> def; + + assertFalse(MtlsUtils.canBeEnabled(envProvider, propProvider, null)); + } + + @Test + void canBeEnabled_autoPolicy_noConfig_returnsFalse() throws IOException { + EnvironmentProvider envProvider = name -> null; + PropertyProvider propProvider = + new PropertyProvider() { + @Override + public String getProperty(String name, String def) { + if ("user.home".equals(name)) return tempDir.toString(); + return def; + } + }; + + assertFalse(MtlsUtils.canBeEnabled(envProvider, propProvider, null)); + } + + @Test + void canBeEnabled_alwaysPolicy_returnsFalse() throws IOException { + EnvironmentProvider envProvider = + name -> "GOOGLE_API_USE_MTLS_ENDPOINT".equals(name) ? "always" : null; + PropertyProvider propProvider = (name, def) -> def; + + assertFalse(MtlsUtils.canBeEnabled(envProvider, propProvider, null)); + assertTrue(MtlsUtils.shouldMtlsEndpointBeUsed(envProvider, propProvider, null)); + } + + @Test + void getWorkloadCertificateConfiguration_malformedJson_throwsException() throws IOException { + Path configFile = tempDir.resolve("malformed.json"); + Files.write(configFile, "{invalid-json}".getBytes()); + + EnvironmentProvider envProvider = name -> null; + PropertyProvider propProvider = (name, def) -> def; + + assertThrows( + Exception.class, + () -> + MtlsUtils.getWorkloadCertificateConfiguration( + envProvider, propProvider, configFile.toString())); + } + + @Test + void prepareTransportFactoryIfMtlsEnabled_nullInput_returnsNull() throws IOException { + assertNull( + MtlsUtils.prepareTransportFactoryIfMtlsEnabled( + null, name -> null, (name, def) -> def, null)); + } + + @Test + void prepareTransportFactoryIfMtlsEnabled_mtlsFactory_returnsAsIs() + throws java.security.GeneralSecurityException, IOException { + java.security.KeyStore dummyKeyStore = + java.security.KeyStore.getInstance(java.security.KeyStore.getDefaultType()); + dummyKeyStore.load(null, null); + MtlsHttpTransportFactory mtlsFactory = new MtlsHttpTransportFactory(dummyKeyStore); + + HttpTransportFactory result = + MtlsUtils.prepareTransportFactoryIfMtlsEnabled( + mtlsFactory, name -> null, (name, def) -> def, null); + + assertSame(mtlsFactory, result); + } + + @Test + void prepareTransportFactoryIfMtlsEnabled_customFactory_mtlsAlways_returnsAsIs() + throws IOException { + HttpTransportFactory customFactory = () -> null; + EnvironmentProvider envProvider = + name -> "GOOGLE_API_USE_MTLS_ENDPOINT".equals(name) ? "always" : null; + PropertyProvider propProvider = (name, def) -> def; + + HttpTransportFactory result = + MtlsUtils.prepareTransportFactoryIfMtlsEnabled( + customFactory, envProvider, propProvider, null); + + assertSame(customFactory, result); + } + + @Test + void prepareTransportFactoryIfMtlsEnabled_customFactory_mtlsAuto_withConfig_returnsAsIs() + throws IOException { + HttpTransportFactory customFactory = () -> null; + EnvironmentProvider envProvider = + name -> + "GOOGLE_API_CERTIFICATE_CONFIG".equals(name) + ? "testresources/mtls/certificate_config.json" + : null; + PropertyProvider propProvider = (name, def) -> def; + + HttpTransportFactory result = + MtlsUtils.prepareTransportFactoryIfMtlsEnabled( + customFactory, envProvider, propProvider, null); + + assertSame(customFactory, result); + } + + @Test + void prepareTransportFactoryIfMtlsEnabled_defaultFactory_mtlsAlways_upgradesToMtlsFactory() + throws IOException { + EnvironmentProvider envProvider = + new EnvironmentProvider() { + @Override + public String getEnv(String name) { + if ("GOOGLE_API_USE_CLIENT_CERTIFICATE".equals(name)) { + return "true"; + } + if ("GOOGLE_API_USE_MTLS_ENDPOINT".equals(name)) { + return "always"; + } + if ("GOOGLE_API_CERTIFICATE_CONFIG".equals(name)) { + return "testresources/mtls/certificate_config.json"; + } + return null; + } + }; + PropertyProvider propProvider = (name, def) -> def; + + HttpTransportFactory result = + MtlsUtils.prepareTransportFactoryIfMtlsEnabled( + OAuth2Utils.HTTP_TRANSPORT_FACTORY, envProvider, propProvider, null); + + assertTrue(result instanceof MtlsHttpTransportFactory); + } + + @Test + void + prepareTransportFactoryIfMtlsEnabled_defaultFactory_mtlsAlways_clientCertDisabled_returnsAsIs() + throws IOException { + EnvironmentProvider envProvider = + name -> { + if ("GOOGLE_API_USE_CLIENT_CERTIFICATE".equals(name)) { + return "false"; + } + if ("GOOGLE_API_USE_MTLS_ENDPOINT".equals(name)) { + return "always"; + } + return null; + }; + PropertyProvider propProvider = (name, def) -> def; + + HttpTransportFactory result = + MtlsUtils.prepareTransportFactoryIfMtlsEnabled( + OAuth2Utils.HTTP_TRANSPORT_FACTORY, envProvider, propProvider, null); + + assertSame(OAuth2Utils.HTTP_TRANSPORT_FACTORY, result); + } + + @Test + void prepareTransportFactoryIfMtlsEnabled_defaultFactory_mtlsAlways_missingConfig_returnsAsIs() + throws IOException { + EnvironmentProvider envProvider = + name -> "GOOGLE_API_USE_MTLS_ENDPOINT".equals(name) ? "always" : null; + PropertyProvider propProvider = (name, def) -> def; + + HttpTransportFactory result = + MtlsUtils.prepareTransportFactoryIfMtlsEnabled( + OAuth2Utils.HTTP_TRANSPORT_FACTORY, envProvider, propProvider, null); + + assertSame(OAuth2Utils.HTTP_TRANSPORT_FACTORY, result); + } + + @Test + void prepareTransportFactoryIfMtlsEnabled_defaultFactory_mtlsAuto_noConfig_returnsAsIs() + throws IOException { + EnvironmentProvider envProvider = name -> null; + PropertyProvider propProvider = (name, def) -> def; + + HttpTransportFactory result = + MtlsUtils.prepareTransportFactoryIfMtlsEnabled( + OAuth2Utils.HTTP_TRANSPORT_FACTORY, envProvider, propProvider, null); + + assertSame(OAuth2Utils.HTTP_TRANSPORT_FACTORY, result); + } + + private String createJsonConfigString(Path certPath, Path keyPath) { + return "{\"cert_configs\":{\"workload\":{\"cert_path\":\"" + + certPath.toString().replace("\\", "\\\\") + + "\",\"key_path\":\"" + + keyPath.toString().replace("\\", "\\\\") + + "\"}}}"; + } } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/X509ProviderTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/X509ProviderTest.java index 5ddd1a169d29..6a2693a62273 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/X509ProviderTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/X509ProviderTest.java @@ -32,6 +32,7 @@ package com.google.auth.mtls; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -50,9 +51,12 @@ import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; class X509ProviderTest { + @TempDir Path tempDir; + private static final String TEST_CERT_PATH = "testresources/mtls/test_cert.pem"; private static final String TEST_CONFIG_PATH = "testresources/mtls/certificate_config.json"; @@ -70,15 +74,16 @@ void x509Provider_fileDoesntExist_throws() { @Test void x509Provider_emptyFile_throws() throws IOException { - Path emptyConfig = Files.createTempFile("emptyConfig", ".txt"); - emptyConfig.toFile().deleteOnExit(); + Path emptyConfig = tempDir.resolve("emptyConfig.txt"); + Files.createFile(emptyConfig); X509Provider testProvider = new X509Provider(emptyConfig.toString()); String expectedErrorMessage = "no JSON input found"; - IllegalArgumentException exception = - assertThrows(IllegalArgumentException.class, testProvider::getKeyStore); - assertTrue(exception.getMessage().contains(expectedErrorMessage)); + IOException exception = assertThrows(IOException.class, testProvider::getKeyStore); + assertNotNull(exception.getCause()); + assertTrue(exception.getCause() instanceof IllegalArgumentException); + assertTrue(exception.getCause().getMessage().contains(expectedErrorMessage)); } @Test @@ -142,8 +147,8 @@ void x509Provider_succeeds_withWellKnownPath() @Test void x509Provider_succeeds_withWindowsPath() throws IOException, KeyStoreException, CertificateException { - Path windowsTempDir = Files.createTempDirectory("windowsTempDir"); - windowsTempDir.toFile().deleteOnExit(); + Path windowsTempDir = tempDir.resolve("windowsTempDir"); + Files.createDirectory(windowsTempDir); Path gcloudDir = windowsTempDir.resolve("gcloud"); Files.createDirectory(gcloudDir); Path configPath = gcloudDir.resolve("certificate_config.json"); @@ -172,9 +177,8 @@ void x509Provider_succeeds_withWindowsPath() @Test void x509Provider_certFileDoesntExist_throws() throws IOException { - Path tempConfig = Files.createTempFile("config", ".json"); - tempConfig.toFile().deleteOnExit(); - Path nonExistentCert = tempConfig.getParent().resolve("non_existent_cert.pem"); + Path tempConfig = tempDir.resolve("config_no_cert.json"); + Path nonExistentCert = tempDir.resolve("non_existent_cert.pem"); Files.write( tempConfig, @@ -190,10 +194,8 @@ void x509Provider_certFileDoesntExist_throws() throws IOException { @Test void x509Provider_malformedCert_throws() throws IOException { - Path tempConfig = Files.createTempFile("config", ".json"); - tempConfig.toFile().deleteOnExit(); - Path malformedCert = Files.createTempFile("badcert", ".pem"); - malformedCert.toFile().deleteOnExit(); + Path tempConfig = tempDir.resolve("config_malformed_cert.json"); + Path malformedCert = tempDir.resolve("badcert.pem"); Files.write(malformedCert, "This is not a valid certificate".getBytes()); @@ -208,4 +210,75 @@ void x509Provider_malformedCert_throws() throws IOException { assertThrows(Exception.class, testProvider::getKeyStore); } + + @Test + void x509Provider_missingCertConfigs_throws() throws IOException { + Path tempConfig = tempDir.resolve("config_missing_configs.json"); + + Files.write(tempConfig, "{}".getBytes()); + + X509Provider testProvider = new X509Provider(tempConfig.toString()); + + IOException exception = assertThrows(IOException.class, testProvider::getKeyStore); + assertNotNull(exception.getCause()); + assertTrue(exception.getCause() instanceof IllegalArgumentException); + assertTrue( + exception.getCause().getMessage().contains("The cert_configs object must be provided")); + } + + @Test + void x509Provider_missingCertPath_throws() throws IOException { + Path tempConfig = tempDir.resolve("config_missing_path.json"); + + Files.write( + tempConfig, "{\"cert_configs\":{\"workload\":{\"key_path\":\"key.pem\"}}}".getBytes()); + + X509Provider testProvider = new X509Provider(tempConfig.toString()); + + IOException exception = assertThrows(IOException.class, testProvider::getKeyStore); + assertNotNull(exception.getCause()); + assertTrue(exception.getCause() instanceof IllegalArgumentException); + assertTrue(exception.getCause().getMessage().contains("The cert_path field must be provided")); + } + + // Failure Path: mTLS disabled (allowance = false) throws CertificateSourceUnavailableException + @Test + void x509Provider_allowanceDisabled_throws() throws Exception { + X509Provider provider = + new X509Provider( + name -> "GOOGLE_API_USE_CLIENT_CERTIFICATE".equals(name) ? "false" : null, + (name, def) -> def, + null); + assertThrows(CertificateSourceUnavailableException.class, provider::getKeyStore); + } + + @Test + void x509Provider_isAvailable_succeeds() throws IOException { + X509Provider testProvider = new X509Provider(TEST_CONFIG_PATH); + assertTrue(testProvider.isAvailable()); + } + + @Test + void x509Provider_isAvailable_missingConfig_returnsFalse() throws IOException { + X509Provider testProvider = new X509Provider("badfile.json"); + assertFalse(testProvider.isAvailable()); + } + + @Test + void x509Provider_isAvailable_unaffectedByMtlsFlags() throws IOException { + X509Provider provider = + new X509Provider( + name -> { + if ("GOOGLE_API_USE_CLIENT_CERTIFICATE".equals(name)) { + return "false"; + } + if ("GOOGLE_API_USE_MTLS_ENDPOINT".equals(name)) { + return "never"; + } + return null; + }, + (name, def) -> def, + TEST_CONFIG_PATH); + assertTrue(provider.isAvailable()); + } } 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 26fe9151955b..9274d4d8d6ac 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 @@ -33,6 +33,7 @@ import static com.google.auth.Credentials.GOOGLE_DEFAULT_UNIVERSE; import static com.google.auth.oauth2.TestUtils.createDummyRab; +import static com.google.auth.oauth2.TestUtils.waitForRegionalAccessBoundary; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -57,7 +58,6 @@ 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}. */ @@ -1435,16 +1435,4 @@ public void testRefresh_regionalAccessBoundarySuccess() throws IOException, Inte 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 b6de475ae510..c591d64d9e73 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 @@ -35,6 +35,7 @@ import static com.google.auth.oauth2.ImpersonatedCredentialsTest.SA_CLIENT_EMAIL; import static com.google.auth.oauth2.RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY; import static com.google.auth.oauth2.TestUtils.createDummyRab; +import static com.google.auth.oauth2.TestUtils.waitForRegionalAccessBoundary; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -45,7 +46,6 @@ 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; @@ -1312,18 +1312,6 @@ void getRegionalAccessBoundaryUrl_invalidEmail_returnsNull() throws IOException assertNull(credentials.getRegionalAccessBoundaryUrl()); } - 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/ExternalAccountCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java index db3d1601ed94..36325cb4377b 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 @@ -36,6 +36,7 @@ import static com.google.auth.oauth2.OAuth2Utils.IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_WORKFORCE_POOL; import static com.google.auth.oauth2.OAuth2Utils.IAM_CREDENTIALS_ALLOWED_LOCATIONS_URL_FORMAT_WORKLOAD_POOL; import static com.google.auth.oauth2.TestUtils.createDummyRab; +import static com.google.auth.oauth2.TestUtils.waitForRegionalAccessBoundary; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; @@ -58,7 +59,6 @@ import java.math.BigDecimal; import java.net.URI; import java.util.*; -import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -1516,18 +1516,6 @@ public void refresh_impersonated_workforce_regionalAccessBoundarySuccess() 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 67d0e990ddda..4cb19c837d52 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 @@ -32,6 +32,7 @@ package com.google.auth.oauth2; import static com.google.auth.oauth2.RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY; +import static com.google.auth.oauth2.TestUtils.waitForRegionalAccessBoundary; import static org.junit.Assert.fail; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -1358,18 +1359,6 @@ private GoogleCredentials createTestCredentials(MockTokenServerTransport transpo .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() @@ -1381,6 +1370,65 @@ private void waitForCooldownActive(GoogleCredentials credentials) throws Interru } } + @Test + public void regionalAccessBoundary_refreshUsesMtlsEndpointWhenConfigured() + throws IOException, InterruptedException { + MockTokenServerTransport transport = new MockTokenServerTransport(); + transport.setRegionalAccessBoundary( + new RegionalAccessBoundary("valid", Collections.singletonList("us-central1"), null)); + + // Configure the environment provider to enable mTLS with ALWAYS policy + EnvironmentProvider envProvider = + new EnvironmentProvider() { + @Override + public String getEnv(String name) { + if ("GOOGLE_API_USE_CLIENT_CERTIFICATE".equals(name)) { + return "true"; + } + if ("GOOGLE_API_USE_MTLS_ENDPOINT".equals(name)) { + return "always"; + } + return null; + } + }; + + AccessToken token = + new AccessToken("test-token", new java.util.Date(System.currentTimeMillis() + 3600000L)); + + com.google.auth.mtls.MtlsHttpTransportFactory mockMtlsFactory; + try { + java.security.KeyStore dummyKeyStore = + java.security.KeyStore.getInstance(java.security.KeyStore.getDefaultType()); + dummyKeyStore.load(null, null); + mockMtlsFactory = + new com.google.auth.mtls.MtlsHttpTransportFactory(dummyKeyStore) { + @Override + public com.google.api.client.http.HttpTransport create() { + return transport; + } + }; + } catch (java.security.GeneralSecurityException e) { + throw new RuntimeException(e); + } + + TestRegionalCredentials credentials = + new TestRegionalCredentials(token, envProvider, mockMtlsFactory); + + credentials.getRequestMetadata(URI.create("https://foo.com")); + + // Wait for the async refresh to complete + waitForRegionalAccessBoundary(credentials); + + // Verify a request was made + assertEquals(1, transport.getRegionalAccessBoundaryRequestCount()); + // Verify the request URL used the mTLS subdomain: "iamcredentials.mtls.googleapis.com" + String requestedUrl = transport.getRequest().getUrl(); + assertNotNull(requestedUrl); + assertTrue( + requestedUrl.contains("iamcredentials.mtls.googleapis.com"), + "Expected mTLS URL, but got: " + requestedUrl); + } + private static class TestClock implements Clock { private final AtomicLong currentTime = new AtomicLong(System.currentTimeMillis()); @@ -1393,4 +1441,36 @@ public void advanceTime(long millis) { currentTime.addAndGet(millis); } } + + private static class TestRegionalCredentials extends GoogleCredentials + implements RegionalAccessBoundaryProvider { + private final EnvironmentProvider envProvider; + private final HttpTransportFactory transportFactory; + + TestRegionalCredentials(AccessToken token, EnvironmentProvider envProvider) { + this(token, envProvider, OAuth2Utils.HTTP_TRANSPORT_FACTORY); + } + + TestRegionalCredentials( + AccessToken token, EnvironmentProvider envProvider, HttpTransportFactory transportFactory) { + super(GoogleCredentials.newBuilder().setAccessToken(token)); + this.envProvider = envProvider; + this.transportFactory = transportFactory; + } + + @Override + EnvironmentProvider getEnvironmentProvider() { + return envProvider; + } + + @Override + HttpTransportFactory getTransportFactory() { + return transportFactory; + } + + @Override + public String getRegionalAccessBoundaryUrl() { + return "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/foo/allowedLocations"; + } + } } 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 3a5dcd8720e7..90b288ffe793 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 @@ -35,6 +35,7 @@ import static com.google.auth.oauth2.MockExternalAccountCredentialsTransport.SERVICE_ACCOUNT_IMPERSONATION_URL; import static com.google.auth.oauth2.OAuth2Utils.JSON_FACTORY; import static com.google.auth.oauth2.TestUtils.createDummyRab; +import static com.google.auth.oauth2.TestUtils.waitForRegionalAccessBoundary; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -42,7 +43,6 @@ 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; @@ -1377,16 +1377,4 @@ public void testRefresh_regionalAccessBoundarySuccess() throws IOException, Inte 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 3664fb22c2ff..8d3a2e3c6ef9 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 @@ -33,6 +33,7 @@ import static com.google.auth.oauth2.RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY; import static com.google.auth.oauth2.TestUtils.createDummyRab; +import static com.google.auth.oauth2.TestUtils.waitForRegionalAccessBoundary; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -42,7 +43,6 @@ 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; @@ -1312,18 +1312,6 @@ void refresh_regionalAccessBoundarySuccess() throws IOException, InterruptedExce 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/MockExternalAccountCredentialsTransport.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java index c53eda5b2bd5..fa1e8884a401 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 @@ -84,7 +84,8 @@ public class MockExternalAccountCredentialsTransport extends MockHttpTransport { static final String SERVICE_ACCOUNT_IMPERSONATION_URL = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/testn@test.iam.gserviceaccount.com:generateAccessToken"; - static final String IAM_ENDPOINT = "https://iamcredentials.googleapis.com"; + static final String IAM_ENDPOINT = "iamcredentials.googleapis.com"; + static final String MTLS_IAM_ENDPOINT = "iamcredentials.mtls.googleapis.com"; private final Queue responseSequence = new ArrayDeque<>(); private final Queue responseErrorSequence = new ArrayDeque<>(); @@ -202,10 +203,11 @@ public LowLevelHttpResponse execute() throws IOException { .setContent(response.toPrettyString()); } - if (url.contains(IAM_ENDPOINT)) { + if (url.contains(IAM_ENDPOINT) || url.contains(MTLS_IAM_ENDPOINT)) { if (url.endsWith(REGIONAL_ACCESS_BOUNDARY_URL_END)) { - RegionalAccessBoundary rab = regionalAccessBoundaries.get(url); + String normalizedUrl = url.replace(MTLS_IAM_ENDPOINT, IAM_ENDPOINT); + RegionalAccessBoundary rab = regionalAccessBoundaries.get(normalizedUrl); if (rab == null) { rab = new RegionalAccessBoundary( 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 8576ffe38e3a..1a3d64fc0171 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 @@ -33,10 +33,10 @@ import static com.google.auth.Credentials.GOOGLE_DEFAULT_UNIVERSE; import static com.google.auth.oauth2.MockExternalAccountCredentialsTransport.SERVICE_ACCOUNT_IMPERSONATION_URL; +import static com.google.auth.oauth2.TestUtils.waitForRegionalAccessBoundary; 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; @@ -637,18 +637,6 @@ public void testRefresh_regionalAccessBoundarySuccess() throws IOException, Inte 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 index 0bc7fbe467c9..a43da03c41e6 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/RegionalAccessBoundaryTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/RegionalAccessBoundaryTest.java @@ -31,25 +31,35 @@ package com.google.auth.oauth2; +import static com.google.auth.oauth2.TestUtils.waitForRegionalAccessBoundary; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.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.TestUtils; import com.google.auth.http.HttpTransportFactory; +import com.google.auth.mtls.MtlsHttpTransportFactory; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.io.File; import java.io.IOException; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; +import java.util.Arrays; import java.util.Collections; +import java.util.List; +import java.util.Map; import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicLong; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.Mockito; public class RegionalAccessBoundaryTest { @@ -147,6 +157,25 @@ public void testRefreshClosesResponse() throws Exception { assertTrue(mockResponse.isDisconnected(), "Response should have been disconnected"); } + @Test + public void testRefreshThrowsIOExceptionOnExpiringToken() { + final String url = "https://example.com/rab"; + final AccessToken token = + new AccessToken( + "token", + new java.util.Date( + testClock.currentTimeMillis() + 120_000L)); // 2 minutes, within 3 min skew + + MockHttpTransport transport = new MockHttpTransport.Builder().build(); + HttpTransportFactory transportFactory = () -> transport; + + assertThrows( + IOException.class, + () -> { + RegionalAccessBoundary.refresh(transportFactory, url, token, testClock, 1000); + }); + } + @Test public void testManagerTriggersRefreshInGracePeriod() throws InterruptedException { final String url = @@ -173,7 +202,12 @@ public void testManagerTriggersRefreshInGracePeriod() throws InterruptedExceptio RegionalAccessBoundaryManager manager = new RegionalAccessBoundaryManager(testClock); // 1. Let's first get a RAB into the cache - manager.triggerAsyncRefresh(transportFactory, provider, token); + manager.triggerAsyncRefresh( + transportFactory, + provider, + token, + SystemEnvironmentProvider.getInstance(), + SystemPropertyProvider.getInstance()); // Wait for it to be cached int retries = 0; @@ -204,7 +238,12 @@ public void testManagerTriggersRefreshInGracePeriod() throws InterruptedExceptio HttpTransportFactory transportFactory2 = () -> transport2; // 4. Trigger refresh - should start because we are in grace period - manager.triggerAsyncRefresh(transportFactory2, provider, token); + manager.triggerAsyncRefresh( + transportFactory2, + provider, + token, + SystemEnvironmentProvider.getInstance(), + SystemPropertyProvider.getInstance()); // 5. Wait for background refresh to complete // We expect the cached RAB to eventually change to newerEncoded @@ -288,7 +327,12 @@ public int read() throws java.io.IOException { testClock, RegionalAccessBoundaryManager.DEFAULT_MAX_RETRY_ELAPSED_TIME_MILLIS, testExecutor); - managers[i].triggerAsyncRefresh(transportFactory, provider, token); + managers[i].triggerAsyncRefresh( + transportFactory, + provider, + token, + SystemEnvironmentProvider.getInstance(), + SystemPropertyProvider.getInstance()); } RegionalAccessBoundaryManager extraManager = @@ -298,7 +342,12 @@ public int read() throws java.io.IOException { testExecutor); assertFalse(extraManager.isCooldownActive()); - extraManager.triggerAsyncRefresh(transportFactory, provider, token); + extraManager.triggerAsyncRefresh( + transportFactory, + provider, + token, + SystemEnvironmentProvider.getInstance(), + SystemPropertyProvider.getInstance()); assertTrue( extraManager.isCooldownActive(), @@ -334,4 +383,51 @@ public boolean isDisconnected() { return disconnected; } } + + @Test + public void + regionalAccessBoundary_withMtlsEnabled_shouldCallAllowedLocationsUsingMtlsTransportFactory() + throws IOException, InterruptedException { + + MockExternalAccountCredentialsTransport transport = + new MockExternalAccountCredentialsTransport(); + + // Configure the environment provider to enable mTLS. + // X509Provider will use certificate_config.json to load keys. + TestEnvironmentProvider testEnvProvider = new TestEnvironmentProvider(); + testEnvProvider.setEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "true"); + testEnvProvider.setEnv( + "GOOGLE_API_CERTIFICATE_CONFIG", + new File("testresources/mtls/certificate_config.json").getAbsolutePath()); + + // Mock MtlsHttpTransportFactory to return our mock transport + MtlsHttpTransportFactory mockMtlsFactory = Mockito.mock(MtlsHttpTransportFactory.class); + Mockito.doReturn(transport).when(mockMtlsFactory).create(); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(mockMtlsFactory) + .setEnvironmentProvider(testEnvProvider) + .setAudience( + "//iam.googleapis.com/projects/12345/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("subjectTokenType") + .setSubjectTokenSupplier(context -> "testSubjectToken") + .setTokenUrl("https://sts.googleapis.com/v1/token") + .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)); + + // Verify that MtlsHttpTransportFactory.create() was called to retrieve the mTLS transport + Mockito.verify(mockMtlsFactory, Mockito.times(2)).create(); + } } 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 4bbcaa96c606..db3b4f3b8160 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 @@ -33,6 +33,7 @@ import static com.google.auth.oauth2.RegionalAccessBoundary.X_ALLOWED_LOCATIONS_HEADER_KEY; import static com.google.auth.oauth2.TestUtils.createDummyRab; +import static com.google.auth.oauth2.TestUtils.waitForRegionalAccessBoundary; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/TestUtils.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/TestUtils.java index 52652a71c458..a0434b514a05 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/TestUtils.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/TestUtils.java @@ -74,4 +74,17 @@ static RegionalAccessBoundary createDummyRab(com.google.api.client.util.Clock cl return new RegionalAccessBoundary( "dummy-locations", java.util.Arrays.asList("dummy-loc"), clock); } + + static 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) { + org.junit.jupiter.api.Assertions.fail( + "Timed out waiting for regional access boundary refresh"); + } + } }