diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/DefaultMtlsProviderFactory.java b/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/DefaultMtlsProviderFactory.java
index 7b9b824d5e87..8538f10c0d48 100644
--- a/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/DefaultMtlsProviderFactory.java
+++ b/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/DefaultMtlsProviderFactory.java
@@ -40,10 +40,9 @@ public class DefaultMtlsProviderFactory {
/**
* Creates an instance of {@link MtlsProvider}. It first attempts to create an {@link
- * com.google.auth.mtls.X509Provider}, which also falls back to the GKE credential bundle when no
- * certificate configuration is present. If the certificate source is unavailable, it falls back
- * to creating a {@link SecureConnectProvider}. If the secure connect provider also fails, it
- * throws a {@link com.google.auth.mtls.CertificateSourceUnavailableException}.
+ * com.google.auth.mtls.X509Provider}. If the certificate source is unavailable, it falls back to
+ * creating a {@link SecureConnectProvider}. If the secure connect provider also fails, it throws
+ * a {@link com.google.auth.mtls.CertificateSourceUnavailableException}.
*
* @return an instance of {@link MtlsProvider}.
* @throws com.google.auth.mtls.CertificateSourceUnavailableException if neither provider can be
@@ -54,7 +53,7 @@ public static MtlsProvider create() throws IOException {
// Note: The caller should handle CertificateSourceUnavailableException gracefully, since
// it is an expected error case. All other IOExceptions are unexpected and should be surfaced
// up the call stack.
- MtlsProvider mtlsProvider = X509Provider.createWithGkeCredentialBundleFallback();
+ MtlsProvider mtlsProvider = new X509Provider();
if (mtlsProvider.isAvailable()) {
return mtlsProvider;
}
diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsKeyStoreUtils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsKeyStoreUtils.java
deleted file mode 100644
index d8f51c61935f..000000000000
--- a/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsKeyStoreUtils.java
+++ /dev/null
@@ -1,173 +0,0 @@
-/*
- * 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.mtls;
-
-import com.google.api.client.util.PemReader;
-import java.io.ByteArrayInputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import java.nio.charset.StandardCharsets;
-import java.security.GeneralSecurityException;
-import java.security.KeyFactory;
-import java.security.KeyStore;
-import java.security.PrivateKey;
-import java.security.cert.Certificate;
-import java.security.cert.CertificateException;
-import java.security.cert.CertificateFactory;
-import java.security.cert.X509Certificate;
-import java.security.spec.PKCS8EncodedKeySpec;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-import java.util.logging.Level;
-import java.util.logging.Logger;
-import org.jspecify.annotations.NullMarked;
-
-/**
- * Builds the mTLS {@link KeyStore} from PEM-encoded certificates and a private key.
- *
- *
This replaces {@code com.google.api.client.util.SecurityUtils#createMtlsKeyStore}, which keeps
- * only the first certificate. Here the key entry holds every distinct certificate in the input, so
- * the intermediate certificates are presented too. Some peers (for example, another workload that
- * trusts only a root CA) need them to verify the client certificate. Because every certificate in
- * the input is sent in the TLS handshake, the input should hold only the leaf certificate and its
- * intermediates.
- *
- *
Everything else matches the upstream method: key store type (JKS), alias, empty password,
- * accepted key format (PKCS#8 {@code PRIVATE KEY}), which private key is chosen, and error
- * messages. Any input that the upstream method accepted is still accepted, even though more of it
- * is now used: once a certificate and a private key have been found (the point where the upstream
- * method stopped reading), a malformed later PEM section ends the input instead of failing, and an
- * extra certificate that cannot be parsed is left out of the chain. Unlike the upstream method, the
- * input is decoded as UTF-8 rather than the platform default charset, and it is read to the end.
- */
-@NullMarked
-final class MtlsKeyStoreUtils {
-
- private static final Logger LOGGER = Logger.getLogger(MtlsKeyStoreUtils.class.getName());
-
- private MtlsKeyStoreUtils() {}
-
- /**
- * Creates a key store holding one private key entry.
- *
- * @param certAndKey PEM input containing one or more {@code CERTIFICATE} sections and a PKCS#8
- * {@code PRIVATE KEY} section, in any order. The first certificate must be the one that
- * matches the private key (the leaf); the remaining distinct certificates are kept in input
- * order as the rest of the chain. If there are several private keys, the one used is the same
- * as upstream: the last one before the first certificate, or else the first one after it. The
- * stream is read to the end and is not closed.
- * @throws IllegalArgumentException if the input has no certificate or no private key, or if a PEM
- * section before both a certificate and a private key have been read is malformed
- * @throws GeneralSecurityException if the first certificate or the private key cannot be parsed,
- * or the key store cannot be created
- * @throws IOException if the input cannot be read
- */
- static KeyStore createMtlsKeyStore(InputStream certAndKey)
- throws GeneralSecurityException, IOException {
- List certificates = new ArrayList<>();
- byte[] privateKey = null;
- PemReader reader = new PemReader(new InputStreamReader(certAndKey, StandardCharsets.UTF_8));
- while (true) {
- PemReader.Section section;
- try {
- section = reader.readNextSection();
- } catch (IllegalArgumentException e) {
- if (!certificates.isEmpty() && privateKey != null) {
- // Malformed trailing content after a usable certificate and key: stop here, as the
- // upstream method (which stopped reading at this point) would have.
- break;
- }
- throw e;
- }
- if (section == null) {
- break;
- }
- if ("CERTIFICATE".equals(section.getTitle())) {
- byte[] certBytes = section.getBase64DecodedBytes();
- if (!containsCertificate(certificates, certBytes)) {
- certificates.add(certBytes);
- }
- } else if ("PRIVATE KEY".equals(section.getTitle())
- && (privateKey == null || certificates.isEmpty())) {
- // Same choice as upstream: a later key replaces an earlier one until the first certificate
- // has been seen.
- privateKey = section.getBase64DecodedBytes();
- }
- }
- if (certificates.isEmpty()) {
- throw new IllegalArgumentException("certificate is missing from certAndKey string");
- }
- if (privateKey == null) {
- throw new IllegalArgumentException("private key is missing from certAndKey string");
- }
-
- CertificateFactory certFactory = CertificateFactory.getInstance("X.509");
- List chain = new ArrayList<>(certificates.size());
- chain.add(parseCertificate(certFactory, certificates.get(0)));
- for (int i = 1; i < certificates.size(); i++) {
- try {
- chain.add(parseCertificate(certFactory, certificates.get(i)));
- } catch (CertificateException | RuntimeException e) {
- // The upstream method never parsed extra certificates; leave this one out rather than
- // fail.
- LOGGER.log(Level.FINE, "Skipping unparsable extra certificate in mTLS chain", e);
- }
- }
- PrivateKey key =
- KeyFactory.getInstance(chain.get(0).getPublicKey().getAlgorithm())
- .generatePrivate(new PKCS8EncodedKeySpec(privateKey));
-
- KeyStore keyStore = KeyStore.getInstance("JKS");
- keyStore.load(null);
- keyStore.setKeyEntry("alias", key, new char[] {}, chain.toArray(new X509Certificate[0]));
- return keyStore;
- }
-
- private static boolean containsCertificate(List certificates, byte[] candidate) {
- for (byte[] existing : certificates) {
- if (Arrays.equals(existing, candidate)) {
- return true;
- }
- }
- return false;
- }
-
- private static X509Certificate parseCertificate(CertificateFactory certFactory, byte[] der)
- throws CertificateException {
- Certificate cert = certFactory.generateCertificate(new ByteArrayInputStream(der));
- if (cert == null) {
- throw new CertificateException("Empty certificate");
- }
- return (X509Certificate) cert;
- }
-}
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 4f0d4c034b6d..a5c4c0f86e77 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
@@ -33,19 +33,13 @@
import com.google.api.core.InternalApi;
import com.google.auth.oauth2.EnvironmentProvider;
import com.google.auth.oauth2.PropertyProvider;
-import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Strings;
-import com.google.common.io.BaseEncoding;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
-import java.nio.file.Files;
-import java.nio.file.Paths;
-import java.security.MessageDigest;
import java.util.Locale;
import org.jspecify.annotations.NullMarked;
-import org.jspecify.annotations.Nullable;
/**
* Utility class for mTLS related operations.
@@ -59,257 +53,10 @@ public class MtlsUtils {
static final String WELL_KNOWN_CERTIFICATE_CONFIG_FILE = "certificate_config.json";
static final String CLOUDSDK_CONFIG_DIRECTORY = "gcloud";
- /**
- * Well-known path of the combined certificate chain and private key bundle that GKE delivers to
- * pods with Agent Identity (Pod Certificates). Used as a fallback client certificate source when
- * no certificate configuration file is configured or present.
- */
- static final String GKE_CREDENTIAL_BUNDLE_PATH =
- "/var/run/secrets/workload-spiffe-credentials/x509.credential-bundle.private-key.pem";
-
- // Overridable via setGkeCredentialBundlePathForTesting so unit tests can point to a temp file.
- private static volatile String gkeCredentialBundlePath = GKE_CREDENTIAL_BUNDLE_PATH;
-
private MtlsUtils() {
// Prevent instantiation for Utility class
}
- /**
- * Returns {@code true} if a certificate configuration is configured or present: either {@code
- * GOOGLE_API_CERTIFICATE_CONFIG} is set, or the well-known gcloud {@code certificate_config.json}
- * file exists. When this returns {@code true}, the GKE credential bundle fallback is not used, so
- * the certificate used for mTLS and the certificate bound to tokens always come from the same
- * source.
- */
- public static boolean hasCertificateConfiguration(
- EnvironmentProvider envProvider, PropertyProvider propProvider) {
- if (!Strings.isNullOrEmpty(envProvider.getEnv(CERTIFICATE_CONFIGURATION_ENV_VARIABLE))) {
- return true;
- }
- try {
- return getWellKnownCertificateConfigFile(envProvider, propProvider).exists();
- } catch (IOException e) {
- // APPDATA missing on Windows, etc. No well-known configuration file.
- return false;
- }
- }
-
- /**
- * Returns the GKE credential bundle path if no certificate configuration is configured or present
- * (see {@link #hasCertificateConfiguration}) and the bundle exists and is readable; otherwise
- * returns {@code null}.
- */
- static @Nullable String getGkeCredentialBundlePath(
- EnvironmentProvider envProvider, PropertyProvider propProvider) {
- if (hasCertificateConfiguration(envProvider, propProvider)) {
- return null;
- }
- return findGkeCredentialBundle();
- }
-
- /**
- * Returns {@code true} if the GKE credential bundle at {@code path} is a readable, non-empty
- * regular file.
- */
- public static boolean isGkeCredentialBundleReady(String path) {
- File bundle = new File(path);
- return bundle.isFile() && bundle.canRead() && bundle.length() > 0;
- }
-
- /**
- * Returns the GKE credential bundle path if it is a readable, non-empty regular file, otherwise
- * null.
- */
- private static @Nullable String findGkeCredentialBundle() {
- String path = gkeCredentialBundlePath;
- return isGkeCredentialBundleReady(path) ? path : null;
- }
-
- /** Overrides the GKE credential bundle path for testing; {@code null} restores the default. */
- @VisibleForTesting
- static void setGkeCredentialBundlePathForTesting(@Nullable String path) {
- gkeCredentialBundlePath = path != null ? path : GKE_CREDENTIAL_BUNDLE_PATH;
- }
-
- private static boolean isUseClientCertificateExplicitlyDisabled(
- @Nullable String useClientCertificate) {
- return !Strings.isNullOrEmpty(useClientCertificate)
- && !"true".equalsIgnoreCase(useClientCertificate);
- }
-
- /**
- * Returns if mutual TLS client certificate should be used. Returns true if valid workload
- * certificates are configured or if GOOGLE_API_USE_CLIENT_CERTIFICATE is explicitly set to true
- * (e.g. for Enterprise Certificate Proxy or custom MtlsProviders), unless explicitly disabled via
- * GOOGLE_API_USE_CLIENT_CERTIFICATE (set to a non-empty value other than "true").
- */
- public static boolean useMtlsClientCertificate(
- EnvironmentProvider envProvider, PropertyProvider propProvider) {
- String rawVal = envProvider.getEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE");
- String useClientCertificate = rawVal != null ? rawVal.trim() : null;
- if (isUseClientCertificateExplicitlyDisabled(useClientCertificate)) {
- return false;
- }
- if (getWorkloadCertPath(envProvider, propProvider) != null) {
- return true;
- }
- return "true".equalsIgnoreCase(useClientCertificate);
- }
-
- /**
- * Resolves and returns the path to the mutual TLS client certificate, or null if none should be
- * used.
- *
- *
Possible outcomes:
- *
- *
- *
Non-null {@link String} (Valid happy path): A valid workload certificate
- * configuration was found and both the certificate and private key files exist and are
- * readable; or no certificate configuration is set or present and the GKE credential bundle
- * ({@code /var/run/secrets/workload-spiffe-credentials/
- * x509.credential-bundle.private-key.pem}) exists and is readable, in which case the bundle
- * path is returned.
- *
{@link IllegalStateException} (Invalid state - fail closed): An explicit {@code
- * GOOGLE_API_CERTIFICATE_CONFIG} path or an existing default well-known certificate
- * configuration file is missing, unreadable, malformed, or references missing/unreadable
- * certificate or private key files. This is treated as an unrecoverable misconfiguration.
- *
{@code null} (Safe fallback / fail open): Client certificates are explicitly
- * disabled via {@code GOOGLE_API_USE_CLIENT_CERTIFICATE} (any non-empty value other than
- * {@code "true"}), no explicit configuration is set and neither the default well-known
- * configuration file nor a readable GKE credential bundle exists on disk, or the
- * configuration specifies an non-workload source (e.g., ECP/PKCS11 without a {@code
- * workload} section). Callers can proceed without workload certificate file polling.
- *
- */
- public static @Nullable String getWorkloadCertPath(
- EnvironmentProvider envProvider, PropertyProvider propProvider) {
- String rawVal = envProvider.getEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE");
- String useClientCertificate = rawVal != null ? rawVal.trim() : null;
- if (isUseClientCertificateExplicitlyDisabled(useClientCertificate)) {
- return null;
- }
-
- String explicitConfigPath = envProvider.getEnv(CERTIFICATE_CONFIGURATION_ENV_VARIABLE);
-
- // 1. Explicit Configuration Path (Fail Closed)
- if (!Strings.isNullOrEmpty(explicitConfigPath)) {
- File configFile = new File(explicitConfigPath);
- if (!configFile.exists()) {
- throw new IllegalStateException(
- "Certificate configuration file specified via GOOGLE_API_CERTIFICATE_CONFIG at '"
- + explicitConfigPath
- + "' does not exist.");
- }
- if (!configFile.isFile() || !configFile.canRead()) {
- throw new IllegalStateException(
- "Failed to read certificate configuration file specified via"
- + " GOOGLE_API_CERTIFICATE_CONFIG at '"
- + explicitConfigPath
- + "'.");
- }
- WorkloadCertificateConfiguration config;
- try {
- config = getWorkloadCertificateConfiguration(envProvider, propProvider, explicitConfigPath);
- } catch (CertificateSourceUnavailableException e) {
- // ECP / PKCS11 configuration without workload section; safe fallback
- return null;
- } catch (Exception e) {
- throw new IllegalStateException(
- "Certificate configuration file specified via GOOGLE_API_CERTIFICATE_CONFIG at '"
- + explicitConfigPath
- + "' is malformed: "
- + e.getMessage(),
- e);
- }
- checkCertAndKeyFilesReadable(config, explicitConfigPath, false);
- return config.getCertPath();
- }
-
- // 2. Implicit / Default gcloud Configuration Path
- File defaultConfigFile = null;
- try {
- defaultConfigFile = getWellKnownCertificateConfigFile(envProvider, propProvider);
- } catch (IOException e) {
- // APPDATA missing on Windows, etc. Safe fallback.
- }
- if (defaultConfigFile != null && defaultConfigFile.exists()) {
- if (!defaultConfigFile.isFile() || !defaultConfigFile.canRead()) {
- throw new IllegalStateException(
- "Default certificate configuration file at '"
- + defaultConfigFile.getAbsolutePath()
- + "' exists but could not be read.");
- }
- WorkloadCertificateConfiguration config = null;
- try {
- config = getWorkloadCertificateConfiguration(envProvider, propProvider, null);
- } catch (CertificateSourceUnavailableException e) {
- // ECP-only configuration without workload section; safe fallback
- } catch (Exception e) {
- throw new IllegalStateException(
- "Default certificate configuration file at '"
- + defaultConfigFile.getAbsolutePath()
- + "' is malformed: "
- + e.getMessage(),
- e);
- }
- if (config != null) {
- checkCertAndKeyFilesReadable(config, defaultConfigFile.getAbsolutePath(), true);
- return config.getCertPath();
- }
- // A default configuration file exists without a workload section (e.g. ECP); it takes
- // precedence over the GKE credential bundle.
- return null;
- }
-
- // 3. GKE credential bundle (Pod Certificates), used only when no certificate configuration
- // file is configured or present. The combined bundle serves as both certificate and key.
- return findGkeCredentialBundle();
- }
-
- private static void checkCertAndKeyFilesReadable(
- WorkloadCertificateConfiguration config, String configPath, boolean isDefaultConfig) {
- File certFile = new File(config.getCertPath());
- File keyFile = new File(config.getPrivateKeyPath());
- if (!certFile.isFile() || !certFile.canRead() || !keyFile.isFile() || !keyFile.canRead()) {
- String sourcePrefix =
- isDefaultConfig
- ? "referenced by default configuration '"
- : "referenced by configuration '";
- throw new IllegalStateException(
- "Failed to read certificate/key file at '"
- + config.getCertPath()
- + "' or '"
- + config.getPrivateKeyPath()
- + "' "
- + sourcePrefix
- + configPath
- + "'.");
- }
- }
-
- /**
- * Computes the lower-case SHA-256 hex fingerprint of the certificate file at {@code certPath}.
- *
- *
Unlike {@link #getWorkloadCertPath}, which validates configuration at channel initialization
- * and fails closed on errors, this method is called dynamically at runtime during active RPCs to
- * detect certificate rotations on disk. External certificate rotators may temporarily delete,
- * truncate, or rewrite the certificate file mid-RPC. Returning {@code null} on read/digest
- * exceptions (which callers normalize to {@code ""}) allows runtime refresh checks to ignore
- * transient mid-write states and keep the active healthy channel without failing in-flight RPCs.
- */
- public static @Nullable String getCertificateFingerprint(@Nullable String certPath) {
- if (certPath == null) {
- return null;
- }
- try {
- byte[] certBytes = Files.readAllBytes(Paths.get(certPath));
- byte[] digest = MessageDigest.getInstance("SHA-256").digest(certBytes);
- return BaseEncoding.base16().lowerCase().encode(digest);
- } catch (Exception e) {
- return null;
- }
- }
-
/**
* Returns the path to the client certificate file specified by the loaded workload certificate
* configuration.
@@ -318,17 +65,14 @@ private static void checkCertAndKeyFilesReadable(
* @throws IOException if the certificate configuration cannot be found or loaded.
*/
public static String getCertificatePath(
- EnvironmentProvider envProvider,
- PropertyProvider propProvider,
- @Nullable String certConfigPathOverride)
+ EnvironmentProvider envProvider, PropertyProvider propProvider, String certConfigPathOverride)
throws IOException {
String certPath =
getWorkloadCertificateConfiguration(envProvider, propProvider, certConfigPathOverride)
.getCertPath();
if (Strings.isNullOrEmpty(certPath)) {
throw new CertificateSourceUnavailableException(
- "Certificate configuration loaded successfully, but does not contain a"
- + " 'cert_configs.workload.cert_path' path.");
+ "Certificate configuration loaded successfully, but does not contain a 'certificate_file' path.");
}
return certPath;
}
@@ -348,9 +92,7 @@ public static String getCertificatePath(
* @throws IOException if the configuration file cannot be found, read, or parsed
*/
static WorkloadCertificateConfiguration getWorkloadCertificateConfiguration(
- EnvironmentProvider envProvider,
- PropertyProvider propProvider,
- @Nullable String certConfigPathOverride)
+ EnvironmentProvider envProvider, PropertyProvider propProvider, String certConfigPathOverride)
throws IOException {
File certConfig;
if (certConfigPathOverride != null) {
@@ -364,7 +106,7 @@ static WorkloadCertificateConfiguration getWorkloadCertificateConfiguration(
}
}
- if (!certConfig.isFile() || !certConfig.canRead()) {
+ if (!certConfig.isFile()) {
throw new CertificateSourceUnavailableException(
"Certificate configuration file does not exist or is not a file: "
+ certConfig.getAbsolutePath());
diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/SecureConnectProvider.java b/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/SecureConnectProvider.java
index b030a992a9dd..06294c611c7b 100644
--- a/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/SecureConnectProvider.java
+++ b/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/SecureConnectProvider.java
@@ -32,11 +32,10 @@
import com.google.api.client.json.JsonParser;
import com.google.api.client.json.gson.GsonFactory;
+import com.google.api.client.util.SecurityUtils;
import com.google.api.core.InternalApi;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableList;
-import java.io.ByteArrayInputStream;
-import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
@@ -148,25 +147,8 @@ static KeyStore getKeyStore(InputStream metadata, ProcessProvider processProvide
"SecureConnect: Cert provider command failed with exit code: " + exitCode);
}
- // Create mTLS key store with the input certificates from shell command. Read only the bytes
- // already buffered now that the process has exited, so a background child process holding
- // stdout open cannot block waiting for EOF.
- ByteArrayOutputStream stdout = new ByteArrayOutputStream();
- try (InputStream processOut = process.getInputStream()) {
- if (processOut != null) {
- for (int available = processOut.available();
- available > 0;
- available = processOut.available()) {
- byte[] buffer = new byte[available];
- int read = processOut.read(buffer);
- if (read <= 0) {
- break;
- }
- stdout.write(buffer, 0, read);
- }
- }
- }
- return MtlsKeyStoreUtils.createMtlsKeyStore(new ByteArrayInputStream(stdout.toByteArray()));
+ // Create mTLS key store with the input certificates from shell command.
+ return SecurityUtils.createMtlsKeyStore(process.getInputStream());
}
@VisibleForTesting
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 fa7deeda21ca..911c0fdcf922 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
@@ -30,6 +30,7 @@
package com.google.auth.mtls;
+import com.google.api.client.util.SecurityUtils;
import com.google.api.core.InternalApi;
import com.google.auth.oauth2.EnvironmentProvider;
import com.google.auth.oauth2.PropertyProvider;
@@ -37,7 +38,6 @@
import com.google.auth.oauth2.SystemPropertyProvider;
import java.io.File;
import java.io.FileInputStream;
-import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.SequenceInputStream;
@@ -54,8 +54,7 @@
public class X509Provider implements MtlsProvider {
private final EnvironmentProvider envProvider;
private final PropertyProvider propProvider;
- private final @Nullable String certConfigPathOverride;
- private final boolean allowGkeCredentialBundle;
+ private final String certConfigPathOverride;
/**
* Creates an X509 provider with an override path for the certificate configuration, bypassing the
@@ -71,39 +70,9 @@ public X509Provider(
EnvironmentProvider envProvider,
PropertyProvider propProvider,
String certConfigPathOverride) {
- this(envProvider, propProvider, certConfigPathOverride, false);
- }
-
- /**
- * Creates an X509 provider that optionally falls back to the GKE credential bundle when no
- * certificate configuration is present.
- *
- * @param envProvider environment provider used for environment variables
- * @param propProvider property provider used for system properties
- * @param certConfigPathOverride the path to read the certificate configuration from.
- * @param allowGkeCredentialBundle whether to fall back to the GKE credential bundle when no
- * certificate configuration is present and {@code certConfigPathOverride} is null.
- */
- X509Provider(
- EnvironmentProvider envProvider,
- PropertyProvider propProvider,
- @Nullable String certConfigPathOverride,
- boolean allowGkeCredentialBundle) {
this.envProvider = envProvider;
this.propProvider = propProvider;
this.certConfigPathOverride = certConfigPathOverride;
- this.allowGkeCredentialBundle = allowGkeCredentialBundle;
- }
-
- /**
- * Creates an X.509 provider that, in addition to the locations checked by {@link
- * #X509Provider()}, falls back to the GKE credential bundle when no certificate configuration is
- * present, so that the transport uses the same certificate that {@link
- * MtlsUtils#getWorkloadCertPath} reports for certificate-bound tokens.
- */
- static X509Provider createWithGkeCredentialBundleFallback() {
- return new X509Provider(
- SystemEnvironmentProvider.getInstance(), SystemPropertyProvider.getInstance(), null, true);
}
/**
@@ -136,62 +105,18 @@ public X509Provider() {
*
The certificate config override path, if set.
*
The path pointed to by the "GOOGLE_API_CERTIFICATE_CONFIG" environment variable
*
The well known gcloud location for the certificate configuration file.
- *
If none of the above is configured or present, and this provider was created with {@link
- * #createWithGkeCredentialBundleFallback()}, the GKE credential bundle at {@code
- * /var/run/secrets/workload-spiffe-credentials/x509.credential-bundle.private-key.pem},
- * which contains both the certificate chain and the private key.
*
*
- * @return a KeyStore containing the X.509 certificate chain and private key from the certificate
- * source above.
+ * @return a KeyStore containing the X.509 certificate specified by the certificate configuration.
* @throws CertificateSourceUnavailableException if the certificate source is unavailable (ex.
* missing configuration file)
* @throws IOException if a general I/O error occurs while creating the KeyStore
*/
@Override
public KeyStore getKeyStore() throws CertificateSourceUnavailableException, IOException {
- WorkloadCertificateConfiguration workloadCertConfig;
- try {
- workloadCertConfig =
- MtlsUtils.getWorkloadCertificateConfiguration(
- envProvider, propProvider, certConfigPathOverride);
- } catch (CertificateSourceUnavailableException configUnavailable) {
- // Only fall back to the GKE credential bundle when created for the mTLS transport
- // (allowGkeCredentialBundle == true, rather than public X.509 WIF constructors) and when no
- // explicit certificate configuration path override was passed to the constructor.
- if (allowGkeCredentialBundle && certConfigPathOverride == null) {
- // getWorkloadCertificateConfiguration also throws CertificateSourceUnavailableException
- // when GOOGLE_API_CERTIFICATE_CONFIG is explicitly set or when a well-known
- // certificate_config.json exists with only a non-workload (e.g. ECP) configuration;
- // getGkeCredentialBundlePath checks hasCertificateConfiguration so we do not fall back to
- // the GKE bundle in those cases.
- String gkeCredentialBundlePath =
- MtlsUtils.getGkeCredentialBundlePath(envProvider, propProvider);
- if (gkeCredentialBundlePath != null) {
- try (InputStream bundleStream = new FileInputStream(new File(gkeCredentialBundlePath))) {
- return MtlsKeyStoreUtils.createMtlsKeyStore(bundleStream);
- } catch (FileNotFoundException e) {
- // The bundle was removed (e.g. mid-rotation) after the availability check.
- throw new CertificateSourceUnavailableException(
- "GKE credential bundle at "
- + gkeCredentialBundlePath
- + " was removed before it could be read (for example, during certificate"
- + " rotation). Retry the request or verify that the GKE Pod Certificate volume"
- + " is mounted.",
- e);
- } catch (Exception e) {
- throw new IOException(
- "X509Provider: Unable to load GKE credential bundle at "
- + gkeCredentialBundlePath
- + ". Verify that the file contains a valid PEM-encoded X.509 certificate chain"
- + " and PKCS#8 private key, or set GOOGLE_API_USE_CLIENT_CERTIFICATE=false to"
- + " disable mTLS.",
- e);
- }
- }
- }
- throw configUnavailable;
- }
+ 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()));
@@ -201,7 +126,7 @@ public KeyStore getKeyStore() throws CertificateSourceUnavailableException, IOEx
new SequenceInputStream(certStream, privateKeyStream)) {
// Build a key store using the combined stream.
- return MtlsKeyStoreUtils.createMtlsKeyStore(certAndPrivateKeyStream);
+ return SecurityUtils.createMtlsKeyStore(certAndPrivateKeyStream);
} catch (CertificateSourceUnavailableException e) {
// Throw the CertificateSourceUnavailableException without wrapping.
throw e;
diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityCacheUtils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityCacheUtils.java
deleted file mode 100644
index 0dfa9d63c8a4..000000000000
--- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityCacheUtils.java
+++ /dev/null
@@ -1,377 +0,0 @@
-/*
- * Copyright 2026, Google Inc. All rights reserved.
- *
- * 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 Inc. 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.auth.oauth2.AgentIdentityUtils.CertInfo;
-import com.google.common.base.Strings;
-import java.io.IOException;
-import java.nio.file.AccessDeniedException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.nio.file.attribute.BasicFileAttributes;
-import java.nio.file.attribute.FileTime;
-import java.util.Objects;
-import org.jspecify.annotations.NullMarked;
-import org.jspecify.annotations.Nullable;
-
-/**
- * Utility class for in-memory caching and filesystem metadata validation of Agent Identity
- * certificates and configuration files.
- */
-@NullMarked
-final class AgentIdentityCacheUtils {
-
- // In-memory cache of verified Agent Identity info to avoid redundant disk reads, X.509/PKCS#8
- // parsing, and cryptographic signature verification on every token refresh when files are
- // unchanged.
- private static volatile @Nullable CachedAgentIdentityInfo cachedAgentIdentityInfo;
-
- private AgentIdentityCacheUtils() {}
-
- /**
- * Captures filesystem metadata (path, modification time, size, and OS file key/inode) for a
- * certificate, key, or configuration file to detect rotation on disk without re-parsing unchanged
- * files.
- */
- static final class FileMetadata {
- private final String path;
- private final FileTime lastModifiedTime;
- private final long size;
- private final @Nullable Object fileKey;
-
- private FileMetadata(
- final String path,
- final FileTime lastModifiedTime,
- final long size,
- final @Nullable Object fileKey) {
- this.path = path;
- this.lastModifiedTime = lastModifiedTime;
- this.size = size;
- this.fileKey = fileKey;
- }
-
- /** Returns the filesystem path captured by this metadata snapshot. */
- String getPath() {
- return path;
- }
-
- /** Reads the current filesystem attributes for the given file path. */
- static @Nullable FileMetadata of(final @Nullable String pathStr) throws IOException {
- if (Strings.isNullOrEmpty(pathStr)) {
- return null;
- }
- Path path = Paths.get(pathStr);
- BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class);
- return new FileMetadata(pathStr, attrs.lastModifiedTime(), attrs.size(), attrs.fileKey());
- }
-
- /** Returns true if the given metadata matches this file's path, mtime, size, and file key. */
- boolean matches(final @Nullable FileMetadata other) {
- if (other == null) {
- return false;
- }
- return Objects.equals(this.path, other.path)
- && Objects.equals(this.lastModifiedTime, other.lastModifiedTime)
- && this.size == other.size
- && Objects.equals(this.fileKey, other.fileKey);
- }
-
- /**
- * Re-reads the file's current attributes from disk and returns {@code true} if the file still
- * exists on disk with the exact same modification time, size, and OS file key/inode as when
- * this snapshot was captured.
- */
- boolean isUnchangedOnDisk() {
- try {
- return matches(FileMetadata.of(this.path));
- } catch (IOException e) {
- return false;
- }
- }
-
- /**
- * Returns {@code true} if {@code expectedPath} equals this snapshot's path and the file on disk
- * is unchanged since this snapshot was captured.
- */
- boolean isUnchangedAtPath(final @Nullable String expectedPath) {
- return Objects.equals(this.path, expectedPath) && isUnchangedOnDisk();
- }
- }
-
- /**
- * Stores an in-memory snapshot of verified Agent Identity certificate information and its
- * associated filesystem metadata to avoid redundant disk I/O and cryptographic checks on
- * subsequent token refreshes.
- */
- static final class CachedAgentIdentityInfo {
- /**
- * Filesystem metadata of the JSON certificate configuration file ({@code
- * GOOGLE_API_CERTIFICATE_CONFIG}) when it was parsed, or {@code null} if the certificate was
- * discovered from the well-known directory.
- */
- final @Nullable FileMetadata configMetadata;
-
- /**
- * Filesystem metadata of the X.509 certificate or combined credential bundle file when it was
- * read.
- */
- final @Nullable FileMetadata certMetadata;
-
- /**
- * Filesystem metadata of the private key or combined credential bundle file when it was read,
- * or {@code null} when {@link #shouldRequestBoundToken} is {@code false} (for non-agent
- * certificates where the private key is not read).
- */
- final @Nullable FileMetadata keyMetadata;
-
- /**
- * Whether the parsed certificate's Subject Alternative Names matched an allowed Agent Identity
- * SPIFFE trust domain pattern and therefore requires requesting a bound token.
- */
- final boolean shouldRequestBoundToken;
-
- /**
- * The verified {@link AgentIdentityUtils.CertInfo} (parsed {@code X509Certificate} and PEM
- * certificate chain) when {@link #shouldRequestBoundToken} is {@code true}, or {@code null}
- * when {@link #shouldRequestBoundToken} is {@code false}.
- */
- final @Nullable CertInfo certInfo;
-
- /**
- * Whether {@link #certMetadata} points to the well-known GKE credential bundle path ({@link
- * AgentIdentityUtils#isGkeCredentialBundle}).
- */
- final boolean isGkeCredentialBundle;
-
- /**
- * Constructs a cached snapshot of Agent Identity evaluation state and file metadata.
- *
- * @param configMetadata filesystem metadata of the certificate config file, or {@code null}
- * when discovered from the well-known directory
- * @param certMetadata filesystem metadata of the certificate or bundle file
- * @param keyMetadata filesystem metadata of the private key or bundle file, or {@code null}
- * when {@code shouldRequestBoundToken} is {@code false}
- * @param shouldRequestBoundToken {@code true} if the certificate matched an Agent Identity
- * SPIFFE pattern and requires a bound token; {@code false} otherwise
- * @param certInfo the verified {@link AgentIdentityUtils.CertInfo} when {@code
- * shouldRequestBoundToken} is {@code true}, or {@code null} otherwise
- */
- CachedAgentIdentityInfo(
- final @Nullable FileMetadata configMetadata,
- final @Nullable FileMetadata certMetadata,
- final @Nullable FileMetadata keyMetadata,
- final boolean shouldRequestBoundToken,
- final @Nullable CertInfo certInfo) {
- this.configMetadata = configMetadata;
- this.certMetadata = certMetadata;
- this.keyMetadata = keyMetadata;
- this.shouldRequestBoundToken = shouldRequestBoundToken;
- this.certInfo = certInfo;
- this.isGkeCredentialBundle =
- certMetadata != null && AgentIdentityUtils.isGkeCredentialBundle(certMetadata.getPath());
- }
-
- /**
- * Returns {@code true} if this cached entry was discovered from the well-known directory (not a
- * certificate config file) and has both certificate and key metadata available for fallback.
- */
- boolean canUseWellKnownFallback() {
- return configMetadata == null && certMetadata != null && keyMetadata != null;
- }
- }
-
- /** Returns the current in-memory cached Agent Identity info, or {@code null} if none. */
- static @Nullable CachedAgentIdentityInfo getCachedAgentIdentityInfo() {
- return cachedAgentIdentityInfo;
- }
-
- /** Clears the in-memory cached Agent Identity info. */
- static void clearCachedAgentIdentityInfo() {
- cachedAgentIdentityInfo = null;
- }
-
- /**
- * Returns {@code true} if the cached snapshot is non-null and its config, certificate, and key
- * file metadata still match the current files on disk.
- */
- static boolean isCachedInfoValid(
- final @Nullable CachedAgentIdentityInfo cached, final @Nullable String certConfigPath) {
- if (cached == null || cached.certMetadata == null) {
- return false;
- }
- boolean hasConfigEnv = !Strings.isNullOrEmpty(certConfigPath);
- if (hasConfigEnv) {
- // When GOOGLE_API_CERTIFICATE_CONFIG is set, the cache must have been populated from that
- // config file (configMetadata != null) and the config file on disk must be unchanged.
- if (cached.configMetadata == null
- || !cached.configMetadata.isUnchangedAtPath(certConfigPath)) {
- return false;
- }
- } else {
- // When GOOGLE_API_CERTIFICATE_CONFIG is unset, the cache must have been populated from the
- // well-known directory (where configMetadata is null).
- if (cached.configMetadata != null) {
- return false;
- }
- if (cached.certMetadata.path.endsWith("certificates.pem")
- && Files.exists(
- Paths.get(AgentIdentityUtils.getWellKnownDir(), "credentialbundle.pem"))) {
- // credentialbundle.pem takes precedence if added after certificates.pem was cached
- return false;
- }
- }
- if (!cached.certMetadata.isUnchangedOnDisk()) {
- return false;
- }
- if (!cached.shouldRequestBoundToken) {
- return true;
- }
- return cached.keyMetadata != null && cached.keyMetadata.isUnchangedOnDisk();
- }
-
- /**
- * Checks whether the resolved {@code certPath}, {@code keyPath}, and {@code configMetaBefore}
- * match the given cached snapshot.
- *
- *
Even after {@link #isCachedInfoValid} returns {@code false} at the start of {@code
- * AgentIdentityUtils.getAgentIdentityCertInfo()}, this check can still hit in three cases:
- *
- *
- *
The config file was deleted after caching while the cert and key files on disk remain
- * unchanged ({@code getPathsFromConfigWithRetry} fell back to the cached paths and {@code
- * configMetadata}, where {@code tryUpdateCache} will not overwrite the cache while the
- * config file is missing).
- *
Another thread completed rotation and updated {@code cachedAgentIdentityInfo} while the
- * current thread was resolving paths.
- *
Package-private {@code AgentIdentityUtils.loadAndVerifyCredentials(certPath, keyPath)}
- * was invoked directly without going through {@code getAgentIdentityCertInfo()}.
- *
- */
- static boolean isPostResolutionCacheHit(
- final @Nullable CachedAgentIdentityInfo cached,
- final @Nullable String certPath,
- final @Nullable String keyPath,
- final @Nullable FileMetadata configMetaBefore) {
- if (cached == null || cached.certMetadata == null) {
- return false;
- }
- boolean configMatches =
- configMetaBefore == null
- ? cached.configMetadata == null
- : configMetaBefore.matches(cached.configMetadata);
- if (!configMatches || !cached.certMetadata.isUnchangedAtPath(certPath)) {
- return false;
- }
- if (!cached.shouldRequestBoundToken) {
- return true;
- }
- return !Strings.isNullOrEmpty(keyPath)
- && cached.keyMetadata != null
- && cached.keyMetadata.isUnchangedAtPath(keyPath);
- }
-
- /**
- * Returns the latest {@link CachedAgentIdentityInfo} if it contains a verified bound-token {@link
- * AgentIdentityUtils.CertInfo}, or falls back to {@code initialCached}.
- *
- *
{@code initialCached} is captured at the start of {@code getAgentIdentityCertInfo()} so that
- * if the cache is cleared or overwritten with a non-agent certificate during retry backoff, the
- * caller still retains the last-known-good bound certificate snapshot, while preferring a fresher
- * bound certificate if another thread completed rotation in the meantime.
- */
- static @Nullable CachedAgentIdentityInfo getLatestOrInitialCache(
- final @Nullable CachedAgentIdentityInfo initialCached) {
- CachedAgentIdentityInfo latest = cachedAgentIdentityInfo;
- return (latest != null && latest.shouldRequestBoundToken && latest.certInfo != null)
- ? latest
- : initialCached;
- }
-
- /**
- * Safely updates the in-memory cache only if file metadata remained stable across the read/verify
- * operation. Returns {@code true} if the post-read cert/key metadata matched the pre-read
- * metadata, or {@code false} if the cert or key changed or disappeared mid-read. If only the
- * config file changed, returns {@code true} without updating the cache, so the next refresh
- * re-resolves paths from the new config.
- */
- static boolean tryUpdateCache(
- final @Nullable FileMetadata configMetaBefore,
- final @Nullable FileMetadata certMetaBefore,
- final @Nullable FileMetadata keyMetaBefore,
- final boolean shouldRequestBoundToken,
- final @Nullable CertInfo certInfo) {
- if (certMetaBefore == null || !certMetaBefore.isUnchangedOnDisk()) {
- return false;
- }
- boolean configStable = configMetaBefore == null || configMetaBefore.isUnchangedOnDisk();
- if (!shouldRequestBoundToken) {
- if (configStable) {
- cachedAgentIdentityInfo =
- new CachedAgentIdentityInfo(configMetaBefore, certMetaBefore, null, false, null);
- }
- return true;
- }
- if (keyMetaBefore != null && keyMetaBefore.isUnchangedOnDisk()) {
- if (configStable) {
- cachedAgentIdentityInfo =
- new CachedAgentIdentityInfo(
- configMetaBefore, certMetaBefore, keyMetaBefore, true, certInfo);
- }
- return true;
- }
- return false;
- }
-
- /**
- * Checks if a file exists, is a non-empty regular file, and is readable, throwing {@link
- * AccessDeniedException} if permission is denied.
- */
- static boolean checkExistsOrAccessDenied(final Path path) throws AccessDeniedException {
- try {
- BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class);
- if (!attrs.isRegularFile()) {
- return false;
- }
- if (!Files.isReadable(path)) {
- if (!Files.exists(path)) {
- return false; // Deleted mid-rotation; let the caller retry.
- }
- throw new AccessDeniedException(path.toString());
- }
- return attrs.size() > 0;
- } catch (AccessDeniedException e) {
- throw e;
- } catch (IOException e) {
- return false;
- }
- }
-}
diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityCertificateValidationUtils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityCertificateValidationUtils.java
deleted file mode 100644
index e39b9bc36480..000000000000
--- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityCertificateValidationUtils.java
+++ /dev/null
@@ -1,248 +0,0 @@
-/*
- * Copyright 2026, Google Inc. All rights reserved.
- *
- * 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 Inc. 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.common.collect.ImmutableList;
-import java.io.ByteArrayInputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.Paths;
-import java.security.GeneralSecurityException;
-import java.security.KeyFactory;
-import java.security.PrivateKey;
-import java.security.PublicKey;
-import java.security.Signature;
-import java.security.cert.CertificateFactory;
-import java.security.cert.CertificateParsingException;
-import java.security.cert.X509Certificate;
-import java.security.spec.X509EncodedKeySpec;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.List;
-import java.util.Locale;
-import java.util.logging.Level;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-import org.jspecify.annotations.NullMarked;
-import org.jspecify.annotations.Nullable;
-
-/**
- * Utility class for parsing, validating, and verifying X.509 certificates, SPIFFE IDs, and PKCS#8
- * private keys for Agent Identity token binding.
- */
-@NullMarked
-final class AgentIdentityCertificateValidationUtils {
-
- private static final LoggerProvider LOGGER_PROVIDER =
- LoggerProvider.forClazz(AgentIdentityCertificateValidationUtils.class);
-
- // Allowed SPIFFE trust domain patterns for agentic identities.
- private static final List AGENT_IDENTITY_SPIFFE_PATTERNS =
- ImmutableList.of(
- Pattern.compile("^agents\\.global\\.org-\\d+\\.system\\.id\\.goog$"),
- Pattern.compile("^agents\\.global\\.proj-\\d+\\.system\\.id\\.goog$"),
- Pattern.compile("^agents-nonprod\\.global\\.org-\\d+\\.system\\.id\\.goog$"),
- Pattern.compile("^agents-nonprod\\.global\\.proj-\\d+\\.system\\.id\\.goog$"));
-
- // Subject Alternative Name (SAN) type for URI as defined in RFC 5280 Section 4.2.1.6.
- private static final int SAN_URI_TYPE = 6;
-
- private static final String SPIFFE_SCHEME_PREFIX = "spiffe://";
-
- private static final byte[] VERIFICATION_DATA =
- "verification-data".getBytes(StandardCharsets.UTF_8);
-
- private AgentIdentityCertificateValidationUtils() {}
-
- /** Thrown for key algorithms that can never be verified, so callers fail without retrying. */
- static final class UnsupportedKeyAlgorithmException extends IOException {
- private static final long serialVersionUID = 1L;
-
- UnsupportedKeyAlgorithmException(final @Nullable String algorithm) {
- super("Unsupported key algorithm: " + algorithm);
- }
- }
-
- /**
- * Thrown when a certificate PEM block contains malformed X.509 data, so callers fail fast without
- * retrying.
- */
- static final class InvalidCertificateException extends IOException {
- private static final long serialVersionUID = 1L;
-
- InvalidCertificateException(final String message, final Throwable cause) {
- super(message, cause);
- }
- }
-
- /**
- * Reads the full certificate chain from the specified path as a PEM string.
- *
- *
Extracts only the {@code -----BEGIN CERTIFICATE-----} blocks using {@link
- * OAuth2Utils#PEM_CERT_PATTERN}, stripping any private keys or non-certificate data that may be
- * present in a combined bundle file.
- */
- static String readCertificateChain(final String certPath) throws IOException {
- byte[] certData = Files.readAllBytes(Paths.get(certPath));
- String content = new String(certData, StandardCharsets.UTF_8);
- Matcher matcher = OAuth2Utils.PEM_CERT_PATTERN.matcher(content);
- StringBuilder certChain = new StringBuilder();
- while (matcher.find()) {
- certChain.append(matcher.group(0)).append("\n");
- }
- if (certChain.length() == 0) {
- throw new IOException("No PEM certificates found in certificate file: " + certPath);
- }
- return certChain.toString();
- }
-
- /** Parses the X509 certificate from the specified content string. */
- static X509Certificate parseCertificateContent(final String certContent) throws IOException {
- try (InputStream stream =
- new ByteArrayInputStream(certContent.getBytes(StandardCharsets.UTF_8))) {
- CertificateFactory cf = CertificateFactory.getInstance("X.509");
- return (X509Certificate) cf.generateCertificate(stream);
- } catch (GeneralSecurityException e) {
- throw new InvalidCertificateException(
- "Failed to parse Agent Identity certificate for bound token request.", e);
- }
- }
-
- /** Reads the private key from the specified path using PKCS8 format. */
- static PrivateKey readPrivateKey(final String keyPath, final @Nullable String algorithm)
- throws IOException {
- String keyPem = new String(Files.readAllBytes(Paths.get(keyPath)), StandardCharsets.UTF_8);
- OAuth2Utils.Pkcs8Algorithm pkcs8Alg;
- if ("EC".equals(algorithm) || "ECDSA".equals(algorithm)) {
- pkcs8Alg = OAuth2Utils.Pkcs8Algorithm.EC;
- } else if ("RSA".equals(algorithm)) {
- pkcs8Alg = OAuth2Utils.Pkcs8Algorithm.RSA;
- } else {
- throw new UnsupportedKeyAlgorithmException(algorithm);
- }
- return OAuth2Utils.privateKeyFromPkcs8(keyPem, pkcs8Alg);
- }
-
- /**
- * Verifies that the private key corresponds to the public key in the certificate by performing a
- * test signature and verification.
- */
- static boolean verifyKeyPair(final X509Certificate cert, final PrivateKey privateKey)
- throws UnsupportedKeyAlgorithmException {
- PublicKey publicKey = cert.getPublicKey();
- String keyAlgorithm = publicKey.getAlgorithm();
- String sigAlg;
- if ("RSA".equals(keyAlgorithm)) {
- sigAlg = "SHA256withRSA";
- } else if ("EC".equals(keyAlgorithm) || "ECDSA".equals(keyAlgorithm)) {
- sigAlg = "SHA256withECDSA";
- } else {
- throw new UnsupportedKeyAlgorithmException(keyAlgorithm);
- }
-
- try {
- if ("ECDSA".equals(keyAlgorithm) && publicKey.getEncoded() != null) {
- // SunEC rejects keys whose algorithm is "ECDSA" rather than "EC"; normalize the key.
- publicKey =
- KeyFactory.getInstance("EC")
- .generatePublic(new X509EncodedKeySpec(publicKey.getEncoded()));
- }
-
- Signature signer = Signature.getInstance(sigAlg);
- signer.initSign(privateKey);
- signer.update(VERIFICATION_DATA);
- byte[] signature = signer.sign();
-
- Signature verifier = Signature.getInstance(sigAlg);
- verifier.initVerify(publicKey);
- verifier.update(VERIFICATION_DATA);
-
- return verifier.verify(signature);
- } catch (Exception e) {
- LoggingUtils.log(
- LOGGER_PROVIDER,
- Level.FINE,
- Collections.emptyMap(),
- "Key pair verification failed: " + e.getMessage());
- return false;
- }
- }
-
- /**
- * Determines if a bound token should be requested by checking if any of the certificate's Subject
- * Alternative Names (SANs) match allowed SPIFFE patterns.
- */
- static boolean shouldRequestBoundToken(final X509Certificate cert) {
- Collection> sans;
- try {
- sans = cert.getSubjectAlternativeNames();
- } catch (CertificateParsingException e) {
- LoggingUtils.log(
- LOGGER_PROVIDER,
- Level.FINE,
- Collections.emptyMap(),
- "Failed to parse Subject Alternative Names from certificate: " + e.getMessage());
- return false;
- }
- if (sans == null) {
- return false;
- }
- // Iterate through all Subject Alternative Names
- for (List> san : sans) {
- // Check if the SAN entry is a URI (type 6) with a String value
- if (san.size() < 2
- || !(san.get(0) instanceof Integer)
- || (Integer) san.get(0) != SAN_URI_TYPE
- || !(san.get(1) instanceof String)) {
- continue;
- }
- String uri = ((String) san.get(1)).toLowerCase(Locale.US);
- // Check if the URI starts with "spiffe://"
- if (!uri.startsWith(SPIFFE_SCHEME_PREFIX)) {
- continue;
- }
- String withoutScheme = uri.substring(SPIFFE_SCHEME_PREFIX.length());
- int slashIndex = withoutScheme.indexOf('/');
- // Extract the trust domain (part before the first slash)
- String trustDomain =
- (slashIndex == -1) ? withoutScheme : withoutScheme.substring(0, slashIndex);
- // Match the trust domain against allowed agent patterns
- for (Pattern pattern : AGENT_IDENTITY_SPIFFE_PATTERNS) {
- if (pattern.matcher(trustDomain).matches()) {
- return true;
- }
- }
- }
- return false;
- }
-}
diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java
deleted file mode 100644
index 2bbe599df301..000000000000
--- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java
+++ /dev/null
@@ -1,964 +0,0 @@
-/*
- * Copyright 2026, Google Inc. All rights reserved.
- *
- * 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 Inc. 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.json.GenericJson;
-import com.google.api.client.json.JsonObjectParser;
-import com.google.auth.mtls.MtlsUtils;
-import com.google.auth.oauth2.AgentIdentityCacheUtils.CachedAgentIdentityInfo;
-import com.google.auth.oauth2.AgentIdentityCacheUtils.FileMetadata;
-import com.google.auth.oauth2.AgentIdentityCertificateValidationUtils.InvalidCertificateException;
-import com.google.auth.oauth2.AgentIdentityCertificateValidationUtils.UnsupportedKeyAlgorithmException;
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Strings;
-import java.io.IOException;
-import java.io.InputStream;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.AccessDeniedException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.security.PrivateKey;
-import java.security.cert.X509Certificate;
-import java.util.Collections;
-import java.util.Map;
-import java.util.logging.Level;
-import org.jspecify.annotations.NullMarked;
-import org.jspecify.annotations.Nullable;
-
-/** Utility class for Agent Identity runtime certificate discovery and token binding. */
-@NullMarked
-final class AgentIdentityUtils {
-
- private static final LoggerProvider LOGGER_PROVIDER =
- LoggerProvider.forClazz(AgentIdentityUtils.class);
-
- /**
- * Environment variable pointing to the client certificate configuration file.
- *
- *
If set, certificate and key paths are resolved from the configuration file specified by this
- * variable.
- */
- static final String GOOGLE_API_CERTIFICATE_CONFIG = "GOOGLE_API_CERTIFICATE_CONFIG";
-
- /**
- * Environment variable to explicitly enable or disable runtime token binding. Defaults to true if
- * unset.
- */
- static final String GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN =
- "GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN";
-
- /**
- * Legacy Cloud Run environment variable to prevent agent token sharing for GCP services. Used as
- * a fallback if {@link #GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN} is unset.
- */
- static final String GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES =
- "GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES";
-
- /**
- * Environment variable to explicitly enable or disable client certificate authentication (mTLS).
- *
- *
When set to {@code "true"}, mTLS is enforced. When set to any other non-empty value (such as
- * {@code "false"}), mTLS and token binding are disabled.
- */
- static final String GOOGLE_API_USE_CLIENT_CERTIFICATE = "GOOGLE_API_USE_CLIENT_CERTIFICATE";
-
- private static volatile String wellKnownDir = "/var/run/secrets/workload-spiffe-credentials/";
-
- /**
- * File name of the combined certificate chain and private key bundle that GKE delivers to the
- * well-known directory for pods with Agent Identity (Pod Certificates).
- *
- *
When {@link #GOOGLE_API_CERTIFICATE_CONFIG} is unset and no well-known gcloud certificate
- * configuration file exists, the presence of this bundle auto-enables mTLS on its own (no {@link
- * #GOOGLE_API_USE_CLIENT_CERTIFICATE} required). It is never polled for, because GKE delivers it
- * before the container starts and polling on its absence would delay every workload without Agent
- * Identity. It must be a readable, non-empty file. When present and allowed, it takes precedence
- * over the other well-known files, matching the certificate the transport presents.
- */
- static final String GKE_CREDENTIAL_BUNDLE_FILE = "x509.credential-bundle.private-key.pem";
-
- private static volatile String gkeCredentialBundlePath =
- Paths.get(wellKnownDir, GKE_CREDENTIAL_BUNDLE_FILE).toString();
-
- // Retries for verifying certificate and private key matching during atomic key rotation.
- private static final int CERT_KEY_MATCH_RETRIES = 3;
-
- private static final long CERT_KEY_MATCH_RETRY_INTERVAL_MS = 100;
-
- // Polling configuration for initial container startup credential file readiness.
- // Matches Python google-auth implementation (_agent_identity_utils.py) for initial
- // asynchronous credential delivery (50 * 100ms + 50 * 500ms = 30 seconds total).
- private static final int FAST_POLL_CYCLES = 50;
-
- private static final long FAST_POLL_INTERVAL_MS = 100; // 0.1 seconds
-
- private static final long SLOW_POLL_INTERVAL_MS = 500; // 0.5 seconds
-
- private static final long TOTAL_TIMEOUT_MS = 30000; // 30 seconds
-
- private static final int TOTAL_POLL_CYCLES =
- FAST_POLL_CYCLES
- + (int)
- ((TOTAL_TIMEOUT_MS - (FAST_POLL_CYCLES * FAST_POLL_INTERVAL_MS))
- / SLOW_POLL_INTERVAL_MS);
-
- private static volatile EnvironmentProvider environmentProvider =
- SystemEnvironmentProvider.getInstance();
-
- private static volatile PropertyProvider propertyProvider = SystemPropertyProvider.getInstance();
-
- private static volatile TimeService timeService = Thread::sleep;
-
- // Tracks whether initial container startup discovery has completed. The 30-second
- // polling loop (TOTAL_POLL_CYCLES) is strictly limited to initial startup so subsequent
- // token refreshes never block for 30 seconds.
- private static volatile boolean initialStartupCompleted = false;
-
- // Tracks whether the FINE-level message for explicitly disabled mTLS when certificates are
- // present has already been logged in this process to prevent log spam on repeated token
- // refreshes.
- private static volatile boolean mtlsDisabledLogged = false;
-
- // Tracks whether mTLS has already been evaluated as explicitly disabled via
- // GOOGLE_API_USE_CLIENT_CERTIFICATE, so later refreshes skip re-reading the config from disk.
- private static volatile boolean mtlsDisabledEvaluated = false;
-
- private AgentIdentityUtils() {}
-
- @VisibleForTesting
- @FunctionalInterface
- interface TimeService {
- /**
- * Causes the currently executing thread to sleep for the specified number of milliseconds.
- *
- * @param millis the length of time to sleep in milliseconds
- * @throws InterruptedException if interrupted while sleeping
- */
- void sleep(final long millis) throws InterruptedException;
- }
-
- /**
- * Holds the parsed {@link X509Certificate} and cached PEM certificate chain content for token
- * binding.
- *
- *
This class intentionally stores in-memory certificate content rather than file paths to
- * prevent redundant filesystem reads and race conditions when binding tokens to requests.
- */
- static class CertInfo {
- private final X509Certificate certificate;
- private final String certContent;
-
- CertInfo(final X509Certificate certificate, final String certContent) {
- this.certificate = certificate;
- this.certContent = certContent;
- }
-
- /** Returns the parsed {@link X509Certificate}. */
- X509Certificate getCertificate() {
- return certificate;
- }
-
- /** Returns the raw PEM certificate chain content. */
- String getCertContent() {
- return certContent;
- }
- }
-
- /** Holds the resolved filesystem paths for the certificate and private key. */
- private static final class ResolvedCertAndKeyPaths {
- private final @Nullable String certPath;
- private final @Nullable String keyPath;
- private final boolean hasWorkloadConfig;
- private final @Nullable FileMetadata configMetadata;
-
- ResolvedCertAndKeyPaths(
- final @Nullable String certPath,
- final @Nullable String keyPath,
- final boolean hasWorkloadConfig) {
- this(certPath, keyPath, hasWorkloadConfig, null);
- }
-
- ResolvedCertAndKeyPaths(
- final @Nullable String certPath,
- final @Nullable String keyPath,
- final boolean hasWorkloadConfig,
- final @Nullable FileMetadata configMetadata) {
- this.certPath = certPath;
- this.keyPath = keyPath;
- this.hasWorkloadConfig = hasWorkloadConfig;
- this.configMetadata = configMetadata;
- }
-
- /** Returns the path to the certificate or bundle file. */
- @Nullable String getCertPath() {
- return certPath;
- }
-
- /** Returns the path to the private key file, or bundle path if combined. */
- @Nullable String getKeyPath() {
- return keyPath;
- }
-
- /** Returns whether a workload configuration was parsed from the certificate config file. */
- boolean hasWorkloadConfig() {
- return hasWorkloadConfig;
- }
-
- /** Returns the filesystem metadata of the config file when it was parsed, or null. */
- @Nullable FileMetadata getConfigMetadata() {
- return configMetadata;
- }
- }
-
- /**
- * Thrown when a workload configuration block is present in the certificate config file but is
- * missing {@code cert_path} or {@code key_path}, so callers fail fast without polling.
- */
- private static final class IncompleteWorkloadConfigException extends IOException {
- private static final long serialVersionUID = 1L;
-
- IncompleteWorkloadConfigException(final String message) {
- super(message);
- }
- }
-
- private static long getSleepIntervalMs(final int cycle) {
- return (cycle < FAST_POLL_CYCLES) ? FAST_POLL_INTERVAL_MS : SLOW_POLL_INTERVAL_MS;
- }
-
- private static @Nullable String getTrimmedEnv(final String name) {
- String val = environmentProvider.getEnv(name);
- return val != null ? val.trim() : null;
- }
-
- /**
- * Reads the trimmed {@link #GOOGLE_API_USE_CLIENT_CERTIFICATE} value from {@link
- * #environmentProvider}. Queried via {@code environmentProvider} rather than cached in a {@code
- * static final} field so test overrides via {@link #setEnvironmentProvider} take effect.
- */
- private static @Nullable String getUseClientCertificateEnv() {
- return getTrimmedEnv(GOOGLE_API_USE_CLIENT_CERTIFICATE);
- }
-
- /**
- * Returns {@code true} if {@link #GOOGLE_API_USE_CLIENT_CERTIFICATE} is set to {@code "true"}
- * (case-insensitive).
- */
- private static boolean isMtlsExplicitlyEnabled() {
- return "true".equalsIgnoreCase(getUseClientCertificateEnv());
- }
-
- /**
- * Returns {@code true} if {@link #GOOGLE_API_USE_CLIENT_CERTIFICATE} is set to a non-empty value
- * other than {@code "true"} (case-insensitive).
- */
- private static boolean isMtlsExplicitlyDisabled() {
- String useClientCert = getUseClientCertificateEnv();
- return !Strings.isNullOrEmpty(useClientCert) && !"true".equalsIgnoreCase(useClientCert);
- }
-
- /**
- * Resolves symlinks on existing ancestor directories of {@code path} (for example, {@code
- * /var/run} to {@code /run} before startup files are created), preserving any trailing
- * non-existent path elements.
- */
- private static Path resolveRealPathAllowingMissing(final Path path) {
- Path normalized = path.toAbsolutePath().normalize();
- Path current = normalized;
- while (current != null) {
- try {
- Path realCurrent = current.toRealPath();
- Path relative = current.relativize(normalized);
- return relative.toString().isEmpty()
- ? realCurrent
- : realCurrent.resolve(relative).normalize();
- } catch (IOException e) {
- current = current.getParent();
- }
- }
- return normalized;
- }
-
- /** Checks whether the given path resides within the well-known certificate directory. */
- private static boolean isPathInWellKnownDir(final @Nullable String pathStr) {
- if (Strings.isNullOrEmpty(pathStr)) {
- return false;
- }
- try {
- Path path = resolveRealPathAllowingMissing(Paths.get(pathStr));
- Path wellKnown = resolveRealPathAllowingMissing(Paths.get(wellKnownDir));
- return path.startsWith(wellKnown);
- } catch (Exception e) {
- return false;
- }
- }
-
- /** Returns {@code true} if {@code path} is the GKE credential bundle in the well-known dir. */
- static boolean isGkeCredentialBundle(final @Nullable String path) {
- return path != null && gkeCredentialBundlePath.equals(path);
- }
-
- /**
- * Returns whether the resolved certificate paths came from a workload certificate configuration
- * or point at the GKE credential bundle (either of which can enable mTLS without {@link
- * #GOOGLE_API_USE_CLIENT_CERTIFICATE} being set to {@code "true"}).
- */
- private static boolean hasWorkloadConfigOrGkeBundle(
- final boolean hasWorkloadConfig, final @Nullable String certPath) {
- return hasWorkloadConfig || isGkeCredentialBundle(certPath);
- }
-
- /**
- * Retrieves the certificate and raw PEM content for the Agent Identity.
- *
- *
This method attempts to load the certificate and private key for the agent identity. It
- * first checks the location specified by the {@code GOOGLE_API_CERTIFICATE_CONFIG} environment
- * variable. If not set, it falls back to well-known default locations.
- *
- *
To handle transient race conditions during certificate rotation on disk, this method employs
- * a retry mechanism with backoff when reading the configuration and certificate files. Note that
- * callers such as {@link ComputeEngineCredentials#refreshAccessToken()} invoke this method via
- * {@link #getBoundTokenPayload()} under {@link OAuth2Credentials}'s token refresh coalescing,
- * which already serializes concurrent token refreshes to mitigate thundering herd contention.
- *
- * @return A {@link CertInfo} object containing the parsed {@link X509Certificate} and its raw PEM
- * chain content, or {@code null} if the agent identity features are disabled, opted out, or
- * if no valid credentials could be loaded.
- * @throws IOException If an I/O error occurs while reading the files, or if the key-pair
- * verification fails after retries.
- */
- static @Nullable CertInfo getAgentIdentityCertInfo() throws IOException {
- if (!isTokenBindingEnabled()) {
- return null;
- }
- if (mtlsDisabledEvaluated && isMtlsExplicitlyDisabled()) {
- return null;
- }
- CachedAgentIdentityInfo initialCached = AgentIdentityCacheUtils.getCachedAgentIdentityInfo();
- String certConfigPath = getTrimmedEnv(GOOGLE_API_CERTIFICATE_CONFIG);
-
- // Fast-path: check cached info and file metadata before parsing JSON config or paths
- if (initialCached != null
- && AgentIdentityCacheUtils.isCachedInfoValid(initialCached, certConfigPath)) {
- if (!initialCached.shouldRequestBoundToken) {
- return null;
- }
- // isCachedInfoValid() verified that the cached certificate file still exists on disk with
- // matching metadata, so certsPresent is guaranteed to be true here.
- boolean hasConfigOrGkeBundle =
- hasWorkloadConfigOrGkeBundle(
- initialCached.configMetadata != null, initialCached.certMetadata.getPath());
- if (!shouldEnableMtls(/* certsPresent= */ true, hasConfigOrGkeBundle)) {
- return null;
- }
- return initialCached.certInfo;
- }
-
- ResolvedCertAndKeyPaths paths = resolveCertAndKeyPaths(certConfigPath, initialCached);
- boolean hasConfigOrGkeBundle =
- hasWorkloadConfigOrGkeBundle(paths.hasWorkloadConfig(), paths.getCertPath());
- boolean certsPresent = !Strings.isNullOrEmpty(paths.getCertPath());
-
- if (!shouldEnableMtls(certsPresent, hasConfigOrGkeBundle)) {
- if (isMtlsExplicitlyDisabled()) {
- mtlsDisabledEvaluated = true;
- }
- return null;
- }
-
- return loadAndVerifyCredentials(
- paths.getCertPath(), paths.getKeyPath(), paths.getConfigMetadata(), initialCached);
- }
-
- /**
- * Resolves the paths for the certificate and private key based on the config path or well-known
- * locations.
- */
- private static ResolvedCertAndKeyPaths resolveCertAndKeyPaths(
- final @Nullable String certConfigPath, final @Nullable CachedAgentIdentityInfo cached)
- throws IOException {
- if (!Strings.isNullOrEmpty(certConfigPath)) {
- // Read cert and key paths from config file. We use retry with backoff to handle
- // startup delivery (when in well-known directory) and transient rotation race conditions.
- return getPathsFromConfigWithRetry(certConfigPath, cached);
- }
- // Fallback to well-known locations. We use retry with backoff here as well to handle
- // startup delivery (when explicit mTLS is enabled) and race conditions during file
- // replacement by a rotation process.
- return getWellKnownCertificatePathWithRetry(cached);
- }
-
- /**
- * Loads the certificate and private key, and verifies that they form a valid cryptographic
- * key-pair, supporting both separate files and combined bundle files.
- *
- *
Checks {@link AgentIdentityCertificateValidationUtils#shouldRequestBoundToken} right after
- * parsing the certificate content before reading the private key or running signature
- * verification. Caches verified results in memory so unchanged files are not re-read and
- * re-verified on every token refresh.
- *
- * @param certPath The path to the certificate or bundle file.
- * @param keyPath The path to the private key or bundle file.
- * @return A {@link CertInfo} object containing the parsed {@link X509Certificate} and the raw PEM
- * certificate chain (with private keys stripped), or {@code null} if the certificate does not
- * match agent SPIFFE patterns.
- * @throws IOException If the files cannot be read or parsed, or if key-pair verification fails
- * after retries.
- */
- static @Nullable CertInfo loadAndVerifyCredentials(
- final @Nullable String certPath, final @Nullable String keyPath) throws IOException {
- return loadAndVerifyCredentials(
- certPath, keyPath, null, AgentIdentityCacheUtils.getCachedAgentIdentityInfo());
- }
-
- private static @Nullable CertInfo loadAndVerifyCredentials(
- final @Nullable String certPath,
- final @Nullable String keyPath,
- final @Nullable FileMetadata configMetaBefore,
- final @Nullable CachedAgentIdentityInfo initialCached)
- throws IOException {
- if (Strings.isNullOrEmpty(certPath)) {
- return null;
- }
-
- // Even when isCachedInfoValid() in getAgentIdentityCertInfo() returned false, the cache can
- // still match here in three cases:
- // 1. The config file was deleted after caching while the cert/key files on disk remain
- // unchanged (getPathsFromConfigWithRetry fell back to cached paths + configMetadata).
- // 2. Another thread completed rotation and updated the cache while this thread was resolving
- // paths in resolveCertAndKeyPaths().
- // 3. Package-private loadAndVerifyCredentials(certPath, keyPath) was invoked directly.
- CachedAgentIdentityInfo cached = AgentIdentityCacheUtils.getLatestOrInitialCache(initialCached);
- if (AgentIdentityCacheUtils.isPostResolutionCacheHit(
- cached, certPath, keyPath, configMetaBefore)) {
- return cached.shouldRequestBoundToken ? cached.certInfo : null;
- }
-
- Exception lastException = null;
- int retries = 0;
- while (retries < CERT_KEY_MATCH_RETRIES) {
- try {
- FileMetadata certMeta = FileMetadata.of(certPath);
- String certContent = AgentIdentityCertificateValidationUtils.readCertificateChain(certPath);
- X509Certificate cert =
- AgentIdentityCertificateValidationUtils.parseCertificateContent(certContent);
-
- if (!AgentIdentityCertificateValidationUtils.shouldRequestBoundToken(cert)) {
- if (AgentIdentityCacheUtils.tryUpdateCache(
- configMetaBefore, certMeta, null, false, null)) {
- return null;
- }
- lastException = new IOException("Certificate file modified during read.");
- } else {
- FileMetadata keyMeta = FileMetadata.of(keyPath);
- PrivateKey privateKey =
- AgentIdentityCertificateValidationUtils.readPrivateKey(
- keyPath, cert.getPublicKey().getAlgorithm());
-
- if (AgentIdentityCertificateValidationUtils.verifyKeyPair(cert, privateKey)) {
- CertInfo info = new CertInfo(cert, certContent);
- if (AgentIdentityCacheUtils.tryUpdateCache(
- configMetaBefore, certMeta, keyMeta, true, info)) {
- return info;
- }
- lastException = new IOException("Certificate or key file modified during read.");
- LoggingUtils.log(
- LOGGER_PROVIDER,
- Level.FINE,
- Collections.emptyMap(),
- "Cert or key file modified during read, retrying...");
- } else {
- lastException = new IOException("Certificate and private key do not match.");
- LoggingUtils.log(
- LOGGER_PROVIDER,
- Level.FINE,
- Collections.emptyMap(),
- "Cert and key mismatch, retrying...");
- }
- }
- } catch (AccessDeniedException e) {
- throw new IOException(
- "Permission denied reading certificate or key files for Agent Identity.", e);
- } catch (UnsupportedKeyAlgorithmException | InvalidCertificateException e) {
- throw e;
- } catch (Exception e) {
- lastException = e;
- LoggingUtils.log(
- LOGGER_PROVIDER,
- Level.FINE,
- Collections.emptyMap(),
- "Failed to read or verify cert/key, retrying: " + e.getMessage());
- }
-
- retries++;
- if (retries < CERT_KEY_MATCH_RETRIES) {
- try {
- timeService.sleep(CERT_KEY_MATCH_RETRY_INTERVAL_MS); // 0.1 seconds backoff
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- throw new IOException("Interrupted while waiting for cert/key match.", e);
- }
- }
- }
-
- // If files were transiently missing, unreadable, or mid-rotation (lastException != null)
- // and we already have a verified credential cached in memory, fall back to cached credentials
- // rather than failing or caching an unbound token. Re-read cachedAgentIdentityInfo in case
- // another thread completed rotation while this thread was sleeping.
- CachedAgentIdentityInfo fallbackCached =
- AgentIdentityCacheUtils.getLatestOrInitialCache(initialCached);
- if (lastException != null
- && fallbackCached != null
- && fallbackCached.shouldRequestBoundToken
- && fallbackCached.certInfo != null) {
- LoggingUtils.log(
- LOGGER_PROVIDER,
- Level.FINE,
- Collections.emptyMap(),
- "Agent Identity certificate/key files transiently unavailable during rotation; falling"
- + " back to cached credentials.");
- return fallbackCached.certInfo;
- }
-
- throw new GoogleAuthException(
- true,
- CERT_KEY_MATCH_RETRIES,
- String.format(
- "Agent Identity certificate and private key mismatch or read"
- + " failure after %d attempts.",
- CERT_KEY_MATCH_RETRIES),
- lastException);
- }
-
- /**
- * Checks if runtime token binding is enabled.
- *
- *
Checks {@link #GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN} first; if unset, falls back to the
- * legacy {@link #GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES}. Defaults to {@code
- * true} if neither is set.
- */
- private static boolean isTokenBindingEnabled() {
- String enableRuntimeBoundToken = getTrimmedEnv(GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN);
- if (!Strings.isNullOrEmpty(enableRuntimeBoundToken)) {
- return !"false".equalsIgnoreCase(enableRuntimeBoundToken);
- }
- String legacyPreventSharing =
- getTrimmedEnv(GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES);
- if (!Strings.isNullOrEmpty(legacyPreventSharing)) {
- return !"false".equalsIgnoreCase(legacyPreventSharing);
- }
- return true;
- }
-
- /**
- * Reads the certificate path from the config file with retry logic to handle startup delivery and
- * rotation race conditions.
- */
- private static ResolvedCertAndKeyPaths getPathsFromConfigWithRetry(
- final String certConfigPath, final @Nullable CachedAgentIdentityInfo initialCached)
- throws IOException {
- if (isMtlsExplicitlyDisabled()) {
- try {
- if (AgentIdentityCacheUtils.checkExistsOrAccessDenied(Paths.get(certConfigPath))) {
- ResolvedCertAndKeyPaths paths = extractPathsFromConfig(certConfigPath);
- if (paths.hasWorkloadConfig() && Files.exists(Paths.get(paths.getCertPath()))) {
- return paths;
- }
- }
- } catch (Exception ignored) {
- // Do not fail when mTLS is explicitly disabled
- }
- return new ResolvedCertAndKeyPaths(null, null, false);
- }
-
- boolean inWellKnownDir = isPathInWellKnownDir(certConfigPath);
- boolean shouldPoll = !initialStartupCompleted && inWellKnownDir;
- int maxCycles = shouldPoll ? TOTAL_POLL_CYCLES : 1;
- boolean warned = false;
- IOException lastParseException = null;
-
- for (int cycle = 0; cycle < maxCycles; cycle++) {
- try {
- if (AgentIdentityCacheUtils.checkExistsOrAccessDenied(Paths.get(certConfigPath))) {
- ResolvedCertAndKeyPaths paths = extractPathsFromConfig(certConfigPath);
- if (!paths.hasWorkloadConfig()) {
- // Valid non-workload config (e.g. enterprise certs) - exit early without polling!
- initialStartupCompleted = true;
- return paths;
- }
- // The config file itself may reside outside wellKnownDir while referencing cert_path or
- // key_path inside wellKnownDir that are still being delivered at startup.
- if (!initialStartupCompleted
- && !shouldPoll
- && (isPathInWellKnownDir(paths.getCertPath())
- || isPathInWellKnownDir(paths.getKeyPath()))) {
- shouldPoll = true;
- maxCycles = TOTAL_POLL_CYCLES;
- }
- boolean certReady =
- AgentIdentityCacheUtils.checkExistsOrAccessDenied(Paths.get(paths.getCertPath()));
- boolean keyReady =
- AgentIdentityCacheUtils.checkExistsOrAccessDenied(Paths.get(paths.getKeyPath()));
- if (certReady && keyReady) {
- initialStartupCompleted = true;
- return paths;
- }
- }
- } catch (AccessDeniedException e) {
- String failedFile = e.getFile() != null ? e.getFile() : certConfigPath;
- throw new IOException(
- "Permission denied reading certificate config file: " + failedFile, e);
- } catch (IncompleteWorkloadConfigException e) {
- // Incomplete workload config (missing cert_path or key_path) will not become ready via
- // startup polling; break out of the polling loop immediately so initial startup throws
- // without waiting 30s (or falls back to a previously cached config in steady state).
- lastParseException = e;
- break;
- } catch (IOException e) {
- CachedAgentIdentityInfo latestCached =
- AgentIdentityCacheUtils.getLatestOrInitialCache(initialCached);
- if (!shouldPoll && cycle + 1 >= maxCycles && latestCached == null) {
- initialStartupCompleted = true;
- throw e; // Fail fast on malformed JSON syntax errors when not polling
- }
- lastParseException = e;
- // When polling is active, fall through to retry (handles partial/empty JSON during startup)
- }
- if (cycle + 1 < maxCycles) {
- if (shouldPoll && !warned) {
- LoggingUtils.log(
- LOGGER_PROVIDER,
- Level.FINE,
- Collections.emptyMap(),
- String.format(
- "Agent Identity certificate config or referenced credential files not yet ready"
- + " at %s (from %s environment variable). Retrying for up to %d seconds.",
- certConfigPath, GOOGLE_API_CERTIFICATE_CONFIG, TOTAL_TIMEOUT_MS / 1000));
- warned = true;
- }
- try {
- timeService.sleep(getSleepIntervalMs(cycle));
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- throw new IOException(
- "Interrupted while waiting for Agent Identity certificate files for bound"
- + " token request.",
- e);
- }
- }
- }
- initialStartupCompleted = true;
-
- CachedAgentIdentityInfo fallbackCached =
- AgentIdentityCacheUtils.getLatestOrInitialCache(initialCached);
- if (fallbackCached != null
- && fallbackCached.configMetadata != null
- && fallbackCached.certMetadata != null
- && fallbackCached.keyMetadata != null) {
- LoggingUtils.log(
- LOGGER_PROVIDER,
- Level.FINE,
- Collections.emptyMap(),
- String.format(
- "Agent Identity certificate config at %s is unavailable; falling back to cached"
- + " certificate and key paths.",
- certConfigPath));
- return new ResolvedCertAndKeyPaths(
- fallbackCached.certMetadata.getPath(),
- fallbackCached.keyMetadata.getPath(),
- true,
- fallbackCached.configMetadata);
- }
-
- throw new IOException(
- "Unable to find Agent Identity certificate config or file for bound token request"
- + " after multiple retries. Token binding protection is failing. You can turn"
- + " off this protection by setting "
- + GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN
- + " to false to fall back to unbound tokens.",
- lastParseException);
- }
-
- /** Searches for certificates at well-known locations. */
- private static ResolvedCertAndKeyPaths getWellKnownCertificatePathWithRetry(
- final @Nullable CachedAgentIdentityInfo initialCached) throws IOException {
- if (isMtlsExplicitlyDisabled()) {
- initialStartupCompleted = true;
- return new ResolvedCertAndKeyPaths(null, null, false);
- }
-
- // Startup polling is only performed when GOOGLE_API_CERTIFICATE_CONFIG is set and points to the
- // well-known directory (see getPathsFromConfigWithRetry). When GOOGLE_API_CERTIFICATE_CONFIG is
- // unset, GKE mounts the Pod Certificate volume before the container starts (and on GCE an empty
- // well-known directory may exist without any certificates ever being delivered), so we perform
- // a single immediate check without startup polling.
- // The GKE credential bundle is only used when no well-known gcloud certificate configuration
- // file exists (it would otherwise be the transport's mTLS certificate source).
- boolean gkeBundleAllowed =
- !MtlsUtils.hasCertificateConfiguration(environmentProvider, propertyProvider);
- String gkeBundlePath = gkeCredentialBundlePath;
- if (gkeBundleAllowed && MtlsUtils.isGkeCredentialBundleReady(gkeBundlePath)) {
- initialStartupCompleted = true;
- // GKE combined bundle file contains both certificate chain and private key
- return new ResolvedCertAndKeyPaths(gkeBundlePath, gkeBundlePath, false);
- }
-
- if (!isMtlsExplicitlyEnabled()) {
- // Without a config file or GOOGLE_API_USE_CLIENT_CERTIFICATE="true", only the GKE credential
- // bundle can auto-enable mTLS. If a GKE credential was previously cached and the bundle is
- // transiently unavailable (e.g. mid-rotation), return the cached paths so
- // loadAndVerifyCredentials can fall back to the cached credential.
- initialStartupCompleted = true;
- CachedAgentIdentityInfo fallbackCached =
- AgentIdentityCacheUtils.getLatestOrInitialCache(initialCached);
- if (gkeBundleAllowed
- && fallbackCached != null
- && fallbackCached.canUseWellKnownFallback()
- && fallbackCached.isGkeCredentialBundle) {
- return new ResolvedCertAndKeyPaths(
- fallbackCached.certMetadata.getPath(), fallbackCached.keyMetadata.getPath(), false);
- }
- return new ResolvedCertAndKeyPaths(null, null, false);
- }
-
- String bundlePath = Paths.get(wellKnownDir, "credentialbundle.pem").toString();
- String certOnlyPath = Paths.get(wellKnownDir, "certificates.pem").toString();
- String keyOnlyPath = Paths.get(wellKnownDir, "private_key.pem").toString();
-
- try {
- if (AgentIdentityCacheUtils.checkExistsOrAccessDenied(Paths.get(bundlePath))) {
- initialStartupCompleted = true;
- // Combined bundle file contains both certificate chain and private key
- return new ResolvedCertAndKeyPaths(bundlePath, bundlePath, false);
- }
- if (AgentIdentityCacheUtils.checkExistsOrAccessDenied(Paths.get(certOnlyPath))
- && AgentIdentityCacheUtils.checkExistsOrAccessDenied(Paths.get(keyOnlyPath))) {
- initialStartupCompleted = true;
- return new ResolvedCertAndKeyPaths(certOnlyPath, keyOnlyPath, false);
- }
- } catch (AccessDeniedException e) {
- initialStartupCompleted = true;
- LoggingUtils.log(
- LOGGER_PROVIDER,
- Level.FINE,
- Collections.emptyMap(),
- "Permission denied reading well-known certificates. Falling back to unbound token.");
- return new ResolvedCertAndKeyPaths(null, null, false);
- } catch (Exception e) {
- LoggingUtils.log(
- LOGGER_PROVIDER,
- Level.FINE,
- Collections.emptyMap(),
- "Unexpected error checking well-known certificates: " + e.getMessage());
- }
- initialStartupCompleted = true;
-
- CachedAgentIdentityInfo fallbackCached =
- AgentIdentityCacheUtils.getLatestOrInitialCache(initialCached);
- if (fallbackCached != null
- && fallbackCached.canUseWellKnownFallback()
- // A cached GKE credential is only reused while the GKE bundle is still allowed (no
- // certificate configuration exists), matching the certificate the transport presents.
- && (!fallbackCached.isGkeCredentialBundle || gkeBundleAllowed)) {
- return new ResolvedCertAndKeyPaths(
- fallbackCached.certMetadata.getPath(), fallbackCached.keyMetadata.getPath(), false);
- }
-
- return new ResolvedCertAndKeyPaths(null, null, false);
- }
-
- /**
- * Determines if mTLS should be enabled based on environment variables and certificate presence.
- *
- *
Note that {@code configExists} is required even when {@code certsPresent} is {@code true}:
- * when {@code GOOGLE_API_USE_CLIENT_CERTIFICATE} is unset (Case 3), mTLS is only inferred if a
- * workload config exists ({@code configExists == true}), whereas when {@code
- * GOOGLE_API_USE_CLIENT_CERTIFICATE="true"} (Case 1), mTLS is enabled whenever {@code
- * certsPresent == true} even from the well-known directory ({@code configExists == false}).
- *
- *
Callers pass {@code configExists == true} for the GKE credential bundle as well (see {@link
- * #GKE_CREDENTIAL_BUNDLE_FILE}), since its presence auto-enables mTLS on its own.
- */
- static boolean shouldEnableMtls(final boolean certsPresent, final boolean configExists)
- throws IOException {
- String useClientCert = getUseClientCertificateEnv();
-
- // Case 1: Explicitly enabled via environment variable
- if ("true".equalsIgnoreCase(useClientCert)) {
- if (certsPresent) {
- // Certs are available, enable mTLS
- return true;
- }
- if (configExists) {
- // Config exists but files are missing - fail fast
- throw new IOException(
- "Certificate intent established via config, but cert files are missing.");
- }
- // Neither exist, do not enable
- return false;
- }
- // Case 2: Explicitly disabled via environment variable (any non-empty value other than "true")
- else if (!Strings.isNullOrEmpty(useClientCert)) {
- if (certsPresent && !mtlsDisabledLogged) {
- mtlsDisabledLogged = true;
- // Log that we are ignoring present certs because it was explicitly disabled
- LoggingUtils.log(
- LOGGER_PROVIDER,
- Level.FINE,
- Collections.emptyMap(),
- "Token binding protection is disabled because mTLS was explicitly disabled"
- + " via GOOGLE_API_USE_CLIENT_CERTIFICATE.");
- }
- return false;
- }
- // Case 3: Environment variable is unset
- else {
- if (configExists) {
- if (certsPresent) {
- // Infer mTLS is enabled because workload config and certs are present
- return true;
- }
- // Config exists but files are missing - fail fast
- throw new IOException(
- "Certificate intent inferred via config, but cert files are missing.");
- }
- // No workload config to infer mTLS from, do not enable
- return false;
- }
- }
-
- /** Retrieves the bound token payload (certificate chain) if applicable. */
- static @Nullable String getBoundTokenPayload() throws IOException {
- CertInfo info = getAgentIdentityCertInfo();
- return info != null ? info.getCertContent() : null;
- }
-
- /** Extracts the certificate and private key paths from the JSON configuration file. */
- private static ResolvedCertAndKeyPaths extractPathsFromConfig(final String certConfigPath)
- throws IOException {
- try {
- FileMetadata configMetadata = FileMetadata.of(certConfigPath);
- try (InputStream stream = Files.newInputStream(Paths.get(certConfigPath))) {
- JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY);
- GenericJson config =
- parser.parseAndClose(stream, StandardCharsets.UTF_8, GenericJson.class);
- Object certConfigsObj = config.get("cert_configs");
- if (certConfigsObj instanceof Map, ?>) {
- Map, ?> certConfigs = (Map, ?>) certConfigsObj;
- Object workloadObj = certConfigs.get("workload");
- if (workloadObj instanceof Map, ?>) {
- Map, ?> workload = (Map, ?>) workloadObj;
- String certPath =
- workload.get("cert_path") instanceof String
- ? (String) workload.get("cert_path")
- : null;
- String keyPath =
- workload.get("key_path") instanceof String
- ? (String) workload.get("key_path")
- : null;
- if (Strings.isNullOrEmpty(certPath) || Strings.isNullOrEmpty(keyPath)) {
- throw new IncompleteWorkloadConfigException(
- "Workload certificate config is missing cert_path or key_path.");
- }
- return new ResolvedCertAndKeyPaths(certPath, keyPath, true, configMetadata);
- }
- }
- return new ResolvedCertAndKeyPaths(null, null, false, configMetadata);
- }
- } catch (AccessDeniedException | IncompleteWorkloadConfigException e) {
- throw e;
- } catch (Exception e) {
- throw new IOException("Failed to parse Agent Identity config JSON", e);
- }
- }
-
- /** Resets all static state and overrides for testing. */
- @VisibleForTesting
- static void resetForTest() {
- wellKnownDir = "/var/run/secrets/workload-spiffe-credentials/";
- gkeCredentialBundlePath = Paths.get(wellKnownDir, GKE_CREDENTIAL_BUNDLE_FILE).toString();
- environmentProvider = SystemEnvironmentProvider.getInstance();
- propertyProvider = SystemPropertyProvider.getInstance();
- timeService = Thread::sleep;
- resetCachedState();
- }
-
- private static void resetCachedState() {
- AgentIdentityCacheUtils.clearCachedAgentIdentityInfo();
- initialStartupCompleted = false;
- mtlsDisabledLogged = false;
- mtlsDisabledEvaluated = false;
- }
-
- /** Returns the current well-known certificate directory path. */
- static String getWellKnownDir() {
- return wellKnownDir;
- }
-
- /** Sets the well-known certificate directory path for testing. */
- @VisibleForTesting
- static void setWellKnownDir(final String dir) {
- wellKnownDir = dir;
- gkeCredentialBundlePath = Paths.get(dir, GKE_CREDENTIAL_BUNDLE_FILE).toString();
- resetCachedState();
- }
-
- /** Sets the environment variable provider for testing. */
- @VisibleForTesting
- static void setEnvironmentProvider(final EnvironmentProvider provider) {
- environmentProvider = provider;
- resetCachedState();
- }
-
- /**
- * Sets the system property provider for testing. Used to resolve the well-known gcloud
- * certificate configuration file location.
- */
- @VisibleForTesting
- static void setPropertyProvider(final PropertyProvider provider) {
- propertyProvider = provider;
- resetCachedState();
- }
-
- /** Sets the time and sleep service for testing. */
- @VisibleForTesting
- static void setTimeService(final TimeService service) {
- timeService = service;
- resetCachedState();
- }
-
- /** Returns whether the message for explicitly disabled mTLS has been logged. */
- @VisibleForTesting
- static boolean isMtlsDisabledLogged() {
- return mtlsDisabledLogged;
- }
-}
diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java
index dae2ddb57f7b..5b2ad1169ed0 100644
--- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java
+++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java
@@ -51,6 +51,7 @@
import java.util.Base64;
import java.util.List;
import java.util.regex.Matcher;
+import java.util.regex.Pattern;
import org.jspecify.annotations.NullMarked;
/**
@@ -65,6 +66,9 @@ public class CertificateIdentityPoolSubjectTokenSupplier
private final IdentityPoolCredentialSource credentialSource;
+ private static final Pattern PEM_CERT_PATTERN =
+ Pattern.compile("-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----", Pattern.DOTALL);
+
CertificateIdentityPoolSubjectTokenSupplier(IdentityPoolCredentialSource credentialSource) {
this.credentialSource = checkNotNull(credentialSource, "credentialSource cannot be null");
// This check ensures that the credential source was intended for certificate usage.
@@ -218,8 +222,7 @@ private void populateCertChainFromTrustChain(
// elsewhere in the chain.
if (encodedCurrentCert.equals(encodedLeafCert)) {
throw new IllegalArgumentException(
- "The leaf certificate should only appear at the beginning of the trust chain file, or"
- + " be omitted entirely.");
+ "The leaf certificate should only appear at the beginning of the trust chain file, or be omitted entirely.");
}
// Add the current certificate to the chain.
@@ -257,7 +260,7 @@ static List readTrustChain(String trustChainPath)
// Split the file content into PEM certificate blocks.
String content = new String(trustChainData, StandardCharsets.UTF_8);
- Matcher matcher = OAuth2Utils.PEM_CERT_PATTERN.matcher(content);
+ Matcher matcher = PEM_CERT_PATTERN.matcher(content);
while (matcher.find()) {
String pemCertBlock = matcher.group(0);
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 7495e3d08827..a3b87c0c6489 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
@@ -34,14 +34,11 @@
import static com.google.common.base.MoreObjects.firstNonNull;
import com.google.api.client.http.GenericUrl;
-import com.google.api.client.http.HttpContent;
import com.google.api.client.http.HttpHeaders;
-import com.google.api.client.http.HttpMediaType;
import com.google.api.client.http.HttpRequest;
import com.google.api.client.http.HttpResponse;
import com.google.api.client.http.HttpResponseException;
import com.google.api.client.http.HttpStatusCodes;
-import com.google.api.client.http.json.JsonHttpContent;
import com.google.api.client.json.JsonObjectParser;
import com.google.api.client.util.GenericData;
import com.google.auth.CredentialTypeForMetrics;
@@ -71,7 +68,6 @@
import java.util.Collections;
import java.util.Date;
import java.util.List;
-import java.util.Map;
import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;
@@ -81,11 +77,7 @@
/**
* OAuth2 credentials representing the built-in service account for a Google Compute Engine VM.
*
- *
Fetches access tokens from the Google Compute Engine metadata server. When a workload
- * certificate for an agent identity is available, requests certificate-bound access tokens and ID
- * tokens by default (see {@code IdTokenProvider.Option.DISABLE_BOUND_ID_TOKEN} and the Google
- * Auth Library guide).
+ *
Fetches access tokens from the Google Compute Engine metadata server.
*
*
These credentials use the IAM API to sign data. See {@link #sign(byte[])} for more details.
*/
@@ -328,8 +320,7 @@ public String getUniverseDomain() throws IOException {
private String getUniverseDomainFromMetadata() throws IOException {
HttpResponse response =
- getMetadataResponse(
- buildMetadataRequest(getUniverseDomainUrl()), RequestType.UNTRACKED, false);
+ getMetadataResponse(getUniverseDomainUrl(), RequestType.UNTRACKED, false);
int statusCode = response.getStatusCode();
if (statusCode == HttpStatusCodes.STATUS_CODE_NOT_FOUND) {
return Credentials.GOOGLE_DEFAULT_UNIVERSE;
@@ -388,9 +379,7 @@ private String getUniverseDomainFromMetadata() throws IOException {
private @Nullable String getProjectIdFromMetadata() {
try {
- HttpResponse response =
- getMetadataResponse(
- buildMetadataRequest(getProjectIdUrl()), RequestType.UNTRACKED, false);
+ HttpResponse response = getMetadataResponse(getProjectIdUrl(), RequestType.UNTRACKED, false);
int statusCode = response.getStatusCode();
if (statusCode == HttpStatusCodes.STATUS_CODE_NOT_FOUND) {
LoggingUtils.log(
@@ -432,23 +421,10 @@ private String getUniverseDomainFromMetadata() throws IOException {
/** Refresh the access token by getting it from the GCE metadata server */
@Override
public AccessToken refreshAccessToken() throws IOException {
- String tokenUrl = createTokenUrlWithScopes();
- String boundTokenPayload = AgentIdentityUtils.getBoundTokenPayload();
- HttpRequest request =
- boundTokenPayload != null
- ? buildBoundTokenRequest(tokenUrl, boundTokenPayload)
- : buildMetadataRequest(tokenUrl);
- HttpResponse response = getMetadataResponse(request, RequestType.ACCESS_TOKEN_REQUEST, true);
+ HttpResponse response =
+ getMetadataResponse(createTokenUrlWithScopes(), RequestType.ACCESS_TOKEN_REQUEST, true);
int statusCode = response.getStatusCode();
if (statusCode == HttpStatusCodes.STATUS_CODE_NOT_FOUND) {
- if (boundTokenPayload != null) {
- throw new IOException(
- String.format(
- "Error code %s trying to get bound security access token from Compute Engine"
- + " metadata for the default service account. The Compute Engine metadata"
- + " server endpoint does not support bound tokens.",
- statusCode));
- }
throw new IOException(
String.format(
"Error code %s trying to get security access token from Compute Engine metadata for"
@@ -494,8 +470,6 @@ public AccessToken refreshAccessToken() throws IOException {
*
* IdTokenProvider.Option.FORMAT_FULL
* IdTokenProvider.Option.LICENSES_TRUE
- * IdTokenProvider.Option.DISABLE_BOUND_ID_TOKEN (request an ID token that is not bound to the
- * agent identity certificate)
* If no options are set, the defaults are "&format=standard&licenses=false"
* @throws IOException if the attempt to get an IdToken failed
* @return IdToken object which includes the raw id_token, JsonWebSignature
@@ -503,14 +477,7 @@ public AccessToken refreshAccessToken() throws IOException {
@Override
public IdToken idTokenWithAudience(
String targetAudience, @Nullable List options) throws IOException {
- // Checked before getBoundTokenPayload() so an opted-out target skips the certificate lookup.
- boolean bindIdToken =
- options == null || !options.contains(IdTokenProvider.Option.DISABLE_BOUND_ID_TOKEN);
- String boundTokenPayload = bindIdToken ? AgentIdentityUtils.getBoundTokenPayload() : null;
GenericUrl documentUrl = new GenericUrl(getIdentityDocumentUrl());
- if (boundTokenPayload != null) {
- documentUrl.set("format", "full");
- }
if (options != null) {
if (options.contains(IdTokenProvider.Option.FORMAT_FULL)) {
documentUrl.set("format", "full");
@@ -522,21 +489,10 @@ public IdToken idTokenWithAudience(
}
}
documentUrl.set("audience", targetAudience);
- HttpRequest request =
- boundTokenPayload != null
- ? buildBoundTokenRequest(documentUrl.toString(), boundTokenPayload)
- : buildMetadataRequest(documentUrl.toString());
- HttpResponse response = getMetadataResponse(request, RequestType.ID_TOKEN_REQUEST, true);
+ HttpResponse response =
+ getMetadataResponse(documentUrl.toString(), RequestType.ID_TOKEN_REQUEST, true);
int statusCode = response.getStatusCode();
if (statusCode == HttpStatusCodes.STATUS_CODE_NOT_FOUND) {
- if (boundTokenPayload != null) {
- throw new IOException(
- String.format(
- "Error code %s trying to get bound identity token from Compute Engine metadata."
- + " The Compute Engine metadata server endpoint does not support bound"
- + " tokens.",
- statusCode));
- }
throw new IOException(
String.format(
"Error code %s trying to get identity token from"
@@ -563,25 +519,11 @@ public IdToken idTokenWithAudience(
return IdToken.create(rawToken);
}
- private HttpRequest buildMetadataRequest(String url) throws IOException {
- return transportFactory.create().createRequestFactory().buildGetRequest(new GenericUrl(url));
- }
-
- private HttpRequest buildBoundTokenRequest(String url, String boundTokenPayload)
- throws IOException {
- Map payload = Collections.singletonMap("certificate_chain", boundTokenPayload);
- HttpContent content =
- new JsonHttpContent(OAuth2Utils.JSON_FACTORY, payload)
- .setMediaType(new HttpMediaType("application/json"));
- return transportFactory
- .create()
- .createRequestFactory()
- .buildPostRequest(new GenericUrl(url), content);
- }
-
private HttpResponse getMetadataResponse(
- HttpRequest request, RequestType requestType, boolean shouldSendMetricsHeader)
- throws IOException {
+ String url, RequestType requestType, boolean shouldSendMetricsHeader) throws IOException {
+ GenericUrl genericUrl = new GenericUrl(url);
+ HttpRequest request =
+ transportFactory.create().createRequestFactory().buildGetRequest(genericUrl);
// Disable automatic logging by google-http-java-client to prevent leakage of sensitive tokens.
// Client Library Debug Logging via LoggingUtils is used instead where appropriate.
request.setLoggingEnabled(false);
@@ -898,8 +840,7 @@ public byte[] sign(byte[] toSign) {
private String getDefaultServiceAccount() throws IOException {
HttpResponse response =
- getMetadataResponse(
- buildMetadataRequest(getDefaultServiceAccountUrl()), RequestType.UNTRACKED, false);
+ getMetadataResponse(getDefaultServiceAccountUrl(), RequestType.UNTRACKED, false);
int statusCode = response.getStatusCode();
if (statusCode == HttpStatusCodes.STATUS_CODE_NOT_FOUND) {
throw new IOException(
diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdTokenProvider.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdTokenProvider.java
index e1673f19d3da..d1dc1112b85f 100644
--- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdTokenProvider.java
+++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdTokenProvider.java
@@ -48,7 +48,6 @@ public interface IdTokenProvider {
*
*
FORMAT_FULL
*
LICENSES_TRUE
- *
DISABLE_BOUND_ID_TOKEN
*
*
*
@@ -61,28 +60,7 @@ public interface IdTokenProvider {
public enum Option {
FORMAT_FULL("formatFull"),
LICENSES_TRUE("licensesTrue"),
- INCLUDE_EMAIL("includeEmail"),
- /**
- * Requests an ID token that is not bound to the workload's agent identity certificate.
- *
- *
This option is only supported by {@link ComputeEngineCredentials}; other {@link
- * IdTokenProvider} implementations do not request bound ID tokens and ignore this option.
- *
- *
When an agent identity certificate is available, {@link ComputeEngineCredentials} requests
- * certificate-bound ID tokens by default. A bound ID token is only accepted by targets that
- * authenticate the caller over mTLS with the same certificate. Pass this option for targets
- * that are called over standard (non-mTLS) HTTPS, for example a Cloud Run service reached
- * through its {@code *.run.app} URL or a custom domain.
- *
- *
If the certificate on disk differs from the active one, this refresh is treated like a
- * rotation refresh: channels that fail to be recreated are dropped (and the pool refilled), so
- * the switch is completed and the generation advanced only once every channel left in the pool
- * uses the new certificate. Otherwise a channel that fails to be recreated keeps its slot, and
- * the generation is not advanced, so that a periodic refresh without a rotation does not make
- * in-flight {@code UNAUTHENTICATED} failures eligible for a rotation retry.
- */
private void refreshSafely() {
try {
- // See refresh(): a generation change while waiting for the lock means a concurrent refresh
- // just recreated every channel on a new certificate.
- long generationBeforeLock = generation.get();
- synchronized (entryWriteLock) {
- if (workloadCertPath == null) {
- refreshAll();
- return;
- }
- if (generation.get() != generationBeforeLock) {
- LOG.fine(
- "Skipping pre-emptive channel refresh: channels were just recreated by a concurrent"
- + " refresh");
- return;
- }
- String currentDiskFingerprint = rotationTracker.readDiskFingerprint();
- if (currentDiskFingerprint.isEmpty()) {
- // The configured certificate could not be read, which normally means it is being
- // rewritten during a rotation. Skip this refresh rather than recreate channels from a
- // partially written certificate; the next periodic or reactive refresh recreates them.
- LOG.fine(
- "Skipping pre-emptive channel refresh: the workload certificate could not be read;"
- + " channels will be recreated on the next refresh");
- return;
- }
- boolean certChanged = !rotationTracker.isAlreadyActive(currentDiskFingerprint);
- if (refreshAll(/* dropUnrefreshedChannels= */ certChanged) && certChanged) {
- completeCertificateSwitch(currentDiskFingerprint);
- }
- }
+ refresh();
} catch (Exception e) {
- LOG.log(Level.WARNING, "Failed to pre-emptively refresh channels", e);
+ LOG.log(Level.WARNING, "Failed to pre-emptively refresh channnels", e);
}
}
- @VisibleForTesting
- void invalidateDiskFingerprintCache() {
- rotationTracker.invalidateCache();
- }
-
- boolean shouldRefresh() {
- return rotationTracker.shouldRefresh();
- }
-
/**
* Replace all of the channels in the channel pool with fresh ones. This is meant to mitigate the
- * hourly GFE disconnects by giving clients the ability to prime the channel on reconnect, and to
- * pick up a rotated mTLS workload certificate.
+ * hourly GFE disconnects by giving clients the ability to prime the channel on reconnect.
*
- *
This is done on a best effort basis. When no workload certificate is configured, a channel
- * whose replacement fails to construct continues to be used. When a workload certificate is
- * configured, a channel whose replacement fails to construct is dropped so that no traffic is
- * routed to a channel using the old certificate; if no replacement can be constructed at all, the
- * pool is left unchanged.
+ *
This is done on a best effort basis. If the replacement channel fails to construct, the old
+ * channel will continue to be used.
*/
@InternalApi("Visible for testing")
void refresh() {
- // A generation change while waiting for the lock means a concurrent refresh already switched
- // every channel to a new certificate, so there is no need to read the certificate again.
- long generationBeforeLock = generation.get();
// Note: synchronization is necessary in case refresh is called concurrently:
// - thread1 fails to replace a single entry
// - thread2 succeeds replacing an entry
@@ -536,179 +443,28 @@ void refresh() {
// - then thread2 will shut down channel that thread1 will put back into circulation (after it
// replaces the list)
synchronized (entryWriteLock) {
- if (isShutdown) {
- return;
- }
- if (workloadCertPath == null) {
- refreshAll();
- return;
- }
- if (generation.get() != generationBeforeLock) {
- LOG.fine(
- "Channel pool was already refreshed by a concurrent thread, skipping duplicate"
- + " refresh");
- return;
- }
- String currentDiskFingerprint = rotationTracker.readDiskFingerprint();
- if (currentDiskFingerprint.isEmpty()) {
- return;
- }
-
- // Double-check fingerprint inside the lock
- if (rotationTracker.isAlreadyActive(currentDiskFingerprint)) {
- LOG.fine(
- "Channel pool was already refreshed by a concurrent thread, skipping duplicate"
- + " refresh");
- return;
- }
-
- // Drop any channel that fails to refresh so that no traffic is routed to the old certificate.
- if (refreshAll(/* dropUnrefreshedChannels= */ true)) {
- completeCertificateSwitch(currentDiskFingerprint);
- }
- }
- }
-
- /**
- * Records that every channel in the pool now uses the certificate with the given fingerprint.
- * Must be called while holding {@code entryWriteLock}, after the channels have been swapped.
- *
- *
The generation is incremented before the fingerprint is marked active, so that a concurrent
- * failing RPC that no longer sees a pending rotation ({@link #shouldRefresh()} is {@code false})
- * is guaranteed to see the new generation and be retried on the new channels.
- */
- private void completeCertificateSwitch(String newFingerprint) {
- generation.incrementAndGet();
- rotationTracker.markRefreshed(newFingerprint);
- LOG.fine("Channel pool switched to certificate with fingerprint: " + newFingerprint);
- }
-
- @InternalApi("Visible for testing")
- @Nullable String getWorkloadCertPath() {
- return workloadCertPath;
- }
-
- @InternalApi("Visible for testing")
- boolean refreshAll() {
- return refreshAll(/* dropUnrefreshedChannels= */ false);
- }
-
- /**
- * Replaces the channels in the pool with freshly created ones.
- *
- * @param dropUnrefreshedChannels if {@code false}, a channel that fails to be recreated keeps its
- * slot in the pool. If {@code true} (used for certificate rotation), only newly created
- * channels are kept so no traffic is routed to a channel using the old certificate, and the
- * pool is then refilled asynchronously to its size before the refresh.
- * @return if {@code dropUnrefreshedChannels} is {@code false}, whether every channel was
- * recreated; otherwise, whether at least one channel was recreated (i.e. every channel left
- * in the pool was newly created)
- */
- @InternalApi("Visible for testing")
- boolean refreshAll(boolean dropUnrefreshedChannels) {
- synchronized (entryWriteLock) {
- if (isShutdown) {
- return false;
- }
LOG.fine("Refreshing all channels");
- // All writes to entries happen under entryWriteLock, so this snapshot is the current pool.
- ImmutableList currentEntries = entries.get();
- List keptEntries = new ArrayList<>(currentEntries.size());
- List retiredEntries = new ArrayList<>(currentEntries.size());
- List createdEntries = new ArrayList<>(currentEntries.size());
- boolean allCreated = !currentEntries.isEmpty();
-
- try {
- for (Entry oldEntry : currentEntries) {
- try {
- Entry newEntry = new Entry(channelFactory.createSingleChannel());
- createdEntries.add(newEntry);
- keptEntries.add(newEntry);
- retiredEntries.add(oldEntry);
- } catch (Exception e) {
- allCreated = false;
- if (dropUnrefreshedChannels) {
- retiredEntries.add(oldEntry);
- } else {
- keptEntries.add(oldEntry);
- }
- LOG.log(
- Level.WARNING,
- dropUnrefreshedChannels
- ? "Failed to refresh channel, dropping old channel"
- : "Failed to refresh channel, leaving old channel",
- e);
- }
- }
-
- if (createdEntries.isEmpty()) {
- return false;
- }
-
- entries.set(ImmutableList.copyOf(keptEntries));
- createdEntries.clear(); // Ownership transferred to pool
+ ArrayList newEntries = new ArrayList<>(entries.get());
- // Shutdown the channels that were cycled out.
- retiredEntries.forEach(Entry::requestShutdown);
-
- // Restore the pool to its pre-refresh size right away, rather than leave a dynamically
- // sized
- // pool to grow back over several resize() runs while traffic queues on fewer channels.
- if (dropUnrefreshedChannels && !allCreated) {
- scheduleRefill(currentEntries.size());
- }
- return dropUnrefreshedChannels || allCreated;
- } finally {
- // If an Error aborted before the swap, shut down newly created channels so they don't leak
- for (Entry e : createdEntries) {
- e.requestShutdown();
+ for (int i = 0; i < newEntries.size(); i++) {
+ try {
+ newEntries.set(i, new Entry(channelFactory.createSingleChannel()));
+ } catch (IOException e) {
+ LOG.log(Level.WARNING, "Failed to refresh channel, leaving old channel", e);
}
}
- }
- }
- /**
- * Schedules a one-shot task that restores the pool to {@code targetSize} channels after a
- * certificate rotation refresh dropped channels that failed to refresh.
- */
- private void scheduleRefill(int targetSize) {
- try {
- backgroundExecutorProvider.getExecutor().execute(() -> refillSafely(targetSize));
- } catch (RuntimeException e) {
- LOG.log(Level.WARNING, "Failed to schedule channel pool refill", e);
- }
- }
+ ImmutableList replacedEntries = entries.getAndSet(ImmutableList.copyOf(newEntries));
- private void refillSafely(int targetSize) {
- try {
- synchronized (entryWriteLock) {
- if (isShutdown) {
- return;
- }
- if (entries.get().size() < targetSize) {
- expand(targetSize);
+ // Shutdown the channels that were cycled out.
+ for (Entry e : replacedEntries) {
+ if (!newEntries.contains(e)) {
+ e.requestShutdown();
}
}
- } catch (Exception e) {
- LOG.log(Level.WARNING, "Failed to refill channel pool", e);
}
}
- /**
- * Returns the current channel pool generation counter.
- *
- *
The generation is a monotonically increasing counter incremented each time the pool switches
- * every channel to a new mTLS certificate (a reactive refresh after a rotation, or a periodic
- * refresh that picks up a rotated certificate). Periodic refreshes that do not change the
- * certificate do not increment it. Retry loops ({@code AttemptCallable} and {@code
- * ServerStreamingAttemptCallable}) snapshot the generation before starting an RPC attempt and
- * compare it after an {@code UNAUTHENTICATED} failure to determine whether the pool rotated to a
- * new certificate during or after the attempt.
- */
- long getGeneration() {
- return generation.get();
- }
-
/**
* Get and retain a Channel Entry. The returned Entry will have its rpc count incremented,
* preventing it from getting recycled.
@@ -860,30 +616,17 @@ public ClientCall newCall(
MethodDescriptor methodDescriptor, CallOptions callOptions) {
Entry entry = getRetainedEntry(affinity);
- try {
- return new ReleasingClientCall<>(
- entry.channel.newCall(methodDescriptor, callOptions), entry);
- } catch (Throwable t) {
- entry.release();
- throw t;
- }
+
+ return new ReleasingClientCall<>(entry.channel.newCall(methodDescriptor, callOptions), entry);
}
}
- /**
- * ClientCall wrapper that makes sure to decrement the outstanding RPC count on completion.
- *
- *
Contract: Exactly one call to {@link #start(Listener, Metadata)} is required to balance
- * reference counts. Early cancellation before {@code start()} is recorded and safely decrements
- * the reference count when {@code start()} is subsequently invoked.
- */
+ /** ClientCall wrapper that makes sure to decrement the outstanding RPC count on completion. */
static class ReleasingClientCall extends SimpleForwardingClientCall {
- private final Object callLock = new Object();
- private volatile @Nullable CancellationException cancellationException;
+ private @Nullable CancellationException cancellationException;
final Entry entry;
private final AtomicBoolean wasClosed = new AtomicBoolean();
private final AtomicBoolean wasReleased = new AtomicBoolean();
- private final AtomicBoolean wasStarted = new AtomicBoolean();
public ReleasingClientCall(ClientCall delegate, Entry entry) {
super(delegate);
@@ -892,81 +635,51 @@ public ReleasingClientCall(ClientCall delegate, Entry entry) {
@Override
public void start(Listener responseListener, Metadata headers) {
- synchronized (callLock) {
- if (!wasStarted.compareAndSet(false, true)) {
- throw new IllegalStateException("Call is already started");
- }
- if (cancellationException != null) {
- if (wasReleased.compareAndSet(false, true)) {
- entry.release();
- }
- throw new IllegalStateException("Call is already cancelled", cancellationException);
- }
- try {
- super.start(
- new SimpleForwardingClientCallListener(responseListener) {
- @Override
- public void onClose(Status status, Metadata trailers) {
- if (!wasClosed.compareAndSet(false, true)) {
+ if (cancellationException != null) {
+ throw new IllegalStateException("Call is already cancelled", cancellationException);
+ }
+ try {
+ super.start(
+ new SimpleForwardingClientCallListener(responseListener) {
+ @Override
+ public void onClose(Status status, Metadata trailers) {
+ if (!wasClosed.compareAndSet(false, true)) {
+ LOG.log(
+ Level.WARNING,
+ "Call is being closed more than once. Please make sure that onClose() is not being manually called.");
+ return;
+ }
+ try {
+ super.onClose(status, trailers);
+ } finally {
+ if (wasReleased.compareAndSet(false, true)) {
+ entry.release();
+ } else {
LOG.log(
Level.WARNING,
- "Call is being closed more than once. Please make sure that onClose() is"
- + " not being manually called.");
- return;
- }
- try {
- super.onClose(status, trailers);
- } finally {
- if (wasReleased.compareAndSet(false, true)) {
- entry.release();
- } else {
- LOG.log(
- Level.WARNING,
- "Entry was released before the call is closed. This may be due to an"
- + " exception on start of the call.");
- }
+ "Entry was released before the call is closed. This may be due to an exception on start of the call.");
}
}
- },
- headers);
- } catch (Throwable t) {
- // In case start failed, make sure to release
- if (wasReleased.compareAndSet(false, true)) {
- entry.release();
- } else {
- LOG.log(
- Level.WARNING,
- "The entry is already released. This indicates that onClose() has already been"
- + " called previously");
- }
- throw t;
+ }
+ },
+ headers);
+ } catch (Exception e) {
+ // In case start failed, make sure to release
+ if (wasReleased.compareAndSet(false, true)) {
+ entry.release();
+ } else {
+ LOG.log(
+ Level.WARNING,
+ "The entry is already released. This indicates that onClose() has already been called previously");
}
+ throw e;
}
}
@Override
public void cancel(@Nullable String message, @Nullable Throwable cause) {
- boolean releaseImmediately = false;
- try {
- synchronized (callLock) {
- this.cancellationException = new CancellationException(message);
- if (!wasStarted.get()) {
- releaseImmediately = true;
- }
- if (delegate() != null) {
- super.cancel(message, cause);
- }
- }
- } catch (Throwable t) {
- if (!wasStarted.get()) {
- releaseImmediately = true;
- }
- throw t;
- } finally {
- if (releaseImmediately && wasReleased.compareAndSet(false, true)) {
- entry.release();
- }
- }
+ this.cancellationException = new CancellationException(message);
+ super.cancel(message, cause);
}
}
}
diff --git a/sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/GrpcCallContext.java b/sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/GrpcCallContext.java
index 8d4080247ec4..2337ca2f6f07 100644
--- a/sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/GrpcCallContext.java
+++ b/sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/GrpcCallContext.java
@@ -98,7 +98,6 @@ public final class GrpcCallContext implements ApiCallContext {
private final ApiCallContextOptions options;
private final EndpointContext endpointContext;
private final boolean isDirectPath;
- @Nullable private final TransportChannel transportChannel;
/** Returns an empty instance with a null channel and default {@link CallOptions}. */
public static GrpcCallContext createDefault() {
@@ -115,8 +114,7 @@ public static GrpcCallContext createDefault() {
null,
null,
null,
- false,
- null);
+ false);
}
/** Returns an instance with the given channel and {@link CallOptions}. */
@@ -134,8 +132,7 @@ public static GrpcCallContext of(Channel channel, CallOptions callOptions) {
null,
null,
null,
- false,
- null);
+ false);
}
private GrpcCallContext(
@@ -151,8 +148,7 @@ private GrpcCallContext(
@Nullable RetrySettings retrySettings,
@Nullable Set retryableCodes,
@Nullable EndpointContext endpointContext,
- boolean isDirectPath,
- @Nullable TransportChannel transportChannel) {
+ boolean isDirectPath) {
this.channel = channel;
this.credentials = credentials;
Preconditions.checkNotNull(callOptions);
@@ -172,7 +168,6 @@ private GrpcCallContext(
this.endpointContext =
endpointContext == null ? EndpointContext.getDefaultInstance() : endpointContext;
this.isDirectPath = isDirectPath;
- this.transportChannel = transportChannel;
}
/**
@@ -214,13 +209,7 @@ public GrpcCallContext withCredentials(Credentials newCredentials) {
retrySettings,
retryableCodes,
endpointContext,
- isDirectPath,
- transportChannel);
- }
-
- @Override
- public TransportChannel getTransportChannel() {
- return transportChannel;
+ isDirectPath);
}
@Override
@@ -244,8 +233,7 @@ public GrpcCallContext withTransportChannel(TransportChannel inputChannel) {
retrySettings,
retryableCodes,
endpointContext,
- transportChannel.isDirectPath(),
- inputChannel);
+ transportChannel.isDirectPath());
}
@Override
@@ -264,8 +252,7 @@ public GrpcCallContext withEndpointContext(EndpointContext endpointContext) {
retrySettings,
retryableCodes,
endpointContext,
- isDirectPath,
- transportChannel);
+ isDirectPath);
}
/** This method is obsolete. Use {@link #withTimeoutDuration(java.time.Duration)} instead. */
@@ -300,8 +287,7 @@ public GrpcCallContext withTimeoutDuration(java.time.@Nullable Duration timeout)
retrySettings,
retryableCodes,
endpointContext,
- isDirectPath,
- transportChannel);
+ isDirectPath);
}
@Override
@@ -347,8 +333,7 @@ public GrpcCallContext withStreamWaitTimeoutDuration(
retrySettings,
retryableCodes,
endpointContext,
- isDirectPath,
- transportChannel);
+ isDirectPath);
}
/**
@@ -383,8 +368,7 @@ public GrpcCallContext withStreamIdleTimeoutDuration(
retrySettings,
retryableCodes,
endpointContext,
- isDirectPath,
- transportChannel);
+ isDirectPath);
}
@BetaApi("The surface for channel affinity is not stable yet and may change in the future.")
@@ -402,8 +386,7 @@ public GrpcCallContext withChannelAffinity(@Nullable Integer affinity) {
retrySettings,
retryableCodes,
endpointContext,
- isDirectPath,
- transportChannel);
+ isDirectPath);
}
@BetaApi("The surface for extra headers is not stable yet and may change in the future.")
@@ -425,8 +408,7 @@ public GrpcCallContext withExtraHeaders(Map> extraHeaders)
retrySettings,
retryableCodes,
endpointContext,
- isDirectPath,
- transportChannel);
+ isDirectPath);
}
@Override
@@ -449,8 +431,7 @@ public GrpcCallContext withRetrySettings(RetrySettings retrySettings) {
retrySettings,
retryableCodes,
endpointContext,
- isDirectPath,
- transportChannel);
+ isDirectPath);
}
@Override
@@ -473,8 +454,7 @@ public GrpcCallContext withRetryableCodes(Set retryableCodes) {
retrySettings,
retryableCodes,
endpointContext,
- isDirectPath,
- transportChannel);
+ isDirectPath);
}
@Override
@@ -561,12 +541,6 @@ public ApiCallContext merge(ApiCallContext inputCallContext) {
newCallOptions = newCallOptions.withOption(TRACER_KEY, newTracer);
}
- TransportChannel newTransportChannel = grpcCallContext.transportChannel;
- if (newTransportChannel == null
- && (grpcCallContext.channel == null || grpcCallContext.channel.equals(channel))) {
- newTransportChannel = transportChannel;
- }
-
// The EndpointContext is not updated as there should be no reason for a user
// to update this.
return new GrpcCallContext(
@@ -582,8 +556,7 @@ public ApiCallContext merge(ApiCallContext inputCallContext) {
newRetrySettings,
newRetryableCodes,
endpointContext,
- newIsDirectPath,
- newTransportChannel);
+ newIsDirectPath);
}
/** The {@link Channel} set on this context. */
@@ -661,8 +634,7 @@ public GrpcCallContext withChannel(@Nullable Channel newChannel) {
retrySettings,
retryableCodes,
endpointContext,
- isDirectPath,
- (newChannel != null && newChannel.equals(channel)) ? transportChannel : null);
+ isDirectPath);
}
/** Returns a new instance with the call options set to the given call options. */
@@ -680,8 +652,7 @@ public GrpcCallContext withCallOptions(CallOptions newCallOptions) {
retrySettings,
retryableCodes,
endpointContext,
- isDirectPath,
- transportChannel);
+ isDirectPath);
}
public GrpcCallContext withRequestParamsDynamicHeaderOption(String requestParams) {
@@ -725,8 +696,7 @@ public GrpcCallContext withOption(Key key, T value) {
retrySettings,
retryableCodes,
endpointContext,
- isDirectPath,
- transportChannel);
+ isDirectPath);
}
/** {@inheritDoc} */
@@ -787,8 +757,7 @@ public int hashCode() {
options,
retrySettings,
retryableCodes,
- endpointContext,
- transportChannel);
+ endpointContext);
}
@Override
@@ -812,8 +781,7 @@ public boolean equals(@Nullable Object o) {
&& Objects.equals(options, that.options)
&& Objects.equals(retrySettings, that.retrySettings)
&& Objects.equals(retryableCodes, that.retryableCodes)
- && Objects.equals(endpointContext, that.endpointContext)
- && Objects.equals(transportChannel, that.transportChannel);
+ && Objects.equals(endpointContext, that.endpointContext);
}
Metadata getMetadata() {
diff --git a/sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/GrpcTransportChannel.java b/sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/GrpcTransportChannel.java
index 63180a8149f6..e0a520facb17 100644
--- a/sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/GrpcTransportChannel.java
+++ b/sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/GrpcTransportChannel.java
@@ -68,32 +68,6 @@ public Channel getChannel() {
return getManagedChannel();
}
- @Override
- public void refresh() {
- Channel channel = getChannel();
- if (channel instanceof ChannelPool) {
- ((ChannelPool) channel).refresh();
- }
- }
-
- @Override
- public boolean shouldRefresh() {
- Channel channel = getChannel();
- if (channel instanceof ChannelPool) {
- return ((ChannelPool) channel).shouldRefresh();
- }
- return false;
- }
-
- @Override
- public long getGeneration() {
- Channel channel = getChannel();
- if (channel instanceof ChannelPool) {
- return ((ChannelPool) channel).getGeneration();
- }
- return 0;
- }
-
@Override
public void shutdown() {
getManagedChannel().shutdown();
diff --git a/sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/InstantiatingGrpcChannelProvider.java b/sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/InstantiatingGrpcChannelProvider.java
index 91299357da10..f300ac611da8 100644
--- a/sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/InstantiatingGrpcChannelProvider.java
+++ b/sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/InstantiatingGrpcChannelProvider.java
@@ -46,6 +46,7 @@
import com.google.auth.ApiKeyCredentials;
import com.google.auth.Credentials;
import com.google.auth.mtls.CertificateSourceUnavailableException;
+import com.google.auth.mtls.DefaultMtlsProviderFactory;
import com.google.auth.mtls.MtlsProvider;
import com.google.auth.oauth2.ComputeEngineCredentials;
import com.google.auth.oauth2.SecureSessionAgent;
@@ -399,21 +400,12 @@ public TransportChannel getTransportChannel() throws IOException {
}
private TransportChannel createChannel() throws IOException {
- // Only track the workload certificate for rotation when the pool's channels actually present
- // it, mirroring the credential selection in createSingleChannel(): DirectPath channels use
- // GoogleDefaultChannelCredentials (ALTS) rather than the client certificate, and without an
- // mtlsProvider there is no client certificate KeyStore (S2A or plain TLS is used instead).
- String workloadCertPath =
- !this.canUseDirectPath() && mtlsProvider != null
- ? certificateBasedAccess.getWorkloadCertPath()
- : null;
return GrpcTransportChannel.newBuilder()
.setManagedChannel(
ChannelPool.create(
channelPoolSettings,
InstantiatingGrpcChannelProvider.this::createSingleChannel,
- backgroundExecutor,
- workloadCertPath))
+ backgroundExecutor))
.setDirectPath(this.canUseDirectPath())
.build();
}
@@ -472,9 +464,8 @@ private void logDirectPathMisconfig() {
level,
"Env var "
+ DIRECT_PATH_ENV_ENABLE_XDS
- + " was found and set to TRUE, but DirectPath was not enabled for this client. If"
- + " this is intended for this client, please note that this is a misconfiguration"
- + " and set the attemptDirectPath option as well.");
+ + " was found and set to TRUE, but DirectPath was not enabled for this client. If this is intended for "
+ + "this client, please note that this is a misconfiguration and set the attemptDirectPath option as well.");
}
// Case 2: Direct Path xDS was enabled via Builder. Direct Path Traffic Director must be set
// (enabled with `setAttemptDirectPath(true)`) along with xDS.
@@ -482,9 +473,7 @@ private void logDirectPathMisconfig() {
else if (isDirectPathXdsEnabledViaBuilderOption()) {
LOG.log(
level,
- "DirectPath is misconfigured. The DirectPath XDS option was set, but the"
- + " attemptDirectPath option was not. Please set both the attemptDirectPath and"
- + " attemptDirectPathXds options.");
+ "DirectPath is misconfigured. The DirectPath XDS option was set, but the attemptDirectPath option was not. Please set both the attemptDirectPath and attemptDirectPathXds options.");
}
} else {
// Case 3: credential is not correctly set
@@ -675,8 +664,7 @@ ChannelCredentials createS2ASecuredChannelCredentials() {
// Fallback to plaintext connection to S2A.
LOG.log(
Level.INFO,
- "Cannot establish an mTLS connection to S2A because autoconfig endpoint did not"
- + " return a mtls address to reach S2A.");
+ "Cannot establish an mTLS connection to S2A because autoconfig endpoint did not return a mtls address to reach S2A.");
s2aChannelCredentials = createPlaintextToS2AChannelCredentials(plaintextAddress);
return s2aChannelCredentials;
}
@@ -695,9 +683,7 @@ ChannelCredentials createS2ASecuredChannelCredentials() {
// Fallback to plaintext-to-S2A connection on error.
LOG.log(
Level.WARNING,
- "Cannot establish an mTLS connection to S2A due to error creating MTLS to MDS"
- + " TlsChannelCredentials credentials, falling back to plaintext connection to"
- + " S2A: "
+ "Cannot establish an mTLS connection to S2A due to error creating MTLS to MDS TlsChannelCredentials credentials, falling back to plaintext connection to S2A: "
+ ignore.getMessage());
s2aChannelCredentials = createPlaintextToS2AChannelCredentials(plaintextAddress);
return s2aChannelCredentials;
@@ -767,8 +753,6 @@ public ManagedChannelBuilder> createChannelBuilder() throws IOException {
if (channelCredentials != null) {
// Create the channel using channel credentials created via DCA.
builder = Grpc.newChannelBuilder(endpoint, channelCredentials);
- } else if (mtlsProvider != null && certificateBasedAccess.useMtlsClientCertificate()) {
- throw new IOException("Failed to initialize mTLS channel credentials");
} else {
// Could not create channel credentials via DCA. In accordance with
// https://google.aip.dev/auth/4115, if credentials not available through
@@ -1407,23 +1391,17 @@ public InstantiatingGrpcChannelProvider build() {
if (mtlsProvider == null) {
// Attempt to create default MtlsProvider from environment.
try {
- mtlsProvider = certificateBasedAccess.createDefaultMtlsProvider();
+ mtlsProvider = DefaultMtlsProviderFactory.create();
} catch (CertificateSourceUnavailableException e) {
// This is okay. Leave mtlsProvider as null so that we will not auto-upgrade
// to mTLS endpoints. See https://google.aip.dev/auth/4114.
} catch (IOException e) {
- mtlsProvider =
- new MtlsProvider() {
- @Override
- public boolean isAvailable() throws IOException {
- throw e;
- }
-
- @Override
- public KeyStore getKeyStore() throws IOException {
- throw e;
- }
- };
+ LOG.log(
+ Level.WARNING,
+ "DefaultMtlsProviderFactory encountered unexpected IOException: " + e.getMessage());
+ LOG.log(
+ Level.WARNING,
+ "mTLS configuration was detected on the device, but mTLS failed to initialize. Falling back to non-mTLS channel.");
}
}
}
diff --git a/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/ChannelPoolTest.java b/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/ChannelPoolTest.java
index ed658c2f45d8..5bfdc7754759 100644
--- a/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/ChannelPoolTest.java
+++ b/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/ChannelPoolTest.java
@@ -47,7 +47,6 @@
import com.google.api.gax.rpc.StreamController;
import com.google.api.gax.rpc.UnaryCallSettings;
import com.google.api.gax.rpc.UnaryCallable;
-import com.google.api.gax.rpc.mtls.CertificateRotationTracker;
import com.google.api.gax.util.FakeLogHandler;
import com.google.auth.Credentials;
import com.google.common.collect.ImmutableList;
@@ -62,27 +61,16 @@
import io.grpc.Status;
import io.grpc.stub.ClientCalls;
import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.nio.file.StandardCopyOption;
-import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.CancellationException;
-import java.util.concurrent.CountDownLatch;
-import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
-import java.util.concurrent.Future;
-import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
-import java.util.logging.Level;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
@@ -93,18 +81,13 @@
class ChannelPoolTest {
private static final int DEFAULT_AWAIT_TERMINATION_SEC = 10;
private ChannelPool pool;
- private Path tempCert;
@AfterEach
- void cleanup() throws InterruptedException, IOException {
+ void cleanup() throws InterruptedException {
if (pool != null) {
pool.shutdown();
pool.awaitTermination(DEFAULT_AWAIT_TERMINATION_SEC, TimeUnit.SECONDS);
}
- if (tempCert != null) {
- Files.deleteIfExists(tempCert);
- tempCert = null;
- }
}
@Test
@@ -118,7 +101,6 @@ void testAuthority() throws IOException {
ChannelPool.create(
ChannelPoolSettings.staticallySized(2),
new FakeChannelFactory(Arrays.asList(sub1, sub2)),
- null,
null);
assertThat(pool.authority()).isEqualTo("myAuth");
}
@@ -135,7 +117,6 @@ void testRoundRobin() throws IOException {
ChannelPool.create(
ChannelPoolSettings.staticallySized(channels.size()),
new FakeChannelFactory(channels),
- null,
null);
verifyTargetChannel(pool, channels, sub1);
@@ -214,7 +195,6 @@ void ensureEvenDistribution() throws InterruptedException, IOException {
ChannelPool.create(
ChannelPoolSettings.staticallySized(numChannels),
new FakeChannelFactory(Arrays.asList(channels)),
- null,
null);
int numThreads = 20;
@@ -253,7 +233,6 @@ void channelPrimerShouldCallPoolConstruction() throws IOException {
.setPreemptiveRefreshEnabled(true)
.build(),
new FakeChannelFactory(Arrays.asList(channel1, channel2), mockChannelPrimer),
- null,
null);
Mockito.verify(mockChannelPrimer, Mockito.times(2))
.primeChannel(Mockito.any(ManagedChannel.class));
@@ -294,8 +273,7 @@ void channelPrimerIsCalledPeriodically() throws IOException {
.setPreemptiveRefreshEnabled(true)
.build(),
channelFactory,
- provider,
- null);
+ provider);
// 1 call during the creation
Mockito.verify(mockChannelPrimer, Mockito.times(1))
.primeChannel(Mockito.any(ManagedChannel.class));
@@ -319,7 +297,7 @@ void callShouldCompleteAfterCreation() throws IOException {
ManagedChannel replacementChannel = mock(ManagedChannel.class);
FakeChannelFactory channelFactory =
new FakeChannelFactory(ImmutableList.of(underlyingChannel, replacementChannel));
- pool = ChannelPool.create(ChannelPoolSettings.staticallySized(1), channelFactory, null, null);
+ pool = ChannelPool.create(ChannelPoolSettings.staticallySized(1), channelFactory, null);
// create a mock call when new call comes to the underlying channel
MockClientCall mockClientCall = new MockClientCall<>(1, Status.OK);
@@ -344,7 +322,7 @@ void callShouldCompleteAfterCreation() throws IOException {
ClientCall call =
pool.newCall(FakeMethodDescriptor.create(), CallOptions.DEFAULT);
- pool.refreshAll();
+ pool.refresh();
// shutdown is not called because there is still an outstanding call, even if it hasn't started
Mockito.verify(underlyingChannel, Mockito.after(200).never()).shutdown();
@@ -368,7 +346,7 @@ void callShouldCompleteAfterStarted() throws IOException {
FakeChannelFactory channelFactory =
new FakeChannelFactory(ImmutableList.of(underlyingChannel, replacementChannel));
- pool = ChannelPool.create(ChannelPoolSettings.staticallySized(1), channelFactory, null, null);
+ pool = ChannelPool.create(ChannelPoolSettings.staticallySized(1), channelFactory, null);
// create a mock call when new call comes to the underlying channel
MockClientCall mockClientCall = new MockClientCall<>(1, Status.OK);
@@ -395,7 +373,7 @@ void callShouldCompleteAfterStarted() throws IOException {
// start clientCall
call.start(listener, new Metadata());
- pool.refreshAll();
+ pool.refresh();
// shutdown is not called because there is still an outstanding call
Mockito.verify(underlyingChannel, Mockito.after(200).never()).shutdown();
@@ -413,7 +391,7 @@ void channelShouldShutdown() throws IOException {
FakeChannelFactory channelFactory =
new FakeChannelFactory(ImmutableList.of(underlyingChannel, replacementChannel));
- pool = ChannelPool.create(ChannelPoolSettings.staticallySized(1), channelFactory, null, null);
+ pool = ChannelPool.create(ChannelPoolSettings.staticallySized(1), channelFactory, null);
// create a mock call when new call comes to the underlying channel
MockClientCall mockClientCall = new MockClientCall<>(1, Status.OK);
@@ -444,1009 +422,11 @@ void channelShouldShutdown() throws IOException {
call.sendMessage("message");
// shutdown is not called because it has not been shutdown yet
Mockito.verify(underlyingChannel, Mockito.after(200).never()).shutdown();
- pool.refreshAll();
+ pool.refresh();
// shutdown is called because the outstanding call has completed
Mockito.verify(underlyingChannel, Mockito.atLeastOnce()).shutdown();
}
- @Test
- void testCancelBeforeStartReleasesChannelEntry() throws IOException {
- ManagedChannel underlyingChannel = mock(ManagedChannel.class);
- ManagedChannel replacementChannel = mock(ManagedChannel.class);
- FakeChannelFactory channelFactory =
- new FakeChannelFactory(ImmutableList.of(underlyingChannel, replacementChannel));
- pool = ChannelPool.create(ChannelPoolSettings.staticallySized(1), channelFactory, null, null);
-
- ClientCall call =
- pool.newCall(FakeMethodDescriptor.create(), CallOptions.DEFAULT);
-
- pool.refreshAll();
- Mockito.verify(underlyingChannel, Mockito.never()).shutdown();
-
- call.cancel("Cancelled early", null);
- Mockito.verify(underlyingChannel, Mockito.times(1)).shutdown();
- }
-
- @Test
- void channelReactiveMTlsRefresh_swapsChannelsOnlyWhenCertChanges()
- throws IOException, InterruptedException {
- ManagedChannel underlyingChannel1 = Mockito.mock(ManagedChannel.class);
- ManagedChannel underlyingChannel2 = Mockito.mock(ManagedChannel.class);
-
- FakeChannelFactory channelFactory =
- new FakeChannelFactory(ImmutableList.of(underlyingChannel1, underlyingChannel2));
-
- // Create a temp file to act as the cert
- tempCert = Files.createTempFile("cert", ".pem");
-
- Path clientCert = Paths.get("src", "test", "resources", "client_cert.pem");
- Files.copy(clientCert, tempCert, StandardCopyOption.REPLACE_EXISTING);
-
- ChannelPoolSettings channelPoolSettings =
- ChannelPoolSettings.builder().setInitialChannelCount(1).build();
-
- pool = ChannelPool.create(channelPoolSettings, channelFactory, null, tempCert.toString());
-
- // Initially uses channel1
- pool.newCall(FakeMethodDescriptor.create(), CallOptions.DEFAULT);
- Mockito.verify(underlyingChannel1, Mockito.times(1))
- .newCall(Mockito.>any(), Mockito.any(CallOptions.class));
-
- // Try a reactive refresh *without* changing the cert content (should no-op)
- pool.refresh();
-
- // Verify it's STILL channel1
- pool.newCall(FakeMethodDescriptor.create(), CallOptions.DEFAULT);
- Mockito.verify(underlyingChannel1, Mockito.times(2))
- .newCall(Mockito.>any(), Mockito.any(CallOptions.class));
-
- // The ChannelPool caches fingerprints for 1000ms, wait for it to expire
- pool.invalidateDiskFingerprintCache();
-
- Path rootCert = Paths.get("src", "test", "resources", "root_cert.pem");
- Files.copy(rootCert, tempCert, StandardCopyOption.REPLACE_EXISTING);
-
- // Try a reactive refresh *with* a changed cert content (should swap channels)
- pool.refresh();
-
- // Verify it is NOW channel2
- pool.newCall(FakeMethodDescriptor.create(), CallOptions.DEFAULT);
- Mockito.verify(underlyingChannel2, Mockito.times(1))
- .newCall(Mockito.>any(), Mockito.any(CallOptions.class));
- }
-
- @Test
- void channelReactiveMTlsRefresh_failedCreationDoesNotMutateFingerprintAndAllowsRetry()
- throws IOException {
- ManagedChannel channel1 = Mockito.mock(ManagedChannel.class);
- ManagedChannel channel2 = Mockito.mock(ManagedChannel.class);
- ChannelFactory channelFactory =
- Mockito.mock(ChannelFactory.class, Mockito.withSettings().withoutAnnotations());
-
- // Initial creation returns channel1, refresh attempt 1 throws IOException, refresh attempt 2
- // returns channel2
- Mockito.when(channelFactory.createSingleChannel())
- .thenReturn(channel1)
- .thenThrow(new IOException("Transient channel creation error"))
- .thenReturn(channel2);
-
- tempCert = Files.createTempFile("cert", ".pem");
- Path clientCert = Paths.get("src", "test", "resources", "client_cert.pem");
- Files.copy(clientCert, tempCert, StandardCopyOption.REPLACE_EXISTING);
-
- ChannelPoolSettings channelPoolSettings =
- ChannelPoolSettings.builder().setInitialChannelCount(1).build();
-
- pool = ChannelPool.create(channelPoolSettings, channelFactory, null, tempCert.toString());
-
- // Initially uses channel1
- pool.newCall(FakeMethodDescriptor.create(), CallOptions.DEFAULT);
- Mockito.verify(channel1, Mockito.times(1))
- .newCall(Mockito.>any(), Mockito.any(CallOptions.class));
-
- // Rotate cert on disk
- pool.invalidateDiskFingerprintCache();
- Path rootCert = Paths.get("src", "test", "resources", "root_cert.pem");
- Files.copy(rootCert, tempCert, StandardCopyOption.REPLACE_EXISTING);
-
- // Refresh attempt 1: createSingleChannel throws IOException.
- // Refresh should fail to replace channel and MUST NOT record the new cert fingerprint as
- // active.
- pool.refresh();
-
- // Verify still channel1
- pool.newCall(FakeMethodDescriptor.create(), CallOptions.DEFAULT);
- Mockito.verify(channel1, Mockito.times(2))
- .newCall(Mockito.>any(), Mockito.any(CallOptions.class));
-
- // Refresh attempt 2: with the same cert file on disk (cache expired), channelFactory now
- // succeeds.
- // If the fingerprint had been mutated on the failed attempt, this call would be skipped as a
- // duplicate!
- pool.invalidateDiskFingerprintCache();
- pool.refresh();
-
- // Verify it has now swapped to channel2!
- pool.newCall(FakeMethodDescriptor.create(), CallOptions.DEFAULT);
- Mockito.verify(channel2, Mockito.times(1))
- .newCall(Mockito.>any(), Mockito.any(CallOptions.class));
- }
-
- private void writeCert(String resourceName) throws IOException {
- if (tempCert == null) {
- tempCert = Files.createTempFile("cert", ".pem");
- }
- Files.copy(
- Paths.get("src", "test", "resources", resourceName),
- tempCert,
- StandardCopyOption.REPLACE_EXISTING);
- }
-
- /** Creates an mTLS pool backed by {@code executor} and then rotates the certificate on disk. */
- private ChannelPool createMtlsPoolAndRotateCert(
- ChannelPoolSettings settings,
- ChannelFactory channelFactory,
- ScheduledExecutorService executor)
- throws IOException {
- writeCert("client_cert.pem");
- pool =
- new ChannelPool(
- settings, channelFactory, FixedExecutorProvider.create(executor), tempCert.toString());
- pool.invalidateDiskFingerprintCache();
- writeCert("root_cert.pem");
- assertThat(pool.shouldRefresh()).isTrue();
- return pool;
- }
-
- private static ScheduledExecutorService mockExecutor() {
- return Mockito.mock(
- ScheduledExecutorService.class, Mockito.withSettings().withoutAnnotations());
- }
-
- private static ChannelFactory mockChannelFactory() {
- return Mockito.mock(ChannelFactory.class, Mockito.withSettings().withoutAnnotations());
- }
-
- @Test
- void channelReactiveMTlsRefresh_partialFailureInStaticPool_dropsUnrefreshedChannelsAndRefills()
- throws IOException {
- ScheduledExecutorService executor = mockExecutor();
- ManagedChannel initial1 = Mockito.mock(ManagedChannel.class);
- ManagedChannel initial2 = Mockito.mock(ManagedChannel.class);
- ManagedChannel rotated1 = Mockito.mock(ManagedChannel.class);
- ManagedChannel refilled = Mockito.mock(ManagedChannel.class);
- ChannelFactory channelFactory = mockChannelFactory();
- Mockito.when(channelFactory.createSingleChannel())
- .thenReturn(initial1, initial2)
- .thenReturn(rotated1)
- .thenThrow(new IOException("Transient failure on second sub-channel"))
- .thenReturn(refilled);
-
- createMtlsPoolAndRotateCert(ChannelPoolSettings.staticallySized(2), channelFactory, executor);
- long genBefore = pool.getGeneration();
-
- pool.refresh();
-
- // Only the newly created channel is kept; both channels on the old certificate are retired.
- assertThat(pool.entries.get()).hasSize(1);
- Mockito.verify(initial1).shutdown();
- Mockito.verify(initial2).shutdown();
- Mockito.verify(rotated1, Mockito.never()).shutdown();
- pool.newCall(FakeMethodDescriptor.create(), CallOptions.DEFAULT);
- Mockito.verify(rotated1)
- .newCall(Mockito.>any(), Mockito.any(CallOptions.class));
- assertThat(pool.getGeneration()).isEqualTo(genBefore + 1);
- // Every channel left in the pool uses the new certificate, so it is recorded as active.
- pool.invalidateDiskFingerprintCache();
- assertThat(pool.shouldRefresh()).isFalse();
-
- // A one-time refill restores the configured size.
- ArgumentCaptor refillTask = ArgumentCaptor.forClass(Runnable.class);
- Mockito.verify(executor).execute(refillTask.capture());
- refillTask.getValue().run();
- assertThat(pool.entries.get()).hasSize(2);
- Mockito.verify(channelFactory, Mockito.times(5)).createSingleChannel();
-
- // Running the refill again on a full pool creates no channels.
- refillTask.getValue().run();
- assertThat(pool.entries.get()).hasSize(2);
- Mockito.verify(channelFactory, Mockito.times(5)).createSingleChannel();
- Mockito.verify(refilled, Mockito.never()).shutdown();
- Mockito.verify(executor, Mockito.times(1)).execute(Mockito.any(Runnable.class));
- }
-
- @Test
- void channelReactiveMTlsRefresh_fullSuccess_doesNotScheduleRefill() throws IOException {
- ScheduledExecutorService executor = mockExecutor();
- ManagedChannel initial1 = Mockito.mock(ManagedChannel.class);
- ManagedChannel initial2 = Mockito.mock(ManagedChannel.class);
- ManagedChannel rotated1 = Mockito.mock(ManagedChannel.class);
- ManagedChannel rotated2 = Mockito.mock(ManagedChannel.class);
- ChannelFactory channelFactory = mockChannelFactory();
- Mockito.when(channelFactory.createSingleChannel())
- .thenReturn(initial1, initial2, rotated1, rotated2);
-
- createMtlsPoolAndRotateCert(ChannelPoolSettings.staticallySized(2), channelFactory, executor);
-
- pool.refresh();
-
- assertThat(pool.entries.get()).hasSize(2);
- Mockito.verify(initial1).shutdown();
- Mockito.verify(initial2).shutdown();
- pool.invalidateDiskFingerprintCache();
- assertThat(pool.shouldRefresh()).isFalse();
- Mockito.verify(executor, Mockito.never()).execute(Mockito.any(Runnable.class));
- }
-
- @Test
- void refreshAll_partialFailureWithoutRotation_keepsOldChannelAndDoesNotScheduleRefill()
- throws IOException {
- // Non-rotation refreshes (e.g. the preemptive refresh) keep per-slot fallback even for mTLS.
- ScheduledExecutorService executor = mockExecutor();
- ManagedChannel initial1 = Mockito.mock(ManagedChannel.class);
- ManagedChannel initial2 = Mockito.mock(ManagedChannel.class);
- ManagedChannel refreshed1 = Mockito.mock(ManagedChannel.class);
- ChannelFactory channelFactory = mockChannelFactory();
- Mockito.when(channelFactory.createSingleChannel())
- .thenReturn(initial1, initial2)
- .thenReturn(refreshed1)
- .thenThrow(new IOException("Transient failure on second sub-channel"));
-
- writeCert("client_cert.pem");
- pool =
- new ChannelPool(
- ChannelPoolSettings.staticallySized(2),
- channelFactory,
- FixedExecutorProvider.create(executor),
- tempCert.toString());
- long genBefore = pool.getGeneration();
- List before = pool.entries.get();
-
- assertThat(pool.refreshAll()).isFalse();
-
- assertThat(pool.entries.get()).hasSize(2);
- // Each channel keeps its slot, so affinity-to-index mapping is unchanged.
- assertThat(pool.entries.get().get(0)).isNotSameInstanceAs(before.get(0));
- assertThat(pool.entries.get().get(1)).isSameInstanceAs(before.get(1));
- Mockito.verify(initial1).shutdown();
- Mockito.verify(initial2, Mockito.never()).shutdown();
- assertThat(pool.getGeneration()).isEqualTo(genBefore);
- Mockito.verify(executor, Mockito.never()).execute(Mockito.any(Runnable.class));
- }
-
- @Test
- void channelReactiveMTlsRefresh_allChannelsFail_leavesPoolUnchangedAndDoesNotRefill()
- throws IOException {
- ScheduledExecutorService executor = mockExecutor();
- ManagedChannel initial1 = Mockito.mock(ManagedChannel.class);
- ManagedChannel initial2 = Mockito.mock(ManagedChannel.class);
- ChannelFactory channelFactory = mockChannelFactory();
- Mockito.when(channelFactory.createSingleChannel())
- .thenReturn(initial1, initial2)
- .thenThrow(new IOException("Failure on first sub-channel"))
- .thenThrow(new IOException("Failure on second sub-channel"));
-
- createMtlsPoolAndRotateCert(ChannelPoolSettings.staticallySized(2), channelFactory, executor);
- long genBefore = pool.getGeneration();
- List before = pool.entries.get();
-
- pool.refresh();
-
- // Nothing could be rebuilt, so the pool keeps its old channels and the rotation stays pending.
- assertThat(pool.entries.get()).containsExactlyElementsIn(before).inOrder();
- Mockito.verify(initial1, Mockito.never()).shutdown();
- Mockito.verify(initial2, Mockito.never()).shutdown();
- assertThat(pool.getGeneration()).isEqualTo(genBefore);
- pool.invalidateDiskFingerprintCache();
- assertThat(pool.shouldRefresh()).isTrue();
- Mockito.verify(executor, Mockito.never()).execute(Mockito.any(Runnable.class));
- }
-
- @Test
- void channelReactiveMTlsRefresh_partialFailureInDynamicPool_refillsToPreRefreshSize()
- throws IOException {
- ScheduledExecutorService executor = mockExecutor();
- ManagedChannel initial1 = Mockito.mock(ManagedChannel.class);
- ManagedChannel initial2 = Mockito.mock(ManagedChannel.class);
- ManagedChannel rotated1 = Mockito.mock(ManagedChannel.class);
- ManagedChannel refilled = Mockito.mock(ManagedChannel.class);
- ChannelFactory channelFactory = mockChannelFactory();
- Mockito.when(channelFactory.createSingleChannel())
- .thenReturn(initial1, initial2)
- .thenReturn(rotated1)
- .thenThrow(new IOException("Transient failure on second sub-channel"))
- .thenReturn(refilled);
-
- createMtlsPoolAndRotateCert(
- ChannelPoolSettings.builder()
- .setInitialChannelCount(2)
- .setMinChannelCount(2)
- .setMaxChannelCount(4)
- .setMinRpcsPerChannel(1)
- .setMaxRpcsPerChannel(2)
- .build(),
- channelFactory,
- executor);
-
- pool.refresh();
-
- assertThat(pool.entries.get()).hasSize(1);
- Mockito.verify(initial1).shutdown();
- Mockito.verify(initial2).shutdown();
- pool.invalidateDiskFingerprintCache();
- assertThat(pool.shouldRefresh()).isFalse();
-
- // Dynamic pools are refilled right away too, rather than waiting for resize().
- ArgumentCaptor refillTask = ArgumentCaptor.forClass(Runnable.class);
- Mockito.verify(executor).execute(refillTask.capture());
- refillTask.getValue().run();
- assertThat(pool.entries.get()).hasSize(2);
- Mockito.verify(refilled, Mockito.never()).shutdown();
- }
-
- @Test
- void channelReactiveMTlsRefresh_partialFailureAfterResize_refillsToPreRefreshSize()
- throws IOException {
- ScheduledExecutorService executor = mockExecutor();
- ChannelFactory channelFactory = mockChannelFactory();
- Mockito.when(channelFactory.createSingleChannel())
- .thenReturn(
- // initial pool of 4
- Mockito.mock(ManagedChannel.class),
- Mockito.mock(ManagedChannel.class),
- Mockito.mock(ManagedChannel.class),
- Mockito.mock(ManagedChannel.class),
- // rotation refresh: 1 of the 2 remaining channels is recreated
- Mockito.mock(ManagedChannel.class))
- .thenThrow(new IOException("Transient failure on second sub-channel"))
- .thenReturn(Mockito.mock(ManagedChannel.class));
-
- createMtlsPoolAndRotateCert(
- ChannelPoolSettings.builder()
- .setInitialChannelCount(4)
- .setMinChannelCount(2)
- .setMaxChannelCount(6)
- .setMinRpcsPerChannel(1)
- .setMaxRpcsPerChannel(2)
- .build(),
- channelFactory,
- executor);
- // With no load, resize() shrinks the pool below its initial channel count.
- pool.resize();
- assertThat(pool.entries.get()).hasSize(2);
-
- pool.refresh();
- assertThat(pool.entries.get()).hasSize(1);
-
- // The refill targets the size before the refresh, not the initial channel count.
- ArgumentCaptor refillTask = ArgumentCaptor.forClass(Runnable.class);
- Mockito.verify(executor).execute(refillTask.capture());
- refillTask.getValue().run();
- assertThat(pool.entries.get()).hasSize(2);
- Mockito.verify(channelFactory, Mockito.times(7)).createSingleChannel();
- }
-
- @Test
- void refill_afterShutdown_createsNoChannels() throws IOException {
- ScheduledExecutorService executor = mockExecutor();
- ChannelFactory channelFactory = mockChannelFactory();
- Mockito.when(channelFactory.createSingleChannel())
- .thenReturn(
- Mockito.mock(ManagedChannel.class),
- Mockito.mock(ManagedChannel.class),
- Mockito.mock(ManagedChannel.class))
- .thenThrow(new IOException("Transient failure on second sub-channel"))
- .thenReturn(Mockito.mock(ManagedChannel.class));
-
- createMtlsPoolAndRotateCert(ChannelPoolSettings.staticallySized(2), channelFactory, executor);
- pool.refresh();
- ArgumentCaptor refillTask = ArgumentCaptor.forClass(Runnable.class);
- Mockito.verify(executor).execute(refillTask.capture());
-
- pool.shutdown();
- refillTask.getValue().run();
-
- assertThat(pool.entries.get()).hasSize(1);
- Mockito.verify(channelFactory, Mockito.times(4)).createSingleChannel();
- }
-
- @Test
- void resize_afterShutdown_createsNoChannels() throws IOException {
- ScheduledExecutorService executor = mockExecutor();
- ChannelFactory channelFactory = mockChannelFactory();
- Mockito.when(channelFactory.createSingleChannel())
- .thenReturn(
- Mockito.mock(ManagedChannel.class),
- Mockito.mock(ManagedChannel.class),
- Mockito.mock(ManagedChannel.class))
- .thenThrow(new IOException("Transient failure on second sub-channel"))
- .thenReturn(Mockito.mock(ManagedChannel.class));
- createMtlsPoolAndRotateCert(
- ChannelPoolSettings.builder()
- .setInitialChannelCount(2)
- .setMinChannelCount(2)
- .setMaxChannelCount(4)
- .setMinRpcsPerChannel(1)
- .setMaxRpcsPerChannel(2)
- .build(),
- channelFactory,
- executor);
- ArgumentCaptor resizeTask = ArgumentCaptor.forClass(Runnable.class);
- Mockito.verify(executor)
- .scheduleAtFixedRate(
- resizeTask.capture(), Mockito.anyLong(), Mockito.anyLong(), Mockito.any());
- // Drop the pool below its minimum size, so that resize() would expand it.
- pool.refresh();
- assertThat(pool.entries.get()).hasSize(1);
-
- // A resize run that was already waiting for the lock when the pool was shut down.
- pool.shutdown();
- resizeTask.getValue().run();
-
- assertThat(pool.entries.get()).hasSize(1);
- Mockito.verify(channelFactory, Mockito.times(4)).createSingleChannel();
- }
-
- @Test
- void refill_channelCreationFailure_isLoggedAndDoesNotThrow() throws IOException {
- ScheduledExecutorService executor = mockExecutor();
- ChannelFactory channelFactory = mockChannelFactory();
- Mockito.when(channelFactory.createSingleChannel())
- .thenReturn(
- Mockito.mock(ManagedChannel.class),
- Mockito.mock(ManagedChannel.class),
- Mockito.mock(ManagedChannel.class))
- .thenThrow(new IOException("Transient failure on second sub-channel"))
- .thenThrow(new IOException("Checked failure during refill"))
- .thenThrow(new RuntimeException("Unchecked failure during refill"))
- .thenReturn(Mockito.mock(ManagedChannel.class));
-
- createMtlsPoolAndRotateCert(ChannelPoolSettings.staticallySized(2), channelFactory, executor);
- pool.refresh();
- ArgumentCaptor refillTask = ArgumentCaptor.forClass(Runnable.class);
- Mockito.verify(executor).execute(refillTask.capture());
-
- // Checked and unchecked failures are both handled by expand(); the pool stays usable at its
- // reduced size.
- FakeLogHandler logHandler = new FakeLogHandler();
- ChannelPool.LOG.addHandler(logHandler);
- try {
- refillTask.getValue().run();
- assertThat(pool.entries.get()).hasSize(1);
-
- refillTask.getValue().run();
- assertThat(pool.entries.get()).hasSize(1);
- } finally {
- ChannelPool.LOG.removeHandler(logHandler);
- }
- assertThat(logHandler.getAllMessages())
- .containsExactly("Failed to add channel", "Failed to add channel");
-
- refillTask.getValue().run();
- assertThat(pool.entries.get()).hasSize(2);
- }
-
- @Test
- void refill_failureAfterPartialProgress_keepsCreatedChannels() throws IOException {
- ScheduledExecutorService executor = mockExecutor();
- ManagedChannel refilled = Mockito.mock(ManagedChannel.class);
- ChannelFactory channelFactory = mockChannelFactory();
- Mockito.when(channelFactory.createSingleChannel())
- .thenReturn(
- Mockito.mock(ManagedChannel.class),
- Mockito.mock(ManagedChannel.class),
- Mockito.mock(ManagedChannel.class),
- Mockito.mock(ManagedChannel.class))
- .thenThrow(new IOException("Transient failure on second sub-channel"))
- .thenThrow(new IOException("Transient failure on third sub-channel"))
- .thenReturn(refilled)
- .thenThrow(new RuntimeException("Unchecked failure during refill"));
-
- createMtlsPoolAndRotateCert(ChannelPoolSettings.staticallySized(3), channelFactory, executor);
- pool.refresh();
- assertThat(pool.entries.get()).hasSize(1);
- ArgumentCaptor refillTask = ArgumentCaptor.forClass(Runnable.class);
- Mockito.verify(executor).execute(refillTask.capture());
-
- refillTask.getValue().run();
-
- // The channel created before the failure is added to the pool rather than orphaned.
- assertThat(pool.entries.get()).hasSize(2);
- Mockito.verify(refilled, Mockito.never()).shutdown();
- Mockito.verify(channelFactory, Mockito.times(8)).createSingleChannel();
- }
-
- @Test
- void channelReactiveMTlsRefresh_refillRejectedByExecutor_stillCompletesRefresh()
- throws IOException {
- ScheduledExecutorService executor = mockExecutor();
- Mockito.doThrow(new RejectedExecutionException("Executor shut down"))
- .when(executor)
- .execute(Mockito.any(Runnable.class));
- ManagedChannel initial1 = Mockito.mock(ManagedChannel.class);
- ManagedChannel initial2 = Mockito.mock(ManagedChannel.class);
- ChannelFactory channelFactory = mockChannelFactory();
- Mockito.when(channelFactory.createSingleChannel())
- .thenReturn(initial1, initial2, Mockito.mock(ManagedChannel.class))
- .thenThrow(new IOException("Transient failure on second sub-channel"));
-
- createMtlsPoolAndRotateCert(ChannelPoolSettings.staticallySized(2), channelFactory, executor);
- long genBefore = pool.getGeneration();
-
- pool.refresh();
-
- Mockito.verify(executor).execute(Mockito.any(Runnable.class));
- assertThat(pool.entries.get()).hasSize(1);
- Mockito.verify(initial1).shutdown();
- Mockito.verify(initial2).shutdown();
- assertThat(pool.getGeneration()).isEqualTo(genBefore + 1);
- pool.invalidateDiskFingerprintCache();
- assertThat(pool.shouldRefresh()).isFalse();
- }
-
- @Test
- void refreshAll_runtimeExceptionOrError_doesNotLeakCreatedChannels() throws IOException {
- ManagedChannel initial1 = Mockito.mock(ManagedChannel.class);
- ManagedChannel initial2 = Mockito.mock(ManagedChannel.class);
- ManagedChannel createdBeforeRuntimeEx = Mockito.mock(ManagedChannel.class);
- ManagedChannel createdBeforeError = Mockito.mock(ManagedChannel.class);
- ChannelFactory channelFactory =
- Mockito.mock(ChannelFactory.class, Mockito.withSettings().withoutAnnotations());
-
- Mockito.when(channelFactory.createSingleChannel())
- .thenReturn(initial1, initial2)
- .thenReturn(createdBeforeRuntimeEx)
- .thenThrow(new RuntimeException("Unchecked runtime exception"))
- .thenReturn(createdBeforeError)
- .thenThrow(new AssertionError("Simulated Error during refresh"));
-
- pool = ChannelPool.create(ChannelPoolSettings.staticallySized(2), channelFactory, null, null);
-
- // Case 1: RuntimeException on channel 1 after creating channel 0 -> caught as Exception,
- // partial progress committed
- boolean allCreated = pool.refreshAll();
- assertThat(allCreated).isFalse();
- Mockito.verify(initial1).shutdown();
-
- // Case 2: Error on channel 1 after creating channel 0 -> aborts, finally block must shut down
- // createdBeforeError
- assertThrows(AssertionError.class, () -> pool.refreshAll());
- Mockito.verify(createdBeforeError).shutdown();
- }
-
- @Test
- void refresh_onShutdownPool_noOpsAndCreatesNoChannels() throws IOException {
- ManagedChannel channel1 = mock(ManagedChannel.class);
- ManagedChannel channel2 = mock(ManagedChannel.class);
- ChannelFactory channelFactory =
- Mockito.mock(ChannelFactory.class, Mockito.withSettings().withoutAnnotations());
- Mockito.when(channelFactory.createSingleChannel()).thenReturn(channel1, channel2);
-
- pool = ChannelPool.create(ChannelPoolSettings.staticallySized(1), channelFactory, null, null);
- Mockito.verify(channelFactory, Mockito.times(1)).createSingleChannel();
-
- pool.shutdown();
- assertThat(pool.isShutdown()).isTrue();
-
- // Invoking refresh or refreshAll on shut down pool must no-op and never create new subchannels
- pool.refresh();
- boolean refreshed = pool.refreshAll();
- assertThat(refreshed).isFalse();
- Mockito.verify(channelFactory, Mockito.times(1)).createSingleChannel();
- assertThat(pool.isShutdown()).isTrue();
- }
-
- @Test
- void refreshAll_doesNotIncrementGeneration() throws IOException {
- ManagedChannel channel1 = mock(ManagedChannel.class);
- ManagedChannel channel2 = mock(ManagedChannel.class);
- ChannelFactory channelFactory =
- Mockito.mock(ChannelFactory.class, Mockito.withSettings().withoutAnnotations());
- Mockito.when(channelFactory.createSingleChannel()).thenReturn(channel1, channel2);
-
- pool = ChannelPool.create(ChannelPoolSettings.staticallySized(1), channelFactory, null, null);
- assertThat(pool.getGeneration()).isEqualTo(0);
-
- // Only a switch to a new certificate advances the generation.
- assertThat(pool.refreshAll()).isTrue();
- Mockito.verify(channel1).shutdown();
- assertThat(pool.getGeneration()).isEqualTo(0);
- }
-
- /**
- * Creates an mTLS pool with preemptive refresh enabled and returns the scheduled periodic refresh
- * task.
- */
- private Runnable createPreemptiveRefreshMtlsPool(int size, ChannelFactory channelFactory)
- throws IOException {
- return createPreemptiveRefreshMtlsPool(size, channelFactory, mockExecutor());
- }
-
- private Runnable createPreemptiveRefreshMtlsPool(
- int size, ChannelFactory channelFactory, ScheduledExecutorService executor)
- throws IOException {
- List refreshTasks = new ArrayList<>();
- Mockito.doAnswer(
- invocation -> {
- refreshTasks.add(invocation.getArgument(0));
- return Mockito.mock(
- ScheduledFuture.class, Mockito.withSettings().withoutAnnotations());
- })
- .when(executor)
- .scheduleAtFixedRate(
- Mockito.any(Runnable.class), Mockito.anyLong(), Mockito.anyLong(), Mockito.any());
- writeCert("client_cert.pem");
- pool =
- new ChannelPool(
- ChannelPoolSettings.staticallySized(size).toBuilder()
- .setPreemptiveRefreshEnabled(true)
- .build(),
- channelFactory,
- FixedExecutorProvider.create(executor),
- tempCert.toString());
- assertThat(refreshTasks).hasSize(1);
- return refreshTasks.get(0);
- }
-
- private String readCertFingerprint() {
- return new CertificateRotationTracker(tempCert.toString()).readDiskFingerprint();
- }
-
- @Test
- void preemptiveRefresh_withoutRotation_doesNotIncrementGeneration() throws IOException {
- ManagedChannel initial = Mockito.mock(ManagedChannel.class);
- ManagedChannel refreshed = Mockito.mock(ManagedChannel.class);
- ChannelFactory channelFactory = mockChannelFactory();
- Mockito.when(channelFactory.createSingleChannel()).thenReturn(initial, refreshed);
- Runnable preemptiveRefresh = createPreemptiveRefreshMtlsPool(1, channelFactory);
-
- FakeLogHandler logHandler = new FakeLogHandler();
- Level originalLevel = ChannelPool.LOG.getLevel();
- ChannelPool.LOG.setLevel(Level.FINE);
- ChannelPool.LOG.addHandler(logHandler);
- try {
- pool.invalidateDiskFingerprintCache();
- preemptiveRefresh.run();
- } finally {
- ChannelPool.LOG.removeHandler(logHandler);
- ChannelPool.LOG.setLevel(originalLevel);
- }
-
- // The channels are replaced, but the certificate did not change.
- Mockito.verify(initial).shutdown();
- assertThat(pool.getGeneration()).isEqualTo(0);
- assertThat(logHandler.getAllMessages()).contains("Refreshing all channels");
- assertThat(String.join("\n", logHandler.getAllMessages()))
- .doesNotContain("Channel pool switched to certificate");
- }
-
- @Test
- void preemptiveRefresh_pickingUpRotatedCert_incrementsGeneration() throws IOException {
- ManagedChannel initial = Mockito.mock(ManagedChannel.class);
- ManagedChannel rotated = Mockito.mock(ManagedChannel.class);
- ChannelFactory channelFactory = mockChannelFactory();
- Mockito.when(channelFactory.createSingleChannel()).thenReturn(initial, rotated);
- Runnable preemptiveRefresh = createPreemptiveRefreshMtlsPool(1, channelFactory);
-
- pool.invalidateDiskFingerprintCache();
- writeCert("root_cert.pem");
- preemptiveRefresh.run();
-
- Mockito.verify(initial).shutdown();
- assertThat(pool.getGeneration()).isEqualTo(1);
- pool.invalidateDiskFingerprintCache();
- assertThat(pool.shouldRefresh()).isFalse();
- }
-
- @Test
- void preemptiveRefresh_whenCertUnreadable_skipsRefreshAndLogs() throws IOException {
- ManagedChannel initial = Mockito.mock(ManagedChannel.class);
- ChannelFactory channelFactory = mockChannelFactory();
- Mockito.when(channelFactory.createSingleChannel()).thenReturn(initial);
- Runnable preemptiveRefresh = createPreemptiveRefreshMtlsPool(1, channelFactory);
-
- // An empty certificate file is what a reader sees while the certificate is being rewritten.
- Files.write(tempCert, new byte[0]);
- FakeLogHandler logHandler = new FakeLogHandler();
- Level originalLevel = ChannelPool.LOG.getLevel();
- ChannelPool.LOG.setLevel(Level.FINE);
- ChannelPool.LOG.addHandler(logHandler);
- try {
- pool.invalidateDiskFingerprintCache();
- preemptiveRefresh.run();
- } finally {
- ChannelPool.LOG.removeHandler(logHandler);
- ChannelPool.LOG.setLevel(originalLevel);
- }
-
- Mockito.verify(channelFactory, Mockito.times(1)).createSingleChannel();
- Mockito.verify(initial, Mockito.never()).shutdown();
- assertThat(pool.getGeneration()).isEqualTo(0);
- assertThat(String.join("\n", logHandler.getAllMessages()))
- .contains("Skipping pre-emptive channel refresh");
- assertThat(logHandler.getAllMessages()).doesNotContain("Refreshing all channels");
- }
-
- @Test
- void preemptiveRefresh_partialFailureOnCertChange_dropsOldChannelsAndCompletesSwitch()
- throws IOException {
- ScheduledExecutorService executor = mockExecutor();
- ManagedChannel initial1 = Mockito.mock(ManagedChannel.class);
- ManagedChannel initial2 = Mockito.mock(ManagedChannel.class);
- ManagedChannel rotated1 = Mockito.mock(ManagedChannel.class);
- ManagedChannel refilled = Mockito.mock(ManagedChannel.class);
- ChannelFactory channelFactory = mockChannelFactory();
- Mockito.when(channelFactory.createSingleChannel())
- .thenReturn(initial1, initial2)
- .thenReturn(rotated1)
- .thenThrow(new IOException("Transient failure on second sub-channel"))
- .thenReturn(refilled);
- Runnable preemptiveRefresh = createPreemptiveRefreshMtlsPool(2, channelFactory, executor);
-
- pool.invalidateDiskFingerprintCache();
- writeCert("root_cert.pem");
- preemptiveRefresh.run();
-
- // The channel that failed to refresh still uses the old certificate, so it is dropped and
- // every channel left in the pool uses the new certificate.
- Mockito.verify(initial1).shutdown();
- Mockito.verify(initial2).shutdown();
- assertThat(pool.entries.get()).hasSize(1);
- assertThat(pool.getGeneration()).isEqualTo(1);
- pool.invalidateDiskFingerprintCache();
- assertThat(pool.shouldRefresh()).isFalse();
-
- // The pool is refilled to its size before the refresh.
- ArgumentCaptor refillTask = ArgumentCaptor.forClass(Runnable.class);
- Mockito.verify(executor).execute(refillTask.capture());
- refillTask.getValue().run();
- assertThat(pool.entries.get()).hasSize(2);
- Mockito.verify(refilled, Mockito.never()).shutdown();
- }
-
- @Test
- void preemptiveRefresh_allChannelsFailOnCertChange_leavesRotationPending() throws IOException {
- ScheduledExecutorService executor = mockExecutor();
- ManagedChannel initial1 = Mockito.mock(ManagedChannel.class);
- ManagedChannel initial2 = Mockito.mock(ManagedChannel.class);
- ChannelFactory channelFactory = mockChannelFactory();
- Mockito.when(channelFactory.createSingleChannel())
- .thenReturn(initial1, initial2)
- .thenThrow(new IOException("Transient failure"));
- Runnable preemptiveRefresh = createPreemptiveRefreshMtlsPool(2, channelFactory, executor);
- List entriesBefore = pool.entries.get();
-
- pool.invalidateDiskFingerprintCache();
- writeCert("root_cert.pem");
- preemptiveRefresh.run();
-
- // No channel could be recreated, so the pool is left unchanged and the rotation stays pending
- // for the next periodic or reactive refresh.
- assertThat(pool.entries.get()).containsExactlyElementsIn(entriesBefore).inOrder();
- Mockito.verify(initial1, Mockito.never()).shutdown();
- Mockito.verify(initial2, Mockito.never()).shutdown();
- assertThat(pool.getGeneration()).isEqualTo(0);
- pool.invalidateDiskFingerprintCache();
- assertThat(pool.shouldRefresh()).isTrue();
- Mockito.verify(executor, Mockito.never()).execute(Mockito.any(Runnable.class));
- }
-
- @Test
- void preemptiveRefresh_partialFailureWithoutCertChange_keepsOldChannel() throws IOException {
- ScheduledExecutorService executor = mockExecutor();
- ManagedChannel initial1 = Mockito.mock(ManagedChannel.class);
- ManagedChannel initial2 = Mockito.mock(ManagedChannel.class);
- ManagedChannel refreshed1 = Mockito.mock(ManagedChannel.class);
- ChannelFactory channelFactory = mockChannelFactory();
- Mockito.when(channelFactory.createSingleChannel())
- .thenReturn(initial1, initial2)
- .thenReturn(refreshed1)
- .thenThrow(new IOException("Transient failure on second sub-channel"));
- Runnable preemptiveRefresh = createPreemptiveRefreshMtlsPool(2, channelFactory, executor);
- ChannelPool.Entry initialEntry2 = pool.entries.get().get(1);
-
- pool.invalidateDiskFingerprintCache();
- preemptiveRefresh.run();
-
- // The certificate did not change, so the channel that failed to refresh keeps its slot.
- Mockito.verify(initial1).shutdown();
- Mockito.verify(initial2, Mockito.never()).shutdown();
- assertThat(pool.entries.get()).hasSize(2);
- assertThat(pool.entries.get().get(1)).isSameInstanceAs(initialEntry2);
- assertThat(pool.getGeneration()).isEqualTo(0);
- Mockito.verify(executor, Mockito.never()).execute(Mockito.any(Runnable.class));
- }
-
- /** Waits until {@code thread} is blocked waiting for a monitor lock. */
- private static void awaitBlocked(Thread thread) throws InterruptedException {
- long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
- while (thread.getState() != Thread.State.BLOCKED) {
- assertThat(System.nanoTime()).isLessThan(deadline);
- Thread.sleep(1);
- }
- }
-
- /**
- * Starts a reactive refresh that holds the pool lock while it recreates the channel on the
- * rotated certificate, runs {@code waiter} on another thread until it blocks on the lock, and
- * rotates the certificate again before letting the first refresh complete. A waiter that re-read
- * the certificate would see the second rotation and refresh again.
- */
- private void runWhileConcurrentSwitchHoldsLock(
- CountDownLatch switchStarted, CountDownLatch releaseSwitch, Runnable waiter)
- throws Exception {
- Thread switchingThread = new Thread(pool::refresh);
- Thread waitingThread = new Thread(waiter);
- try {
- switchingThread.start();
- assertThat(switchStarted.await(5, TimeUnit.SECONDS)).isTrue();
- waitingThread.start();
- awaitBlocked(waitingThread);
- writeCert("client_cert.pem");
- } finally {
- releaseSwitch.countDown();
- }
- switchingThread.join(5000);
- waitingThread.join(5000);
- assertThat(switchingThread.isAlive()).isFalse();
- assertThat(waitingThread.isAlive()).isFalse();
- }
-
- @Test
- void refresh_concurrentSwitchWhileWaitingForLock_skipsWithoutReadingDisk() throws Exception {
- CountDownLatch switchStarted = new CountDownLatch(1);
- CountDownLatch releaseSwitch = new CountDownLatch(1);
- ChannelFactory channelFactory = mockChannelFactory();
- Mockito.when(channelFactory.createSingleChannel())
- .thenReturn(Mockito.mock(ManagedChannel.class))
- .thenAnswer(
- invocation -> {
- switchStarted.countDown();
- releaseSwitch.await();
- return Mockito.mock(ManagedChannel.class);
- })
- .thenReturn(Mockito.mock(ManagedChannel.class));
- createMtlsPoolAndRotateCert(
- ChannelPoolSettings.staticallySized(1), channelFactory, mockExecutor());
-
- runWhileConcurrentSwitchHoldsLock(switchStarted, releaseSwitch, pool::refresh);
-
- // Only the initial channel and the concurrent switch created channels; the waiter skipped.
- Mockito.verify(channelFactory, Mockito.times(2)).createSingleChannel();
- assertThat(pool.getGeneration()).isEqualTo(1);
- // The second rotation is still detected for the next UNAUTHENTICATED failure.
- pool.invalidateDiskFingerprintCache();
- assertThat(pool.shouldRefresh()).isTrue();
- }
-
- @Test
- void preemptiveRefresh_concurrentSwitchWhileWaitingForLock_skipsRefresh() throws Exception {
- CountDownLatch switchStarted = new CountDownLatch(1);
- CountDownLatch releaseSwitch = new CountDownLatch(1);
- ChannelFactory channelFactory = mockChannelFactory();
- Mockito.when(channelFactory.createSingleChannel())
- .thenReturn(Mockito.mock(ManagedChannel.class))
- .thenAnswer(
- invocation -> {
- switchStarted.countDown();
- releaseSwitch.await();
- return Mockito.mock(ManagedChannel.class);
- })
- .thenReturn(Mockito.mock(ManagedChannel.class));
- Runnable preemptiveRefresh = createPreemptiveRefreshMtlsPool(1, channelFactory);
- pool.invalidateDiskFingerprintCache();
- writeCert("root_cert.pem");
-
- runWhileConcurrentSwitchHoldsLock(switchStarted, releaseSwitch, preemptiveRefresh);
-
- // The channels were just recreated by the concurrent switch, so the periodic refresh skipped.
- Mockito.verify(channelFactory, Mockito.times(2)).createSingleChannel();
- assertThat(pool.getGeneration()).isEqualTo(1);
- pool.invalidateDiskFingerprintCache();
- assertThat(pool.shouldRefresh()).isTrue();
- }
-
- @Test
- void refresh_whenCertUnchanged_noOpsAndDoesNotIncrementGeneration() throws IOException {
- ChannelFactory channelFactory = mockChannelFactory();
- Mockito.when(channelFactory.createSingleChannel())
- .thenReturn(Mockito.mock(ManagedChannel.class), Mockito.mock(ManagedChannel.class));
- writeCert("client_cert.pem");
- pool =
- new ChannelPool(
- ChannelPoolSettings.staticallySized(1),
- channelFactory,
- FixedExecutorProvider.create(mockExecutor()),
- tempCert.toString());
-
- pool.invalidateDiskFingerprintCache();
- pool.refresh();
-
- assertThat(pool.getGeneration()).isEqualTo(0);
- Mockito.verify(channelFactory, Mockito.times(1)).createSingleChannel();
- }
-
- @Test
- void refresh_onRotation_logsNewCertificateFingerprint() throws IOException {
- ChannelFactory channelFactory = mockChannelFactory();
- Mockito.when(channelFactory.createSingleChannel())
- .thenReturn(Mockito.mock(ManagedChannel.class), Mockito.mock(ManagedChannel.class));
- writeCert("client_cert.pem");
- String oldFingerprint = readCertFingerprint();
- createMtlsPoolAndRotateCert(
- ChannelPoolSettings.staticallySized(1), channelFactory, mockExecutor());
- String newFingerprint = readCertFingerprint();
- assertThat(newFingerprint).isNotEqualTo(oldFingerprint);
-
- FakeLogHandler logHandler = new FakeLogHandler();
- Level originalLevel = ChannelPool.LOG.getLevel();
- ChannelPool.LOG.setLevel(Level.FINE);
- ChannelPool.LOG.addHandler(logHandler);
- try {
- pool.refresh();
- } finally {
- ChannelPool.LOG.removeHandler(logHandler);
- ChannelPool.LOG.setLevel(originalLevel);
- }
-
- assertThat(pool.getGeneration()).isEqualTo(1);
- assertThat(logHandler.getAllMessages())
- .contains("Channel pool switched to certificate with fingerprint: " + newFingerprint);
- assertThat(String.join("\n", logHandler.getAllMessages())).doesNotContain(oldFingerprint);
- }
-
- @Test
- void shutdown_interruptsInProgressRefresh() throws Exception {
- CountDownLatch refreshStarted = new CountDownLatch(1);
- CountDownLatch release = new CountDownLatch(1);
- AtomicBoolean refreshInterrupted = new AtomicBoolean();
- AtomicInteger createdChannels = new AtomicInteger();
- ChannelFactory channelFactory =
- () -> {
- if (createdChannels.getAndIncrement() > 0) {
- // The preemptive refresh blocks while creating its replacement channel.
- refreshStarted.countDown();
- try {
- release.await();
- } catch (InterruptedException e) {
- refreshInterrupted.set(true);
- Thread.currentThread().interrupt();
- throw new IOException("Interrupted while creating channel", e);
- }
- }
- return Mockito.mock(ManagedChannel.class);
- };
- ScheduledExecutorService realExecutor = Executors.newSingleThreadScheduledExecutor();
- ScheduledExecutorService executor = mockExecutor();
- Mockito.doAnswer(
- invocation ->
- realExecutor.schedule(
- (Runnable) invocation.getArgument(0), 0, TimeUnit.MILLISECONDS))
- .when(executor)
- .scheduleAtFixedRate(
- Mockito.any(Runnable.class), Mockito.anyLong(), Mockito.anyLong(), Mockito.any());
- try {
- pool =
- new ChannelPool(
- ChannelPoolSettings.staticallySized(1).toBuilder()
- .setPreemptiveRefreshEnabled(true)
- .build(),
- channelFactory,
- FixedExecutorProvider.create(executor),
- null);
- assertThat(refreshStarted.await(5, TimeUnit.SECONDS)).isTrue();
-
- // The refresh holds the pool's write lock; shutdown must interrupt it rather than wait.
- Assertions.assertTimeoutPreemptively(Duration.ofSeconds(5), () -> pool.shutdown());
-
- assertThat(refreshInterrupted.get()).isTrue();
- assertThat(pool.isShutdown()).isTrue();
- } finally {
- release.countDown();
- realExecutor.shutdownNow();
- }
- }
-
@Test
void channelRefreshShouldSwapChannels() throws IOException {
ManagedChannel underlyingChannel1 = mock(ManagedChannel.class);
@@ -1470,8 +450,7 @@ void channelRefreshShouldSwapChannels() throws IOException {
.setPreemptiveRefreshEnabled(true)
.build(),
channelFactory,
- provider,
- null);
+ provider);
Mockito.reset(underlyingChannel1);
pool.newCall(FakeMethodDescriptor.create(), CallOptions.DEFAULT);
@@ -1480,41 +459,10 @@ void channelRefreshShouldSwapChannels() throws IOException {
.newCall(Mockito.>any(), Mockito.any(CallOptions.class));
// swap channel
- pool.refreshAll();
-
- pool.newCall(FakeMethodDescriptor.create(), CallOptions.DEFAULT);
-
- Mockito.verify(underlyingChannel2, Mockito.only())
- .newCall(Mockito.>any(), Mockito.any(CallOptions.class));
- }
-
- @Test
- void testRefreshWithNullWorkloadCertPathSwapsChannel() throws IOException {
- ScheduledExecutorService executor =
- Mockito.mock(ScheduledExecutorService.class, Mockito.withSettings().withoutAnnotations());
- FixedExecutorProvider provider = FixedExecutorProvider.create(executor);
- ManagedChannel underlyingChannel1 = Mockito.mock(ManagedChannel.class);
- ManagedChannel underlyingChannel2 = Mockito.mock(ManagedChannel.class);
- FakeChannelFactory channelFactory =
- new FakeChannelFactory(ImmutableList.of(underlyingChannel1, underlyingChannel2));
- pool =
- new ChannelPool(
- ChannelPoolSettings.staticallySized(1).toBuilder()
- .setPreemptiveRefreshEnabled(true)
- .build(),
- channelFactory,
- provider,
- null);
- Mockito.reset(underlyingChannel1);
-
- pool.newCall(FakeMethodDescriptor.create(), CallOptions.DEFAULT);
- Mockito.verify(underlyingChannel1, Mockito.only())
- .newCall(Mockito.>any(), Mockito.any(CallOptions.class));
-
- // Calling refresh() when workloadCertPath is null should fall back to refreshAll()
pool.refresh();
pool.newCall(FakeMethodDescriptor.create(), CallOptions.DEFAULT);
+
Mockito.verify(underlyingChannel2, Mockito.only())
.newCall(Mockito.>any(), Mockito.any(CallOptions.class));
}
@@ -1538,8 +486,7 @@ void channelCountShouldNotChangeWhenOutstandingRpcsAreWithinLimits() throws Exce
.setMaxRpcsPerChannel(2)
.build(),
channelFactory,
- provider,
- null);
+ provider);
assertThat(pool.entries.get()).hasSize(2);
// Start the minimum number of
@@ -1606,8 +553,7 @@ void customResizeDeltaIsRespected() throws Exception {
.setMaxResizeDelta(5)
.build(),
channelFactory,
- provider,
- null);
+ provider);
assertThat(pool.entries.get()).hasSize(2);
// Add 20 RPCs to push expansion
@@ -1640,8 +586,7 @@ void removedIdleChannelsAreShutdown() throws Exception {
.setMaxRpcsPerChannel(2)
.build(),
channelFactory,
- provider,
- null);
+ provider);
assertThat(pool.entries.get()).hasSize(2);
// With no outstanding RPCs, the pool should shrink
@@ -1669,8 +614,7 @@ void removedActiveChannelsAreShutdown() throws Exception {
.setMaxRpcsPerChannel(2)
.build(),
channelFactory,
- provider,
- null);
+ provider);
assertThat(pool.entries.get()).hasSize(2);
// Start 2 RPCs
@@ -1708,7 +652,7 @@ void testReleasingClientCallCancelEarly() throws IOException {
Mockito.when(fakeChannel.newCall(Mockito.any(), Mockito.any())).thenReturn(mockClientCall);
ChannelPoolSettings channelPoolSettings = ChannelPoolSettings.staticallySized(1);
ChannelFactory factory = new FakeChannelFactory(ImmutableList.of(fakeChannel));
- pool = ChannelPool.create(channelPoolSettings, factory, null, null);
+ pool = ChannelPool.create(channelPoolSettings, factory, null);
EndpointContext endpointContext =
Mockito.mock(EndpointContext.class, Mockito.withSettings().withoutAnnotations());
@@ -1773,8 +717,7 @@ void repeatedResizingLogsWarningOnExpand() throws Exception {
.setMaxChannelCount(10)
.build(),
channelFactory,
- provider,
- null);
+ provider);
assertThat(pool.entries.get()).hasSize(1);
FakeLogHandler logHandler = new FakeLogHandler();
@@ -1826,8 +769,7 @@ void repeatedResizingLogsWarningOnShrink() throws Exception {
.setMaxChannelCount(10)
.build(),
channelFactory,
- provider,
- null);
+ provider);
assertThat(pool.entries.get()).hasSize(10);
FakeLogHandler logHandler = new FakeLogHandler();
@@ -1863,7 +805,7 @@ void testDoubleRelease() throws Exception {
ChannelPoolSettings channelPoolSettings = ChannelPoolSettings.staticallySized(1);
ChannelFactory factory = new FakeChannelFactory(ImmutableList.of(fakeChannel));
- pool = ChannelPool.create(channelPoolSettings, factory, null, null);
+ pool = ChannelPool.create(channelPoolSettings, factory, null);
EndpointContext endpointContext =
Mockito.mock(EndpointContext.class, Mockito.withSettings().withoutAnnotations());
@@ -1901,8 +843,7 @@ void testDoubleRelease() throws Exception {
// Ensure that the channel pool properly logged the double call and kept the refCount correct
assertThat(logHandler.getAllMessages())
.contains(
- "Call is being closed more than once. Please make sure that onClose() is not being"
- + " manually called.");
+ "Call is being closed more than once. Please make sure that onClose() is not being manually called.");
assertThat(pool.entries.get()).hasSize(1);
ChannelPool.Entry entry = pool.entries.get().get(0);
assertThat(entry.outstandingRpcs.get()).isEqualTo(0);
@@ -1938,8 +879,7 @@ void minChannelsClampedToMaxChannelCountUnderHighLoad() throws Exception {
.setMaxChannelCount(5)
.build(),
channelFactory,
- provider,
- null);
+ provider);
assertThat(pool.entries.get()).hasSize(1);
// Add 20 RPCs, which would require 10 channels (20/2)
@@ -1974,8 +914,7 @@ void maxChannelsClampedToMinChannelCountUnderLowLoad() throws Exception {
.setMaxChannelCount(10)
.build(),
channelFactory,
- provider,
- null);
+ provider);
assertThat(pool.entries.get()).hasSize(5);
// With no outstanding RPCs, the pool should want to shrink to 0
@@ -1985,272 +924,4 @@ void maxChannelsClampedToMinChannelCountUnderLowLoad() throws Exception {
// Should be clamped to minChannelCount = 3
assertThat(pool.entries.get()).hasSize(3);
}
-
- @Test
- void shouldRefresh_doesNotCacheNegativeResultAndDetectsSubsequentRotationImmediately()
- throws IOException {
- ManagedChannel initial = Mockito.mock(ManagedChannel.class);
- ManagedChannel rotated = Mockito.mock(ManagedChannel.class);
- ChannelFactory channelFactory =
- Mockito.mock(ChannelFactory.class, Mockito.withSettings().withoutAnnotations());
- Mockito.when(channelFactory.createSingleChannel()).thenReturn(initial, rotated);
-
- tempCert = Files.createTempFile("cert", ".pem");
- Path clientCert = Paths.get("src", "test", "resources", "client_cert.pem");
- Files.copy(clientCert, tempCert, StandardCopyOption.REPLACE_EXISTING);
-
- pool =
- ChannelPool.create(
- ChannelPoolSettings.staticallySized(1), channelFactory, null, tempCert.toString());
-
- // First check returns false (unchanged disk cert)
- assertThat(pool.shouldRefresh()).isFalse();
-
- // Immediately rotate cert on disk WITHOUT invalidating the 1-second cache
- Path rootCert = Paths.get("src", "test", "resources", "root_cert.pem");
- Files.copy(rootCert, tempCert, StandardCopyOption.REPLACE_EXISTING);
-
- // Must immediately detect rotation because negative/unchanged disk checks are not cached for 1s
- assertThat(pool.shouldRefresh()).isTrue();
-
- // Refresh should update activeCertFingerprint and clear any cached positive check
- pool.refresh();
- assertThat(pool.shouldRefresh()).isFalse();
- }
-
- @Test
- void newCall_whenDelegateThrowsError_releasesEntryAndShutsDownRetiredChannel()
- throws IOException {
- ManagedChannel initial = Mockito.mock(ManagedChannel.class);
- ManagedChannel rotated = Mockito.mock(ManagedChannel.class);
- ChannelFactory channelFactory =
- Mockito.mock(ChannelFactory.class, Mockito.withSettings().withoutAnnotations());
- Mockito.when(channelFactory.createSingleChannel()).thenReturn(initial, rotated);
- Mockito.when(initial.newCall(Mockito.any(), Mockito.any()))
- .thenThrow(new LinkageError("Simulated native/JNI linkage error"));
-
- pool = ChannelPool.create(ChannelPoolSettings.staticallySized(1), channelFactory, null, null);
-
- assertThrows(LinkageError.class, () -> pool.newCall(METHOD_RECOGNIZE, CallOptions.DEFAULT));
-
- // Rotating the pool should immediately shut down initial channel because its ref count is 0
- pool.refresh();
- Mockito.verify(initial).shutdown();
- }
-
- @Test
- @SuppressWarnings("unchecked")
- void start_whenDelegateThrowsError_releasesEntryAndShutsDownRetiredChannel() throws IOException {
- ManagedChannel initial = Mockito.mock(ManagedChannel.class);
- ManagedChannel rotated = Mockito.mock(ManagedChannel.class);
- ClientCall mockCall = Mockito.mock(ClientCall.class);
- ChannelFactory channelFactory =
- Mockito.mock(ChannelFactory.class, Mockito.withSettings().withoutAnnotations());
- Mockito.when(channelFactory.createSingleChannel()).thenReturn(initial, rotated);
- Mockito.when(initial.newCall(Mockito.any(), Mockito.any())).thenReturn((ClientCall) mockCall);
- Mockito.doThrow(new AssertionError("Simulated Error in start"))
- .when(mockCall)
- .start(Mockito.any(), Mockito.any());
-
- pool = ChannelPool.create(ChannelPoolSettings.staticallySized(1), channelFactory, null, null);
-
- ClientCall call = pool.newCall(METHOD_RECOGNIZE, CallOptions.DEFAULT);
- // Rotate pool while call is retained
- pool.refresh();
- Mockito.verify(initial, Mockito.never()).shutdown();
-
- // Calling start() throws Error, which must release the retained entry and trigger shutdown
- assertThrows(
- AssertionError.class,
- () -> call.start(new ClientCall.Listener() {}, new Metadata()));
- Mockito.verify(initial).shutdown();
- }
-
- @Test
- @SuppressWarnings("unchecked")
- void cancel_whenDelegateThrowsException_releasesEntryAndShutsDownRetiredChannel()
- throws IOException {
- ManagedChannel initial = Mockito.mock(ManagedChannel.class);
- ManagedChannel rotated = Mockito.mock(ManagedChannel.class);
- ClientCall mockCall = Mockito.mock(ClientCall.class);
- ChannelFactory channelFactory =
- Mockito.mock(ChannelFactory.class, Mockito.withSettings().withoutAnnotations());
- Mockito.when(channelFactory.createSingleChannel()).thenReturn(initial, rotated);
- Mockito.when(initial.newCall(Mockito.any(), Mockito.any())).thenReturn((ClientCall) mockCall);
- Mockito.doThrow(new RuntimeException("Simulated cancel exception"))
- .when(mockCall)
- .cancel(Mockito.any(), Mockito.any());
-
- pool = ChannelPool.create(ChannelPoolSettings.staticallySized(1), channelFactory, null, null);
-
- ClientCall call = pool.newCall(METHOD_RECOGNIZE, CallOptions.DEFAULT);
- pool.refresh();
- Mockito.verify(initial, Mockito.never()).shutdown();
-
- assertThrows(RuntimeException.class, () -> call.cancel("cancelled", null));
- Mockito.verify(initial).shutdown();
- }
-
- @Test
- @SuppressWarnings("unchecked")
- void concurrentStartAndCancel_neverLeaksOrDoubleReleasesEntry() throws Exception {
- ManagedChannel initial = Mockito.mock(ManagedChannel.class);
- ManagedChannel rotated = Mockito.mock(ManagedChannel.class);
- ChannelFactory channelFactory =
- Mockito.mock(ChannelFactory.class, Mockito.withSettings().withoutAnnotations());
- Mockito.when(channelFactory.createSingleChannel()).thenReturn(initial, rotated);
-
- Mockito.when(initial.newCall(Mockito.any(), Mockito.any()))
- .thenAnswer(
- invocation ->
- new ClientCall() {
- private Listener listener;
- private boolean cancelled;
-
- @Override
- public synchronized void start(
- Listener responseListener, Metadata headers) {
- this.listener = responseListener;
- if (cancelled) {
- responseListener.onClose(Status.CANCELLED, new Metadata());
- }
- }
-
- @Override
- public synchronized void cancel(String message, Throwable cause) {
- cancelled = true;
- if (listener != null) {
- listener.onClose(Status.CANCELLED, new Metadata());
- }
- }
-
- @Override
- public void request(int numMessages) {}
-
- @Override
- public void halfClose() {}
-
- @Override
- public void sendMessage(Color message) {}
- });
-
- pool = ChannelPool.create(ChannelPoolSettings.staticallySized(1), channelFactory, null, null);
-
- int iterations = 100;
- ExecutorService executor = Executors.newFixedThreadPool(2);
- try {
- for (int i = 0; i < iterations; i++) {
- ClientCall call = pool.newCall(METHOD_RECOGNIZE, CallOptions.DEFAULT);
- CyclicBarrier barrier = new CyclicBarrier(2);
- Future> f1 =
- executor.submit(
- () -> {
- try {
- barrier.await();
- call.start(new ClientCall.Listener() {}, new Metadata());
- } catch (Exception ignored) {
- }
- });
- Future> f2 =
- executor.submit(
- () -> {
- try {
- barrier.await();
- call.cancel("cancel", null);
- } catch (Exception ignored) {
- }
- });
- f1.get(5, TimeUnit.SECONDS);
- f2.get(5, TimeUnit.SECONDS);
- }
- } finally {
- executor.shutdownNow();
- }
-
- // Every start/cancel race must leave the entry with no outstanding RPCs: a leak would leave it
- // positive and a double release would make it negative.
- assertThat(pool.entries.get().get(0).outstandingRpcs.get()).isEqualTo(0);
-
- // Rotate pool: initial channel must shut down cleanly, proving outstandingRpcs == 0 (no leaks
- // or negative counts)
- pool.refresh();
- Mockito.verify(initial).shutdown();
- }
-
- @Test
- void cancel_whenStartedAndSuperCancelThrows_doesNotReleasePrematurelyUntilOnClose()
- throws Exception {
- ManagedChannel initial = Mockito.mock(ManagedChannel.class);
- ManagedChannel replacement = Mockito.mock(ManagedChannel.class);
- @SuppressWarnings("unchecked")
- ClientCall delegateCall = Mockito.mock(ClientCall.class);
- @SuppressWarnings("unchecked")
- ArgumentCaptor> listenerCaptor =
- ArgumentCaptor.forClass(ClientCall.Listener.class);
- Mockito.doThrow(new RuntimeException("cancel failure"))
- .when(delegateCall)
- .cancel(Mockito.any(), Mockito.any());
- Mockito.when(initial.newCall(Mockito.eq(METHOD_RECOGNIZE), Mockito.any()))
- .thenReturn(delegateCall);
-
- AtomicInteger createCount = new AtomicInteger(0);
- pool =
- new ChannelPool(
- ChannelPoolSettings.staticallySized(1),
- () -> createCount.getAndIncrement() == 0 ? initial : replacement,
- FixedExecutorProvider.create(Mockito.mock(ScheduledExecutorService.class)),
- null);
-
- ClientCall call = pool.newCall(METHOD_RECOGNIZE, CallOptions.DEFAULT);
- call.start(new ClientCall.Listener() {}, new Metadata());
- Mockito.verify(delegateCall).start(listenerCaptor.capture(), Mockito.any());
-
- assertThrows(RuntimeException.class, () -> call.cancel("abort", null));
-
- // Rotate pool while call is still active (onClose hasn't fired yet):
- // initial channel must NOT be shut down yet because call is still active
- pool.refreshAll();
- Mockito.verify(initial, Mockito.never()).shutdown();
-
- // Once onClose fires, entry is released and initial channel shuts down
- listenerCaptor.getValue().onClose(Status.CANCELLED, new Metadata());
- Mockito.verify(initial).shutdown();
- }
-
- @Test
- void start_whenCalledTwice_throwsIllegalStateExceptionAndDoesNotReleaseFirstCallEntry()
- throws Exception {
- ManagedChannel initial = Mockito.mock(ManagedChannel.class);
- ManagedChannel replacement = Mockito.mock(ManagedChannel.class);
- @SuppressWarnings("unchecked")
- ClientCall delegateCall = Mockito.mock(ClientCall.class);
- @SuppressWarnings("unchecked")
- ArgumentCaptor> listenerCaptor =
- ArgumentCaptor.forClass(ClientCall.Listener.class);
- Mockito.when(initial.newCall(Mockito.eq(METHOD_RECOGNIZE), Mockito.any()))
- .thenReturn(delegateCall);
-
- AtomicInteger createCount = new AtomicInteger(0);
- pool =
- new ChannelPool(
- ChannelPoolSettings.staticallySized(1),
- () -> createCount.getAndIncrement() == 0 ? initial : replacement,
- FixedExecutorProvider.create(Mockito.mock(ScheduledExecutorService.class)),
- null);
-
- ClientCall call = pool.newCall(METHOD_RECOGNIZE, CallOptions.DEFAULT);
- call.start(new ClientCall.Listener() {}, new Metadata());
- Mockito.verify(delegateCall).start(listenerCaptor.capture(), Mockito.any());
-
- // Duplicate start() must throw IllegalStateException without releasing the entry
- assertThrows(
- IllegalStateException.class,
- () -> call.start(new ClientCall.Listener() {}, new Metadata()));
-
- pool.refreshAll();
- Mockito.verify(initial, Mockito.never()).shutdown();
-
- listenerCaptor.getValue().onClose(Status.OK, new Metadata());
- Mockito.verify(initial).shutdown();
- }
}
diff --git a/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/GrpcCallContextTest.java b/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/GrpcCallContextTest.java
index 0222d9178078..e20767fdb8ed 100644
--- a/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/GrpcCallContextTest.java
+++ b/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/GrpcCallContextTest.java
@@ -31,7 +31,6 @@
import static com.google.api.gax.util.TimeConversionTestUtils.testDurationMethod;
import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.Mockito.mock;
@@ -53,7 +52,6 @@
import io.grpc.Metadata.Key;
import io.grpc.auth.MoreCallCredentials;
import java.io.IOException;
-import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
@@ -212,20 +210,6 @@ void testWithLongerTimeout() {
.isEqualTo(java.time.Duration.ofSeconds(5));
}
- @Test
- void testWithNullOrZeroTimeoutClearsExistingTimeout() {
- GrpcCallContext ctxWithTimeout =
- GrpcCallContext.createDefault().withTimeoutDuration(Duration.ofSeconds(5));
-
- // Sanity check
- Truth.assertThat(ctxWithTimeout.getTimeoutDuration()).isEqualTo(Duration.ofSeconds(5));
-
- Duration nullTimeout = null;
- Truth.assertThat(ctxWithTimeout.withTimeoutDuration(nullTimeout).getTimeoutDuration()).isNull();
- Truth.assertThat(ctxWithTimeout.withTimeoutDuration(Duration.ZERO).getTimeoutDuration())
- .isNull();
- }
-
@Test
void testMergeWithNullTimeout() {
java.time.Duration timeout = java.time.Duration.ofSeconds(10);
@@ -510,64 +494,4 @@ private static Map> createTestExtraHeaders(String... keyVal
}
return extraHeaders;
}
-
- @Test
- public void testEqualsAndHashCode() {
- ManagedChannel managedChannel1 = Mockito.mock(ManagedChannel.class);
- ManagedChannel managedChannel2 = Mockito.mock(ManagedChannel.class);
-
- GrpcTransportChannel transportChannel1 = GrpcTransportChannel.create(managedChannel1);
- GrpcTransportChannel transportChannel2 = GrpcTransportChannel.create(managedChannel2);
-
- GrpcCallContext context1 =
- GrpcCallContext.createDefault().withTransportChannel(transportChannel1);
- GrpcCallContext context2 =
- GrpcCallContext.createDefault().withTransportChannel(transportChannel1);
- GrpcCallContext context3 =
- GrpcCallContext.createDefault().withTransportChannel(transportChannel2);
-
- assertEquals(context1, context2);
- assertEquals(context1.hashCode(), context2.hashCode());
-
- assertNotEquals(context1, context3);
- }
-
- @Test
- public void testMergeWithCustomChannelClearsTransportChannel() {
- ManagedChannel defaultChannel = Mockito.mock(ManagedChannel.class);
- ManagedChannel customChannel = Mockito.mock(ManagedChannel.class);
- GrpcTransportChannel transportChannel = GrpcTransportChannel.create(defaultChannel);
-
- GrpcCallContext baseContext =
- GrpcCallContext.createDefault().withTransportChannel(transportChannel);
- GrpcCallContext overrideContext = GrpcCallContext.of(customChannel, CallOptions.DEFAULT);
-
- GrpcCallContext mergedContext = (GrpcCallContext) baseContext.merge(overrideContext);
- assertEquals(customChannel, mergedContext.getChannel());
- assertNull(mergedContext.getTransportChannel());
- }
-
- @Test
- public void testWithChannelWithCustomChannelClearsTransportChannel() {
- ManagedChannel defaultChannel = Mockito.mock(ManagedChannel.class);
- ManagedChannel customChannel = Mockito.mock(ManagedChannel.class);
- GrpcTransportChannel transportChannel = GrpcTransportChannel.create(defaultChannel);
-
- GrpcCallContext baseContext =
- GrpcCallContext.createDefault().withTransportChannel(transportChannel);
- GrpcCallContext updatedContext = baseContext.withChannel(customChannel);
-
- assertEquals(customChannel, updatedContext.getChannel());
- assertNull(updatedContext.getTransportChannel());
-
- // Clearing channel via withChannel(null) also clears transportChannel
- GrpcCallContext nullChannelContext = baseContext.withChannel(null);
- assertNull(nullChannelContext.getChannel());
- assertNull(nullChannelContext.getTransportChannel());
-
- // Merging a cleared context into defaultContext falls back to defaultContext's transportChannel
- GrpcCallContext mergedWithNullChannel = (GrpcCallContext) baseContext.merge(nullChannelContext);
- assertEquals(defaultChannel, mergedWithNullChannel.getChannel());
- assertEquals(transportChannel, mergedWithNullChannel.getTransportChannel());
- }
}
diff --git a/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/GrpcClientCallsTest.java b/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/GrpcClientCallsTest.java
index 6877eb1dbe1e..2aa9279e249f 100644
--- a/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/GrpcClientCallsTest.java
+++ b/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/GrpcClientCallsTest.java
@@ -125,7 +125,6 @@ void testAffinity() throws IOException {
ChannelPool.create(
ChannelPoolSettings.staticallySized(2),
new FakeChannelFactory(Arrays.asList(channel0, channel1)),
- null,
null);
GrpcCallContext context = defaultCallContext.withChannel(pool);
diff --git a/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/InstantiatingGrpcChannelProviderTest.java b/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/InstantiatingGrpcChannelProviderTest.java
index 5ffb6943a4fe..0b460d6d8e4f 100644
--- a/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/InstantiatingGrpcChannelProviderTest.java
+++ b/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/InstantiatingGrpcChannelProviderTest.java
@@ -48,11 +48,9 @@
import com.google.api.gax.rpc.internal.EnvironmentProvider;
import com.google.api.gax.rpc.mtls.AbstractMtlsTransportChannelTest;
import com.google.api.gax.rpc.mtls.CertificateBasedAccess;
-import com.google.api.gax.rpc.testing.FakeMtlsProvider;
import com.google.auth.ApiKeyCredentials;
import com.google.auth.Credentials;
import com.google.auth.http.AuthHttpConstants;
-import com.google.auth.mtls.CertificateSourceUnavailableException;
import com.google.auth.mtls.MtlsProvider;
import com.google.auth.oauth2.CloudShellCredentials;
import com.google.auth.oauth2.ComputeEngineCredentials;
@@ -665,9 +663,7 @@ private void createAndCloseTransportChannel(InstantiatingGrpcChannelProvider pro
createAndCloseTransportChannel(provider);
assertThat(logHandler.getAllMessages())
.contains(
- "DirectPath is misconfigured. The DirectPath XDS option was set, but the"
- + " attemptDirectPath option was not. Please set both the attemptDirectPath and"
- + " attemptDirectPathXds options.");
+ "DirectPath is misconfigured. The DirectPath XDS option was set, but the attemptDirectPath option was not. Please set both the attemptDirectPath and attemptDirectPathXds options.");
InstantiatingGrpcChannelProvider.LOG.removeHandler(logHandler);
}
@@ -685,10 +681,8 @@ void testLogDirectPathMisconfig_AttemptDirectPathNotSetAndAttemptDirectPathXdsSe
createAndCloseTransportChannel(provider);
assertThat(logHandler.getAllMessages())
.contains(
- "Env var GOOGLE_CLOUD_ENABLE_DIRECT_PATH_XDS was found and set to TRUE, but DirectPath"
- + " was not enabled for this client. If this is intended for this client, please"
- + " note that this is a misconfiguration and set the attemptDirectPath option as"
- + " well.");
+ "Env var GOOGLE_CLOUD_ENABLE_DIRECT_PATH_XDS was found and set to TRUE, but DirectPath was not enabled for this client. If this is intended for "
+ + "this client, please note that this is a misconfiguration and set the attemptDirectPath option as well.");
InstantiatingGrpcChannelProvider.LOG.removeHandler(logHandler);
}
@@ -883,14 +877,12 @@ public void canUseDirectPath_directPathEnvVarDisabled() throws IOException {
@Test
public void canUseDirectPath_directPathEnvVarNotSet_attemptDirectPathIsTrue() {
System.setProperty("os.name", "Linux");
- EnvironmentProvider envProvider = name -> null;
InstantiatingGrpcChannelProvider.Builder builder =
InstantiatingGrpcChannelProvider.newBuilder()
.setCertificateBasedAccess(certificateBasedAccess)
.setAttemptDirectPath(true)
.setCredentials(computeEngineCredentials)
- .setEndpoint(DEFAULT_ENDPOINT)
- .setEnvProvider(envProvider);
+ .setEndpoint(DEFAULT_ENDPOINT);
InstantiatingGrpcChannelProvider provider =
new InstantiatingGrpcChannelProvider(builder, GCE_PRODUCTION_NAME_AFTER_2016);
Truth.assertThat(provider.canUseDirectPath()).isTrue();
@@ -899,14 +891,12 @@ public void canUseDirectPath_directPathEnvVarNotSet_attemptDirectPathIsTrue() {
@Test
public void canUseDirectPath_directPathEnvVarNotSet_attemptDirectPathIsFalse() {
System.setProperty("os.name", "Linux");
- EnvironmentProvider envProvider = name -> null;
InstantiatingGrpcChannelProvider.Builder builder =
InstantiatingGrpcChannelProvider.newBuilder()
.setCertificateBasedAccess(certificateBasedAccess)
.setAttemptDirectPath(false)
.setCredentials(computeEngineCredentials)
- .setEndpoint(DEFAULT_ENDPOINT)
- .setEnvProvider(envProvider);
+ .setEndpoint(DEFAULT_ENDPOINT);
InstantiatingGrpcChannelProvider provider =
new InstantiatingGrpcChannelProvider(builder, GCE_PRODUCTION_NAME_AFTER_2016);
Truth.assertThat(provider.canUseDirectPath()).isFalse();
@@ -1211,8 +1201,7 @@ void createS2ASecuredChannelCredentials_bothS2AAddressesNull_returnsNull() {
assertThat(provider.createS2ASecuredChannelCredentials()).isNotNull();
assertThat(logHandler.getAllMessages())
.contains(
- "Cannot establish an mTLS connection to S2A because autoconfig endpoint did not return"
- + " a mtls address to reach S2A.");
+ "Cannot establish an mTLS connection to S2A because autoconfig endpoint did not return a mtls address to reach S2A.");
InstantiatingGrpcChannelProvider.LOG.removeHandler(logHandler);
}
@@ -1258,8 +1247,7 @@ void createS2ASecuredChannelCredentials_returnsPlaintextToS2AS2AChannelCredentia
assertThat(provider.createS2ASecuredChannelCredentials()).isNotNull();
assertThat(logHandler.getAllMessages())
.contains(
- "Cannot establish an mTLS connection to S2A because MTLS to MDS credentials do not"
- + " exist on filesystem, falling back to plaintext connection to S2A");
+ "Cannot establish an mTLS connection to S2A because MTLS to MDS credentials do not exist on filesystem, falling back to plaintext connection to S2A");
InstantiatingGrpcChannelProvider.LOG.removeHandler(logHandler);
}
@@ -1354,140 +1342,6 @@ void testSettingBackgroundExecutor() {
assertThat(provider.getBackgroundExecutor()).isEqualTo(mockExecutor);
}
- @Test
- void createChannel_whenDirectPathEnabled_ignoresWorkloadCertPath() throws Exception {
- System.setProperty("os.name", "Linux");
- EnvironmentProvider envProvider =
- mock(EnvironmentProvider.class, Mockito.withSettings().withoutAnnotations());
- Mockito.when(
- envProvider.getenv(
- InstantiatingGrpcChannelProvider.DIRECT_PATH_ENV_DISABLE_DIRECT_PATH))
- .thenReturn("false");
- CertificateBasedAccess mtlsCertificateBasedAccess =
- mock(CertificateBasedAccess.class, Mockito.withSettings().withoutAnnotations());
- Mockito.when(mtlsCertificateBasedAccess.useMtlsClientCertificate()).thenReturn(true);
- Mockito.when(mtlsCertificateBasedAccess.getWorkloadCertPath())
- .thenReturn("/path/to/workload/cert.pem");
- MtlsProvider mtlsProvider =
- new FakeMtlsProvider(FakeMtlsProvider.createTestMtlsKeyStore(), "", false);
-
- InstantiatingGrpcChannelProvider.Builder builder =
- InstantiatingGrpcChannelProvider.newBuilder()
- .setCertificateBasedAccess(mtlsCertificateBasedAccess)
- .setMtlsProvider(mtlsProvider)
- .setAttemptDirectPath(true)
- .setCredentials(computeEngineCredentials)
- .setEndpoint(DEFAULT_ENDPOINT)
- .setEnvProvider(envProvider)
- .setHeaderProvider(
- mock(HeaderProvider.class, Mockito.withSettings().withoutAnnotations()));
- InstantiatingGrpcChannelProvider provider =
- new InstantiatingGrpcChannelProvider(builder, GCE_PRODUCTION_NAME_AFTER_2016);
- Truth.assertThat(provider.canUseDirectPath()).isTrue();
-
- TransportChannel transportChannel = provider.getTransportChannel();
- try {
- ChannelPool pool = (ChannelPool) ((GrpcTransportChannel) transportChannel).getChannel();
- assertThat(pool.getWorkloadCertPath()).isNull();
- } finally {
- transportChannel.shutdownNow();
- }
- }
-
- @Test
- void createChannel_whenMtlsActive_passesWorkloadCertPathToChannelPool() throws Exception {
- CertificateBasedAccess mtlsCertificateBasedAccess =
- mock(CertificateBasedAccess.class, Mockito.withSettings().withoutAnnotations());
- Mockito.when(mtlsCertificateBasedAccess.useMtlsClientCertificate()).thenReturn(true);
- Mockito.when(mtlsCertificateBasedAccess.getWorkloadCertPath())
- .thenReturn("/path/to/workload/cert.pem");
- MtlsProvider mtlsProvider =
- new FakeMtlsProvider(FakeMtlsProvider.createTestMtlsKeyStore(), "", false);
-
- InstantiatingGrpcChannelProvider provider =
- InstantiatingGrpcChannelProvider.newBuilder()
- .setCertificateBasedAccess(mtlsCertificateBasedAccess)
- .setMtlsProvider(mtlsProvider)
- .setAttemptDirectPath(false)
- .setEndpoint(DEFAULT_ENDPOINT)
- .setHeaderProvider(
- mock(HeaderProvider.class, Mockito.withSettings().withoutAnnotations()))
- .build();
-
- TransportChannel transportChannel = provider.getTransportChannel();
- try {
- ChannelPool pool = (ChannelPool) ((GrpcTransportChannel) transportChannel).getChannel();
- assertThat(pool.getWorkloadCertPath()).isEqualTo("/path/to/workload/cert.pem");
- } finally {
- transportChannel.shutdownNow();
- }
- }
-
- @Test
- void createChannelBuilder_whenMtlsActiveAndKeyStoreNull_throwsIOException() {
- CertificateBasedAccess mtlsCertificateBasedAccess =
- mock(CertificateBasedAccess.class, Mockito.withSettings().withoutAnnotations());
- Mockito.when(mtlsCertificateBasedAccess.useMtlsClientCertificate()).thenReturn(true);
- MtlsProvider mtlsProviderWithNullKeyStore = new FakeMtlsProvider(null, "", false);
-
- InstantiatingGrpcChannelProvider provider =
- InstantiatingGrpcChannelProvider.newBuilder()
- .setCertificateBasedAccess(mtlsCertificateBasedAccess)
- .setMtlsProvider(mtlsProviderWithNullKeyStore)
- .setAttemptDirectPath(false)
- .setEndpoint(DEFAULT_ENDPOINT)
- .setHeaderProvider(
- mock(HeaderProvider.class, Mockito.withSettings().withoutAnnotations()))
- .build();
-
- IOException thrown = assertThrows(IOException.class, provider::createChannelBuilder);
- assertThat(thrown).hasMessageThat().contains("Failed to initialize mTLS channel credentials");
- }
-
- @Test
- void createChannelBuilder_whenDefaultMtlsProviderThrowsIOException_propagatesIOException()
- throws Exception {
- CertificateBasedAccess mtlsCertificateBasedAccess =
- mock(CertificateBasedAccess.class, Mockito.withSettings().withoutAnnotations());
- Mockito.when(mtlsCertificateBasedAccess.useMtlsClientCertificate()).thenReturn(true);
- IOException expected = new IOException("Corrupt PEM file");
- Mockito.when(mtlsCertificateBasedAccess.createDefaultMtlsProvider()).thenThrow(expected);
-
- InstantiatingGrpcChannelProvider provider =
- InstantiatingGrpcChannelProvider.newBuilder()
- .setCertificateBasedAccess(mtlsCertificateBasedAccess)
- .setAttemptDirectPath(false)
- .setEndpoint(DEFAULT_ENDPOINT)
- .setHeaderProvider(
- mock(HeaderProvider.class, Mockito.withSettings().withoutAnnotations()))
- .build();
-
- IOException thrown = assertThrows(IOException.class, provider::createChannelBuilder);
- assertThat(thrown).isSameInstanceAs(expected);
- }
-
- @Test
- void
- createChannelBuilder_whenDefaultMtlsProviderThrowsCertificateSourceUnavailable_fallsBackWithoutMtls()
- throws Exception {
- CertificateBasedAccess mtlsCertificateBasedAccess =
- mock(CertificateBasedAccess.class, Mockito.withSettings().withoutAnnotations());
- Mockito.when(mtlsCertificateBasedAccess.useMtlsClientCertificate()).thenReturn(true);
- Mockito.when(mtlsCertificateBasedAccess.createDefaultMtlsProvider())
- .thenThrow(new CertificateSourceUnavailableException("No certificates on disk"));
-
- InstantiatingGrpcChannelProvider provider =
- InstantiatingGrpcChannelProvider.newBuilder()
- .setCertificateBasedAccess(mtlsCertificateBasedAccess)
- .setAttemptDirectPath(false)
- .setEndpoint(DEFAULT_ENDPOINT)
- .setHeaderProvider(
- mock(HeaderProvider.class, Mockito.withSettings().withoutAnnotations()))
- .build();
-
- assertThat(provider.createChannelBuilder()).isNotNull();
- }
-
private static class FakeLogHandler extends Handler {
List records = new ArrayList<>();
diff --git a/sdk-platform-java/gax-java/gax-httpjson/BUILD.bazel b/sdk-platform-java/gax-java/gax-httpjson/BUILD.bazel
index e97aedebabfd..c4f78edb79a2 100644
--- a/sdk-platform-java/gax-java/gax-httpjson/BUILD.bazel
+++ b/sdk-platform-java/gax-java/gax-httpjson/BUILD.bazel
@@ -53,10 +53,6 @@ java_library(
srcs = glob(["src/test/java/**/*.java"]),
javacopts = _JAVA_COPTS,
plugins = ["//:auto_value_plugin"],
- resources = glob([
- "src/test/resources/com/google/api/gax/httpjson/tls13TestCa.pem",
- "src/test/resources/com/google/api/gax/httpjson/tls13TestServerCertAndKey.pem",
- ]),
visibility = ["//visibility:public"],
deps = [":gax_httpjson"] + _COMPILE_DEPS + _TEST_COMPILE_DEPS,
)
diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallContext.java b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallContext.java
index c5cb09338872..daefe8736eda 100644
--- a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallContext.java
+++ b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallContext.java
@@ -81,7 +81,6 @@ public final class HttpJsonCallContext implements ApiCallContext {
private final @Nullable RetrySettings retrySettings;
private final @Nullable ImmutableSet retryableCodes;
private final EndpointContext endpointContext;
- @Nullable private final TransportChannel transportChannel;
/** Returns an empty instance. */
public static HttpJsonCallContext createDefault() {
@@ -96,7 +95,6 @@ public static HttpJsonCallContext createDefault() {
null,
null,
null,
- null,
null);
}
@@ -112,7 +110,6 @@ public static HttpJsonCallContext of(HttpJsonChannel channel, HttpJsonCallOption
null,
null,
null,
- null,
null);
}
@@ -127,8 +124,7 @@ private HttpJsonCallContext(
@Nullable ApiTracer tracer,
@Nullable RetrySettings defaultRetrySettings,
@Nullable Set defaultRetryableCodes,
- @Nullable EndpointContext endpointContext,
- @Nullable TransportChannel transportChannel) {
+ @Nullable EndpointContext endpointContext) {
this.channel = channel;
this.callOptions = callOptions;
this.timeout = timeout;
@@ -144,7 +140,6 @@ private HttpJsonCallContext(
// a valid EndpointContext with user configurations after the client has been initialized.
this.endpointContext =
endpointContext == null ? EndpointContext.getDefaultInstance() : endpointContext;
- this.transportChannel = transportChannel;
}
/**
@@ -224,12 +219,6 @@ public HttpJsonCallContext merge(ApiCallContext inputCallContext) {
newRetryableCodes = this.retryableCodes;
}
- TransportChannel newTransportChannel = httpJsonCallContext.transportChannel;
- if (newTransportChannel == null
- && (httpJsonCallContext.channel == null || httpJsonCallContext.channel.equals(channel))) {
- newTransportChannel = this.transportChannel;
- }
-
// The EndpointContext is not updated as there should be no reason for a user
// to update this.
return new HttpJsonCallContext(
@@ -243,8 +232,7 @@ public HttpJsonCallContext merge(ApiCallContext inputCallContext) {
newTracer,
newRetrySettings,
newRetryableCodes,
- endpointContext,
- newTransportChannel);
+ endpointContext);
}
@Override
@@ -262,24 +250,7 @@ public HttpJsonCallContext withTransportChannel(TransportChannel inputChannel) {
"Expected HttpJsonTransportChannel, got " + inputChannel.getClass().getName());
}
HttpJsonTransportChannel transportChannel = (HttpJsonTransportChannel) inputChannel;
- return new HttpJsonCallContext(
- transportChannel.getChannel(),
- this.callOptions,
- this.timeout,
- this.streamWaitTimeout,
- this.streamIdleTimeout,
- this.extraHeaders,
- this.options,
- this.tracer,
- this.retrySettings,
- this.retryableCodes,
- this.endpointContext,
- transportChannel);
- }
-
- @Override
- public TransportChannel getTransportChannel() {
- return transportChannel;
+ return withChannel(transportChannel.getChannel());
}
/** This method is obsolete. Use {@link #withTimeoutDuration(java.time.Duration)} instead. */
@@ -303,8 +274,7 @@ public HttpJsonCallContext withEndpointContext(EndpointContext endpointContext)
this.tracer,
this.retrySettings,
this.retryableCodes,
- endpointContext,
- this.transportChannel);
+ endpointContext);
}
@Override
@@ -330,8 +300,7 @@ public HttpJsonCallContext withTimeoutDuration(java.time.Duration timeout) {
this.tracer,
this.retrySettings,
this.retryableCodes,
- this.endpointContext,
- this.transportChannel);
+ this.endpointContext);
}
/** This method is obsolete. Use {@link #getTimeoutDuration()} instead. */
@@ -376,8 +345,7 @@ public HttpJsonCallContext withStreamWaitTimeoutDuration(
this.tracer,
this.retrySettings,
this.retryableCodes,
- this.endpointContext,
- this.transportChannel);
+ this.endpointContext);
}
/** This method is obsolete. Use {@link #getStreamWaitTimeoutDuration()} instead. */
@@ -427,8 +395,7 @@ public HttpJsonCallContext withStreamIdleTimeoutDuration(
this.tracer,
this.retrySettings,
this.retryableCodes,
- this.endpointContext,
- this.transportChannel);
+ this.endpointContext);
}
/** This method is obsolete. Use {@link #getStreamIdleTimeoutDuration()} instead. */
@@ -465,8 +432,7 @@ public ApiCallContext withExtraHeaders(Map> extraHeaders) {
this.tracer,
this.retrySettings,
this.retryableCodes,
- this.endpointContext,
- this.transportChannel);
+ this.endpointContext);
}
@BetaApi("The surface for extra headers is not stable yet and may change in the future.")
@@ -490,8 +456,7 @@ public ApiCallContext withOption(Key key, T value) {
this.tracer,
this.retrySettings,
this.retryableCodes,
- this.endpointContext,
- this.transportChannel);
+ this.endpointContext);
}
/** {@inheritDoc} */
@@ -561,8 +526,7 @@ public HttpJsonCallContext withRetrySettings(RetrySettings retrySettings) {
this.tracer,
retrySettings,
this.retryableCodes,
- this.endpointContext,
- this.transportChannel);
+ this.endpointContext);
}
@Override
@@ -583,8 +547,7 @@ public HttpJsonCallContext withRetryableCodes(Set retryableCode
this.tracer,
this.retrySettings,
retryableCodes,
- this.endpointContext,
- this.transportChannel);
+ this.endpointContext);
}
public HttpJsonCallContext withChannel(@Nullable HttpJsonChannel newChannel) {
@@ -599,8 +562,7 @@ public HttpJsonCallContext withChannel(@Nullable HttpJsonChannel newChannel) {
this.tracer,
this.retrySettings,
this.retryableCodes,
- this.endpointContext,
- (newChannel != null && newChannel.equals(this.channel)) ? this.transportChannel : null);
+ this.endpointContext);
}
public HttpJsonCallContext withCallOptions(HttpJsonCallOptions newCallOptions) {
@@ -615,8 +577,7 @@ public HttpJsonCallContext withCallOptions(HttpJsonCallOptions newCallOptions) {
this.tracer,
this.retrySettings,
this.retryableCodes,
- this.endpointContext,
- this.transportChannel);
+ this.endpointContext);
}
@Deprecated
@@ -651,8 +612,7 @@ public HttpJsonCallContext withTracer(ApiTracer newTracer) {
newTracer,
this.retrySettings,
this.retryableCodes,
- this.endpointContext,
- this.transportChannel);
+ this.endpointContext);
}
@Override
@@ -672,8 +632,7 @@ public boolean equals(@Nullable Object o) {
&& Objects.equals(this.tracer, that.tracer)
&& Objects.equals(this.retrySettings, that.retrySettings)
&& Objects.equals(this.retryableCodes, that.retryableCodes)
- && Objects.equals(this.endpointContext, that.endpointContext)
- && Objects.equals(this.transportChannel, that.transportChannel);
+ && Objects.equals(this.endpointContext, that.endpointContext);
}
@Override
@@ -687,7 +646,6 @@ public int hashCode() {
tracer,
retrySettings,
retryableCodes,
- endpointContext,
- transportChannel);
+ endpointContext);
}
}
diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonTransportChannel.java b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonTransportChannel.java
index fde0673f650d..813622b6a97e 100644
--- a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonTransportChannel.java
+++ b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonTransportChannel.java
@@ -64,21 +64,6 @@ public HttpJsonChannel getChannel() {
return getManagedChannel();
}
- @Override
- public void refresh() {
- getManagedChannel().refresh();
- }
-
- @Override
- public boolean shouldRefresh() {
- return getManagedChannel().shouldRefresh();
- }
-
- @Override
- public long getGeneration() {
- return getManagedChannel().getGeneration();
- }
-
@Override
public void shutdown() {
getManagedChannel().shutdown();
diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/InstantiatingHttpJsonChannelProvider.java b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/InstantiatingHttpJsonChannelProvider.java
index 0766a8032df1..f0a6aa380d37 100644
--- a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/InstantiatingHttpJsonChannelProvider.java
+++ b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/InstantiatingHttpJsonChannelProvider.java
@@ -40,6 +40,7 @@
import com.google.api.gax.rpc.mtls.CertificateBasedAccess;
import com.google.auth.Credentials;
import com.google.auth.mtls.CertificateSourceUnavailableException;
+import com.google.auth.mtls.DefaultMtlsProviderFactory;
import com.google.auth.mtls.MtlsProvider;
import com.google.common.annotations.VisibleForTesting;
import java.io.IOException;
@@ -49,11 +50,9 @@
import java.util.Map;
import java.util.concurrent.Executor;
import java.util.concurrent.ScheduledExecutorService;
-import java.util.function.Supplier;
+import java.util.logging.Level;
import java.util.logging.Logger;
-import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
-import javax.net.ssl.TrustManagerFactory;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
@@ -217,116 +216,49 @@ private NetHttpTransport.Builder configureMtls(NetHttpTransport.Builder builder)
// Fall back to standard JDK JSSE if Conscrypt provider is unavailable
return builder;
}
- // NetHttpTransport.Builder.trustCertificates() in google-http-client initializes with the
- // default SunX509 KeyManagerFactory (via SslUtils.getDefaultKeyManagerFactory()), which is not
- // supported by Conscrypt. Explicitly initialize SSLContext with the Conscrypt provider so that
- // the client certificate key managers and trust manager factory (TMF) are bound to Conscrypt's
- // TLS implementation (supporting PQC key exchange).
+ // Explicitly initialize SSLContext with the Conscrypt provider so that the client certificate
+ // key managers
+ // and trust manager factory (TMF) are bound to Conscrypt's TLS implementation (supporting PQC
+ // key exchange).
SSLContext sslContext = SSLContext.getInstance("TLS", conscryptProvider);
- // The TrustManagerFactory must come from the same provider as the SSLContext. On TLS 1.3,
- // Conscrypt passes authType "GENERIC" to the trust manager, which the JDK (SunJSSE) PKIX
- // trust manager rejects for CA-issued server certificates that carry a KeyUsage extension
- // (e.g. Google front ends), failing the handshake with "Unknown authType: GENERIC".
- // Conscrypt's trust manager loads the same default trust store as the JDK.
- // The KeyManagerFactory must also come from Conscrypt: since JDK 26 (JDK-8359956) the JDK's
- // SunX509 key manager applies algorithm constraints to the client certificate, and when called
- // from a Conscrypt handshake it rejects valid certificates (e.g. SHA256withRSA), so no client
- // certificate is sent.
SslUtils.initSslContext(
sslContext,
null,
- TrustManagerFactory.getInstance("PKIX", conscryptProvider),
+ SslUtils.getPkixTrustManagerFactory(),
mtlsKeyStore,
"",
- KeyManagerFactory.getInstance("PKIX", conscryptProvider));
+ SslUtils.getDefaultKeyManagerFactory());
builder.setSslSocketFactory(sslContext.getSocketFactory());
return builder;
}
- private HttpTransport createChannelHttpTransport() throws IOException, GeneralSecurityException {
- HttpTransport transport = createHttpTransport();
- if (mtlsProvider != null
- && certificateBasedAccess.useMtlsClientCertificate()
- && !((NetHttpTransport) transport).isMtls()) {
- // mTLS is enabled but the provider returned no client certificate. Fail instead of silently
- // using a transport without the certificate, matching InstantiatingGrpcChannelProvider.
- // During certificate rotation, this makes RefreshingHttpJsonChannel keep the current
- // authenticated transport instead of swapping in one without a client certificate.
- throw new IOException("Failed to initialize mTLS HttpTransport");
- }
- return transport;
- }
-
- private ManagedHttpJsonChannel createSingleManagedChannel()
- throws IOException, GeneralSecurityException {
- return buildManagedChannel(
- httpTransport != null ? httpTransport : createChannelHttpTransport());
- }
-
- private ManagedHttpJsonChannel buildManagedChannel(HttpTransport httpTransportToUse) {
- return ManagedHttpJsonChannel.newBuilder()
- .setEndpoint(endpoint)
- .setExecutor(executor)
- .setHttpTransport(httpTransportToUse)
- .setManageHttpTransport(httpTransport == null)
- .build();
- }
-
private HttpJsonTransportChannel createChannel() throws IOException, GeneralSecurityException {
- boolean isMtlsActive =
- httpTransport == null
- && mtlsProvider != null
- && certificateBasedAccess.useMtlsClientCertificate();
- String workloadCertPath = isMtlsActive ? certificateBasedAccess.getWorkloadCertPath() : null;
-
- ManagedHttpJsonChannel baseChannel;
- if (workloadCertPath != null) {
- Supplier transportFactory =
- () -> {
- try {
- return createChannelHttpTransport();
- } catch (IOException | GeneralSecurityException e) {
- throw new RuntimeException("Failed to create mTLS HttpTransport", e);
- }
- };
- // RefreshingHttpJsonChannel records the baseline certificate fingerprint before creating the
- // initial transport, so a rotation during startup is detected on the next auth failure.
- try {
- baseChannel =
- new RefreshingHttpJsonChannel(
- transportFactory, this::buildManagedChannel, workloadCertPath);
- } catch (RuntimeException e) {
- if (e.getCause() instanceof IOException) {
- throw (IOException) e.getCause();
- }
- if (e.getCause() instanceof GeneralSecurityException) {
- throw (GeneralSecurityException) e.getCause();
- }
- throw e;
- }
- } else {
- baseChannel = createSingleManagedChannel();
+ HttpTransport httpTransportToUse = httpTransport;
+ if (httpTransportToUse == null) {
+ httpTransportToUse = createHttpTransport();
}
- try {
- ManagedHttpJsonChannel channel = baseChannel;
-
- HttpJsonClientInterceptor headerInterceptor =
- new HttpJsonHeaderInterceptor(headerProvider.getHeaders());
-
- channel = new ManagedHttpJsonInterceptorChannel(channel, new HttpJsonLoggingInterceptor());
- channel = new ManagedHttpJsonInterceptorChannel(channel, headerInterceptor);
- if (interceptorProvider != null && interceptorProvider.getInterceptors() != null) {
- for (HttpJsonClientInterceptor interceptor : interceptorProvider.getInterceptors()) {
- channel = new ManagedHttpJsonInterceptorChannel(channel, interceptor);
- }
+ // Pass the executor to the ManagedChannel. If no executor was provided (or null),
+ // the channel will use a default executor for the calls.
+ ManagedHttpJsonChannel channel =
+ ManagedHttpJsonChannel.newBuilder()
+ .setEndpoint(endpoint)
+ .setExecutor(executor)
+ .setHttpTransport(httpTransportToUse)
+ .build();
+
+ HttpJsonClientInterceptor headerInterceptor =
+ new HttpJsonHeaderInterceptor(headerProvider.getHeaders());
+
+ channel = new ManagedHttpJsonInterceptorChannel(channel, new HttpJsonLoggingInterceptor());
+ channel = new ManagedHttpJsonInterceptorChannel(channel, headerInterceptor);
+ if (interceptorProvider != null && interceptorProvider.getInterceptors() != null) {
+ for (HttpJsonClientInterceptor interceptor : interceptorProvider.getInterceptors()) {
+ channel = new ManagedHttpJsonInterceptorChannel(channel, interceptor);
}
-
- return HttpJsonTransportChannel.newBuilder().setManagedChannel(channel).build();
- } catch (Throwable t) {
- baseChannel.shutdownNow();
- throw t;
}
+
+ return HttpJsonTransportChannel.newBuilder().setManagedChannel(channel).build();
}
/** The endpoint to be used for the channel. */
@@ -451,23 +383,18 @@ public InstantiatingHttpJsonChannelProvider build() {
if (mtlsProvider == null) {
// Attempt to create default MtlsProvider from environment.
try {
- mtlsProvider = certificateBasedAccess.createDefaultMtlsProvider();
+ mtlsProvider = DefaultMtlsProviderFactory.create();
} catch (CertificateSourceUnavailableException e) {
// This is okay. Leave mtlsProvider as null so that we will not auto-upgrade
// to mTLS endpoints. See https://google.aip.dev/auth/4114.
} catch (IOException e) {
- mtlsProvider =
- new MtlsProvider() {
- @Override
- public boolean isAvailable() throws IOException {
- throw e;
- }
-
- @Override
- public KeyStore getKeyStore() throws IOException {
- throw e;
- }
- };
+ LOG.log(
+ Level.WARNING,
+ "DefaultMtlsProviderFactory encountered unexpected IOException: " + e.getMessage());
+ LOG.log(
+ Level.WARNING,
+ "mTLS configuration was detected on the device, but mTLS failed to initialize."
+ + " Falling back to non-mTLS channel.");
}
}
}
diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ManagedHttpJsonChannel.java b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ManagedHttpJsonChannel.java
index bf79e17d7d89..87767bee5c7f 100644
--- a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ManagedHttpJsonChannel.java
+++ b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ManagedHttpJsonChannel.java
@@ -51,35 +51,12 @@ public class ManagedHttpJsonChannel implements HttpJsonChannel, BackgroundResour
private final Executor executor;
private final boolean usingDefaultExecutor;
private final String endpoint;
- private volatile HttpTransport httpTransport;
- private final boolean usingDefaultTransport;
+ private final HttpTransport httpTransport;
private final ScheduledExecutorService deadlineScheduledExecutorService;
private boolean isTransportShutdown;
protected ManagedHttpJsonChannel() {
- this(null, true, null, null, true);
- }
-
- /**
- * Constructor for subclasses that delegate all calls to a wrapped channel. The argument is
- * unused; it only distinguishes this overload from {@link #ManagedHttpJsonChannel()}, which would
- * otherwise allocate a transport and executor that the wrapper never uses or shuts down.
- */
- ManagedHttpJsonChannel(boolean isDelegatingWrapper) {
- this.executor = null;
- this.usingDefaultExecutor = false;
- this.endpoint = null;
- this.httpTransport = null;
- this.usingDefaultTransport = false;
- this.deadlineScheduledExecutorService = null;
- }
-
- /**
- * Returns a monotonic generation counter tracking the number of successful refreshes or channel
- * rotations performed by this channel. Always {@code 0} for channels that do not refresh.
- */
- public long getGeneration() {
- return 0;
+ this(null, true, null, null);
}
String getEndpoint() {
@@ -91,20 +68,11 @@ HttpTransport getHttpTransport() {
return httpTransport;
}
- /**
- * Replaces the transport used by calls created after this method returns. Calls that were already
- * created keep using the transport they were created with.
- */
- void setHttpTransport(HttpTransport httpTransport) {
- this.httpTransport = httpTransport;
- }
-
private ManagedHttpJsonChannel(
@Nullable Executor executor,
boolean usingDefaultExecutor,
@Nullable String endpoint,
- @Nullable HttpTransport httpTransport,
- boolean usingDefaultTransport) {
+ @Nullable HttpTransport httpTransport) {
this.executor = executor;
this.usingDefaultExecutor = usingDefaultExecutor;
this.endpoint = endpoint;
@@ -114,7 +82,6 @@ private ManagedHttpJsonChannel(
new NetHttpTransport.Builder())
.build()
: httpTransport;
- this.usingDefaultTransport = usingDefaultTransport || httpTransport == null;
this.deadlineScheduledExecutorService = Executors.newSingleThreadScheduledExecutor();
}
@@ -131,22 +98,6 @@ public HttpJsonClientCall newCall(
deadlineScheduledExecutorService);
}
- /**
- * Refreshes or recreates the underlying transport of this channel if a certificate rotation has
- * been detected. By default, this is a no-op.
- */
- public void refresh() {
- // No-op: this channel has no certificate to rotate. Overridden by RefreshingHttpJsonChannel.
- }
-
- /**
- * Returns true if a certificate rotation has been detected on disk and this channel should be
- * refreshed, or false otherwise. Always {@code false} for channels that do not refresh.
- */
- public boolean shouldRefresh() {
- return false;
- }
-
@VisibleForTesting
Executor getExecutor() {
return executor;
@@ -165,9 +116,7 @@ public synchronized void shutdown() {
((ExecutorService) executor).shutdown();
}
deadlineScheduledExecutorService.shutdown();
- if (usingDefaultTransport) {
- httpTransport.shutdown();
- }
+ httpTransport.shutdown();
isTransportShutdown = true;
} catch (IOException e) {
// TODO: Log this scenario once we implemented the Cloud SDK logging.
@@ -209,9 +158,7 @@ public void shutdownNow() {
((ExecutorService) executor).shutdownNow();
}
deadlineScheduledExecutorService.shutdownNow();
- if (usingDefaultTransport) {
- httpTransport.shutdown();
- }
+ httpTransport.shutdown();
isTransportShutdown = true;
} catch (IOException e) {
// TODO: Log this scenario once we implemented the Cloud SDK logging.
@@ -258,11 +205,9 @@ public static class Builder {
private String endpoint;
private HttpTransport httpTransport;
private boolean usingDefaultExecutor;
- private boolean usingDefaultTransport;
private Builder() {
this.usingDefaultExecutor = false;
- this.usingDefaultTransport = false;
}
public Builder setExecutor(Executor executor) {
@@ -280,11 +225,6 @@ public Builder setHttpTransport(HttpTransport httpTransport) {
return this;
}
- Builder setManageHttpTransport(boolean manageHttpTransport) {
- this.usingDefaultTransport = manageHttpTransport;
- return this;
- }
-
public ManagedHttpJsonChannel build() {
Preconditions.checkNotNull(endpoint);
@@ -297,8 +237,14 @@ public ManagedHttpJsonChannel build() {
usingDefaultExecutor = true;
}
- return new ManagedHttpJsonChannel(
- executor, usingDefaultExecutor, endpoint, httpTransport, usingDefaultTransport);
+ if (httpTransport == null) {
+ httpTransport =
+ HttpJsonConscryptUtils.configureConscryptSecurityProvider(
+ new NetHttpTransport.Builder())
+ .build();
+ }
+
+ return new ManagedHttpJsonChannel(executor, usingDefaultExecutor, endpoint, httpTransport);
}
}
}
diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ManagedHttpJsonInterceptorChannel.java b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ManagedHttpJsonInterceptorChannel.java
index 9adbfdd5c329..eaaa8c3a7c56 100644
--- a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ManagedHttpJsonInterceptorChannel.java
+++ b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ManagedHttpJsonInterceptorChannel.java
@@ -29,9 +29,7 @@
*/
package com.google.api.gax.httpjson;
-import com.google.api.client.http.HttpTransport;
import com.google.common.annotations.VisibleForTesting;
-import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;
import org.jspecify.annotations.NullMarked;
@@ -43,57 +41,22 @@ class ManagedHttpJsonInterceptorChannel extends ManagedHttpJsonChannel {
ManagedHttpJsonInterceptorChannel(
ManagedHttpJsonChannel channel, HttpJsonClientInterceptor interceptor) {
- super(true);
+ super();
this.channel = channel;
this.interceptor = interceptor;
}
- /** {@inheritDoc} */
- @Override
- public long getGeneration() {
- return channel.getGeneration();
- }
-
@VisibleForTesting
ManagedHttpJsonChannel getChannel() {
return channel;
}
- @Override
- String getEndpoint() {
- return channel.getEndpoint();
- }
-
- @Override
- @VisibleForTesting
- HttpTransport getHttpTransport() {
- return channel.getHttpTransport();
- }
-
- @Override
- @VisibleForTesting
- Executor getExecutor() {
- return channel.getExecutor();
- }
-
@Override
public HttpJsonClientCall newCall(
ApiMethodDescriptor methodDescriptor, HttpJsonCallOptions callOptions) {
return interceptor.interceptCall(methodDescriptor, callOptions, channel);
}
- /** {@inheritDoc} */
- @Override
- public void refresh() {
- channel.refresh();
- }
-
- /** {@inheritDoc} */
- @Override
- public boolean shouldRefresh() {
- return channel.shouldRefresh();
- }
-
@Override
public synchronized void shutdown() {
channel.shutdown();
diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/RefreshingHttpJsonChannel.java b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/RefreshingHttpJsonChannel.java
deleted file mode 100644
index 6d20a69139f8..000000000000
--- a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/RefreshingHttpJsonChannel.java
+++ /dev/null
@@ -1,213 +0,0 @@
-/*
- * 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.api.gax.httpjson;
-
-import com.google.api.client.http.HttpTransport;
-import com.google.api.core.InternalApi;
-import com.google.api.gax.rpc.mtls.CertificateRotationTracker;
-import com.google.api.gax.rpc.mtls.WorkloadCertificateUtils;
-import com.google.common.annotations.VisibleForTesting;
-import java.util.concurrent.Executor;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicLong;
-import java.util.function.Function;
-import java.util.function.Supplier;
-import java.util.logging.Level;
-import java.util.logging.Logger;
-
-/**
- * An implementation of {@link ManagedHttpJsonChannel} that supports dynamic mTLS certificate
- * rotation. When the workload certificate on disk changes, {@link #refresh()} replaces the {@link
- * HttpTransport} of the underlying channel. Calls that were already created keep using the
- * transport they were created with, so in-flight requests are not interrupted.
- */
-@InternalApi
-public class RefreshingHttpJsonChannel extends ManagedHttpJsonChannel {
-
- private static final Logger LOG = Logger.getLogger(RefreshingHttpJsonChannel.class.getName());
-
- private final CertificateRotationTracker rotationTracker;
- private final Supplier transportFactory;
- private final String workloadCertPath;
- private final ManagedHttpJsonChannel delegate;
- private final Object refreshLock = new Object();
- private final AtomicLong generation = new AtomicLong(0);
-
- /**
- * @param transportFactory creates a transport configured with the certificate currently on disk
- * @param channelFactory creates the underlying channel from the initial transport
- * @param workloadCertPath path of the workload certificate to monitor for rotation
- */
- public RefreshingHttpJsonChannel(
- Supplier transportFactory,
- Function channelFactory,
- String workloadCertPath) {
- super(true);
- this.transportFactory = transportFactory;
- this.workloadCertPath = workloadCertPath;
- // Record the baseline fingerprint before the initial transport loads the certificate from disk,
- // so a rotation between the two steps is detected as a fingerprint change.
- this.rotationTracker =
- new CertificateRotationTracker(this::getWorkloadCertPath, this::getCertificateFingerprint);
- this.delegate = channelFactory.apply(transportFactory.get());
- }
-
- @VisibleForTesting
- String getWorkloadCertPath() {
- return workloadCertPath;
- }
-
- @VisibleForTesting
- String getCertificateFingerprint(String certPath) {
- return WorkloadCertificateUtils.getCertificateFingerprint(certPath);
- }
-
- /** {@inheritDoc} */
- @Override
- public boolean shouldRefresh() {
- return rotationTracker.shouldRefresh();
- }
-
- /** {@inheritDoc} */
- @Override
- public void refresh() {
- // A generation change while waiting for the lock means a concurrent refresh already swapped in
- // a transport with a new certificate, so there is no need to read the certificate again.
- long generationBeforeLock = generation.get();
- synchronized (refreshLock) {
- if (isShutdown()) {
- return;
- }
- if (generation.get() != generationBeforeLock) {
- LOG.fine(
- "HTTP/JSON channel was already refreshed by a concurrent thread, skipping duplicate"
- + " refresh");
- return;
- }
- String currentDiskFingerprint = rotationTracker.readDiskFingerprint();
- if (currentDiskFingerprint.isEmpty()) {
- return;
- }
-
- // Double-check inside refreshLock
- if (rotationTracker.isAlreadyActive(currentDiskFingerprint)) {
- LOG.fine(
- "HTTP/JSON channel was already refreshed by a concurrent thread, skipping duplicate"
- + " refresh");
- return;
- }
-
- LOG.info("mTLS certificate rotation detected. Refreshing HTTP/JSON transport.");
-
- HttpTransport newTransport;
- try {
- newTransport = transportFactory.get();
- } catch (Exception e) {
- LOG.log(Level.WARNING, "Failed to refresh HTTP/JSON transport, keeping old transport", e);
- return;
- }
- // The previous transport is not shut down: calls created before the swap may still be using
- // it, and it needs no explicit shutdown because its idle keep-alive connections expire.
- delegate.setHttpTransport(newTransport);
- // Order matters: swap the transport, then bump generation, then mark the tracker refreshed,
- // so any failing RPC that observes the new fingerprint also observes the new generation.
- generation.incrementAndGet();
- rotationTracker.markRefreshed(currentDiskFingerprint);
- }
- }
-
- /** {@inheritDoc} */
- @Override
- public long getGeneration() {
- return generation.get();
- }
-
- @Override
- public HttpJsonClientCall newCall(
- ApiMethodDescriptor methodDescriptor, HttpJsonCallOptions callOptions) {
- return delegate.newCall(methodDescriptor, callOptions);
- }
-
- @Override
- Executor getExecutor() {
- return delegate.getExecutor();
- }
-
- @Override
- String getEndpoint() {
- return delegate.getEndpoint();
- }
-
- @Override
- @VisibleForTesting
- HttpTransport getHttpTransport() {
- return delegate.getHttpTransport();
- }
-
- @VisibleForTesting
- void invalidateDiskFingerprintCache() {
- rotationTracker.invalidateCache();
- }
-
- @Override
- public void shutdown() {
- // Serialized with refresh() so that a transport swap cannot race with shutdown.
- synchronized (refreshLock) {
- delegate.shutdown();
- }
- }
-
- @Override
- public boolean isShutdown() {
- return delegate.isShutdown();
- }
-
- @Override
- public boolean isTerminated() {
- return delegate.isTerminated();
- }
-
- @Override
- public void shutdownNow() {
- synchronized (refreshLock) {
- delegate.shutdownNow();
- }
- }
-
- @Override
- public boolean awaitTermination(long duration, TimeUnit unit) throws InterruptedException {
- return delegate.awaitTermination(duration, unit);
- }
-
- @Override
- public void close() {
- shutdown();
- }
-}
diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonCallContextTest.java b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonCallContextTest.java
index 6963ca2c1f88..08044522e729 100644
--- a/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonCallContextTest.java
+++ b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonCallContextTest.java
@@ -47,7 +47,6 @@
import com.google.auth.Credentials;
import com.google.common.collect.ImmutableMap;
import com.google.common.truth.Truth;
-import java.time.Duration;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
@@ -208,19 +207,6 @@ void testWithLongerTimeout() {
.isEqualTo(java.time.Duration.ofSeconds(5));
}
- @Test
- void testWithNullOrZeroTimeoutClearsExistingTimeout() {
- HttpJsonCallContext ctxWithTimeout =
- HttpJsonCallContext.createDefault().withTimeoutDuration(Duration.ofSeconds(5));
-
- // Sanity check
- Truth.assertThat(ctxWithTimeout.getTimeoutDuration()).isEqualTo(Duration.ofSeconds(5));
-
- Duration nullTimeout = null;
- assertNull(ctxWithTimeout.withTimeoutDuration(nullTimeout).getTimeoutDuration());
- assertNull(ctxWithTimeout.withTimeoutDuration(Duration.ZERO).getTimeoutDuration());
- }
-
@Test
void testMergeWithNullTimeout() {
java.time.Duration timeout = java.time.Duration.ofSeconds(10);
@@ -348,55 +334,4 @@ void testMergeOptions() {
assertEquals(testContext2, mergedContext.getOption(contextKey2));
assertEquals(testContext3, mergedContext.getOption(contextKey3));
}
-
- @Test
- void testWithChannelClearsStaleTransportChannel() {
- ManagedHttpJsonChannel channel1 =
- mock(ManagedHttpJsonChannel.class, Mockito.withSettings().withoutAnnotations());
- ManagedHttpJsonChannel channel2 =
- mock(ManagedHttpJsonChannel.class, Mockito.withSettings().withoutAnnotations());
-
- HttpJsonTransportChannel transportChannel1 =
- HttpJsonTransportChannel.newBuilder().setManagedChannel(channel1).build();
-
- HttpJsonCallContext context =
- HttpJsonCallContext.createDefault().withTransportChannel(transportChannel1);
- Truth.assertThat(context.getTransportChannel()).isSameInstanceAs(transportChannel1);
-
- // Retains transportChannel when setting same channel
- Truth.assertThat(context.withChannel(channel1).getTransportChannel())
- .isSameInstanceAs(transportChannel1);
-
- // Clears transportChannel to null when setting null or a different channel
- HttpJsonCallContext nullChannelContext = context.withChannel(null);
- Truth.assertThat(nullChannelContext.getChannel()).isNull();
- Truth.assertThat(nullChannelContext.getTransportChannel()).isNull();
- Truth.assertThat(context.withChannel(channel2).getTransportChannel()).isNull();
-
- // Merging a context with a cleared channel into the original context keeps the original
- // channel and transportChannel
- HttpJsonCallContext mergedWithNullChannel = context.merge(nullChannelContext);
- Truth.assertThat(mergedWithNullChannel.getChannel()).isSameInstanceAs(channel1);
- Truth.assertThat(mergedWithNullChannel.getTransportChannel())
- .isSameInstanceAs(transportChannel1);
- }
-
- @Test
- void testMergeClearsStaleTransportChannel() {
- ManagedHttpJsonChannel channel1 =
- mock(ManagedHttpJsonChannel.class, Mockito.withSettings().withoutAnnotations());
- ManagedHttpJsonChannel channel2 =
- mock(ManagedHttpJsonChannel.class, Mockito.withSettings().withoutAnnotations());
-
- HttpJsonTransportChannel transportChannel1 =
- HttpJsonTransportChannel.newBuilder().setManagedChannel(channel1).build();
-
- HttpJsonCallContext context1 =
- HttpJsonCallContext.createDefault().withTransportChannel(transportChannel1);
- HttpJsonCallContext context2 = HttpJsonCallContext.createDefault().withChannel(channel2);
-
- HttpJsonCallContext merged = context1.merge(context2);
- Truth.assertThat(merged.getChannel()).isSameInstanceAs(channel2);
- Truth.assertThat(merged.getTransportChannel()).isNull();
- }
}
diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/InstantiatingHttpJsonChannelProviderTest.java b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/InstantiatingHttpJsonChannelProviderTest.java
index 74a578da24cb..8c95c1d2e1c4 100644
--- a/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/InstantiatingHttpJsonChannelProviderTest.java
+++ b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/InstantiatingHttpJsonChannelProviderTest.java
@@ -31,34 +31,21 @@
import static com.google.common.truth.Truth.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertThrows;
-import static org.mockito.Mockito.mock;
-import com.google.api.client.http.HttpTransport;
import com.google.api.client.http.javanet.NetHttpTransport;
-import com.google.api.gax.rpc.HeaderProvider;
import com.google.api.gax.rpc.TransportChannelProvider;
import com.google.api.gax.rpc.mtls.AbstractMtlsTransportChannelTest;
import com.google.api.gax.rpc.mtls.CertificateBasedAccess;
-import com.google.api.gax.rpc.testing.FakeMtlsProvider;
-import com.google.auth.mtls.CertificateSourceUnavailableException;
import com.google.auth.mtls.MtlsProvider;
import java.io.IOException;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.Path;
import java.security.GeneralSecurityException;
-import java.security.KeyStore;
import java.util.Collections;
import java.util.Map;
import java.util.concurrent.Executor;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledThreadPoolExecutor;
-import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.io.TempDir;
-import org.mockito.Mockito;
class InstantiatingHttpJsonChannelProviderTest extends AbstractMtlsTransportChannelTest {
@@ -68,10 +55,9 @@ class InstantiatingHttpJsonChannelProviderTest extends AbstractMtlsTransportChan
@BeforeEach
public void setup() throws IOException {
- certificateBasedAccess = Mockito.mock(CertificateBasedAccess.class);
- Mockito.when(certificateBasedAccess.getMtlsEndpointUsagePolicy())
- .thenReturn(CertificateBasedAccess.MtlsEndpointUsagePolicy.NEVER);
- Mockito.when(certificateBasedAccess.useMtlsClientCertificate()).thenReturn(false);
+ certificateBasedAccess =
+ new CertificateBasedAccess(
+ name -> name.equals("GOOGLE_API_USE_MTLS_ENDPOINT") ? "never" : "false");
}
@Test
@@ -193,384 +179,20 @@ void managedChannelUsesCustomExecutor() throws IOException {
instantiatingHttpJsonChannelProvider.getTransportChannel().shutdownNow();
}
- @Test
- void managedChannelDoesNotShutdownCustomHttpTransport() throws IOException {
- HttpTransport mockHttpTransport = Mockito.mock(HttpTransport.class);
-
- InstantiatingHttpJsonChannelProvider provider =
- InstantiatingHttpJsonChannelProvider.newBuilder()
- .setEndpoint(DEFAULT_ENDPOINT)
- .setHttpTransport(mockHttpTransport)
- .setCertificateBasedAccess(certificateBasedAccess)
- .build();
- provider = (InstantiatingHttpJsonChannelProvider) provider.withHeaders(DEFAULT_HEADER_MAP);
-
- HttpJsonTransportChannel httpJsonTransportChannel = provider.getTransportChannel();
-
- // Verify custom transport is injected (direct ManagedHttpJsonChannel when workloadCertPath is
- // null)
- ManagedHttpJsonInterceptorChannel interceptorChannel =
- (ManagedHttpJsonInterceptorChannel) httpJsonTransportChannel.getManagedChannel();
- ManagedHttpJsonInterceptorChannel managedHttpJsonChannel =
- (ManagedHttpJsonInterceptorChannel) interceptorChannel.getChannel();
- ManagedHttpJsonChannel channel = managedHttpJsonChannel.getChannel();
-
- assertThat(channel.getHttpTransport()).isEqualTo(mockHttpTransport);
-
- // Perform a shutdown
- provider.getTransportChannel().shutdownNow();
-
- // Verify that shutdown() was NOT called on the custom HttpTransport
- Mockito.verify(mockHttpTransport, Mockito.never()).shutdown();
- }
-
- @Test
- void channelCreation_withWorkloadCertPath_wrapsWithRefreshingHttpJsonChannel()
- throws IOException, GeneralSecurityException {
- Mockito.when(certificateBasedAccess.useMtlsClientCertificate()).thenReturn(true);
- Mockito.when(certificateBasedAccess.getWorkloadCertPath()).thenReturn("fake/cert/path.json");
- MtlsProvider mtlsProvider =
- new FakeMtlsProvider(FakeMtlsProvider.createTestMtlsKeyStore(), "", false);
-
- InstantiatingHttpJsonChannelProvider provider =
- InstantiatingHttpJsonChannelProvider.newBuilder()
- .setEndpoint(DEFAULT_ENDPOINT)
- .setMtlsProvider(mtlsProvider)
- .setCertificateBasedAccess(certificateBasedAccess)
- .build();
- provider = (InstantiatingHttpJsonChannelProvider) provider.withHeaders(DEFAULT_HEADER_MAP);
-
- HttpJsonTransportChannel httpJsonTransportChannel = provider.getTransportChannel();
-
- ManagedHttpJsonInterceptorChannel interceptorChannel =
- (ManagedHttpJsonInterceptorChannel) httpJsonTransportChannel.getManagedChannel();
- ManagedHttpJsonInterceptorChannel managedHttpJsonChannel =
- (ManagedHttpJsonInterceptorChannel) interceptorChannel.getChannel();
- assertThat(managedHttpJsonChannel.getChannel()).isInstanceOf(RefreshingHttpJsonChannel.class);
-
- provider.getTransportChannel().shutdownNow();
- }
-
- @Test
- void channelCreation_withCustomHttpTransport_ignoresWorkloadCertPathAndDoesNotWrap()
- throws IOException, GeneralSecurityException {
- // mTLS is otherwise fully configured, so only the custom transport prevents wrapping. Lenient
- // because a custom transport is expected to skip these lookups.
- Mockito.lenient().when(certificateBasedAccess.useMtlsClientCertificate()).thenReturn(true);
- Mockito.lenient()
- .when(certificateBasedAccess.getWorkloadCertPath())
- .thenReturn("fake/cert/path.json");
- MtlsProvider mtlsProvider =
- new FakeMtlsProvider(FakeMtlsProvider.createTestMtlsKeyStore(), "", false);
- HttpTransport mockHttpTransport = Mockito.mock(HttpTransport.class);
-
- InstantiatingHttpJsonChannelProvider provider =
- InstantiatingHttpJsonChannelProvider.newBuilder()
- .setEndpoint(DEFAULT_ENDPOINT)
- .setHttpTransport(mockHttpTransport)
- .setMtlsProvider(mtlsProvider)
- .setCertificateBasedAccess(certificateBasedAccess)
- .build();
- provider = (InstantiatingHttpJsonChannelProvider) provider.withHeaders(DEFAULT_HEADER_MAP);
-
- HttpJsonTransportChannel httpJsonTransportChannel = provider.getTransportChannel();
-
- ManagedHttpJsonInterceptorChannel interceptorChannel =
- (ManagedHttpJsonInterceptorChannel) httpJsonTransportChannel.getManagedChannel();
- ManagedHttpJsonInterceptorChannel managedHttpJsonChannel =
- (ManagedHttpJsonInterceptorChannel) interceptorChannel.getChannel();
- assertThat(managedHttpJsonChannel.getChannel())
- .isNotInstanceOf(RefreshingHttpJsonChannel.class);
- Mockito.verify(certificateBasedAccess, Mockito.never()).getWorkloadCertPath();
-
- httpJsonTransportChannel.shutdownNow();
- }
-
- @Test
- void getTransportChannel_withMtlsKeyStore_usesMtlsTransport() throws IOException {
- Mockito.when(certificateBasedAccess.useMtlsClientCertificate()).thenReturn(true);
- MtlsProvider mtlsProvider =
- new FakeMtlsProvider(FakeMtlsProvider.createTestMtlsKeyStore(), "", false);
-
- InstantiatingHttpJsonChannelProvider provider =
- InstantiatingHttpJsonChannelProvider.newBuilder()
- .setEndpoint(DEFAULT_ENDPOINT)
- .setMtlsProvider(mtlsProvider)
- .setCertificateBasedAccess(certificateBasedAccess)
- .build();
- provider = (InstantiatingHttpJsonChannelProvider) provider.withHeaders(DEFAULT_HEADER_MAP);
-
- HttpJsonTransportChannel httpJsonTransportChannel = provider.getTransportChannel();
- ManagedHttpJsonInterceptorChannel interceptorChannel =
- (ManagedHttpJsonInterceptorChannel) httpJsonTransportChannel.getManagedChannel();
- ManagedHttpJsonChannel channel =
- ((ManagedHttpJsonInterceptorChannel) interceptorChannel.getChannel()).getChannel();
-
- assertThat(channel).isNotInstanceOf(RefreshingHttpJsonChannel.class);
- assertThat(((NetHttpTransport) channel.getHttpTransport()).isMtls()).isTrue();
-
- httpJsonTransportChannel.shutdownNow();
- }
-
- @Test
- void refresh_whenKeyStoreUnavailableDuringRotation_keepsCurrentTransport(@TempDir Path tempDir)
- throws IOException {
- Path certPath = tempDir.resolve("cert.pem");
- Files.write(certPath, "certificate-v1".getBytes(StandardCharsets.UTF_8));
- Mockito.when(certificateBasedAccess.useMtlsClientCertificate()).thenReturn(true);
- Mockito.when(certificateBasedAccess.getWorkloadCertPath()).thenReturn(certPath.toString());
- KeyStore keyStore = FakeMtlsProvider.createTestMtlsKeyStore();
- AtomicReference currentKeyStore = new AtomicReference<>(keyStore);
- MtlsProvider mtlsProvider =
- new MtlsProvider() {
- @Override
- public KeyStore getKeyStore() {
- return currentKeyStore.get();
- }
-
- @Override
- public boolean isAvailable() {
- return true;
- }
- };
-
- InstantiatingHttpJsonChannelProvider provider =
- InstantiatingHttpJsonChannelProvider.newBuilder()
- .setEndpoint(DEFAULT_ENDPOINT)
- .setMtlsProvider(mtlsProvider)
- .setCertificateBasedAccess(certificateBasedAccess)
- .build();
- provider = (InstantiatingHttpJsonChannelProvider) provider.withHeaders(DEFAULT_HEADER_MAP);
- HttpJsonTransportChannel httpJsonTransportChannel = provider.getTransportChannel();
- ManagedHttpJsonInterceptorChannel interceptorChannel =
- (ManagedHttpJsonInterceptorChannel) httpJsonTransportChannel.getManagedChannel();
- RefreshingHttpJsonChannel channel =
- (RefreshingHttpJsonChannel)
- ((ManagedHttpJsonInterceptorChannel) interceptorChannel.getChannel()).getChannel();
- HttpTransport initialTransport = channel.getHttpTransport();
- assertThat(((NetHttpTransport) initialTransport).isMtls()).isTrue();
-
- // The certificate rotates on disk, but the provider cannot return the new key store yet.
- Files.write(certPath, "certificate-v2".getBytes(StandardCharsets.UTF_8));
- currentKeyStore.set(null);
- assertThat(channel.shouldRefresh()).isTrue();
- channel.refresh();
-
- // The current authenticated transport is kept instead of one without a client certificate.
- assertThat(channel.getHttpTransport()).isSameInstanceAs(initialTransport);
- assertThat(channel.getGeneration()).isEqualTo(0);
- assertThat(channel.shouldRefresh()).isTrue();
-
- // Once the key store is available again, the next refresh swaps in a new mTLS transport.
- currentKeyStore.set(keyStore);
- channel.refresh();
- assertThat(channel.getHttpTransport()).isNotSameInstanceAs(initialTransport);
- assertThat(((NetHttpTransport) channel.getHttpTransport()).isMtls()).isTrue();
- assertThat(channel.getGeneration()).isEqualTo(1);
-
- httpJsonTransportChannel.shutdownNow();
- }
-
- @Test
- void getTransportChannel_whenMtlsKeyStoreThrowsIOException_throwsCheckedIOException()
- throws Exception {
- Mockito.when(certificateBasedAccess.useMtlsClientCertificate()).thenReturn(true);
- Mockito.when(certificateBasedAccess.getWorkloadCertPath()).thenReturn("fake/cert/path.json");
- MtlsProvider failingMtlsProvider =
- Mockito.mock(MtlsProvider.class, Mockito.withSettings().withoutAnnotations());
- Mockito.when(failingMtlsProvider.getKeyStore())
- .thenThrow(new IOException("Simulated keystore read failure"));
-
- InstantiatingHttpJsonChannelProvider provider =
- InstantiatingHttpJsonChannelProvider.newBuilder()
- .setEndpoint(DEFAULT_ENDPOINT)
- .setMtlsProvider(failingMtlsProvider)
- .setCertificateBasedAccess(certificateBasedAccess)
- .build();
- final InstantiatingHttpJsonChannelProvider finalProvider =
- (InstantiatingHttpJsonChannelProvider) provider.withHeaders(DEFAULT_HEADER_MAP);
-
- // Must throw checked IOException directly (not wrapped in RuntimeException)
- IOException thrown = assertThrows(IOException.class, finalProvider::getTransportChannel);
- assertThat(thrown).hasMessageThat().contains("Simulated keystore read failure");
- }
-
- @Test
- void getTransportChannel_whenMtlsActiveAndKeyStoreNull_throwsIOException() {
- Mockito.when(certificateBasedAccess.useMtlsClientCertificate()).thenReturn(true);
- MtlsProvider providerWithNullKeyStore = new FakeMtlsProvider(null, "", false);
-
- InstantiatingHttpJsonChannelProvider provider =
- InstantiatingHttpJsonChannelProvider.newBuilder()
- .setEndpoint(DEFAULT_ENDPOINT)
- .setMtlsProvider(providerWithNullKeyStore)
- .setCertificateBasedAccess(certificateBasedAccess)
- .build();
- InstantiatingHttpJsonChannelProvider finalProvider =
- (InstantiatingHttpJsonChannelProvider) provider.withHeaders(DEFAULT_HEADER_MAP);
-
- IOException thrown = assertThrows(IOException.class, finalProvider::getTransportChannel);
- assertThat(thrown).hasMessageThat().contains("Failed to initialize mTLS HttpTransport");
- }
-
- @Test
- void getTransportChannel_whenRotationEnabledAndKeyStoreNull_throwsIOException() {
- Mockito.when(certificateBasedAccess.useMtlsClientCertificate()).thenReturn(true);
- Mockito.when(certificateBasedAccess.getWorkloadCertPath()).thenReturn("fake/cert/path.json");
- MtlsProvider providerWithNullKeyStore = new FakeMtlsProvider(null, "", false);
-
- InstantiatingHttpJsonChannelProvider provider =
- InstantiatingHttpJsonChannelProvider.newBuilder()
- .setEndpoint(DEFAULT_ENDPOINT)
- .setMtlsProvider(providerWithNullKeyStore)
- .setCertificateBasedAccess(certificateBasedAccess)
- .build();
- InstantiatingHttpJsonChannelProvider finalProvider =
- (InstantiatingHttpJsonChannelProvider) provider.withHeaders(DEFAULT_HEADER_MAP);
-
- IOException thrown = assertThrows(IOException.class, finalProvider::getTransportChannel);
- assertThat(thrown).hasMessageThat().contains("Failed to initialize mTLS HttpTransport");
- }
-
- @Test
- void getTransportChannel_whenKeyStoreUninitialized_wrapsGeneralSecurityException()
- throws Exception {
- Mockito.when(certificateBasedAccess.useMtlsClientCertificate()).thenReturn(true);
- Mockito.when(certificateBasedAccess.getWorkloadCertPath()).thenReturn("fake/cert/path.json");
- // A KeyStore that was never loaded makes transport creation fail with a KeyStoreException.
- KeyStore uninitializedKeyStore = KeyStore.getInstance("PKCS12");
- MtlsProvider providerWithBadKeyStore = new FakeMtlsProvider(uninitializedKeyStore, "", false);
-
- InstantiatingHttpJsonChannelProvider provider =
- InstantiatingHttpJsonChannelProvider.newBuilder()
- .setEndpoint(DEFAULT_ENDPOINT)
- .setMtlsProvider(providerWithBadKeyStore)
- .setCertificateBasedAccess(certificateBasedAccess)
- .build();
- InstantiatingHttpJsonChannelProvider finalProvider =
- (InstantiatingHttpJsonChannelProvider) provider.withHeaders(DEFAULT_HEADER_MAP);
-
- // The GeneralSecurityException must be unwrapped from the factory's RuntimeException, so that
- // getTransportChannel() surfaces it as the direct cause of its checked IOException.
- IOException thrown = assertThrows(IOException.class, finalProvider::getTransportChannel);
- assertThat(thrown).hasCauseThat().isInstanceOf(GeneralSecurityException.class);
- }
-
- @Test
- void getTransportChannel_whenMtlsKeyStoreThrowsRuntimeException_propagatesUnwrapped()
- throws Exception {
- Mockito.when(certificateBasedAccess.useMtlsClientCertificate()).thenReturn(true);
- Mockito.when(certificateBasedAccess.getWorkloadCertPath()).thenReturn("fake/cert/path.json");
- IllegalStateException failure = new IllegalStateException("Simulated keystore failure");
- MtlsProvider failingMtlsProvider =
- Mockito.mock(MtlsProvider.class, Mockito.withSettings().withoutAnnotations());
- Mockito.when(failingMtlsProvider.getKeyStore()).thenThrow(failure);
-
- InstantiatingHttpJsonChannelProvider provider =
- InstantiatingHttpJsonChannelProvider.newBuilder()
- .setEndpoint(DEFAULT_ENDPOINT)
- .setMtlsProvider(failingMtlsProvider)
- .setCertificateBasedAccess(certificateBasedAccess)
- .build();
- InstantiatingHttpJsonChannelProvider finalProvider =
- (InstantiatingHttpJsonChannelProvider) provider.withHeaders(DEFAULT_HEADER_MAP);
-
- IllegalStateException thrown =
- assertThrows(IllegalStateException.class, finalProvider::getTransportChannel);
- assertThat(thrown).isSameInstanceAs(failure);
- }
-
- @Test
- void getTransportChannel_whenDefaultMtlsProviderThrowsIOException_propagatesIOException()
- throws Exception {
- Mockito.when(certificateBasedAccess.useMtlsClientCertificate()).thenReturn(true);
- IOException expected = new IOException("Corrupt PEM file");
- Mockito.when(certificateBasedAccess.createDefaultMtlsProvider()).thenThrow(expected);
-
- InstantiatingHttpJsonChannelProvider provider =
- InstantiatingHttpJsonChannelProvider.newBuilder()
- .setEndpoint(DEFAULT_ENDPOINT)
- .setCertificateBasedAccess(certificateBasedAccess)
- .build();
- InstantiatingHttpJsonChannelProvider finalProvider =
- (InstantiatingHttpJsonChannelProvider) provider.withHeaders(DEFAULT_HEADER_MAP);
-
- IOException thrown = assertThrows(IOException.class, finalProvider::getTransportChannel);
- assertThat(thrown).isSameInstanceAs(expected);
- }
-
- @Test
- void
- getTransportChannel_whenDefaultMtlsProviderThrowsCertificateSourceUnavailable_fallsBackWithoutMtls()
- throws Exception {
- Mockito.when(certificateBasedAccess.useMtlsClientCertificate()).thenReturn(true);
- Mockito.when(certificateBasedAccess.createDefaultMtlsProvider())
- .thenThrow(new CertificateSourceUnavailableException("No certificates on disk"));
-
- InstantiatingHttpJsonChannelProvider provider =
- InstantiatingHttpJsonChannelProvider.newBuilder()
- .setEndpoint(DEFAULT_ENDPOINT)
- .setCertificateBasedAccess(certificateBasedAccess)
- .build();
- InstantiatingHttpJsonChannelProvider finalProvider =
- (InstantiatingHttpJsonChannelProvider) provider.withHeaders(DEFAULT_HEADER_MAP);
-
- HttpTransport transport = finalProvider.createHttpTransport();
- assertThat(transport).isInstanceOf(NetHttpTransport.class);
- assertThat(((NetHttpTransport) transport).isMtls()).isFalse();
-
- HttpJsonTransportChannel channel = finalProvider.getTransportChannel();
- assertThat(channel).isNotNull();
- channel.shutdownNow();
- }
-
- @Test
- void createHttpTransport_withMtlsKeyStore_returnsMtlsTransport()
+ @Override
+ protected Object getMtlsObjectFromTransportChannel(
+ MtlsProvider provider, CertificateBasedAccess certificateBasedAccess)
throws IOException, GeneralSecurityException {
- Mockito.when(certificateBasedAccess.useMtlsClientCertificate()).thenReturn(true);
- MtlsProvider provider =
- new FakeMtlsProvider(FakeMtlsProvider.createTestMtlsKeyStore(), "", false);
-
InstantiatingHttpJsonChannelProvider channelProvider =
InstantiatingHttpJsonChannelProvider.newBuilder()
- .setEndpoint(DEFAULT_ENDPOINT)
- .setMtlsProvider(provider)
- .setCertificateBasedAccess(certificateBasedAccess)
- .build();
-
- HttpTransport transport = channelProvider.createHttpTransport();
- assertThat(transport).isNotNull();
- assertThat(transport).isInstanceOf(NetHttpTransport.class);
- assertThat(((NetHttpTransport) transport).isMtls()).isTrue();
- }
-
- @Test
- void createHttpTransport_whenMtlsProviderNullOrNotUsingClientCert_returnsNonMtlsTransport()
- throws IOException, GeneralSecurityException {
- InstantiatingHttpJsonChannelProvider nullMtlsProviderChannelProvider =
- InstantiatingHttpJsonChannelProvider.newBuilder()
- .setEndpoint(DEFAULT_ENDPOINT)
- .setMtlsProvider(null)
- .setCertificateBasedAccess(certificateBasedAccess)
- .build();
- NetHttpTransport nullMtlsProviderTransport =
- (NetHttpTransport) nullMtlsProviderChannelProvider.createHttpTransport();
- assertThat(nullMtlsProviderTransport).isNotNull();
- assertThat(nullMtlsProviderTransport.isMtls()).isFalse();
-
- Mockito.when(certificateBasedAccess.useMtlsClientCertificate()).thenReturn(false);
- MtlsProvider provider =
- new FakeMtlsProvider(FakeMtlsProvider.createTestMtlsKeyStore(), "", false);
- InstantiatingHttpJsonChannelProvider disabledMtlsChannelProvider =
- InstantiatingHttpJsonChannelProvider.newBuilder()
- .setEndpoint(DEFAULT_ENDPOINT)
+ .setEndpoint("localhost:8080")
.setMtlsProvider(provider)
.setCertificateBasedAccess(certificateBasedAccess)
+ .setHeaderProvider(Collections::emptyMap)
+ .setExecutor(Runnable::run)
.build();
- NetHttpTransport disabledMtlsTransport =
- (NetHttpTransport) disabledMtlsChannelProvider.createHttpTransport();
- assertThat(disabledMtlsTransport).isNotNull();
- assertThat(disabledMtlsTransport.isMtls()).isFalse();
+ NetHttpTransport transport = (NetHttpTransport) channelProvider.createHttpTransport();
+ return (transport != null && transport.isMtls()) ? transport : null;
}
@Test
@@ -592,21 +214,4 @@ void testConfigureConscryptSecurityProvider_returnsConfiguredBuilder() {
HttpJsonConscryptUtils.configureConscryptSecurityProvider(builder);
assertThat(result).isSameInstanceAs(builder);
}
-
- @Override
- protected Object getMtlsObjectFromTransportChannel(
- MtlsProvider provider, CertificateBasedAccess certificateBasedAccess)
- throws IOException, GeneralSecurityException {
- InstantiatingHttpJsonChannelProvider channelProvider =
- InstantiatingHttpJsonChannelProvider.newBuilder()
- .setEndpoint("localhost:8080")
- .setMtlsProvider(provider)
- .setCertificateBasedAccess(certificateBasedAccess)
- .setHeaderProvider(
- mock(HeaderProvider.class, Mockito.withSettings().withoutAnnotations()))
- .setExecutor(mock(Executor.class))
- .build();
- NetHttpTransport transport = (NetHttpTransport) channelProvider.createHttpTransport();
- return (transport != null && transport.isMtls()) ? transport : null;
- }
}
diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/InstantiatingHttpJsonChannelProviderTls13Test.java b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/InstantiatingHttpJsonChannelProviderTls13Test.java
deleted file mode 100644
index 99bd98bb9fb6..000000000000
--- a/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/InstantiatingHttpJsonChannelProviderTls13Test.java
+++ /dev/null
@@ -1,279 +0,0 @@
-/*
- * 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.api.gax.httpjson;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.api.client.http.GenericUrl;
-import com.google.api.client.http.HttpResponse;
-import com.google.api.client.http.HttpTransport;
-import com.google.api.client.util.SecurityUtils;
-import com.google.api.gax.rpc.mtls.CertificateBasedAccess;
-import com.google.api.gax.rpc.testing.FakeMtlsProvider;
-import java.io.BufferedReader;
-import java.io.File;
-import java.io.FileOutputStream;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import java.io.OutputStream;
-import java.net.InetAddress;
-import java.nio.charset.StandardCharsets;
-import java.security.KeyStore;
-import java.security.cert.CertificateFactory;
-import java.security.cert.X509Certificate;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-import java.util.concurrent.Future;
-import java.util.concurrent.TimeUnit;
-import javax.net.ssl.KeyManagerFactory;
-import javax.net.ssl.SSLContext;
-import javax.net.ssl.SSLServerSocket;
-import javax.net.ssl.SSLSocket;
-import javax.net.ssl.TrustManager;
-import javax.net.ssl.X509TrustManager;
-import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.condition.EnabledIf;
-import org.junit.jupiter.api.io.TempDir;
-import org.mockito.Mockito;
-
-/**
- * Handshake test for the Conscrypt-backed mTLS {@link HttpTransport} built by {@link
- * InstantiatingHttpJsonChannelProvider}.
- *
- *
Regression test for "Unknown authType: GENERIC": on TLS 1.3, Conscrypt passes the authType
- * {@code "GENERIC"} to its trust manager. If the trust manager comes from the JDK (SunJSSE) rather
- * than Conscrypt, it rejects server certificates that are issued by a trusted CA and carry a
- * KeyUsage extension (as Google front ends' certificates do), and every mTLS HTTP/JSON handshake
- * fails. A self-signed server certificate that is itself the trust anchor does not reproduce the
- * issue, so the server here presents a CA-issued leaf with KeyUsage.
- *
- *
The transport always uses the default trust store, so this test points {@code
- * javax.net.ssl.trustStore} at a temporary store containing only the test CA, and restores the
- * previous value afterwards.
- */
-@EnabledIf(
- value = "isConscryptAndTls13Available",
- disabledReason = "Conscrypt native library or TLS 1.3 is unavailable on this platform")
-class InstantiatingHttpJsonChannelProviderTls13Test {
-
- private static final String RESOURCE_DIR = "com/google/api/gax/httpjson/";
- private static final String TRUST_STORE_PROPERTY = "javax.net.ssl.trustStore";
- private static final String TRUST_STORE_PASSWORD_PROPERTY = "javax.net.ssl.trustStorePassword";
- private static final String TRUST_STORE_PASSWORD = "changeit";
-
- @TempDir File tempDir;
-
- private String previousTrustStore;
- private String previousTrustStorePassword;
- private ExecutorService serverExecutor;
-
- /** Condition for {@link EnabledIf}; must be static because it is used at class level. */
- static boolean isConscryptAndTls13Available() {
- return HttpJsonConscryptUtils.getConscryptProvider() != null && isTls13Supported();
- }
-
- @BeforeEach
- void setUp() throws Exception {
- previousTrustStore = System.getProperty(TRUST_STORE_PROPERTY);
- previousTrustStorePassword = System.getProperty(TRUST_STORE_PASSWORD_PROPERTY);
- File trustStoreFile = writeTrustStoreWithTestCa();
- System.setProperty(TRUST_STORE_PROPERTY, trustStoreFile.getAbsolutePath());
- System.setProperty(TRUST_STORE_PASSWORD_PROPERTY, TRUST_STORE_PASSWORD);
-
- serverExecutor = Executors.newSingleThreadExecutor();
- }
-
- @AfterEach
- void tearDown() {
- restoreProperty(TRUST_STORE_PROPERTY, previousTrustStore);
- restoreProperty(TRUST_STORE_PASSWORD_PROPERTY, previousTrustStorePassword);
- if (serverExecutor != null) {
- serverExecutor.shutdownNow();
- }
- }
-
- @Test
- void createHttpTransport_withMtlsAndConscrypt_completesTls13HandshakeWithCaIssuedServerCert()
- throws Exception {
- CertificateBasedAccess certificateBasedAccess = Mockito.mock(CertificateBasedAccess.class);
- Mockito.when(certificateBasedAccess.useMtlsClientCertificate()).thenReturn(true);
- InstantiatingHttpJsonChannelProvider channelProvider =
- InstantiatingHttpJsonChannelProvider.newBuilder()
- .setEndpoint("localhost:443")
- .setMtlsProvider(
- new FakeMtlsProvider(FakeMtlsProvider.createTestMtlsKeyStore(), "", false))
- .setCertificateBasedAccess(certificateBasedAccess)
- .build();
- HttpTransport transport = channelProvider.createHttpTransport();
- assertThat(transport).isNotNull();
-
- final SSLServerSocket serverSocket = createTls13ServerSocket();
- try {
- Future clientCertificatePresented =
- serverExecutor.submit(() -> serveSingleRequest(serverSocket));
-
- HttpResponse response =
- transport
- .createRequestFactory()
- .buildGetRequest(
- new GenericUrl("https://localhost:" + serverSocket.getLocalPort() + "/"))
- .execute();
- try {
- assertThat(response.getStatusCode()).isEqualTo(200);
- assertThat(response.parseAsString()).isEqualTo("ok");
- } finally {
- response.disconnect();
- }
- // execute() is synchronous so the server task is already done; timeout guards against hangs.
- assertThat(clientCertificatePresented.get(10, TimeUnit.SECONDS)).isTrue();
- } finally {
- serverSocket.close();
- }
- }
-
- /** Accepts one connection, answers one HTTP request, and reports if a client cert was sent. */
- private static boolean serveSingleRequest(SSLServerSocket serverSocket) throws Exception {
- SSLSocket socket = (SSLSocket) serverSocket.accept();
- try {
- socket.startHandshake();
- boolean clientCertificatePresented = socket.getSession().getPeerCertificates().length > 0;
- BufferedReader reader =
- new BufferedReader(
- new InputStreamReader(socket.getInputStream(), StandardCharsets.US_ASCII));
- String line;
- while ((line = reader.readLine()) != null && !line.isEmpty()) {
- // Drain request headers.
- }
- OutputStream out = socket.getOutputStream();
- out.write(
- ("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")
- .getBytes(StandardCharsets.US_ASCII));
- out.flush();
- return clientCertificatePresented;
- } finally {
- socket.close();
- }
- }
-
- /**
- * Creates a JDK TLS 1.3 server that presents a CA-issued leaf certificate with KeyUsage and
- * requires (but does not validate) a client certificate.
- */
- private static SSLServerSocket createTls13ServerSocket() throws Exception {
- KeyStore serverKeyStore;
- InputStream certAndKey = openResource("tls13TestServerCertAndKey.pem");
- try {
- serverKeyStore = SecurityUtils.createMtlsKeyStore(certAndKey);
- } finally {
- certAndKey.close();
- }
- KeyManagerFactory keyManagerFactory =
- KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
- keyManagerFactory.init(serverKeyStore, new char[0]);
-
- SSLContext serverContext = SSLContext.getInstance("TLSv1.3");
- serverContext.init(
- keyManagerFactory.getKeyManagers(), new TrustManager[] {new AcceptAllTrustManager()}, null);
- SSLServerSocket serverSocket =
- (SSLServerSocket)
- serverContext
- .getServerSocketFactory()
- .createServerSocket(0, 1, InetAddress.getLoopbackAddress());
- serverSocket.setEnabledProtocols(new String[] {"TLSv1.3"});
- serverSocket.setNeedClientAuth(true);
- return serverSocket;
- }
-
- private File writeTrustStoreWithTestCa() throws Exception {
- X509Certificate caCertificate;
- InputStream caPem = openResource("tls13TestCa.pem");
- try {
- caCertificate =
- (X509Certificate) CertificateFactory.getInstance("X.509").generateCertificate(caPem);
- } finally {
- caPem.close();
- }
- KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
- trustStore.load(null, null);
- trustStore.setCertificateEntry("gax-test-ca", caCertificate);
- File trustStoreFile = new File(tempDir, "truststore");
- OutputStream out = new FileOutputStream(trustStoreFile);
- try {
- trustStore.store(out, TRUST_STORE_PASSWORD.toCharArray());
- } finally {
- out.close();
- }
- return trustStoreFile;
- }
-
- private static InputStream openResource(String name) {
- InputStream stream =
- InstantiatingHttpJsonChannelProviderTls13Test.class
- .getClassLoader()
- .getResourceAsStream(RESOURCE_DIR + name);
- if (stream == null) {
- throw new IllegalStateException("Missing test resource: " + RESOURCE_DIR + name);
- }
- return stream;
- }
-
- private static boolean isTls13Supported() {
- try {
- SSLContext.getInstance("TLSv1.3");
- return true;
- } catch (Exception e) {
- return false;
- }
- }
-
- private static void restoreProperty(String key, String value) {
- if (value == null) {
- System.clearProperty(key);
- } else {
- System.setProperty(key, value);
- }
- }
-
- /** Server-side trust manager: the test only checks that a client certificate is presented. */
- private static final class AcceptAllTrustManager implements X509TrustManager {
- @Override
- public void checkClientTrusted(X509Certificate[] chain, String authType) {}
-
- @Override
- public void checkServerTrusted(X509Certificate[] chain, String authType) {}
-
- @Override
- public X509Certificate[] getAcceptedIssuers() {
- return new X509Certificate[0];
- }
- }
-}
diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/RefreshingHttpJsonChannelTest.java b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/RefreshingHttpJsonChannelTest.java
deleted file mode 100644
index 74f462bf831a..000000000000
--- a/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/RefreshingHttpJsonChannelTest.java
+++ /dev/null
@@ -1,607 +0,0 @@
-/*
- * 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.api.gax.httpjson;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertNotSame;
-import static org.junit.jupiter.api.Assertions.assertSame;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-
-import com.google.api.client.http.HttpTransport;
-import com.google.api.client.testing.http.MockHttpTransport;
-import com.google.api.gax.httpjson.testing.MockHttpService;
-import com.google.protobuf.Field;
-import java.util.ArrayDeque;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Queue;
-import java.util.concurrent.CountDownLatch;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicInteger;
-import java.util.function.Function;
-import java.util.function.Supplier;
-import org.jspecify.annotations.Nullable;
-import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-
-class RefreshingHttpJsonChannelTest {
- private static final ApiMethodDescriptor FAKE_METHOD_DESCRIPTOR =
- ApiMethodDescriptor.newBuilder()
- .setFullMethodName("google.cloud.v1.Fake/FakeMethod")
- .setHttpMethod("POST")
- .setRequestFormatter(
- ProtoMessageRequestFormatter.newBuilder()
- .setPath(
- "/fake/v1/name/{name}",
- request -> {
- Map fields = new HashMap<>();
- ProtoRestSerializer serializer = ProtoRestSerializer.create();
- serializer.putPathParam(fields, "name", request.getName());
- return fields;
- })
- .setQueryParamsExtractor(request -> new HashMap<>())
- .setRequestBodyExtractor(
- request ->
- ProtoRestSerializer.create()
- .toBody("*", request.toBuilder().clearName().build(), false))
- .build())
- .setResponseParser(
- ProtoMessageResponseParser.newBuilder()
- .setDefaultInstance(Field.getDefaultInstance())
- .build())
- .build();
-
- private static class FakeHttpJsonClientCall
- extends HttpJsonClientCall {
- @Override
- public void start(Listener responseListener, HttpJsonMetadata requestHeaders) {}
-
- @Override
- public void request(int numMessages) {}
-
- @Override
- public void cancel(@Nullable String message, @Nullable Throwable cause) {}
-
- @Override
- public void sendMessage(RequestT message) {}
-
- @Override
- public void halfClose() {}
- }
-
- private static class FakeManagedHttpJsonChannel extends ManagedHttpJsonChannel {
- private volatile boolean isShutdown = false;
- private volatile boolean isTerminated = false;
- private HttpJsonClientCall, ?> nextCall = null;
-
- @Override
- String getEndpoint() {
- return "https://fake.endpoint:443";
- }
-
- @Override
- public void shutdown() {
- isShutdown = true;
- }
-
- @Override
- public void shutdownNow() {
- isShutdown = true;
- isTerminated = true;
- }
-
- @Override
- public boolean isShutdown() {
- return isShutdown;
- }
-
- @Override
- public boolean isTerminated() {
- return isTerminated;
- }
-
- @Override
- public boolean awaitTermination(long duration, TimeUnit unit) {
- return isTerminated;
- }
-
- @Override
- @SuppressWarnings("unchecked")
- public HttpJsonClientCall newCall(
- ApiMethodDescriptor methodDescriptor,
- HttpJsonCallOptions callOptions) {
- if (nextCall != null) {
- return (HttpJsonClientCall) nextCall;
- }
- return new FakeHttpJsonClientCall<>();
- }
- }
-
- private AtomicInteger transportFactoryCount;
- private HttpTransport lastCreatedTransport;
- private FakeManagedHttpJsonChannel lastCreatedChannel;
- private String testCertPath;
- private String testFingerprint;
- private boolean shouldThrowOnFactory;
- private List createdChannels;
-
- private Supplier transportFactory =
- () -> {
- if (shouldThrowOnFactory) {
- throw new RuntimeException("Simulated factory failure");
- }
- transportFactoryCount.incrementAndGet();
- lastCreatedTransport = new MockHttpTransport();
- return lastCreatedTransport;
- };
-
- private Function channelFactory =
- transport -> {
- lastCreatedChannel = new FakeManagedHttpJsonChannel();
- lastCreatedChannel.setHttpTransport(transport);
- return lastCreatedChannel;
- };
-
- @BeforeEach
- void setUp() {
- transportFactoryCount = new AtomicInteger(0);
- testCertPath = "/fake/path";
- testFingerprint = "fingerprint1";
- shouldThrowOnFactory = false;
- createdChannels = new ArrayList<>();
- }
-
- @AfterEach
- void tearDown() {
- for (RefreshingHttpJsonChannel channel : createdChannels) {
- channel.shutdownNow();
- }
- }
-
- private RefreshingHttpJsonChannel createTestChannel() {
- RefreshingHttpJsonChannel ch =
- new RefreshingHttpJsonChannel(
- () -> transportFactory.get(),
- transport -> channelFactory.apply(transport),
- "fake/cert/path.json") {
- @Override
- String getWorkloadCertPath() {
- return testCertPath;
- }
-
- @Override
- String getCertificateFingerprint(String certPath) {
- return testFingerprint;
- }
- };
- createdChannels.add(ch);
- return ch;
- }
-
- private void rotateCertificate(RefreshingHttpJsonChannel channel) {
- channel.invalidateDiskFingerprintCache();
- testFingerprint = "fingerprint2";
- }
-
- @Test
- void testShouldRefreshFalseWhenCertPathNull() {
- testCertPath = null;
- RefreshingHttpJsonChannel channel = createTestChannel();
- assertFalse(channel.shouldRefresh());
- }
-
- @Test
- void testShouldRefreshFalseWhenUnchanged() {
- RefreshingHttpJsonChannel channel = createTestChannel();
-
- channel.invalidateDiskFingerprintCache(); // Invalidate 1-second cache
- assertFalse(channel.shouldRefresh());
- }
-
- @Test
- void testShouldRefreshTrueWhenChanged() {
- RefreshingHttpJsonChannel channel = createTestChannel();
-
- rotateCertificate(channel);
-
- assertTrue(channel.shouldRefresh());
- }
-
- @Test
- void shouldRefresh_doesNotCacheNegativeResultAndDetectsSubsequentRotationImmediately() {
- RefreshingHttpJsonChannel channel = createTestChannel();
-
- // First check returns false (unchanged fingerprint)
- assertFalse(channel.shouldRefresh());
-
- // Immediately change fingerprint WITHOUT invalidating cache
- testFingerprint = "fingerprint2";
-
- // Must immediately detect rotation because negative/unchanged checks are not cached for 1s
- assertTrue(channel.shouldRefresh());
-
- // Refresh updates activeCertFingerprint and clears cache
- channel.refresh();
- assertFalse(channel.shouldRefresh());
- }
-
- @Test
- void rotationDuringInitialTransportCreation_isDetectedAndRefreshed() {
- transportFactory =
- () -> {
- if (transportFactoryCount.incrementAndGet() == 1) {
- // Simulate the certificate rotating on disk while the initial transport loads it.
- testFingerprint = "fingerprint2";
- }
- lastCreatedTransport = new MockHttpTransport();
- return lastCreatedTransport;
- };
-
- RefreshingHttpJsonChannel channel = createTestChannel();
-
- // The baseline was recorded before the initial transport was created, so the rotation is seen.
- assertTrue(channel.shouldRefresh());
-
- channel.refresh();
- assertEquals(2, transportFactoryCount.get());
- assertEquals(1, channel.getGeneration());
- assertFalse(channel.shouldRefresh());
- }
-
- @Test
- void refresh_swapsTransportAndKeepsChannel() {
- RefreshingHttpJsonChannel channel = createTestChannel();
- FakeManagedHttpJsonChannel underlyingChannel = lastCreatedChannel;
- HttpTransport initialTransport = channel.getHttpTransport();
- assertSame(lastCreatedTransport, initialTransport);
-
- rotateCertificate(channel);
- channel.refresh();
-
- assertEquals(2, transportFactoryCount.get());
- assertNotSame(initialTransport, channel.getHttpTransport());
- assertSame(lastCreatedTransport, channel.getHttpTransport());
- // The underlying channel (and its executors) is reused rather than replaced or shut down.
- assertSame(underlyingChannel, lastCreatedChannel);
- assertFalse(underlyingChannel.isShutdown());
- assertEquals(1, channel.getGeneration());
- assertFalse(channel.shouldRefresh());
- }
-
- @Test
- void refresh_whenCertificateFileEmpty_keepsTransportAndGeneration() {
- RefreshingHttpJsonChannel channel = createTestChannel();
- HttpTransport initialTransport = channel.getHttpTransport();
-
- // A rotation is detected, but when refresh() re-reads the file the rotator has truncated it
- // and not yet written the new certificate.
- rotateCertificate(channel);
- assertTrue(channel.shouldRefresh());
- testFingerprint = "";
- channel.refresh();
-
- // Nothing is swapped and the generation is unchanged, so the failed call is not retried.
- assertEquals(1, transportFactoryCount.get());
- assertSame(initialTransport, channel.getHttpTransport());
- assertEquals(0, channel.getGeneration());
- // While the file stays empty, no refresh is requested.
- channel.invalidateDiskFingerprintCache();
- assertFalse(channel.shouldRefresh());
-
- // Once the new certificate is written, the next check refreshes normally.
- testFingerprint = "fingerprint2";
- assertTrue(channel.shouldRefresh());
- channel.refresh();
- assertEquals(2, transportFactoryCount.get());
- assertNotSame(initialTransport, channel.getHttpTransport());
- assertEquals(1, channel.getGeneration());
- }
-
- @Test
- void callCreatedBeforeRefresh_usesOriginalTransport() throws Exception {
- MockHttpService originalService =
- new MockHttpService(Collections.singletonList(FAKE_METHOD_DESCRIPTOR), "google.com:443");
- MockHttpService rotatedService =
- new MockHttpService(Collections.singletonList(FAKE_METHOD_DESCRIPTOR), "google.com:443");
- Field message = Field.newBuilder().setName("bob").setNumber(1).build();
- originalService.addResponse(message);
- rotatedService.addResponse(message);
- Queue transports =
- new ArrayDeque<>(Arrays.asList(originalService, rotatedService));
- transportFactory = transports::remove;
- channelFactory =
- transport ->
- ManagedHttpJsonChannel.newBuilder()
- .setEndpoint("google.com:443")
- .setHttpTransport(transport)
- .build();
- RefreshingHttpJsonChannel channel = createTestChannel();
- HttpJsonCallOptions callOptions = HttpJsonCallOptions.newBuilder().build();
- HttpJsonCallContext callContext = HttpJsonCallContext.createDefault();
-
- HttpJsonClientCall callBeforeRefresh =
- channel.newCall(FAKE_METHOD_DESCRIPTOR, callOptions);
-
- rotateCertificate(channel);
- channel.refresh();
- assertEquals(1, channel.getGeneration());
-
- assertEquals(
- message,
- HttpJsonClientCalls.futureUnaryCall(callBeforeRefresh, message, callContext)
- .get(10, TimeUnit.SECONDS));
- assertEquals(1, originalService.getRequestPaths().size());
- assertEquals(0, rotatedService.getRequestPaths().size());
-
- HttpJsonClientCall callAfterRefresh =
- channel.newCall(FAKE_METHOD_DESCRIPTOR, callOptions);
- assertEquals(
- message,
- HttpJsonClientCalls.futureUnaryCall(callAfterRefresh, message, callContext)
- .get(10, TimeUnit.SECONDS));
- assertEquals(1, originalService.getRequestPaths().size());
- assertEquals(1, rotatedService.getRequestPaths().size());
- }
-
- @Test
- void testRefreshDoesNotCreateTransportWhenShutdown() {
- RefreshingHttpJsonChannel channel = createTestChannel();
- assertEquals(1, transportFactoryCount.get());
-
- channel.shutdown();
- rotateCertificate(channel);
- channel.refresh();
-
- assertEquals(1, transportFactoryCount.get());
- assertEquals(0, channel.getGeneration());
- }
-
- @Test
- void shutdown_waitsForInProgressRefresh() throws Exception {
- CountDownLatch refreshStarted = new CountDownLatch(1);
- CountDownLatch releaseRefresh = new CountDownLatch(1);
- transportFactory =
- () -> {
- if (transportFactoryCount.incrementAndGet() > 1) {
- refreshStarted.countDown();
- try {
- releaseRefresh.await();
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- }
- }
- return new MockHttpTransport();
- };
- RefreshingHttpJsonChannel channel = createTestChannel();
- FakeManagedHttpJsonChannel underlyingChannel = lastCreatedChannel;
- rotateCertificate(channel);
-
- Thread refreshThread = new Thread(channel::refresh);
- Thread shutdownThread = new Thread(channel::shutdown);
- try {
- refreshThread.start();
- assertTrue(refreshStarted.await(5, TimeUnit.SECONDS));
- shutdownThread.start();
-
- shutdownThread.join(200);
- assertTrue(shutdownThread.isAlive());
- assertFalse(underlyingChannel.isShutdown());
- } finally {
- releaseRefresh.countDown();
- }
- refreshThread.join(5000);
- shutdownThread.join(5000);
- assertFalse(refreshThread.isAlive());
- assertFalse(shutdownThread.isAlive());
- assertEquals(1, channel.getGeneration());
- assertTrue(underlyingChannel.isShutdown());
- }
-
- @Test
- void refresh_concurrentRefreshWhileWaitingForLock_skipsWithoutReadingDisk() throws Exception {
- CountDownLatch refreshStarted = new CountDownLatch(1);
- CountDownLatch releaseRefresh = new CountDownLatch(1);
- transportFactory =
- () -> {
- if (transportFactoryCount.incrementAndGet() == 2) {
- refreshStarted.countDown();
- try {
- releaseRefresh.await();
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- }
- }
- return new MockHttpTransport();
- };
- RefreshingHttpJsonChannel channel = createTestChannel();
- rotateCertificate(channel);
-
- Thread refreshingThread = new Thread(channel::refresh);
- Thread waitingThread = new Thread(channel::refresh);
- try {
- refreshingThread.start();
- assertTrue(refreshStarted.await(5, TimeUnit.SECONDS));
- waitingThread.start();
- long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
- while (waitingThread.getState() != Thread.State.BLOCKED) {
- assertTrue(System.nanoTime() < deadline);
- Thread.sleep(1);
- }
- // Rotate again before the first refresh completes. A waiter that re-read the certificate
- // would see this fingerprint and refresh a second time.
- testFingerprint = "fingerprint3";
- } finally {
- releaseRefresh.countDown();
- }
- refreshingThread.join(5000);
- waitingThread.join(5000);
- assertFalse(refreshingThread.isAlive());
- assertFalse(waitingThread.isAlive());
-
- // Only the initial transport and the concurrent refresh created transports; the waiter skipped.
- assertEquals(2, transportFactoryCount.get());
- assertEquals(1, channel.getGeneration());
- // The second rotation is still detected for the next UNAUTHENTICATED failure.
- channel.invalidateDiskFingerprintCache();
- assertTrue(channel.shouldRefresh());
- }
-
- @Test
- void testRefreshFactoryExceptionDoesNotWedgeFingerprint() {
- RefreshingHttpJsonChannel channel = createTestChannel();
- HttpTransport initialTransport = channel.getHttpTransport();
- assertEquals(1, transportFactoryCount.get());
-
- shouldThrowOnFactory = true;
- rotateCertificate(channel);
-
- // Factory failure is logged and the existing transport is kept
- channel.refresh();
- assertEquals(1, transportFactoryCount.get());
- assertEquals(0, channel.getGeneration());
- assertSame(initialTransport, channel.getHttpTransport());
-
- // Because the factory threw, the new fingerprint is not recorded as active, so the channel
- // still reports that it should be refreshed.
- assertTrue(channel.shouldRefresh());
-
- shouldThrowOnFactory = false;
- channel.refresh();
- assertEquals(2, transportFactoryCount.get());
- assertFalse(channel.shouldRefresh());
- }
-
- @Test
- void testShutdownNowSetsIsShutdown() {
- RefreshingHttpJsonChannel channel = createTestChannel();
- assertFalse(channel.isShutdown());
-
- channel.shutdownNow();
-
- assertTrue(channel.isShutdown());
- }
-
- @Test
- void testAwaitTerminationZeroTimeoutOnTerminatedChannelReturnsTrue() throws InterruptedException {
- RefreshingHttpJsonChannel channel = createTestChannel();
- lastCreatedChannel.isTerminated = true;
-
- channel.shutdown();
- assertTrue(channel.awaitTermination(0, TimeUnit.MILLISECONDS));
- }
-
- @Test
- void testChannelDelegationMethods() {
- RefreshingHttpJsonChannel channel = createTestChannel();
- FakeManagedHttpJsonChannel underlyingChannel = lastCreatedChannel;
- FakeHttpJsonClientCall