diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsUtils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsUtils.java index 0d34cf271986..6b7d4b6bb09f 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 @@ -51,6 +51,12 @@ public class MtlsUtils { static final String WELL_KNOWN_CERTIFICATE_CONFIG_FILE = "certificate_config.json"; static final String CLOUDSDK_CONFIG_DIRECTORY = "gcloud"; + @com.google.common.annotations.VisibleForTesting + static String spiffeDirectory = "/var/run/secrets/workload-spiffe-credentials/"; + static final String SPIFFE_CREDENTIAL_BUNDLE_FILE = "credentialbundle.pem"; + static final String SPIFFE_CERTIFICATE_FILE = "certificates.pem"; + static final String SPIFFE_PRIVATE_KEY_FILE = "private_key.pem"; + private MtlsUtils() { // Prevent instantiation for Utility class } @@ -137,4 +143,73 @@ private static File getWellKnownCertificateConfigFile( } return new File(cloudConfigPath, WELL_KNOWN_CERTIFICATE_CONFIG_FILE); } + + /** + * Centralized helper method to determine if mutual TLS (mTLS) can be enabled. + * + * @param envProvider the environment provider to use for resolving environment variables + * @param propProvider the property provider to use for resolving system properties + * @param certConfigPathOverride optional override path for the configuration file + * @return true if mTLS should be enabled, false otherwise + * @throws IOException if the configuration file is present but contains missing or malformed files + */ + public static boolean canMtlsBeEnabled( + EnvironmentProvider envProvider, PropertyProvider propProvider, String certConfigPathOverride) throws IOException { + + // Check if client certificate usage is allowed + String useClientCertificate = envProvider.getEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE"); + if ("false".equalsIgnoreCase(useClientCertificate)) { + return false; + } + + // Locate and process the certificate configuration file + String envPath = envProvider.getEnv(CERTIFICATE_CONFIGURATION_ENV_VARIABLE); + if (certConfigPathOverride != null || !Strings.isNullOrEmpty(envPath)) { + File certConfigFile = new File(certConfigPathOverride != null ? certConfigPathOverride : envPath); + if (!certConfigFile.isFile()) { + throw new CertificateSourceUnavailableException( + "Certificate configuration file does not exist or is not a file: " + + certConfigFile.getAbsolutePath()); + } + } + + WorkloadCertificateConfiguration workloadCertConfig = null; + try { + workloadCertConfig = getWorkloadCertificateConfiguration(envProvider, propProvider, certConfigPathOverride); + } catch (CertificateSourceUnavailableException e) { + // Config file is simply not present. This is fine, fallback to SPIFFE. + } catch (IOException e) { + // Config file exists but is malformed or points to invalid paths -> throw hard error + throw e; + } + + if (workloadCertConfig != null) { + // Validate referenced files exist + File certFile = new File(workloadCertConfig.getCertPath()); + File keyFile = new File(workloadCertConfig.getPrivateKeyPath()); + if (!certFile.isFile() || !keyFile.isFile()) { + throw new IOException( + String.format( + "Certificate configuration exists but referenced files are missing: cert_path=%s, key_path=%s", + workloadCertConfig.getCertPath(), workloadCertConfig.getPrivateKeyPath())); + } + return true; + } + + // Fallback to SPIFFE discovery if the directory exists + File spiffeDir = new File(spiffeDirectory); + if (spiffeDir.isDirectory()) { + File credentialBundle = new File(spiffeDir, SPIFFE_CREDENTIAL_BUNDLE_FILE); + if (credentialBundle.isFile()) { + return true; + } + File certsFile = new File(spiffeDir, SPIFFE_CERTIFICATE_FILE); + File keyFile = new File(spiffeDir, SPIFFE_PRIVATE_KEY_FILE); + if (certsFile.isFile() && keyFile.isFile()) { + return true; + } + } + + return false; + } } 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 4127b1492460..9db8c643081a 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 @@ -112,41 +112,64 @@ public X509Provider() { * @throws IOException if a general I/O error occurs while creating the KeyStore */ @Override - public KeyStore getKeyStore() throws CertificateSourceUnavailableException, IOException { - WorkloadCertificateConfiguration workloadCertConfig = - MtlsUtils.getWorkloadCertificateConfiguration( - envProvider, propProvider, certConfigPathOverride); - - // Read the certificate and private key file paths into streams. - try (InputStream certStream = new FileInputStream(new File(workloadCertConfig.getCertPath())); - InputStream privateKeyStream = - new FileInputStream(new File(workloadCertConfig.getPrivateKeyPath())); - SequenceInputStream certAndPrivateKeyStream = - new SequenceInputStream(certStream, privateKeyStream)) { - - // Build a key store using the combined stream. - return SecurityUtils.createMtlsKeyStore(certAndPrivateKeyStream); - } catch (CertificateSourceUnavailableException e) { - // Throw the CertificateSourceUnavailableException without wrapping. + public boolean isAvailable() throws IOException { + try { + return MtlsUtils.canMtlsBeEnabled(envProvider, propProvider, certConfigPathOverride); + } catch (IOException e) { + // Broken configuration state defaults to throwing a failure throw e; - } catch (Exception e) { - // Wrap all other exception types to an IOException. - throw new IOException("X509Provider: Unexpected IOException:", e); } } - /** - * Returns true if the X509 mTLS provider is available. - * - * @throws IOException if a general I/O error occurs while determining availability. - */ @Override - public boolean isAvailable() throws IOException { + public KeyStore getKeyStore() throws CertificateSourceUnavailableException, IOException { + if (!MtlsUtils.canMtlsBeEnabled(envProvider, propProvider, certConfigPathOverride)) { + throw new CertificateSourceUnavailableException("mTLS is not enabled or cannot be established."); + } + + // 1. Attempt to load from resolved Config File + WorkloadCertificateConfiguration workloadCertConfig = null; try { - this.getKeyStore(); - } catch (CertificateSourceUnavailableException e) { - return false; + workloadCertConfig = MtlsUtils.getWorkloadCertificateConfiguration(envProvider, propProvider, certConfigPathOverride); + } catch (IOException e) { + // Ignore configuration file errors here to fall back to SPIFFE discovery + } + + if (workloadCertConfig != null) { + try (InputStream certStream = new FileInputStream(new File(workloadCertConfig.getCertPath())); + InputStream privateKeyStream = new FileInputStream(new File(workloadCertConfig.getPrivateKeyPath())); + SequenceInputStream certAndPrivateKeyStream = new SequenceInputStream(certStream, privateKeyStream)) { + return SecurityUtils.createMtlsKeyStore(certAndPrivateKeyStream); + } catch (Exception e) { + throw new IOException("X509Provider: Unexpected error loading from config file:", e); + } + } + + // 2. Fallback: Load from SPIFFE Credentials + File spiffeDir = new File(MtlsUtils.spiffeDirectory); + if (spiffeDir.isDirectory()) { + File credentialBundle = new File(spiffeDir, MtlsUtils.SPIFFE_CREDENTIAL_BUNDLE_FILE); + if (credentialBundle.isFile()) { + try (InputStream bundleStream = new FileInputStream(credentialBundle)) { + return SecurityUtils.createMtlsKeyStore(bundleStream); + } catch (Exception e) { + throw new IOException("X509Provider: Unexpected error loading from SPIFFE bundle:", e); + } + } + + File certsFile = new File(spiffeDir, MtlsUtils.SPIFFE_CERTIFICATE_FILE); + File keyFile = new File(spiffeDir, MtlsUtils.SPIFFE_PRIVATE_KEY_FILE); + if (certsFile.isFile() && keyFile.isFile()) { + try (InputStream certStream = new FileInputStream(certsFile); + InputStream privateKeyStream = new FileInputStream(keyFile); + SequenceInputStream certAndPrivateKeyStream = new SequenceInputStream(certStream, privateKeyStream)) { + return SecurityUtils.createMtlsKeyStore(certAndPrivateKeyStream); + } catch (Exception e) { + throw new IOException("X509Provider: Unexpected error loading from separate SPIFFE files:", e); + } + } } - return true; + + throw new CertificateSourceUnavailableException("mTLS is enabled, but no certificate source was resolved."); } } diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/GoogleCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/GoogleCredentials.java index e423a68ac18b..2b68ad28fc0d 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/GoogleCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/GoogleCredentials.java @@ -59,6 +59,11 @@ import java.util.Map; import java.util.Objects; import javax.annotation.Nullable; +import com.google.auth.mtls.MtlsHttpTransportFactory; +import com.google.auth.mtls.MtlsUtils; +import com.google.auth.mtls.X509Provider; +import java.security.KeyStore; + /** Base type for credentials for authorizing calls to Google APIs using OAuth2. */ public class GoogleCredentials extends OAuth2Credentials implements QuotaProjectIdProvider { @@ -397,6 +402,24 @@ void refreshRegionalAccessBoundaryIfExpired(@Nullable URI uri, @Nullable AccessT return; } + try { + if (MtlsUtils.canMtlsBeEnabled( + SystemEnvironmentProvider.getInstance(), + SystemPropertyProvider.getInstance(), + null)) { + X509Provider x509Provider = new X509Provider( + SystemEnvironmentProvider.getInstance(), + SystemPropertyProvider.getInstance(), + null); + KeyStore mtlsKeyStore = x509Provider.getKeyStore(); + if (mtlsKeyStore != null) { + transportFactory = new MtlsHttpTransportFactory(mtlsKeyStore); + } + } + } catch (Exception e) { + // Graceful fallback to standard transport if mTLS initialization fails + } + regionalAccessBoundaryManager.triggerAsyncRefresh( transportFactory, (RegionalAccessBoundaryProvider) this, token); } diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundary.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundary.java index dfcbe8491cd5..37ba5de893eb 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundary.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundary.java @@ -189,6 +189,10 @@ static RegionalAccessBoundary refresh( throw new IllegalArgumentException("The provided access token is expired."); } + if (transportFactory instanceof com.google.auth.mtls.MtlsHttpTransportFactory) { + url = url.replace("https://iamcredentials.googleapis.com/", "https://iamcredentials.mtls.googleapis.com/"); + } + HttpRequestFactory requestFactory = transportFactory.create().createRequestFactory(); HttpRequest request = requestFactory.buildGetRequest(new GenericUrl(url)); // Disable automatic logging by google-http-java-client to prevent leakage of sensitive tokens. diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsUtilsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsUtilsTest.java index f3fdf05a4c32..e41e0ecf0d04 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsUtilsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsUtilsTest.java @@ -243,4 +243,203 @@ public String getProperty(String name, String def) { assertEquals("APPDATA environment variable is not set on Windows.", exception.getMessage()); } + + // If client certificate usage is explicitly disabled, canMtlsBeEnabled should return false. + @Test + void canMtlsBeEnabled_allowanceExplicitFalse_returnsFalse() throws IOException { + EnvironmentProvider envProvider = + new EnvironmentProvider() { + @Override + public String getEnv(String name) { + if ("GOOGLE_API_USE_CLIENT_CERTIFICATE".equals(name)) { + return "false"; + } + return null; + } + }; + PropertyProvider propProvider = (name, def) -> def; + + assertFalse(MtlsUtils.canMtlsBeEnabled(envProvider, propProvider, null)); + } + + // If client certificate usage is explicitly enabled and a valid configuration is present, canMtlsBeEnabled should return true. + @Test + void canMtlsBeEnabled_allowanceExplicitTrue_withConfig_returnsTrue() throws IOException { + Path configFile = tempDir.resolve("config.json"); + Path certFile = tempDir.resolve("cert.pem"); + Path keyFile = tempDir.resolve("key.pem"); + Files.createFile(certFile); + Files.createFile(keyFile); + Files.write(configFile, createJsonConfigString(certFile, keyFile).getBytes()); + + EnvironmentProvider envProvider = + new EnvironmentProvider() { + @Override + public String getEnv(String name) { + if ("GOOGLE_API_USE_CLIENT_CERTIFICATE".equals(name)) { + return "true"; + } + if ("GOOGLE_API_CERTIFICATE_CONFIG".equals(name)) { + return configFile.toString(); + } + return null; + } + }; + PropertyProvider propProvider = (name, def) -> def; + + assertTrue(MtlsUtils.canMtlsBeEnabled(envProvider, propProvider, null)); + } + + // If client certificate usage is unset but a valid configuration is present, mTLS should be enabled by default (returns true). + @Test + void canMtlsBeEnabled_allowanceUnset_withConfig_returnsTrue() throws IOException { + Path configFile = tempDir.resolve("config.json"); + Path certFile = tempDir.resolve("cert.pem"); + Path keyFile = tempDir.resolve("key.pem"); + Files.createFile(certFile); + Files.createFile(keyFile); + Files.write(configFile, createJsonConfigString(certFile, keyFile).getBytes()); + + EnvironmentProvider envProvider = + new EnvironmentProvider() { + @Override + public String getEnv(String name) { + if ("GOOGLE_API_CERTIFICATE_CONFIG".equals(name)) { + return configFile.toString(); + } + return null; + } + }; + PropertyProvider propProvider = (name, def) -> def; + + assertTrue(MtlsUtils.canMtlsBeEnabled(envProvider, propProvider, null)); + } + + // If the GOOGLE_API_CERTIFICATE_CONFIG environment variable points to a non-existent file, canMtlsBeEnabled should throw an IOException. + @Test + void canMtlsBeEnabled_envVarConfigMissingFile_throwsIOException() throws IOException { + Path nonExistentConfig = tempDir.resolve("non_existent.json"); + EnvironmentProvider envProvider = + new EnvironmentProvider() { + @Override + public String getEnv(String name) { + if ("GOOGLE_API_CERTIFICATE_CONFIG".equals(name)) { + return nonExistentConfig.toString(); + } + return null; + } + }; + PropertyProvider propProvider = (name, def) -> def; + + assertThrows( + IOException.class, + () -> MtlsUtils.canMtlsBeEnabled(envProvider, propProvider, null)); + } + + // If the well-known gcloud certificate configuration file exists, canMtlsBeEnabled should return true. + @Test + void canMtlsBeEnabled_wellKnownConfigExists_returnsTrue() throws IOException { + Path gcloudDir = tempDir.resolve(".config/gcloud"); + Files.createDirectories(gcloudDir); + Path configFile = gcloudDir.resolve("certificate_config.json"); + Path certFile = tempDir.resolve("cert.pem"); + Path keyFile = tempDir.resolve("key.pem"); + Files.createFile(certFile); + Files.createFile(keyFile); + Files.write(configFile, createJsonConfigString(certFile, keyFile).getBytes()); + + EnvironmentProvider envProvider = name -> null; + PropertyProvider propProvider = + new PropertyProvider() { + @Override + public String getProperty(String name, String def) { + if ("os.name".equals(name)) return "Linux"; + if ("user.home".equals(name)) return tempDir.toString(); + return def; + } + }; + + assertTrue(MtlsUtils.canMtlsBeEnabled(envProvider, propProvider, null)); + } + + // If the configuration file exists but the certificate path it references does not exist, canMtlsBeEnabled should throw an IOException. + @Test + void canMtlsBeEnabled_configMissingCertFile_throwsIOException() throws IOException { + Path configFile = tempDir.resolve("config.json"); + Path nonExistentCert = tempDir.resolve("non_existent_cert.pem"); + Path keyFile = tempDir.resolve("key.pem"); + Files.createFile(keyFile); + Files.write(configFile, createJsonConfigString(nonExistentCert, keyFile).getBytes()); + + EnvironmentProvider envProvider = name -> "GOOGLE_API_CERTIFICATE_CONFIG".equals(name) ? configFile.toString() : null; + PropertyProvider propProvider = (name, def) -> def; + + assertThrows( + IOException.class, + () -> MtlsUtils.canMtlsBeEnabled(envProvider, propProvider, null)); + } + + // If the configuration file exists but the private key path it references does not exist, canMtlsBeEnabled should throw an IOException. + @Test + void canMtlsBeEnabled_configMissingKeyFile_throwsIOException() throws IOException { + Path configFile = tempDir.resolve("config.json"); + Path certFile = tempDir.resolve("cert.pem"); + Path nonExistentKey = tempDir.resolve("non_existent_key.pem"); + Files.createFile(certFile); + Files.write(configFile, createJsonConfigString(certFile, nonExistentKey).getBytes()); + + EnvironmentProvider envProvider = name -> "GOOGLE_API_CERTIFICATE_CONFIG".equals(name) ? configFile.toString() : null; + PropertyProvider propProvider = (name, def) -> def; + + assertThrows( + IOException.class, + () -> MtlsUtils.canMtlsBeEnabled(envProvider, propProvider, null)); + } + + // If no configuration file exists but a SPIFFE credential bundle file is present, canMtlsBeEnabled should return true. + @Test + void canMtlsBeEnabled_unset_spiffeBundlePresent_returnsTrue() throws IOException { + Path spiffeDir = tempDir.resolve("spiffe_workload_bundle"); + Files.createDirectory(spiffeDir); + Files.createFile(spiffeDir.resolve("credentialbundle.pem")); + + String originalSpiffeDir = MtlsUtils.spiffeDirectory; + MtlsUtils.spiffeDirectory = spiffeDir.toString() + "/"; + try { + EnvironmentProvider envProvider = name -> null; + PropertyProvider propProvider = (name, def) -> def; + + assertTrue(MtlsUtils.canMtlsBeEnabled(envProvider, propProvider, null)); + } finally { + MtlsUtils.spiffeDirectory = originalSpiffeDir; + } + } + + // If no configuration file exists but separate SPIFFE certificate and key files are present, canMtlsBeEnabled should return true. + @Test + void canMtlsBeEnabled_unset_spiffeCertsPresent_returnsTrue() throws IOException { + Path spiffeDir = tempDir.resolve("spiffe_workload_certs"); + Files.createDirectory(spiffeDir); + Files.createFile(spiffeDir.resolve("certificates.pem")); + Files.createFile(spiffeDir.resolve("private_key.pem")); + + String originalSpiffeDir = MtlsUtils.spiffeDirectory; + MtlsUtils.spiffeDirectory = spiffeDir.toString() + "/"; + try { + EnvironmentProvider envProvider = name -> null; + PropertyProvider propProvider = (name, def) -> def; + + assertTrue(MtlsUtils.canMtlsBeEnabled(envProvider, propProvider, null)); + } finally { + MtlsUtils.spiffeDirectory = originalSpiffeDir; + } + } + + private String createJsonConfigString(Path certPath, Path keyPath) { + return "{\"cert_configs\":{\"workload\":{\"cert_path\":\"" + + certPath.toString().replace("\\", "\\\\") + + "\",\"key_path\":\"" + + keyPath.toString().replace("\\", "\\\\") + + "\"}}}"; + } } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/X509ProviderTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/X509ProviderTest.java index 5ddd1a169d29..86ac1586e4d4 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/X509ProviderTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/X509ProviderTest.java @@ -208,4 +208,63 @@ void x509Provider_malformedCert_throws() throws IOException { assertThrows(Exception.class, testProvider::getKeyStore); } + + // Success Path: SPIFFE Bundle loading + @Test + void x509Provider_loadSpiffeBundle_succeeds() throws Exception { + Path spiffeDir = Files.createTempDirectory("spiffe_bundle"); + spiffeDir.toFile().deleteOnExit(); + Path credentialBundle = spiffeDir.resolve("credentialbundle.pem"); + + // Create credentialbundle.pem by combining valid test cert and key + byte[] certBytes = Files.readAllBytes(new File(TEST_CERT_PATH).toPath()); + byte[] keyBytes = Files.readAllBytes(new File("testresources/mtls/test_key.pem").toPath()); + byte[] bundleBytes = new byte[certBytes.length + keyBytes.length]; + System.arraycopy(certBytes, 0, bundleBytes, 0, certBytes.length); + System.arraycopy(keyBytes, 0, bundleBytes, certBytes.length, keyBytes.length); + Files.write(credentialBundle, bundleBytes); + + String originalSpiffeDir = MtlsUtils.spiffeDirectory; + MtlsUtils.spiffeDirectory = spiffeDir.toString() + "/"; + try { + X509Provider provider = new X509Provider(name -> null, (name, def) -> def, null); + KeyStore keyStore = provider.getKeyStore(); + assertNotNull(keyStore); + assertEquals(1, keyStore.size()); + } finally { + MtlsUtils.spiffeDirectory = originalSpiffeDir; + } + } + + // Success Path: SPIFFE Separate Files loading + @Test + void x509Provider_loadSpiffeSeparateFiles_succeeds() throws Exception { + Path spiffeDir = Files.createTempDirectory("spiffe_separate"); + spiffeDir.toFile().deleteOnExit(); + + Files.copy(new File(TEST_CERT_PATH).toPath(), spiffeDir.resolve("certificates.pem")); + Files.copy(new File("testresources/mtls/test_key.pem").toPath(), spiffeDir.resolve("private_key.pem")); + + String originalSpiffeDir = MtlsUtils.spiffeDirectory; + MtlsUtils.spiffeDirectory = spiffeDir.toString() + "/"; + try { + X509Provider provider = new X509Provider(name -> null, (name, def) -> def, null); + KeyStore keyStore = provider.getKeyStore(); + assertNotNull(keyStore); + assertEquals(1, keyStore.size()); + } finally { + MtlsUtils.spiffeDirectory = originalSpiffeDir; + } + } + + // Failure Path: mTLS disabled (allowance = false) throws CertificateSourceUnavailableException + @Test + void x509Provider_allowanceDisabled_throws() throws Exception { + X509Provider provider = new X509Provider( + name -> "GOOGLE_API_USE_CLIENT_CERTIFICATE".equals(name) ? "false" : null, + (name, def) -> def, + null + ); + assertThrows(CertificateSourceUnavailableException.class, provider::getKeyStore); + } }