From fb3d3752195084823318f5902e8ebd56f8783f82 Mon Sep 17 00:00:00 2001 From: nbayati <99771966+nbayati@users.noreply.github.com> Date: Tue, 22 Apr 2025 11:29:46 -0700 Subject: [PATCH 01/12] feat: Add support for mTLS authentication via X.509 certificates This commit introduces a new credential source type, 'certificate', enabling the use of mTLS for authentication with X.509 certificates. It includes the necessary logic to load certificate configurations (both explicit paths and default locations) and establish an mTLS-enabled transport. --- .../com/google/auth/mtls/X509Provider.java | 37 +++- ...icateIdentityPoolSubjectTokenSupplier.java | 122 +++++++++++++ .../oauth2/IdentityPoolCredentialSource.java | 169 +++++++++++++++++- .../auth/oauth2/IdentityPoolCredentials.java | 60 ++++++- 4 files changed, 377 insertions(+), 11 deletions(-) create mode 100644 oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java diff --git a/oauth2_http/java/com/google/auth/mtls/X509Provider.java b/oauth2_http/java/com/google/auth/mtls/X509Provider.java index 704f85bdd..02654c19a 100644 --- a/oauth2_http/java/com/google/auth/mtls/X509Provider.java +++ b/oauth2_http/java/com/google/auth/mtls/X509Provider.java @@ -52,8 +52,9 @@ public class X509Provider { static final String CERTIFICATE_CONFIGURATION_ENV_VARIABLE = "GOOGLE_API_CERTIFICATE_CONFIG"; static final String WELL_KNOWN_CERTIFICATE_CONFIG_FILE = "certificate_config.json"; static final String CLOUDSDK_CONFIG_DIRECTORY = "gcloud"; + private WorkloadCertificateConfiguration loadedConfig; - private String certConfigPathOverride; + private final String certConfigPathOverride; /** * Creates an X509 provider with an override path for the certificate configuration, bypassing the @@ -75,6 +76,34 @@ public X509Provider() { this(null); } + /** + * Returns the path to the client certificate file specified by the loaded workload certificate + * configuration. + * + *

If the configuration has not been loaded yet (e.g., if {@link #getKeyStore()} has not been + * called), this method will attempt to load it first by searching the override path, environment + * variable, and well-known locations. + * + * @return The path to the certificate file. + * @throws IOException if the certificate configuration cannot be found or loaded, or if the + * configuration file does not specify a certificate path. + * @throws CertificateSourceUnavailableException if the configuration file is not found. + */ + public String getCertificatePath() throws IOException { + if (loadedConfig == null) { + // Attempt to load the configuration. This call might throw IOException or + // CertificateSourceUnavailableException if loading fails. + loadedConfig = getWorkloadCertificateConfiguration(); + } + String certPath = loadedConfig.getCertPath(); + if (Strings.isNullOrEmpty(certPath)) { + // Ensure the loaded configuration actually contains the required path. + throw new IOException( + "Certificate configuration loaded successfully, but does not contain a 'certificate_file' path."); + } + return certPath; + } + /** * Finds the certificate configuration file, then builds a Keystore using the X.509 certificate * and private key pointed to by the configuration. This will check the following locations in @@ -90,8 +119,10 @@ public X509Provider() { * @throws IOException if there is an error retrieving the certificate configuration. */ public KeyStore getKeyStore() throws IOException { - - WorkloadCertificateConfiguration workloadCertConfig = getWorkloadCertificateConfiguration(); + if (loadedConfig == null) { + loadedConfig = getWorkloadCertificateConfiguration(); + } + WorkloadCertificateConfiguration workloadCertConfig = loadedConfig; InputStream certStream = null; InputStream privateKeyStream = null; diff --git a/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java b/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java new file mode 100644 index 000000000..dcd9a29c1 --- /dev/null +++ b/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java @@ -0,0 +1,122 @@ +/* + * Copyright 2025 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.oauth2; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonPrimitive; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Paths; +import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.Base64; + +/** + * Provider for retrieving subject tokens for {@link IdentityPoolCredentials} by reading an X.509 + * certificate from the filesystem. The certificate file (e.g., PEM or DER encoded) is read, the + * leaf certificate is base64-encoded (DER format), wrapped in a JSON array, and used as the subject + * token for STS exchange. + */ +public class CertificateIdentityPoolSubjectTokenSupplier + implements IdentityPoolSubjectTokenSupplier { + + private static final Gson GSON = new Gson(); + private final IdentityPoolCredentialSource credentialSource; + + CertificateIdentityPoolSubjectTokenSupplier(IdentityPoolCredentialSource credentialSource) { + this.credentialSource = checkNotNull(credentialSource, "credentialSource cannot be null"); + // This check ensures that the credential source was intended for certificate usage. + // IdentityPoolCredentials logic should guarantee credentialLocation is set in this case. + checkNotNull( + credentialSource.certificateConfig, + "credentialSource.certificateConfig cannot be null when creating" + + " CertificateIdentityPoolSubjectTokenSupplier"); + } + + private static X509Certificate loadLeafCertificate(String path) + throws IOException, CertificateException { + byte[] leafCertBytes; + try { + // IdentityPoolCredentials should have already validated the path exists via X509Provider. + leafCertBytes = Files.readAllBytes(Paths.get(path)); + } catch (InvalidPathException e) { + throw new IOException("Invalid certificate file path provided: " + path, e); + } + // Files.readAllBytes throws IOException for other read errors. + return parseCertificate(leafCertBytes); + } + + private static X509Certificate parseCertificate(byte[] certData) throws CertificateException { + if (certData == null || certData.length == 0) { + throw new IllegalArgumentException("Invalid certificate data: empty or null input"); + } + + try { + CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509"); + InputStream certificateStream = new ByteArrayInputStream(certData); + return (X509Certificate) certificateFactory.generateCertificate(certificateStream); + } catch (CertificateException e) { + throw new CertificateException("Failed to parse X.509 certificate data.", e); + } + } + + private static String encodeCert(X509Certificate certificate) + throws CertificateEncodingException { + return Base64.getEncoder().encodeToString(certificate.getEncoded()); + } + + @Override + public String getSubjectToken(ExternalAccountSupplierContext context) throws IOException { + try { + // credentialSource.credentialLocation is expected to be non-null here, + // set during IdentityPoolCredentials construction for certificate type. + X509Certificate leafCert = loadLeafCertificate(credentialSource.credentialLocation); + String encodedCert = encodeCert(leafCert); + + JsonArray certChain = new JsonArray(); + certChain.add(new JsonPrimitive(encodedCert)); + + return GSON.toJson(certChain); + } catch (CertificateException e) { + throw new IOException( + "Failed to parse certificate from: " + credentialSource.credentialLocation, e); + } + } +} diff --git a/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentialSource.java b/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentialSource.java index 6fa9e6f41..5e8999934 100644 --- a/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentialSource.java +++ b/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentialSource.java @@ -31,6 +31,8 @@ package com.google.auth.oauth2; +import static com.google.common.base.Preconditions.checkArgument; + import java.util.HashMap; import java.util.Locale; import java.util.Map; @@ -48,6 +50,157 @@ public class IdentityPoolCredentialSource extends ExternalAccountCredentials.Cre String credentialLocation; @Nullable String subjectTokenFieldName; @Nullable Map headers; + @Nullable CertificateConfig certificateConfig; + + /** + * Extracts and configures the {@link CertificateConfig} from the provided credential source. + * + * @param credentialSourceMap A map containing the certificate configuration. + * @return A new {@link CertificateConfig} instance. + * @throws IllegalArgumentException if the 'certificate' entry is not a Map or if required fields + * within the certificate configuration have invalid types. + */ + private CertificateConfig getCertificateConfig(Map credentialSourceMap) { + Object certValue = credentialSourceMap.get("certificate"); + if (!(certValue instanceof Map)) { + throw new IllegalArgumentException( + "The 'certificate' credential source must be a JSON object (Map)."); + } + Map certificateMap = (Map) certValue; + + Boolean useDefaultCertificateConfig = + getOptionalBoolean(certificateMap, "use_default_certificate_config"); + String trustChain = getOptionalString(certificateMap, "trust_chain_path"); + String certificateConfigLocation = + getOptionalString(certificateMap, "certificate_config_location"); + + return new CertificateConfig( + useDefaultCertificateConfig, certificateConfigLocation, trustChain); + } + + /** + * Retrieves an optional boolean value from a map. + * + * @param map The map to retrieve from. + * @param key The key of the boolean value. + * @return The boolean value if present and of the correct type, otherwise null. + * @throws IllegalArgumentException if the value is present but not a boolean. + */ + private @Nullable Boolean getOptionalBoolean(Map map, String key) { + Object value = map.get(key); + if (value == null) { + return null; + } + if (!(value instanceof Boolean)) { + throw new IllegalArgumentException( + String.format( + "Invalid type for '%s' in certificate configuration: expected Boolean, got %s.", + key, value.getClass().getSimpleName())); + } + return (Boolean) value; + } + + /** + * Retrieves an optional string value from a map. + * + * @param map The map to retrieve from. + * @param key The key of the string value. + * @return The string value if present and of the correct type, otherwise null. + * @throws IllegalArgumentException if the value is present but not a string. + */ + private @Nullable String getOptionalString(Map map, String key) { + Object value = map.get(key); + if (value == null) { + return null; + } + if (!(value instanceof String)) { + throw new IllegalArgumentException( + String.format( + "Invalid type for '%s' in certificate configuration: expected String, got %s.", + key, value.getClass().getSimpleName())); + } + return (String) value; + } + /** + * Represents the configuration options for X.509-based workload credentials (mTLS). It specifies + * how to locate and use the client certificate, private key, and optional trust chain for mutual + * TLS authentication. + */ + public static class CertificateConfig implements java.io.Serializable { + private static final long serialVersionUID = 1L; + + /** + * If true, attempts to load the default certificate configuration. It checks the + * GOOGLE_API_CERTIFICATE_CONFIG environment variable first, then a conventional default file + * location. Cannot be true if {@code certificateConfigLocation} is set. + */ + private final boolean useDefaultCertificateConfig; + + /** + * Specifies the path to the client certificate and private key file. This is used when {@code + * useDefaultCertificateConfig} is false or unset. Must be set if {@code + * useDefaultCertificateConfig} is false. + */ + @Nullable private final String certificateConfigLocation; + + /** + * Specifies the path to a PEM-formatted file containing the X.509 certificate trust chain. This + * file should contain any intermediate certificates required to complete the trust chain + * between the leaf certificate (used for mTLS) and the root certificate(s) in your workload + * identity pool's trust store. The leaf certificate and any certificates already present in the + * workload identity pool's trust store are optional in this file. Certificates should be + * ordered with the leaf certificate (or the certificate which signed the leaf) first. + */ + @Nullable private final String trustChainPath; + + /** + * Constructor for {@code CertificateConfig}. + * + * @param useDefaultCertificateConfig Whether to use the default certificate configuration. + * @param certificateConfigLocation Path to the client certificate and private key file. + * @param trustChainPath Path to the trust chain file. + * @throws IllegalArgumentException if the configuration is invalid (e.g., neither default nor + * location is specified, or both are specified). + */ + CertificateConfig( + @Nullable Boolean useDefaultCertificateConfig, + @Nullable String certificateConfigLocation, + @Nullable String trustChainPath) { + + boolean useDefault = useDefaultCertificateConfig != null && useDefaultCertificateConfig; + boolean locationIsPresent = + certificateConfigLocation != null && !certificateConfigLocation.isEmpty(); + + checkArgument( + !(!useDefault && !locationIsPresent), + "credentials: \"certificate\" object must either specify a certificate_config_location or use_default_certificate_config should be true"); + + checkArgument( + !(useDefault && locationIsPresent), + "credentials: \"certificate\" object cannot specify both a certificate_config_location and use_default_certificate_config=true"); + + this.useDefaultCertificateConfig = useDefault; + this.certificateConfigLocation = certificateConfigLocation; + this.trustChainPath = trustChainPath; + } + + /** Returns whether the default certificate configuration should be used. */ + public boolean useDefaultCertificateConfig() { + return useDefaultCertificateConfig; + } + + /** Returns the path to the client certificate file, or null if not set. */ + @Nullable + public String getCertificateConfigLocation() { + return certificateConfigLocation; + } + + /** Returns the path to the trust chain file, or null if not set. */ + @Nullable + public String getTrustChainPath() { + return trustChainPath; + } + } /** * The source of the 3P credential. @@ -69,9 +222,15 @@ public class IdentityPoolCredentialSource extends ExternalAccountCredentials.Cre public IdentityPoolCredentialSource(Map credentialSourceMap) { super(credentialSourceMap); - if (credentialSourceMap.containsKey("file") && credentialSourceMap.containsKey("url")) { + boolean filePresent = credentialSourceMap.containsKey("file"); + boolean urlPresent = credentialSourceMap.containsKey("url"); + boolean certificatePresent = credentialSourceMap.containsKey("certificate"); + + if ((filePresent && urlPresent) + || (filePresent && certificatePresent) + || (urlPresent && certificatePresent)) { throw new IllegalArgumentException( - "Only one credential source type can be set, either file or url."); + "Only one credential source type can be set: 'file', 'url', or 'certificate'."); } if (credentialSourceMap.containsKey("file")) { @@ -80,6 +239,9 @@ public IdentityPoolCredentialSource(Map credentialSourceMap) { } else if (credentialSourceMap.containsKey("url")) { credentialLocation = (String) credentialSourceMap.get("url"); credentialSourceType = IdentityPoolCredentialSourceType.URL; + } else if (credentialSourceMap.containsKey("certificate")) { + credentialSourceType = IdentityPoolCredentialSourceType.CERTIFICATE; + this.certificateConfig = getCertificateConfig(credentialSourceMap); } else { throw new IllegalArgumentException( "Missing credential source file location or URL. At least one must be specified."); @@ -121,7 +283,8 @@ boolean hasHeaders() { enum IdentityPoolCredentialSourceType { FILE, - URL + URL, + CERTIFICATE } enum CredentialFormatType { diff --git a/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java b/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java index 4ab4761e8..c2ee1c39a 100644 --- a/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java @@ -31,10 +31,15 @@ package com.google.auth.oauth2; +import com.google.api.client.http.javanet.NetHttpTransport; import com.google.auth.http.HttpTransportFactory; +import com.google.auth.mtls.X509Provider; +import com.google.auth.oauth2.IdentityPoolCredentialSource.IdentityPoolCredentialSourceType; import com.google.common.annotations.VisibleForTesting; import com.google.errorprone.annotations.CanIgnoreReturnValue; import java.io.IOException; +import java.security.GeneralSecurityException; +import java.security.KeyStore; import java.util.ArrayList; import java.util.Collection; import java.util.Map; @@ -48,6 +53,8 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { static final String FILE_METRICS_HEADER_VALUE = "file"; static final String URL_METRICS_HEADER_VALUE = "url"; + static final String CERTIFICATE_METRICS_HEADER_VALUE = "certificate"; // Added constant + private static final long serialVersionUID = 2471046175477275881L; private final IdentityPoolSubjectTokenSupplier subjectTokenSupplier; private final ExternalAccountSupplierContext supplierContext; @@ -63,6 +70,7 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { .setAudience(this.getAudience()) .setSubjectTokenType(this.getSubjectTokenType()) .build(); + // Check that one and only one of supplier or credential source are provided. if (builder.subjectTokenSupplier != null && credentialSource != null) { throw new IllegalArgumentException( @@ -72,17 +80,60 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { throw new IllegalArgumentException( "A subjectTokenSupplier or a credentialSource must be provided."); } + + // Initialize based on the source type if (builder.subjectTokenSupplier != null) { this.subjectTokenSupplier = builder.subjectTokenSupplier; this.metricsHeaderValue = PROGRAMMATIC_METRICS_HEADER_VALUE; - } else if (credentialSource.credentialSourceType - == IdentityPoolCredentialSource.IdentityPoolCredentialSourceType.FILE) { + } else if (credentialSource.credentialSourceType == IdentityPoolCredentialSourceType.FILE) { this.subjectTokenSupplier = new FileIdentityPoolSubjectTokenSupplier(credentialSource); this.metricsHeaderValue = FILE_METRICS_HEADER_VALUE; - } else { + } else if (credentialSource.credentialSourceType == IdentityPoolCredentialSourceType.URL) { this.subjectTokenSupplier = new UrlIdentityPoolSubjectTokenSupplier(credentialSource, this.transportFactory); this.metricsHeaderValue = URL_METRICS_HEADER_VALUE; + } else if (credentialSource.credentialSourceType + == IdentityPoolCredentialSourceType.CERTIFICATE) { + assert credentialSource.certificateConfig != null; + + try { + final IdentityPoolCredentialSource.CertificateConfig certConfig = + credentialSource.certificateConfig; + + // Determine the certificate path based on the configuration. + String explicitCertConfigPath = null; + if (!certConfig.useDefaultCertificateConfig()) { + explicitCertConfigPath = certConfig.getCertificateConfigLocation(); + if (explicitCertConfigPath == null || explicitCertConfigPath.isEmpty()) { + throw new IllegalArgumentException( + "certificateConfigLocation must be provided when useDefaultCertificateConfig is false."); + } + } + + // Initialize X509Provider with the explicit path (if provided). + X509Provider x509Provider = new X509Provider(explicitCertConfigPath); + + // Update the transport factory to use a mTLS transport with the provided certificate. + KeyStore mtlsKeyStore = x509Provider.getKeyStore(); + final NetHttpTransport mtlsTransport = + new NetHttpTransport.Builder().trustCertificates(null, mtlsKeyStore, "").build(); + this.transportFactory = () -> mtlsTransport; + + // Initialize the subject token supplier with the certificate path + credentialSource.credentialLocation = x509Provider.getCertificatePath(); + this.subjectTokenSupplier = + new CertificateIdentityPoolSubjectTokenSupplier(credentialSource); + + this.metricsHeaderValue = CERTIFICATE_METRICS_HEADER_VALUE; + + } catch (GeneralSecurityException | IOException e) { + // Catch exceptions from X509Provider or transport creation + throw new RuntimeException( + "Failed to initialize mTLS transport for IdentityPoolCredentials using X509Provider", + e); + } + } else { + throw new IllegalArgumentException("Source type not supported."); } } @@ -119,8 +170,7 @@ IdentityPoolSubjectTokenSupplier getIdentityPoolSubjectTokenSupplier() { /** Clones the IdentityPoolCredentials with the specified scopes. */ @Override public IdentityPoolCredentials createScoped(Collection newScopes) { - return new IdentityPoolCredentials( - (IdentityPoolCredentials.Builder) newBuilder(this).setScopes(newScopes)); + return new IdentityPoolCredentials(newBuilder(this).setScopes(newScopes)); } public static Builder newBuilder() { From b7e8ec69322a07e252ef8062ee572eccc906ff36 Mon Sep 17 00:00:00 2001 From: nbayati <99771966+nbayati@users.noreply.github.com> Date: Wed, 23 Apr 2025 16:40:39 -0700 Subject: [PATCH 02/12] Add unit tests for the CertificateIdentityPoolSubjectTokenSupplier class. --- ...icateIdentityPoolSubjectTokenSupplier.java | 4 +- ...eIdentityPoolSubjectTokenSupplierTest.java | 154 ++++++++++++++++++ 2 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 oauth2_http/javatests/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplierTest.java diff --git a/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java b/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java index dcd9a29c1..9477e1b16 100644 --- a/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java +++ b/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java @@ -33,6 +33,7 @@ import static com.google.common.base.Preconditions.checkNotNull; +import com.google.common.annotations.VisibleForTesting; import com.google.gson.Gson; import com.google.gson.JsonArray; import com.google.gson.JsonPrimitive; @@ -83,7 +84,8 @@ private static X509Certificate loadLeafCertificate(String path) return parseCertificate(leafCertBytes); } - private static X509Certificate parseCertificate(byte[] certData) throws CertificateException { + @VisibleForTesting + static X509Certificate parseCertificate(byte[] certData) throws CertificateException { if (certData == null || certData.length == 0) { throw new IllegalArgumentException("Invalid certificate data: empty or null input"); } diff --git a/oauth2_http/javatests/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplierTest.java b/oauth2_http/javatests/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplierTest.java new file mode 100644 index 000000000..50d5a6c93 --- /dev/null +++ b/oauth2_http/javatests/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplierTest.java @@ -0,0 +1,154 @@ +/* + * Copyright 2025, Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.oauth2; + +import static org.junit.Assert.*; + +import com.google.auth.oauth2.IdentityPoolCredentialSource.CertificateConfig; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonPrimitive; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.Base64; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +/** Tests for {@link CertificateIdentityPoolSubjectTokenSupplier}. */ +@RunWith(JUnit4.class) +public class CertificateIdentityPoolSubjectTokenSupplierTest { + + @Rule public MockitoRule mockitoRule = MockitoJUnit.rule(); + @Rule public TemporaryFolder tempFolder = new TemporaryFolder(); + + @Mock private IdentityPoolCredentialSource mockCredentialSource; + @Mock private CertificateConfig mockCertificateConfig; + @Mock private ExternalAccountSupplierContext mockContext; + + private CertificateIdentityPoolSubjectTokenSupplier supplier; + private static final Gson GSON = new Gson(); + + // Certificate data from X509ProviderTest + private static final String TEST_CERT_PEM = + "-----BEGIN CERTIFICATE-----\n" + + "MIICGzCCAYSgAwIBAgIIWrt6xtmHPs4wDQYJKoZIhvcNAQEFBQAwMzExMC8GA1UE\n" + + "AxMoMTAwOTEyMDcyNjg3OC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbTAeFw0x\n" + + "MjEyMDExNjEwNDRaFw0yMjExMjkxNjEwNDRaMDMxMTAvBgNVBAMTKDEwMDkxMjA3\n" + + "MjY4NzguYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20wgZ8wDQYJKoZIhvcNAQEB\n" + + "BQADgY0AMIGJAoGBAL1SdY8jTUVU7O4/XrZLYTw0ON1lV6MQRGajFDFCqD2Fd9tQ\n" + + "GLW8Iftx9wfXe1zuaehJSgLcyCxazfyJoN3RiONBihBqWY6d3lQKqkgsRTNZkdFJ\n" + + "Wdzl/6CxhK9sojh2p0r3tydtv9iwq5fuuWIvtODtT98EgphhncQAqkKoF3zVAgMB\n" + + "AAGjODA2MAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBYGA1UdJQEB/wQM\n" + + "MAoGCCsGAQUFBwMCMA0GCSqGSIb3DQEBBQUAA4GBAD8XQEqzGePa9VrvtEGpf+R4\n" + + "fkxKbcYAzqYq202nKu0kfjhIYkYSBj6gi348YaxE64yu60TVl42l5HThmswUheW4\n" + + "uQIaq36JvwvsDP5Zoj5BgiNSnDAFQp+jJFBRUA5vooJKgKgMDf/r/DCOsbO6VJF1\n" + + "kWwa9n19NFiV0z3m6isj\n" + + "-----END CERTIFICATE-----\n"; + + private static final byte[] TEST_CERT_BYTES = TEST_CERT_PEM.getBytes(StandardCharsets.UTF_8); + private static final byte[] INVALID_CERT_BYTES = + "invalid certificate data".getBytes(StandardCharsets.UTF_8); + + @Before + public void setUp() throws IOException { + File testCertFile = tempFolder.newFile("certificate.pem"); + Files.write(testCertFile.toPath(), TEST_CERT_BYTES); + mockCredentialSource.certificateConfig = mockCertificateConfig; + mockCredentialSource.credentialLocation = testCertFile.getAbsolutePath(); + supplier = new CertificateIdentityPoolSubjectTokenSupplier(mockCredentialSource); + } + + @Test + public void parseCertificate_validData_returnsCertificate() throws Exception { + X509Certificate cert = + CertificateIdentityPoolSubjectTokenSupplier.parseCertificate(TEST_CERT_BYTES); + assertNotNull(cert); + } + + @Test + public void parseCertificate_emptyData_throwsIllegalArgumentException() { + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> CertificateIdentityPoolSubjectTokenSupplier.parseCertificate(new byte[0])); + assertEquals("Invalid certificate data: empty or null input", exception.getMessage()); + } + + @Test + public void parseCertificate_nullData_throwsIllegalArgumentException() { + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> CertificateIdentityPoolSubjectTokenSupplier.parseCertificate(null)); + assertEquals("Invalid certificate data: empty or null input", exception.getMessage()); + } + + @Test + public void parseCertificate_invalidData_throwsCertificateException() { + CertificateException exception = + assertThrows( + CertificateException.class, + () -> CertificateIdentityPoolSubjectTokenSupplier.parseCertificate(INVALID_CERT_BYTES)); + assertEquals("Failed to parse X.509 certificate data.", exception.getMessage()); + } + + @Test + public void getSubjectToken_success() throws Exception { + // Calculate expected result + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + X509Certificate expectedCert = + (X509Certificate) cf.generateCertificate(new ByteArrayInputStream(TEST_CERT_BYTES)); + String expectedEncodedDer = Base64.getEncoder().encodeToString(expectedCert.getEncoded()); + JsonArray expectedJsonArray = new JsonArray(); + expectedJsonArray.add(new JsonPrimitive(expectedEncodedDer)); + String expectedSubjectToken = GSON.toJson(expectedJsonArray); + + // Execute + String actualSubjectToken = supplier.getSubjectToken(mockContext); + + // Verify + assertEquals(expectedSubjectToken, actualSubjectToken); + } +} From 7cd352954c55f7dcec6db0ae53d21ccee772d99a Mon Sep 17 00:00:00 2001 From: nbayati <99771966+nbayati@users.noreply.github.com> Date: Wed, 23 Apr 2025 17:32:06 -0700 Subject: [PATCH 03/12] Refactor: Enhance error handling and readability per review comments --- oauth2_http/java/com/google/auth/mtls/X509Provider.java | 2 +- .../com/google/auth/oauth2/IdentityPoolCredentialSource.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/oauth2_http/java/com/google/auth/mtls/X509Provider.java b/oauth2_http/java/com/google/auth/mtls/X509Provider.java index 02654c19a..c4af248b2 100644 --- a/oauth2_http/java/com/google/auth/mtls/X509Provider.java +++ b/oauth2_http/java/com/google/auth/mtls/X509Provider.java @@ -98,7 +98,7 @@ public String getCertificatePath() throws IOException { String certPath = loadedConfig.getCertPath(); if (Strings.isNullOrEmpty(certPath)) { // Ensure the loaded configuration actually contains the required path. - throw new IOException( + throw new CertificateSourceUnavailableException( "Certificate configuration loaded successfully, but does not contain a 'certificate_file' path."); } return certPath; diff --git a/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentialSource.java b/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentialSource.java index 5e8999934..acabec749 100644 --- a/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentialSource.java +++ b/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentialSource.java @@ -172,7 +172,7 @@ public static class CertificateConfig implements java.io.Serializable { certificateConfigLocation != null && !certificateConfigLocation.isEmpty(); checkArgument( - !(!useDefault && !locationIsPresent), + (useDefault || locationIsPresent), "credentials: \"certificate\" object must either specify a certificate_config_location or use_default_certificate_config should be true"); checkArgument( From 91ec285698df99e599d238d4fb3e05155459e05a Mon Sep 17 00:00:00 2001 From: nbayati <99771966+nbayati@users.noreply.github.com> Date: Fri, 25 Apr 2025 17:11:02 -0700 Subject: [PATCH 04/12] Add unit tests for IdentityPoolCredentialsSource. --- .../oauth2/IdentityPoolCredentialSource.java | 1 + .../IdentityPoolCredentialsSourceTest.java | 156 ++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsSourceTest.java diff --git a/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentialSource.java b/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentialSource.java index acabec749..37b7ace8e 100644 --- a/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentialSource.java +++ b/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentialSource.java @@ -121,6 +121,7 @@ private CertificateConfig getCertificateConfig(Map credentialSou } return (String) value; } + /** * Represents the configuration options for X.509-based workload credentials (mTLS). It specifies * how to locate and use the client certificate, private key, and optional trust chain for mutual diff --git a/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsSourceTest.java b/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsSourceTest.java new file mode 100644 index 000000000..f2d841e36 --- /dev/null +++ b/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsSourceTest.java @@ -0,0 +1,156 @@ +/* + * Copyright 2025 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.oauth2; + +import static com.google.auth.Credentials.GOOGLE_DEFAULT_UNIVERSE; +import static com.google.auth.oauth2.MockExternalAccountCredentialsTransport.SERVICE_ACCOUNT_IMPERSONATION_URL; +import static com.google.auth.oauth2.OAuth2Utils.JSON_FACTORY; +import static org.junit.Assert.*; + +import com.google.api.client.http.HttpTransport; +import com.google.api.client.json.GenericJson; +import com.google.api.client.util.Clock; +import com.google.auth.TestUtils; +import com.google.auth.http.HttpTransportFactory; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import javax.annotation.Nullable; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +import com.google.auth.oauth2.IdentityPoolCredentialSource.IdentityPoolCredentialSourceType; + +/** Tests for {@link IdentityPoolCredentialSource}. */ +@RunWith(JUnit4.class) +public class IdentityPoolCredentialsSourceTest { + + @Test + public void constructor_certificateConfig(){ + Map certificateMap = new HashMap<>(); + certificateMap.put("certificate_config_location", "/path/to/certificate"); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("certificate", certificateMap); + + IdentityPoolCredentialSource credentialSource = new IdentityPoolCredentialSource(credentialSourceMap); + assertEquals(IdentityPoolCredentialSourceType.CERTIFICATE, credentialSource.credentialSourceType); + assertNotNull(credentialSource.certificateConfig); + assertFalse(credentialSource.certificateConfig.useDefaultCertificateConfig()); + assertEquals("/path/to/certificate", credentialSource.certificateConfig.getCertificateConfigLocation()); + } + + @Test + public void constructor_certificateConfig_useDefault(){ + Map certificateMap = new HashMap<>(); + certificateMap.put("use_default_certificate_config", true); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("certificate", certificateMap); + + IdentityPoolCredentialSource credentialSource = new IdentityPoolCredentialSource(credentialSourceMap); + assertEquals(IdentityPoolCredentialSourceType.CERTIFICATE, credentialSource.credentialSourceType); + assertNotNull(credentialSource.certificateConfig); + assertTrue(credentialSource.certificateConfig.useDefaultCertificateConfig()); + } + + @Test + public void constructor_certificateConfig_missingRequiredFields_throws(){ + Map certificateMap = new HashMap<>(); + //Missing both use_default_certificate_config and certificate_config_location + certificateMap.put("trust_chain_path", "path/to/trust/chain"); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("certificate", certificateMap); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> new IdentityPoolCredentialSource(credentialSourceMap) + ); + assertTrue(exception.getMessage().contains("must either specify a certificate_config_location or use_default_certificate_config should be true")); + } + + @Test + public void constructor_certificateConfig_bothFieldsSet_throws(){ + Map certificateMap = new HashMap<>(); + certificateMap.put("use_default_certificate_config", true); + certificateMap.put("certificate_config_location", "/path/to/certificate"); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("certificate", certificateMap); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> new IdentityPoolCredentialSource(credentialSourceMap) + ); + assertTrue(exception.getMessage().contains("cannot specify both a certificate_config_location and use_default_certificate_config=true")); + } + + @Test + public void constructor_certificateConfig_trustChainPath(){ + Map certificateMap = new HashMap<>(); + certificateMap.put("use_default_certificate_config", true); + certificateMap.put("trust_chain_path", "path/to/trust/chain"); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("certificate", certificateMap); + + IdentityPoolCredentialSource credentialSource = new IdentityPoolCredentialSource(credentialSourceMap); + assertEquals(IdentityPoolCredentialSourceType.CERTIFICATE, credentialSource.credentialSourceType); + assertNotNull(credentialSource.certificateConfig); + assertEquals("path/to/trust/chain", credentialSource.certificateConfig.getTrustChainPath()); + } + + + @Test + public void constructor_certificateConfig_invalidType_throws(){ + Map certificateMap = new HashMap<>(); + certificateMap.put("use_default_certificate_config", "invalid-type"); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("certificate", certificateMap); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> new IdentityPoolCredentialSource(credentialSourceMap) + ); + assertTrue(exception.getMessage().contains("Invalid type for 'use_default_certificate_config' in certificate configuration: expected Boolean")); + } + +} From 82442dcc902d916c3011b833ad15ec5bbf561eeb Mon Sep 17 00:00:00 2001 From: nbayati <99771966+nbayati@users.noreply.github.com> Date: Tue, 29 Apr 2025 16:37:19 -0700 Subject: [PATCH 05/12] Add unit tests for IdentityPoolCredentials. --- ...icateIdentityPoolSubjectTokenSupplier.java | 14 +- .../oauth2/IdentityPoolCredentialSource.java | 6 +- .../auth/oauth2/IdentityPoolCredentials.java | 40 ++- .../IdentityPoolCredentialsSourceTest.java | 94 +++--- .../oauth2/IdentityPoolCredentialsTest.java | 309 ++++++++++++++---- 5 files changed, 324 insertions(+), 139 deletions(-) diff --git a/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java b/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java index 9477e1b16..723580054 100644 --- a/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java +++ b/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java @@ -41,7 +41,6 @@ import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; -import java.nio.file.InvalidPathException; import java.nio.file.Paths; import java.security.cert.CertificateEncodingException; import java.security.cert.CertificateException; @@ -74,13 +73,7 @@ public class CertificateIdentityPoolSubjectTokenSupplier private static X509Certificate loadLeafCertificate(String path) throws IOException, CertificateException { byte[] leafCertBytes; - try { - // IdentityPoolCredentials should have already validated the path exists via X509Provider. - leafCertBytes = Files.readAllBytes(Paths.get(path)); - } catch (InvalidPathException e) { - throw new IOException("Invalid certificate file path provided: " + path, e); - } - // Files.readAllBytes throws IOException for other read errors. + leafCertBytes = Files.readAllBytes(Paths.get(path)); return parseCertificate(leafCertBytes); } @@ -95,6 +88,8 @@ static X509Certificate parseCertificate(byte[] certData) throws CertificateExcep InputStream certificateStream = new ByteArrayInputStream(certData); return (X509Certificate) certificateFactory.generateCertificate(certificateStream); } catch (CertificateException e) { + // Catch the original exception to add context about the operation being performed. + // This helps pinpoint the failure point during debugging. throw new CertificateException("Failed to parse X.509 certificate data.", e); } } @@ -117,6 +112,9 @@ public String getSubjectToken(ExternalAccountSupplierContext context) throws IOE return GSON.toJson(certChain); } catch (CertificateException e) { + // Catch CertificateException to provide a more specific error message including + // the path of the file that failed to parse, and re-throw as IOException + // as expected by the getSubjectToken method signature for I/O related issues. throw new IOException( "Failed to parse certificate from: " + credentialSource.credentialLocation, e); } diff --git a/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentialSource.java b/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentialSource.java index 37b7ace8e..0a0ea2523 100644 --- a/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentialSource.java +++ b/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentialSource.java @@ -174,11 +174,11 @@ public static class CertificateConfig implements java.io.Serializable { checkArgument( (useDefault || locationIsPresent), - "credentials: \"certificate\" object must either specify a certificate_config_location or use_default_certificate_config should be true"); + "Invalid 'certificate' configuration in credential source: Must specify either 'certificate_config_location' or set 'use_default_certificate_config' to true."); checkArgument( !(useDefault && locationIsPresent), - "credentials: \"certificate\" object cannot specify both a certificate_config_location and use_default_certificate_config=true"); + "Invalid 'certificate' configuration in credential source: Cannot specify both 'certificate_config_location' and set 'use_default_certificate_config' to true."); this.useDefaultCertificateConfig = useDefault; this.certificateConfigLocation = certificateConfigLocation; @@ -245,7 +245,7 @@ public IdentityPoolCredentialSource(Map credentialSourceMap) { this.certificateConfig = getCertificateConfig(credentialSourceMap); } else { throw new IllegalArgumentException( - "Missing credential source file location or URL. At least one must be specified."); + "Missing credential source file location, URL, or certificate. At least one must be specified."); } Map headersMap = (Map) credentialSourceMap.get("headers"); diff --git a/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java b/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java index c2ee1c39a..b4da67348 100644 --- a/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java @@ -100,19 +100,19 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { final IdentityPoolCredentialSource.CertificateConfig certConfig = credentialSource.certificateConfig; - // Determine the certificate path based on the configuration. - String explicitCertConfigPath = null; - if (!certConfig.useDefaultCertificateConfig()) { - explicitCertConfigPath = certConfig.getCertificateConfigLocation(); - if (explicitCertConfigPath == null || explicitCertConfigPath.isEmpty()) { - throw new IllegalArgumentException( - "certificateConfigLocation must be provided when useDefaultCertificateConfig is false."); - } + // Use the provided X509Provider if available. + X509Provider x509Provider = builder.x509Provider; + if (x509Provider == null) { + // Determine the certificate path based on the configuration. + String explicitCertConfigPath = + certConfig.useDefaultCertificateConfig() + ? null + : certConfig.getCertificateConfigLocation(); + + // Initialize X509Provider with the explicit path (if provided). + x509Provider = new X509Provider(explicitCertConfigPath); } - // Initialize X509Provider with the explicit path (if provided). - X509Provider x509Provider = new X509Provider(explicitCertConfigPath); - // Update the transport factory to use a mTLS transport with the provided certificate. KeyStore mtlsKeyStore = x509Provider.getKeyStore(); final NetHttpTransport mtlsTransport = @@ -129,7 +129,7 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { } catch (GeneralSecurityException | IOException e) { // Catch exceptions from X509Provider or transport creation throw new RuntimeException( - "Failed to initialize mTLS transport for IdentityPoolCredentials using X509Provider", + "Failed to initialize mTLS transport for IdentityPoolCredentials using X509Provider.", e); } } else { @@ -184,6 +184,7 @@ public static Builder newBuilder(IdentityPoolCredentials identityPoolCredentials public static class Builder extends ExternalAccountCredentials.Builder { private IdentityPoolSubjectTokenSupplier subjectTokenSupplier; + private X509Provider x509Provider; Builder() {} @@ -194,6 +195,21 @@ public static class Builder extends ExternalAccountCredentials.Builder { } } + /** + * Sets a custom {@link X509Provider} to manage the client certificate and private key for mTLS. + * If set, this provider will be used instead of the default behavior which initializes an + * {@code X509Provider} based on the {@code certificateConfigLocation} or default paths found in + * the {@code credentialSource}. This is primarily used for testing. + * + * @param x509Provider the custom X509 provider to use. + * @return this {@code Builder} object + */ + @CanIgnoreReturnValue + public Builder setX509Provider(X509Provider x509Provider) { + this.x509Provider = x509Provider; + return this; + } + /** * Sets the subject token supplier. The supplier should return a valid subject token string. * diff --git a/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsSourceTest.java b/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsSourceTest.java index f2d841e36..7b6b0af7e 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsSourceTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsSourceTest.java @@ -31,83 +31,73 @@ package com.google.auth.oauth2; -import static com.google.auth.Credentials.GOOGLE_DEFAULT_UNIVERSE; -import static com.google.auth.oauth2.MockExternalAccountCredentialsTransport.SERVICE_ACCOUNT_IMPERSONATION_URL; -import static com.google.auth.oauth2.OAuth2Utils.JSON_FACTORY; import static org.junit.Assert.*; -import com.google.api.client.http.HttpTransport; -import com.google.api.client.json.GenericJson; -import com.google.api.client.util.Clock; -import com.google.auth.TestUtils; -import com.google.auth.http.HttpTransportFactory; -import java.io.ByteArrayInputStream; -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.util.Arrays; +import com.google.auth.oauth2.IdentityPoolCredentialSource.IdentityPoolCredentialSourceType; import java.util.HashMap; -import java.util.List; import java.util.Map; -import javax.annotation.Nullable; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; -import com.google.auth.oauth2.IdentityPoolCredentialSource.IdentityPoolCredentialSourceType; - /** Tests for {@link IdentityPoolCredentialSource}. */ @RunWith(JUnit4.class) public class IdentityPoolCredentialsSourceTest { @Test - public void constructor_certificateConfig(){ + public void constructor_certificateConfig() { Map certificateMap = new HashMap<>(); certificateMap.put("certificate_config_location", "/path/to/certificate"); Map credentialSourceMap = new HashMap<>(); credentialSourceMap.put("certificate", certificateMap); - IdentityPoolCredentialSource credentialSource = new IdentityPoolCredentialSource(credentialSourceMap); - assertEquals(IdentityPoolCredentialSourceType.CERTIFICATE, credentialSource.credentialSourceType); + IdentityPoolCredentialSource credentialSource = + new IdentityPoolCredentialSource(credentialSourceMap); + assertEquals( + IdentityPoolCredentialSourceType.CERTIFICATE, credentialSource.credentialSourceType); assertNotNull(credentialSource.certificateConfig); assertFalse(credentialSource.certificateConfig.useDefaultCertificateConfig()); - assertEquals("/path/to/certificate", credentialSource.certificateConfig.getCertificateConfigLocation()); + assertEquals( + "/path/to/certificate", credentialSource.certificateConfig.getCertificateConfigLocation()); } @Test - public void constructor_certificateConfig_useDefault(){ + public void constructor_certificateConfig_useDefault() { Map certificateMap = new HashMap<>(); certificateMap.put("use_default_certificate_config", true); Map credentialSourceMap = new HashMap<>(); credentialSourceMap.put("certificate", certificateMap); - IdentityPoolCredentialSource credentialSource = new IdentityPoolCredentialSource(credentialSourceMap); - assertEquals(IdentityPoolCredentialSourceType.CERTIFICATE, credentialSource.credentialSourceType); + IdentityPoolCredentialSource credentialSource = + new IdentityPoolCredentialSource(credentialSourceMap); + assertEquals( + IdentityPoolCredentialSourceType.CERTIFICATE, credentialSource.credentialSourceType); assertNotNull(credentialSource.certificateConfig); assertTrue(credentialSource.certificateConfig.useDefaultCertificateConfig()); } @Test - public void constructor_certificateConfig_missingRequiredFields_throws(){ + public void constructor_certificateConfig_missingRequiredFields_throws() { Map certificateMap = new HashMap<>(); - //Missing both use_default_certificate_config and certificate_config_location + // Missing both use_default_certificate_config and certificate_config_location. certificateMap.put("trust_chain_path", "path/to/trust/chain"); Map credentialSourceMap = new HashMap<>(); credentialSourceMap.put("certificate", certificateMap); - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> new IdentityPoolCredentialSource(credentialSourceMap) - ); - assertTrue(exception.getMessage().contains("must either specify a certificate_config_location or use_default_certificate_config should be true")); + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> new IdentityPoolCredentialSource(credentialSourceMap)); + assertEquals( + "Invalid 'certificate' configuration in credential source: Must specify either 'certificate_config_location' or set 'use_default_certificate_config' to true.", + exception.getMessage()); } @Test - public void constructor_certificateConfig_bothFieldsSet_throws(){ + public void constructor_certificateConfig_bothFieldsSet_throws() { Map certificateMap = new HashMap<>(); certificateMap.put("use_default_certificate_config", true); certificateMap.put("certificate_config_location", "/path/to/certificate"); @@ -115,15 +105,18 @@ public void constructor_certificateConfig_bothFieldsSet_throws(){ Map credentialSourceMap = new HashMap<>(); credentialSourceMap.put("certificate", certificateMap); - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> new IdentityPoolCredentialSource(credentialSourceMap) - ); - assertTrue(exception.getMessage().contains("cannot specify both a certificate_config_location and use_default_certificate_config=true")); + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> new IdentityPoolCredentialSource(credentialSourceMap)); + + assertEquals( + "Invalid 'certificate' configuration in credential source: Cannot specify both 'certificate_config_location' and set 'use_default_certificate_config' to true.", + exception.getMessage()); } @Test - public void constructor_certificateConfig_trustChainPath(){ + public void constructor_certificateConfig_trustChainPath() { Map certificateMap = new HashMap<>(); certificateMap.put("use_default_certificate_config", true); certificateMap.put("trust_chain_path", "path/to/trust/chain"); @@ -131,26 +124,29 @@ public void constructor_certificateConfig_trustChainPath(){ Map credentialSourceMap = new HashMap<>(); credentialSourceMap.put("certificate", certificateMap); - IdentityPoolCredentialSource credentialSource = new IdentityPoolCredentialSource(credentialSourceMap); - assertEquals(IdentityPoolCredentialSourceType.CERTIFICATE, credentialSource.credentialSourceType); + IdentityPoolCredentialSource credentialSource = + new IdentityPoolCredentialSource(credentialSourceMap); + assertEquals( + IdentityPoolCredentialSourceType.CERTIFICATE, credentialSource.credentialSourceType); assertNotNull(credentialSource.certificateConfig); assertEquals("path/to/trust/chain", credentialSource.certificateConfig.getTrustChainPath()); } - @Test - public void constructor_certificateConfig_invalidType_throws(){ + public void constructor_certificateConfig_invalidType_throws() { Map certificateMap = new HashMap<>(); certificateMap.put("use_default_certificate_config", "invalid-type"); Map credentialSourceMap = new HashMap<>(); credentialSourceMap.put("certificate", certificateMap); - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> new IdentityPoolCredentialSource(credentialSourceMap) - ); - assertTrue(exception.getMessage().contains("Invalid type for 'use_default_certificate_config' in certificate configuration: expected Boolean")); - } + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> new IdentityPoolCredentialSource(credentialSourceMap)); + assertEquals( + "Invalid type for 'use_default_certificate_config' in certificate configuration: expected Boolean, got String.", + exception.getMessage()); + } } diff --git a/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java index d6ca66013..24626b05a 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java @@ -35,17 +35,24 @@ import static com.google.auth.oauth2.MockExternalAccountCredentialsTransport.SERVICE_ACCOUNT_IMPERSONATION_URL; import static com.google.auth.oauth2.OAuth2Utils.JSON_FACTORY; import static org.junit.Assert.*; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; import com.google.api.client.http.HttpTransport; import com.google.api.client.json.GenericJson; import com.google.api.client.util.Clock; import com.google.auth.TestUtils; import com.google.auth.http.HttpTransportFactory; +import com.google.auth.mtls.X509Provider; import java.io.ByteArrayInputStream; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; import java.util.Arrays; import java.util.HashMap; import java.util.List; @@ -53,56 +60,21 @@ import javax.annotation.Nullable; import org.junit.Test; import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; +import org.mockito.junit.MockitoJUnitRunner; /** Tests for {@link IdentityPoolCredentials}. */ -@RunWith(JUnit4.class) +@RunWith(MockitoJUnitRunner.class) public class IdentityPoolCredentialsTest extends BaseSerializationTest { private static final String STS_URL = "https://sts.googleapis.com/v1/token"; - private static final Map FILE_CREDENTIAL_SOURCE_MAP = - new HashMap() { - { - put("file", "file"); - } - }; - - private static final IdentityPoolCredentialSource FILE_CREDENTIAL_SOURCE = - new IdentityPoolCredentialSource(FILE_CREDENTIAL_SOURCE_MAP); - - private static final IdentityPoolCredentials FILE_SOURCED_CREDENTIAL = - IdentityPoolCredentials.newBuilder() - .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY) - .setAudience( - "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") - .setSubjectTokenType("subjectTokenType") - .setTokenUrl(STS_URL) - .setTokenInfoUrl("tokenInfoUrl") - .setCredentialSource(FILE_CREDENTIAL_SOURCE) - .build(); - private static final IdentityPoolSubjectTokenSupplier testProvider = (ExternalAccountSupplierContext context) -> "testSubjectToken"; - private static final ExternalAccountSupplierContext emptyContext = - ExternalAccountSupplierContext.newBuilder().setAudience("").setSubjectTokenType("").build(); - - static class MockExternalAccountCredentialsTransportFactory implements HttpTransportFactory { - - MockExternalAccountCredentialsTransport transport = - new MockExternalAccountCredentialsTransport(); - - @Override - public HttpTransport create() { - return transport; - } - } - @Test public void createdScoped_clonedCredentialWithAddedScopes() throws IOException { IdentityPoolCredentials credentials = - IdentityPoolCredentials.newBuilder(FILE_SOURCED_CREDENTIAL) + IdentityPoolCredentials.newBuilder(createBaseFileSourcedCredentials()) .setServiceAccountImpersonationUrl(SERVICE_ACCOUNT_IMPERSONATION_URL) .setQuotaProjectId("quotaProjectId") .setClientId("clientId") @@ -147,7 +119,7 @@ public void retrieveSubjectToken_fileSourced() throws IOException { new IdentityPoolCredentialSource(credentialSourceMap); IdentityPoolCredentials credentials = - IdentityPoolCredentials.newBuilder(FILE_SOURCED_CREDENTIAL) + IdentityPoolCredentials.newBuilder(createBaseFileSourcedCredentials()) .setCredentialSource(credentialSource) .build(); @@ -187,7 +159,7 @@ public void retrieveSubjectToken_fileSourcedWithJsonFormat() throws IOException file.getAbsolutePath()); IdentityPoolCredentials credential = - IdentityPoolCredentials.newBuilder(FILE_SOURCED_CREDENTIAL) + IdentityPoolCredentials.newBuilder(createBaseFileSourcedCredentials()) .setHttpTransportFactory(transportFactory) .setCredentialSource(credentialSource) .build(); @@ -227,7 +199,7 @@ public void retrieveSubjectToken_noFile_throws() { new IdentityPoolCredentialSource(credentialSourceMap); IdentityPoolCredentials credentials = - IdentityPoolCredentials.newBuilder(FILE_SOURCED_CREDENTIAL) + IdentityPoolCredentials.newBuilder(createBaseFileSourcedCredentials()) .setCredentialSource(credentialSource) .build(); @@ -247,7 +219,7 @@ public void retrieveSubjectToken_urlSourced() throws IOException { new MockExternalAccountCredentialsTransportFactory(); IdentityPoolCredentials credential = - IdentityPoolCredentials.newBuilder(FILE_SOURCED_CREDENTIAL) + IdentityPoolCredentials.newBuilder(createBaseFileSourcedCredentials()) .setHttpTransportFactory(transportFactory) .setCredentialSource( buildUrlBasedCredentialSource(transportFactory.transport.getMetadataUrl())) @@ -273,7 +245,7 @@ public void retrieveSubjectToken_urlSourcedWithJsonFormat() throws IOException { buildUrlBasedCredentialSource(transportFactory.transport.getMetadataUrl(), formatMap); IdentityPoolCredentials credential = - IdentityPoolCredentials.newBuilder(FILE_SOURCED_CREDENTIAL) + IdentityPoolCredentials.newBuilder(createBaseFileSourcedCredentials()) .setHttpTransportFactory(transportFactory) .setCredentialSource(credentialSource) .build(); @@ -292,7 +264,7 @@ public void retrieveSubjectToken_urlSourcedCredential_throws() { transportFactory.transport.addResponseErrorSequence(response); IdentityPoolCredentials credential = - IdentityPoolCredentials.newBuilder(FILE_SOURCED_CREDENTIAL) + IdentityPoolCredentials.newBuilder(createBaseFileSourcedCredentials()) .setHttpTransportFactory(transportFactory) .setCredentialSource( buildUrlBasedCredentialSource(transportFactory.transport.getMetadataUrl())) @@ -311,9 +283,10 @@ public void retrieveSubjectToken_urlSourcedCredential_throws() { @Test public void retrieveSubjectToken_provider() throws IOException { - + ExternalAccountSupplierContext emptyContext = + ExternalAccountSupplierContext.newBuilder().setAudience("").setSubjectTokenType("").build(); IdentityPoolCredentials credentials = - IdentityPoolCredentials.newBuilder(FILE_SOURCED_CREDENTIAL) + IdentityPoolCredentials.newBuilder(createBaseFileSourcedCredentials()) .setCredentialSource(null) .setSubjectTokenSupplier(testProvider) .build(); @@ -332,7 +305,7 @@ public void retrieveSubjectToken_providerThrowsError() throws IOException { throw testException; }; IdentityPoolCredentials credentials = - IdentityPoolCredentials.newBuilder(FILE_SOURCED_CREDENTIAL) + IdentityPoolCredentials.newBuilder(createBaseFileSourcedCredentials()) .setCredentialSource(null) .setSubjectTokenSupplier(errorProvider) .build(); @@ -349,8 +322,8 @@ public void retrieveSubjectToken_providerThrowsError() throws IOException { public void retrieveSubjectToken_supplierPassesContext() throws IOException { ExternalAccountSupplierContext expectedContext = ExternalAccountSupplierContext.newBuilder() - .setAudience(FILE_SOURCED_CREDENTIAL.getAudience()) - .setSubjectTokenType(FILE_SOURCED_CREDENTIAL.getSubjectTokenType()) + .setAudience(createBaseFileSourcedCredentials().getAudience()) + .setSubjectTokenType(createBaseFileSourcedCredentials().getSubjectTokenType()) .build(); IdentityPoolSubjectTokenSupplier testSupplier = @@ -360,7 +333,7 @@ public void retrieveSubjectToken_supplierPassesContext() throws IOException { return "token"; }; IdentityPoolCredentials credentials = - IdentityPoolCredentials.newBuilder(FILE_SOURCED_CREDENTIAL) + IdentityPoolCredentials.newBuilder(createBaseFileSourcedCredentials()) .setCredentialSource(null) .setSubjectTokenSupplier(testSupplier) .build(); @@ -379,7 +352,7 @@ public void refreshAccessToken_withoutServiceAccountImpersonation() throws IOExc "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") .setSubjectTokenType("subjectTokenType") .setTokenInfoUrl("tokenInfoUrl") - .setCredentialSource(FILE_CREDENTIAL_SOURCE) + .setCredentialSource(createFileCredentialSource()) .setTokenUrl(transportFactory.transport.getStsUrl()) .setHttpTransportFactory(transportFactory) .setCredentialSource( @@ -402,7 +375,7 @@ public void refreshAccessToken_internalOptionsSet() throws IOException { new MockExternalAccountCredentialsTransportFactory(); IdentityPoolCredentials credential = - IdentityPoolCredentials.newBuilder(FILE_SOURCED_CREDENTIAL) + IdentityPoolCredentials.newBuilder(createBaseFileSourcedCredentials()) .setWorkforcePoolUserProject("userProject") .setAudience( "//iam.googleapis.com/locations/global/workforcePools/pool/providers/provider") @@ -565,7 +538,7 @@ public void refreshAccessToken_workforceWithServiceAccountImpersonation() throws transportFactory.transport.setExpireTime(TestUtils.getDefaultExpireTime()); IdentityPoolCredentials credential = - IdentityPoolCredentials.newBuilder(FILE_SOURCED_CREDENTIAL) + IdentityPoolCredentials.newBuilder(createBaseFileSourcedCredentials()) .setAudience( "//iam.googleapis.com/locations/global/workforcePools/pool/providers/provider") .setTokenUrl(transportFactory.transport.getStsUrl()) @@ -601,7 +574,7 @@ public void refreshAccessToken_workforceWithServiceAccountImpersonationOptions() transportFactory.transport.setExpireTime(TestUtils.getDefaultExpireTime()); IdentityPoolCredentials credential = - IdentityPoolCredentials.newBuilder(FILE_SOURCED_CREDENTIAL) + IdentityPoolCredentials.newBuilder(createBaseFileSourcedCredentials()) .setAudience( "//iam.googleapis.com/locations/global/workforcePools/pool/providers/provider") .setTokenUrl(transportFactory.transport.getStsUrl()) @@ -722,7 +695,7 @@ public void identityPoolCredentialSource_invalidSourceType() { fail("Should not be able to continue without exception."); } catch (IllegalArgumentException exception) { assertEquals( - "Missing credential source file location or URL. At least one must be specified.", + "Missing credential source file location, URL, or certificate. At least one must be specified.", exception.getMessage()); } } @@ -783,6 +756,7 @@ public void identityPoolCredentialSource_subjectTokenFieldNameUnset() { @Test public void builder_allFields() throws IOException { List scopes = Arrays.asList("scope1", "scope2"); + IdentityPoolCredentialSource credentialSource = createFileCredentialSource(); IdentityPoolCredentials credentials = IdentityPoolCredentials.newBuilder() @@ -791,7 +765,7 @@ public void builder_allFields() throws IOException { .setSubjectTokenType("subjectTokenType") .setTokenUrl(STS_URL) .setTokenInfoUrl("tokenInfoUrl") - .setCredentialSource(FILE_CREDENTIAL_SOURCE) + .setCredentialSource(credentialSource) .setServiceAccountImpersonationUrl(SERVICE_ACCOUNT_IMPERSONATION_URL) .setQuotaProjectId("quotaProjectId") .setClientId("clientId") @@ -806,7 +780,7 @@ public void builder_allFields() throws IOException { assertEquals("tokenInfoUrl", credentials.getTokenInfoUrl()); assertEquals( SERVICE_ACCOUNT_IMPERSONATION_URL, credentials.getServiceAccountImpersonationUrl()); - assertEquals(FILE_CREDENTIAL_SOURCE, credentials.getCredentialSource()); + assertEquals(credentialSource, credentials.getCredentialSource()); assertEquals("quotaProjectId", credentials.getQuotaProjectId()); assertEquals("clientId", credentials.getClientId()); assertEquals("clientSecret", credentials.getClientSecret()); @@ -860,7 +834,7 @@ public void builder_invalidWorkforceAudiences_throws() { .setSubjectTokenType("subjectTokenType") .setTokenUrl(STS_URL) .setTokenInfoUrl("tokenInfoUrl") - .setCredentialSource(FILE_CREDENTIAL_SOURCE) + .setCredentialSource(createFileCredentialSource()) .setQuotaProjectId("quotaProjectId") .build(); fail("Exception should be thrown."); @@ -884,7 +858,7 @@ public void builder_emptyWorkforceUserProjectWithWorkforceAudience() { .setSubjectTokenType("subjectTokenType") .setTokenUrl(STS_URL) .setTokenInfoUrl("tokenInfoUrl") - .setCredentialSource(FILE_CREDENTIAL_SOURCE) + .setCredentialSource(createFileCredentialSource()) .setQuotaProjectId("quotaProjectId") .build(); @@ -892,7 +866,7 @@ public void builder_emptyWorkforceUserProjectWithWorkforceAudience() { } @Test - public void builder_supplierAndCredSourceThrows() throws IOException { + public void builder_supplierAndCredSourceThrows() { try { IdentityPoolCredentials credentials = IdentityPoolCredentials.newBuilder() @@ -901,7 +875,7 @@ public void builder_supplierAndCredSourceThrows() throws IOException { .setAudience("audience") .setSubjectTokenType("subjectTokenType") .setTokenUrl(STS_URL) - .setCredentialSource(FILE_CREDENTIAL_SOURCE) + .setCredentialSource(createFileCredentialSource()) .build(); fail("Should not be able to continue without exception."); } catch (IllegalArgumentException exception) { @@ -929,8 +903,10 @@ public void builder_noSupplierOrCredSourceThrows() throws IOException { } } + @Test public void builder_missingUniverseDomain_defaults() throws IOException { List scopes = Arrays.asList("scope1", "scope2"); + IdentityPoolCredentialSource credentialSource = createFileCredentialSource(); IdentityPoolCredentials credentials = IdentityPoolCredentials.newBuilder() @@ -939,7 +915,7 @@ public void builder_missingUniverseDomain_defaults() throws IOException { .setSubjectTokenType("subjectTokenType") .setTokenUrl(STS_URL) .setTokenInfoUrl("tokenInfoUrl") - .setCredentialSource(FILE_CREDENTIAL_SOURCE) + .setCredentialSource(credentialSource) .setServiceAccountImpersonationUrl(SERVICE_ACCOUNT_IMPERSONATION_URL) .setQuotaProjectId("quotaProjectId") .setClientId("clientId") @@ -953,7 +929,7 @@ public void builder_missingUniverseDomain_defaults() throws IOException { assertEquals("tokenInfoUrl", credentials.getTokenInfoUrl()); assertEquals( SERVICE_ACCOUNT_IMPERSONATION_URL, credentials.getServiceAccountImpersonationUrl()); - assertEquals(FILE_CREDENTIAL_SOURCE, credentials.getCredentialSource()); + assertEquals(credentialSource, credentials.getCredentialSource()); assertEquals("quotaProjectId", credentials.getQuotaProjectId()); assertEquals("clientId", credentials.getClientId()); assertEquals("clientSecret", credentials.getClientSecret()); @@ -974,7 +950,7 @@ public void newBuilder_allFields() throws IOException { .setSubjectTokenType("subjectTokenType") .setTokenUrl(STS_URL) .setTokenInfoUrl("tokenInfoUrl") - .setCredentialSource(FILE_CREDENTIAL_SOURCE) + .setCredentialSource(createFileCredentialSource()) .setServiceAccountImpersonationUrl(SERVICE_ACCOUNT_IMPERSONATION_URL) .setQuotaProjectId("quotaProjectId") .setClientId("clientId") @@ -1016,7 +992,7 @@ public void newBuilder_noUniverseDomain_defaults() throws IOException { .setSubjectTokenType("subjectTokenType") .setTokenUrl(STS_URL) .setTokenInfoUrl("tokenInfoUrl") - .setCredentialSource(FILE_CREDENTIAL_SOURCE) + .setCredentialSource(createFileCredentialSource()) .setServiceAccountImpersonationUrl(SERVICE_ACCOUNT_IMPERSONATION_URL) .setQuotaProjectId("quotaProjectId") .setClientId("clientId") @@ -1048,7 +1024,7 @@ public void newBuilder_noUniverseDomain_defaults() throws IOException { @Test public void serialize() throws IOException, ClassNotFoundException { IdentityPoolCredentials testCredentials = - IdentityPoolCredentials.newBuilder(FILE_SOURCED_CREDENTIAL) + IdentityPoolCredentials.newBuilder(createBaseFileSourcedCredentials()) .setServiceAccountImpersonationUrl(SERVICE_ACCOUNT_IMPERSONATION_URL) .setQuotaProjectId("quotaProjectId") .setClientId("clientId") @@ -1060,7 +1036,135 @@ public void serialize() throws IOException, ClassNotFoundException { assertEquals(testCredentials, deserializedCredentials); assertEquals(testCredentials.hashCode(), deserializedCredentials.hashCode()); assertEquals(testCredentials.toString(), deserializedCredentials.toString()); - assertSame(deserializedCredentials.clock, Clock.SYSTEM); + assertSame(Clock.SYSTEM, deserializedCredentials.clock); + } + + @Test + public void build_withCertificateSourceAndCustomX509Provider_success() + throws IOException, KeyStoreException, CertificateException, NoSuchAlgorithmException { + // Create an empty KeyStore and a spy on a custom X509Provider. + KeyStore keyStore = KeyStore.getInstance("JKS"); + keyStore.load(null, null); + TestX509Provider x509Provider = + spy(new TestX509Provider(keyStore, "/path/to/certificate.json")); + + // Set up credential source for certificate type. + Map certificateMap = new HashMap<>(); + certificateMap.put("use_default_certificate_config", true); + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("certificate", certificateMap); + IdentityPoolCredentialSource credentialSource = + new IdentityPoolCredentialSource(credentialSourceMap); + MockExternalAccountCredentialsTransportFactory mockTransportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + // Build credentials with the custom provider. + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setX509Provider(x509Provider) + .setHttpTransportFactory(mockTransportFactory) + .setAudience("test-audience") + .setSubjectTokenType("test-token-type") + .setCredentialSource(credentialSource) + .build(); + + // Verify successful creation and correct internal setup. + assertNotNull("Credentials should be successfully created", credentials); + assertTrue( + "Subject token supplier should be for certificates", + credentials.getIdentityPoolSubjectTokenSupplier() + instanceof CertificateIdentityPoolSubjectTokenSupplier); + assertEquals( + "Metrics header should indicate certificate source", + IdentityPoolCredentials.CERTIFICATE_METRICS_HEADER_VALUE, + credentials.getCredentialSourceType()); + + // Verify the custom provider methods were called during build. + verify(x509Provider).getKeyStore(); + verify(x509Provider).getCertificatePath(); + } + + @Test + public void build_withDefaultCertificate_throwsOnTransportInitFailure() { + // Setup credential source to use default certificate config. + Map certificateMap = new HashMap<>(); + certificateMap.put("use_default_certificate_config", true); + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("certificate", certificateMap); + IdentityPoolCredentialSource credentialSource = + new IdentityPoolCredentialSource(credentialSourceMap); + + // Expect RuntimeException during build due to mTLS setup failure because the certificate file + // doesn't exist. + IdentityPoolCredentials.Builder builder = + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(new MockExternalAccountCredentialsTransportFactory()) + .setAudience("test-audience") + .setSubjectTokenType("test-token-type") + .setCredentialSource(credentialSource); + RuntimeException exception = assertThrows(RuntimeException.class, builder::build); + + assertEquals( + "Failed to initialize mTLS transport for IdentityPoolCredentials using X509Provider.", + exception.getMessage()); + } + + @Test + public void build_withCustomProvider_throwsOnGetKeyStore() + throws IOException, KeyStoreException, CertificateException, NoSuchAlgorithmException { + // Setup a custom provider configured to throw during getKeyStore(). + KeyStore keyStore = KeyStore.getInstance("JKS"); + keyStore.load(null, null); + TestX509Provider x509Provider = new TestX509Provider(keyStore, "/path/to/certificate.json"); + x509Provider.setShouldThrowOnGetKeyStore(true); // Configure to throw + + Map certificateMap = new HashMap<>(); + certificateMap.put("certificate_config_location", "/path/to/certificate.json"); + + // Expect RuntimeException from the custom provider during build. + RuntimeException exception = + assertThrows( + RuntimeException.class, + () -> createCredentialsWithCertificate(x509Provider, certificateMap)); + + assertEquals("Exception on get keystore", exception.getMessage()); + } + + @Test + public void build_withCustomProvider_throwsOnGetCertificatePath() + throws IOException, KeyStoreException, CertificateException, NoSuchAlgorithmException { + // Setup a custom provider configured to throw during getCertificatePath(). + KeyStore keyStore = KeyStore.getInstance("JKS"); + keyStore.load(null, null); + TestX509Provider x509Provider = new TestX509Provider(keyStore, "/path/to/certificate.json"); + x509Provider.setShouldThrowOnGetCertificatePath(true); // Configure to throw + + Map certificateMap = new HashMap<>(); + certificateMap.put("certificate_config_location", "/path/to/certificate.json"); + + // Expect RuntimeException from the custom provider during build. + RuntimeException exception = + assertThrows( + RuntimeException.class, + () -> createCredentialsWithCertificate(x509Provider, certificateMap)); + + assertEquals("Exception on get certificate path", exception.getMessage()); + } + + private void createCredentialsWithCertificate( + X509Provider x509Provider, Map certificateMap) { + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("certificate", certificateMap); + IdentityPoolCredentialSource credentialSource = + new IdentityPoolCredentialSource(credentialSourceMap); + + IdentityPoolCredentials.newBuilder() + .setX509Provider(x509Provider) + .setHttpTransportFactory(new MockExternalAccountCredentialsTransportFactory()) + .setAudience("") + .setSubjectTokenType("") + .setCredentialSource(credentialSource) + .build(); } static InputStream writeIdentityPoolCredentialsStream( @@ -1109,4 +1213,75 @@ private static IdentityPoolCredentialSource buildUrlBasedCredentialSource( return new IdentityPoolCredentialSource(credentialSourceMap); } + + private IdentityPoolCredentials createBaseFileSourcedCredentials() { + Map fileCredentialSourceMap = new HashMap<>(); + fileCredentialSourceMap.put("file", "file"); // Consider using a real temp file setup if needed + IdentityPoolCredentialSource identityPoolCredentialSource = + new IdentityPoolCredentialSource(fileCredentialSourceMap); + + return IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY) + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl(STS_URL) + .setTokenInfoUrl("tokenInfoUrl") + .setCredentialSource(identityPoolCredentialSource) + .build(); + } + + private IdentityPoolCredentialSource createFileCredentialSource() { + Map fileCredentialSourceMap = new HashMap<>(); + fileCredentialSourceMap.put("file", "file"); + return new IdentityPoolCredentialSource(fileCredentialSourceMap); + } + + static class MockExternalAccountCredentialsTransportFactory implements HttpTransportFactory { + + MockExternalAccountCredentialsTransport transport = + new MockExternalAccountCredentialsTransport(); + + @Override + public HttpTransport create() { + return transport; + } + } + + private static class TestX509Provider extends X509Provider { + private final KeyStore keyStore; + private final String certificatePath; + private boolean shouldThrowOnGetKeyStore = false; + private boolean shouldThrowOnGetCertificatePath = false; + + TestX509Provider(KeyStore keyStore, String certificatePath) { + super(); + this.keyStore = keyStore; + this.certificatePath = certificatePath; + } + + @Override + public KeyStore getKeyStore() { + if (shouldThrowOnGetKeyStore) { + throw new RuntimeException("Exception on get keystore"); + } + return keyStore; + } + + @Override + public String getCertificatePath() { + if (shouldThrowOnGetCertificatePath) { + throw new RuntimeException("Exception on get certificate path"); + } + return certificatePath; + } + + void setShouldThrowOnGetKeyStore(boolean shouldThrow) { + this.shouldThrowOnGetKeyStore = shouldThrow; + } + + void setShouldThrowOnGetCertificatePath(boolean shouldThrow) { + this.shouldThrowOnGetCertificatePath = shouldThrow; + } + } } From 26c399814c813cce1ab41bb9261ff7c16f63b461 Mon Sep 17 00:00:00 2001 From: nbayati <99771966+nbayati@users.noreply.github.com> Date: Wed, 30 Apr 2025 15:04:34 -0700 Subject: [PATCH 06/12] Created MtlsHttpTransportFactory class, and added helper methods for IdentityPoolCredentials. --- .../auth/mtls/MtlsHttpTransportFactory.java | 70 +++++++++++++++ .../auth/oauth2/IdentityPoolCredentials.java | 86 ++++++++++--------- .../oauth2/IdentityPoolCredentialsTest.java | 2 +- 3 files changed, 115 insertions(+), 43 deletions(-) create mode 100644 oauth2_http/java/com/google/auth/mtls/MtlsHttpTransportFactory.java diff --git a/oauth2_http/java/com/google/auth/mtls/MtlsHttpTransportFactory.java b/oauth2_http/java/com/google/auth/mtls/MtlsHttpTransportFactory.java new file mode 100644 index 000000000..488fc5904 --- /dev/null +++ b/oauth2_http/java/com/google/auth/mtls/MtlsHttpTransportFactory.java @@ -0,0 +1,70 @@ +/* + * Copyright 2025, 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.mtls; + +import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.auth.http.HttpTransportFactory; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.util.Objects; + +/** + * An HttpTransportFactory that creates {@link NetHttpTransport} instances configured for mTLS + * (mutual TLS) using a specific {@link KeyStore} containing the client's certificate and private + * key. + */ +public class MtlsHttpTransportFactory implements HttpTransportFactory { + private final KeyStore mtlsKeyStore; + + /** + * Constructs a factory for mTLS transports. + * + * @param mtlsKeyStore The {@link KeyStore} containing the client's X509 certificate and private + * key. This {@link KeyStore} is used for client authentication during the TLS handshake. Must + * not be null. + */ + public MtlsHttpTransportFactory(KeyStore mtlsKeyStore) { + this.mtlsKeyStore = Objects.requireNonNull(mtlsKeyStore, "mtlsKeyStore cannot be null"); + } + + @Override + public NetHttpTransport create() { + try { + // Build the mTLS transport using the provided KeyStore. + return new NetHttpTransport.Builder().trustCertificates(null, mtlsKeyStore, "").build(); + } catch (GeneralSecurityException e) { + // Wrap the checked exception in a RuntimeException because the HttpTransportFactory + // interface's create() method doesn't allow throwing checked exceptions. + throw new RuntimeException("Failed to initialize mTLS transport.", e); + } + } +} diff --git a/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java b/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java index b4da67348..81b11978b 100644 --- a/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java @@ -31,14 +31,13 @@ package com.google.auth.oauth2; -import com.google.api.client.http.javanet.NetHttpTransport; import com.google.auth.http.HttpTransportFactory; +import com.google.auth.mtls.MtlsHttpTransportFactory; import com.google.auth.mtls.X509Provider; import com.google.auth.oauth2.IdentityPoolCredentialSource.IdentityPoolCredentialSourceType; import com.google.common.annotations.VisibleForTesting; import com.google.errorprone.annotations.CanIgnoreReturnValue; import java.io.IOException; -import java.security.GeneralSecurityException; import java.security.KeyStore; import java.util.ArrayList; import java.util.Collection; @@ -53,7 +52,7 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { static final String FILE_METRICS_HEADER_VALUE = "file"; static final String URL_METRICS_HEADER_VALUE = "url"; - static final String CERTIFICATE_METRICS_HEADER_VALUE = "certificate"; // Added constant + static final String CERTIFICATE_METRICS_HEADER_VALUE = "certificate"; private static final long serialVersionUID = 2471046175477275881L; private final IdentityPoolSubjectTokenSupplier subjectTokenSupplier; @@ -94,44 +93,8 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { this.metricsHeaderValue = URL_METRICS_HEADER_VALUE; } else if (credentialSource.credentialSourceType == IdentityPoolCredentialSourceType.CERTIFICATE) { - assert credentialSource.certificateConfig != null; - - try { - final IdentityPoolCredentialSource.CertificateConfig certConfig = - credentialSource.certificateConfig; - - // Use the provided X509Provider if available. - X509Provider x509Provider = builder.x509Provider; - if (x509Provider == null) { - // Determine the certificate path based on the configuration. - String explicitCertConfigPath = - certConfig.useDefaultCertificateConfig() - ? null - : certConfig.getCertificateConfigLocation(); - - // Initialize X509Provider with the explicit path (if provided). - x509Provider = new X509Provider(explicitCertConfigPath); - } - - // Update the transport factory to use a mTLS transport with the provided certificate. - KeyStore mtlsKeyStore = x509Provider.getKeyStore(); - final NetHttpTransport mtlsTransport = - new NetHttpTransport.Builder().trustCertificates(null, mtlsKeyStore, "").build(); - this.transportFactory = () -> mtlsTransport; - - // Initialize the subject token supplier with the certificate path - credentialSource.credentialLocation = x509Provider.getCertificatePath(); - this.subjectTokenSupplier = - new CertificateIdentityPoolSubjectTokenSupplier(credentialSource); - - this.metricsHeaderValue = CERTIFICATE_METRICS_HEADER_VALUE; - - } catch (GeneralSecurityException | IOException e) { - // Catch exceptions from X509Provider or transport creation - throw new RuntimeException( - "Failed to initialize mTLS transport for IdentityPoolCredentials using X509Provider.", - e); - } + this.subjectTokenSupplier = createCertificateSubjectTokenSupplier(builder, credentialSource); + this.metricsHeaderValue = CERTIFICATE_METRICS_HEADER_VALUE; } else { throw new IllegalArgumentException("Source type not supported."); } @@ -181,6 +144,45 @@ public static Builder newBuilder(IdentityPoolCredentials identityPoolCredentials return new Builder(identityPoolCredentials); } + private IdentityPoolSubjectTokenSupplier createCertificateSubjectTokenSupplier( + Builder builder, IdentityPoolCredentialSource credentialSource) { + try { + // Configure the mTLS transport with the x509 keystore. + X509Provider x509Provider = getX509Provider(builder, credentialSource); + KeyStore mtlsKeyStore = x509Provider.getKeyStore(); + this.transportFactory = new MtlsHttpTransportFactory(mtlsKeyStore); + + // Initialize the subject token supplier with the certificate path. + credentialSource.credentialLocation = x509Provider.getCertificatePath(); + return new CertificateIdentityPoolSubjectTokenSupplier(credentialSource); + + } catch (IOException e) { + throw new RuntimeException( + // Wrap IOException in RuntimeException because constructors cannot throw checked + // exceptions. + "Failed to initialize IdentityPoolCredentials from certificate source due to an I/O error.", + e); + } + } + + private X509Provider getX509Provider( + Builder builder, IdentityPoolCredentialSource credentialSource) { + final IdentityPoolCredentialSource.CertificateConfig certConfig = + credentialSource.certificateConfig; + + // Use the provided X509Provider if available, otherwise initialize a default one. + X509Provider x509Provider = builder.x509Provider; + if (x509Provider == null) { + // Determine the certificate path based on the configuration. + String explicitCertConfigPath = + certConfig.useDefaultCertificateConfig() + ? null + : certConfig.getCertificateConfigLocation(); + x509Provider = new X509Provider(explicitCertConfigPath); + } + return x509Provider; + } + public static class Builder extends ExternalAccountCredentials.Builder { private IdentityPoolSubjectTokenSupplier subjectTokenSupplier; @@ -205,7 +207,7 @@ public static class Builder extends ExternalAccountCredentials.Builder { * @return this {@code Builder} object */ @CanIgnoreReturnValue - public Builder setX509Provider(X509Provider x509Provider) { + Builder setX509Provider(X509Provider x509Provider) { this.x509Provider = x509Provider; return this; } diff --git a/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java index 24626b05a..27599266d 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java @@ -1105,7 +1105,7 @@ public void build_withDefaultCertificate_throwsOnTransportInitFailure() { RuntimeException exception = assertThrows(RuntimeException.class, builder::build); assertEquals( - "Failed to initialize mTLS transport for IdentityPoolCredentials using X509Provider.", + "Failed to initialize IdentityPoolCredentials from certificate source due to an I/O error.", exception.getMessage()); } From c53de82769ec040933145687185baf944444e564 Mon Sep 17 00:00:00 2001 From: nbayati <99771966+nbayati@users.noreply.github.com> Date: Wed, 30 Apr 2025 15:08:36 -0700 Subject: [PATCH 07/12] Improves readability: Use boolean flags for source type check --- .../google/auth/oauth2/IdentityPoolCredentialSource.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentialSource.java b/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentialSource.java index 0a0ea2523..9d5c12ae9 100644 --- a/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentialSource.java +++ b/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentialSource.java @@ -234,13 +234,13 @@ public IdentityPoolCredentialSource(Map credentialSourceMap) { "Only one credential source type can be set: 'file', 'url', or 'certificate'."); } - if (credentialSourceMap.containsKey("file")) { + if (filePresent) { credentialLocation = (String) credentialSourceMap.get("file"); credentialSourceType = IdentityPoolCredentialSourceType.FILE; - } else if (credentialSourceMap.containsKey("url")) { + } else if (urlPresent) { credentialLocation = (String) credentialSourceMap.get("url"); credentialSourceType = IdentityPoolCredentialSourceType.URL; - } else if (credentialSourceMap.containsKey("certificate")) { + } else if (certificatePresent) { credentialSourceType = IdentityPoolCredentialSourceType.CERTIFICATE; this.certificateConfig = getCertificateConfig(credentialSourceMap); } else { From 2eb5de3eefb86e20af591659c10ec6b58e242c23 Mon Sep 17 00:00:00 2001 From: nbayati <99771966+nbayati@users.noreply.github.com> Date: Thu, 1 May 2025 15:52:47 -0700 Subject: [PATCH 08/12] Refactor: make fields private in IdentityPoolCredentialSource and move the certificate content to a file instead of local variable. --- ...icateIdentityPoolSubjectTokenSupplier.java | 13 +++-- .../FileIdentityPoolSubjectTokenSupplier.java | 6 +-- .../oauth2/IdentityPoolCredentialSource.java | 40 +++++++++++++-- .../auth/oauth2/IdentityPoolCredentials.java | 4 +- .../UrlIdentityPoolSubjectTokenSupplier.java | 2 +- ...eIdentityPoolSubjectTokenSupplierTest.java | 51 +++++++++---------- 6 files changed, 71 insertions(+), 45 deletions(-) diff --git a/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java b/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java index 723580054..2517cb553 100644 --- a/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java +++ b/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java @@ -65,15 +65,14 @@ public class CertificateIdentityPoolSubjectTokenSupplier // This check ensures that the credential source was intended for certificate usage. // IdentityPoolCredentials logic should guarantee credentialLocation is set in this case. checkNotNull( - credentialSource.certificateConfig, + credentialSource.getCertificateConfig(), "credentialSource.certificateConfig cannot be null when creating" + " CertificateIdentityPoolSubjectTokenSupplier"); } private static X509Certificate loadLeafCertificate(String path) throws IOException, CertificateException { - byte[] leafCertBytes; - leafCertBytes = Files.readAllBytes(Paths.get(path)); + byte[] leafCertBytes = Files.readAllBytes(Paths.get(path)); return parseCertificate(leafCertBytes); } @@ -104,11 +103,11 @@ public String getSubjectToken(ExternalAccountSupplierContext context) throws IOE try { // credentialSource.credentialLocation is expected to be non-null here, // set during IdentityPoolCredentials construction for certificate type. - X509Certificate leafCert = loadLeafCertificate(credentialSource.credentialLocation); - String encodedCert = encodeCert(leafCert); + X509Certificate leafCert = loadLeafCertificate(credentialSource.getCredentialLocation()); + String encodedLeafCert = encodeCert(leafCert); JsonArray certChain = new JsonArray(); - certChain.add(new JsonPrimitive(encodedCert)); + certChain.add(new JsonPrimitive(encodedLeafCert)); return GSON.toJson(certChain); } catch (CertificateException e) { @@ -116,7 +115,7 @@ public String getSubjectToken(ExternalAccountSupplierContext context) throws IOE // the path of the file that failed to parse, and re-throw as IOException // as expected by the getSubjectToken method signature for I/O related issues. throw new IOException( - "Failed to parse certificate from: " + credentialSource.credentialLocation, e); + "Failed to parse certificate(s) from: " + credentialSource.getCredentialLocation(), e); } } } diff --git a/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplier.java b/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplier.java index 392d8e5ee..cf0b7d242 100644 --- a/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplier.java +++ b/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplier.java @@ -37,7 +37,6 @@ import com.google.common.io.CharStreams; import java.io.BufferedReader; import java.io.File; -import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; @@ -67,14 +66,15 @@ class FileIdentityPoolSubjectTokenSupplier implements IdentityPoolSubjectTokenSu @Override public String getSubjectToken(ExternalAccountSupplierContext context) throws IOException { - String credentialFilePath = this.credentialSource.credentialLocation; + String credentialFilePath = this.credentialSource.getCredentialLocation(); if (!Files.exists(Paths.get(credentialFilePath), LinkOption.NOFOLLOW_LINKS)) { throw new IOException( String.format( "Invalid credential location. The file at %s does not exist.", credentialFilePath)); } try { - return parseToken(new FileInputStream(new File(credentialFilePath)), this.credentialSource); + return parseToken( + Files.newInputStream(new File(credentialFilePath).toPath()), this.credentialSource); } catch (IOException e) { throw new IOException( "Error when attempting to read the subject token from the credential file.", e); diff --git a/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentialSource.java b/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentialSource.java index 9d5c12ae9..4ab63b8ed 100644 --- a/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentialSource.java +++ b/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentialSource.java @@ -47,10 +47,41 @@ public class IdentityPoolCredentialSource extends ExternalAccountCredentials.Cre private static final long serialVersionUID = -745855247050085694L; IdentityPoolCredentialSourceType credentialSourceType; CredentialFormatType credentialFormatType; - String credentialLocation; + private String credentialLocation; @Nullable String subjectTokenFieldName; @Nullable Map headers; - @Nullable CertificateConfig certificateConfig; + @Nullable private CertificateConfig certificateConfig; + + /** + * Gets the location of the credential source. This could be a file path or a URL, depending on + * the {@link IdentityPoolCredentialSourceType}. + * + * @return The location of the credential source. + */ + public String getCredentialLocation() { + return credentialLocation; + } + + /** + * Sets the location of the credential source. This method should be used to update the credential + * location. + * + * @param credentialLocation The new location of the credential source. + */ + public void setCredentialLocation(String credentialLocation) { + this.credentialLocation = credentialLocation; + } + + /** + * Gets the configuration for X.509-based workload credentials (mTLS), if configured. + * + * @return The {@link CertificateConfig} object, or {@code null} if not configured for + * certificate-based credentials. + */ + @Nullable + public CertificateConfig getCertificateConfig() { + return certificateConfig; + } /** * Extracts and configures the {@link CertificateConfig} from the provided credential source. @@ -60,7 +91,8 @@ public class IdentityPoolCredentialSource extends ExternalAccountCredentials.Cre * @throws IllegalArgumentException if the 'certificate' entry is not a Map or if required fields * within the certificate configuration have invalid types. */ - private CertificateConfig getCertificateConfig(Map credentialSourceMap) { + private CertificateConfig certificateConfigFromSourceMap( + Map credentialSourceMap) { Object certValue = credentialSourceMap.get("certificate"); if (!(certValue instanceof Map)) { throw new IllegalArgumentException( @@ -242,7 +274,7 @@ public IdentityPoolCredentialSource(Map credentialSourceMap) { credentialSourceType = IdentityPoolCredentialSourceType.URL; } else if (certificatePresent) { credentialSourceType = IdentityPoolCredentialSourceType.CERTIFICATE; - this.certificateConfig = getCertificateConfig(credentialSourceMap); + this.certificateConfig = certificateConfigFromSourceMap(credentialSourceMap); } else { throw new IllegalArgumentException( "Missing credential source file location, URL, or certificate. At least one must be specified."); diff --git a/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java b/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java index 81b11978b..204725012 100644 --- a/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java @@ -153,7 +153,7 @@ private IdentityPoolSubjectTokenSupplier createCertificateSubjectTokenSupplier( this.transportFactory = new MtlsHttpTransportFactory(mtlsKeyStore); // Initialize the subject token supplier with the certificate path. - credentialSource.credentialLocation = x509Provider.getCertificatePath(); + credentialSource.setCredentialLocation(x509Provider.getCertificatePath()); return new CertificateIdentityPoolSubjectTokenSupplier(credentialSource); } catch (IOException e) { @@ -168,7 +168,7 @@ private IdentityPoolSubjectTokenSupplier createCertificateSubjectTokenSupplier( private X509Provider getX509Provider( Builder builder, IdentityPoolCredentialSource credentialSource) { final IdentityPoolCredentialSource.CertificateConfig certConfig = - credentialSource.certificateConfig; + credentialSource.getCertificateConfig(); // Use the provided X509Provider if available, otherwise initialize a default one. X509Provider x509Provider = builder.x509Provider; diff --git a/oauth2_http/java/com/google/auth/oauth2/UrlIdentityPoolSubjectTokenSupplier.java b/oauth2_http/java/com/google/auth/oauth2/UrlIdentityPoolSubjectTokenSupplier.java index 788911a6c..af135a01c 100644 --- a/oauth2_http/java/com/google/auth/oauth2/UrlIdentityPoolSubjectTokenSupplier.java +++ b/oauth2_http/java/com/google/auth/oauth2/UrlIdentityPoolSubjectTokenSupplier.java @@ -70,7 +70,7 @@ public String getSubjectToken(ExternalAccountSupplierContext context) throws IOE transportFactory .create() .createRequestFactory() - .buildGetRequest(new GenericUrl(credentialSource.credentialLocation)); + .buildGetRequest(new GenericUrl(credentialSource.getCredentialLocation())); request.setParser(new JsonObjectParser(OAuth2Utils.JSON_FACTORY)); if (credentialSource.hasHeaders()) { diff --git a/oauth2_http/javatests/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplierTest.java b/oauth2_http/javatests/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplierTest.java index 50d5a6c93..57272db81 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplierTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplierTest.java @@ -32,6 +32,7 @@ package com.google.auth.oauth2; import static org.junit.Assert.*; +import static org.mockito.Mockito.when; import com.google.auth.oauth2.IdentityPoolCredentialSource.CertificateConfig; import com.google.gson.Gson; @@ -40,8 +41,11 @@ import java.io.ByteArrayInputStream; import java.io.File; import java.io.IOException; +import java.net.URISyntaxException; +import java.net.URL; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.nio.file.Paths; import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; @@ -49,7 +53,6 @@ import org.junit.Before; import org.junit.Rule; import org.junit.Test; -import org.junit.rules.TemporaryFolder; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; import org.mockito.Mock; @@ -61,7 +64,6 @@ public class CertificateIdentityPoolSubjectTokenSupplierTest { @Rule public MockitoRule mockitoRule = MockitoJUnit.rule(); - @Rule public TemporaryFolder tempFolder = new TemporaryFolder(); @Mock private IdentityPoolCredentialSource mockCredentialSource; @Mock private CertificateConfig mockCertificateConfig; @@ -70,40 +72,33 @@ public class CertificateIdentityPoolSubjectTokenSupplierTest { private CertificateIdentityPoolSubjectTokenSupplier supplier; private static final Gson GSON = new Gson(); - // Certificate data from X509ProviderTest - private static final String TEST_CERT_PEM = - "-----BEGIN CERTIFICATE-----\n" - + "MIICGzCCAYSgAwIBAgIIWrt6xtmHPs4wDQYJKoZIhvcNAQEFBQAwMzExMC8GA1UE\n" - + "AxMoMTAwOTEyMDcyNjg3OC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbTAeFw0x\n" - + "MjEyMDExNjEwNDRaFw0yMjExMjkxNjEwNDRaMDMxMTAvBgNVBAMTKDEwMDkxMjA3\n" - + "MjY4NzguYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20wgZ8wDQYJKoZIhvcNAQEB\n" - + "BQADgY0AMIGJAoGBAL1SdY8jTUVU7O4/XrZLYTw0ON1lV6MQRGajFDFCqD2Fd9tQ\n" - + "GLW8Iftx9wfXe1zuaehJSgLcyCxazfyJoN3RiONBihBqWY6d3lQKqkgsRTNZkdFJ\n" - + "Wdzl/6CxhK9sojh2p0r3tydtv9iwq5fuuWIvtODtT98EgphhncQAqkKoF3zVAgMB\n" - + "AAGjODA2MAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBYGA1UdJQEB/wQM\n" - + "MAoGCCsGAQUFBwMCMA0GCSqGSIb3DQEBBQUAA4GBAD8XQEqzGePa9VrvtEGpf+R4\n" - + "fkxKbcYAzqYq202nKu0kfjhIYkYSBj6gi348YaxE64yu60TVl42l5HThmswUheW4\n" - + "uQIaq36JvwvsDP5Zoj5BgiNSnDAFQp+jJFBRUA5vooJKgKgMDf/r/DCOsbO6VJF1\n" - + "kWwa9n19NFiV0z3m6isj\n" - + "-----END CERTIFICATE-----\n"; - - private static final byte[] TEST_CERT_BYTES = TEST_CERT_PEM.getBytes(StandardCharsets.UTF_8); private static final byte[] INVALID_CERT_BYTES = "invalid certificate data".getBytes(StandardCharsets.UTF_8); + private byte[] testCertBytesFromFile; + @Before - public void setUp() throws IOException { - File testCertFile = tempFolder.newFile("certificate.pem"); - Files.write(testCertFile.toPath(), TEST_CERT_BYTES); - mockCredentialSource.certificateConfig = mockCertificateConfig; - mockCredentialSource.credentialLocation = testCertFile.getAbsolutePath(); + public void setUp() throws IOException, URISyntaxException { + ClassLoader classLoader = getClass().getClassLoader(); + URL leafCertUrl = classLoader.getResource("x509_leaf_certificate.pem"); + assertNotNull("Test leaf certificate file not found!", leafCertUrl); + File testCertFile = new File(leafCertUrl.getFile()); + + when(mockCertificateConfig.useDefaultCertificateConfig()).thenReturn(false); + when(mockCertificateConfig.getCertificateConfigLocation()) + .thenReturn(testCertFile.getAbsolutePath()); + + when(mockCredentialSource.getCertificateConfig()).thenReturn(mockCertificateConfig); + when(mockCredentialSource.getCredentialLocation()).thenReturn(testCertFile.getAbsolutePath()); + supplier = new CertificateIdentityPoolSubjectTokenSupplier(mockCredentialSource); + testCertBytesFromFile = Files.readAllBytes(Paths.get(leafCertUrl.toURI())); } @Test public void parseCertificate_validData_returnsCertificate() throws Exception { X509Certificate cert = - CertificateIdentityPoolSubjectTokenSupplier.parseCertificate(TEST_CERT_BYTES); + CertificateIdentityPoolSubjectTokenSupplier.parseCertificate(testCertBytesFromFile); assertNotNull(cert); } @@ -136,10 +131,10 @@ public void parseCertificate_invalidData_throwsCertificateException() { @Test public void getSubjectToken_success() throws Exception { - // Calculate expected result + // Calculate expected result based on the file content. CertificateFactory cf = CertificateFactory.getInstance("X.509"); X509Certificate expectedCert = - (X509Certificate) cf.generateCertificate(new ByteArrayInputStream(TEST_CERT_BYTES)); + (X509Certificate) cf.generateCertificate(new ByteArrayInputStream(testCertBytesFromFile)); String expectedEncodedDer = Base64.getEncoder().encodeToString(expectedCert.getEncoded()); JsonArray expectedJsonArray = new JsonArray(); expectedJsonArray.add(new JsonPrimitive(expectedEncodedDer)); From 15590543dcb2ae242c7a93fc613b0236c4083b6c Mon Sep 17 00:00:00 2001 From: nbayati <99771966+nbayati@users.noreply.github.com> Date: Thu, 1 May 2025 15:54:05 -0700 Subject: [PATCH 09/12] Update IdentityPoolCredentialsSourceTest to use getters. --- .../IdentityPoolCredentialsSourceTest.java | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsSourceTest.java b/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsSourceTest.java index 7b6b0af7e..2ee80c3d3 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsSourceTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsSourceTest.java @@ -56,10 +56,11 @@ public void constructor_certificateConfig() { new IdentityPoolCredentialSource(credentialSourceMap); assertEquals( IdentityPoolCredentialSourceType.CERTIFICATE, credentialSource.credentialSourceType); - assertNotNull(credentialSource.certificateConfig); - assertFalse(credentialSource.certificateConfig.useDefaultCertificateConfig()); + assertNotNull(credentialSource.getCertificateConfig()); + assertFalse(credentialSource.getCertificateConfig().useDefaultCertificateConfig()); assertEquals( - "/path/to/certificate", credentialSource.certificateConfig.getCertificateConfigLocation()); + "/path/to/certificate", + credentialSource.getCertificateConfig().getCertificateConfigLocation()); } @Test @@ -74,8 +75,8 @@ public void constructor_certificateConfig_useDefault() { new IdentityPoolCredentialSource(credentialSourceMap); assertEquals( IdentityPoolCredentialSourceType.CERTIFICATE, credentialSource.credentialSourceType); - assertNotNull(credentialSource.certificateConfig); - assertTrue(credentialSource.certificateConfig.useDefaultCertificateConfig()); + assertNotNull(credentialSource.getCertificateConfig()); + assertTrue(credentialSource.getCertificateConfig().useDefaultCertificateConfig()); } @Test @@ -128,8 +129,9 @@ public void constructor_certificateConfig_trustChainPath() { new IdentityPoolCredentialSource(credentialSourceMap); assertEquals( IdentityPoolCredentialSourceType.CERTIFICATE, credentialSource.credentialSourceType); - assertNotNull(credentialSource.certificateConfig); - assertEquals("path/to/trust/chain", credentialSource.certificateConfig.getTrustChainPath()); + assertNotNull(credentialSource.getCertificateConfig()); + assertEquals( + "path/to/trust/chain", credentialSource.getCertificateConfig().getTrustChainPath()); } @Test From c9a67cbb5db688ffe5b1ca88753ef8ad063192cc Mon Sep 17 00:00:00 2001 From: nbayati <99771966+nbayati@users.noreply.github.com> Date: Thu, 1 May 2025 16:48:05 -0700 Subject: [PATCH 10/12] chore: Enhance Javadoc, comments, and test robustness for certificate source. --- .../auth/mtls/MtlsHttpTransportFactory.java | 3 ++ .../com/google/auth/mtls/X509Provider.java | 5 +-- ...icateIdentityPoolSubjectTokenSupplier.java | 14 ++++++- ...eIdentityPoolSubjectTokenSupplierTest.java | 6 ++- .../oauth2/IdentityPoolCredentialsTest.java | 38 ++++++++++++++----- .../testresources/x509_leaf_certificate.pem | 19 ++++++++++ 6 files changed, 69 insertions(+), 16 deletions(-) create mode 100644 oauth2_http/testresources/x509_leaf_certificate.pem diff --git a/oauth2_http/java/com/google/auth/mtls/MtlsHttpTransportFactory.java b/oauth2_http/java/com/google/auth/mtls/MtlsHttpTransportFactory.java index 488fc5904..fe4c14209 100644 --- a/oauth2_http/java/com/google/auth/mtls/MtlsHttpTransportFactory.java +++ b/oauth2_http/java/com/google/auth/mtls/MtlsHttpTransportFactory.java @@ -41,6 +41,9 @@ * An HttpTransportFactory that creates {@link NetHttpTransport} instances configured for mTLS * (mutual TLS) using a specific {@link KeyStore} containing the client's certificate and private * key. + * + *

Warning: This class is considered internal and is not intended for direct use by + * library consumers. Its API and behavior may change without notice. */ public class MtlsHttpTransportFactory implements HttpTransportFactory { private final KeyStore mtlsKeyStore; diff --git a/oauth2_http/java/com/google/auth/mtls/X509Provider.java b/oauth2_http/java/com/google/auth/mtls/X509Provider.java index c4af248b2..bbece4838 100644 --- a/oauth2_http/java/com/google/auth/mtls/X509Provider.java +++ b/oauth2_http/java/com/google/auth/mtls/X509Provider.java @@ -122,15 +122,14 @@ public KeyStore getKeyStore() throws IOException { if (loadedConfig == null) { loadedConfig = getWorkloadCertificateConfiguration(); } - WorkloadCertificateConfiguration workloadCertConfig = loadedConfig; InputStream certStream = null; InputStream privateKeyStream = null; SequenceInputStream certAndPrivateKeyStream = null; try { // Read the certificate and private key file paths into separate streams. - File certFile = new File(workloadCertConfig.getCertPath()); - File privateKeyFile = new File(workloadCertConfig.getPrivateKeyPath()); + File certFile = new File(loadedConfig.getCertPath()); + File privateKeyFile = new File(loadedConfig.getPrivateKeyPath()); certStream = createInputStream(certFile); privateKeyStream = createInputStream(privateKeyFile); diff --git a/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java b/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java index 2517cb553..e65fbbfed 100644 --- a/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java +++ b/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java @@ -79,7 +79,8 @@ private static X509Certificate loadLeafCertificate(String path) @VisibleForTesting static X509Certificate parseCertificate(byte[] certData) throws CertificateException { if (certData == null || certData.length == 0) { - throw new IllegalArgumentException("Invalid certificate data: empty or null input"); + throw new IllegalArgumentException( + "Invalid certificate data: Certificate file is empty or null."); } try { @@ -98,6 +99,17 @@ private static String encodeCert(X509Certificate certificate) return Base64.getEncoder().encodeToString(certificate.getEncoded()); } + /** + * Retrieves the X509 subject token. This method loads the leaf certificate specified by the + * {@code credentialSource.credentialLocation}. The subject token is constructed as a JSON array + * containing the base64-encoded (DER format) leaf certificate. This JSON array serves as the + * subject token for mTLS authentication. + * + * @param context The external account supplier context. This parameter is currently not used in + * this implementation. + * @return The JSON string representation of the base64-encoded leaf certificate in a JSON array. + * @throws IOException If an I/O error occurs while reading the certificate file. + */ @Override public String getSubjectToken(ExternalAccountSupplierContext context) throws IOException { try { diff --git a/oauth2_http/javatests/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplierTest.java b/oauth2_http/javatests/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplierTest.java index 57272db81..b38e88198 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplierTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplierTest.java @@ -108,7 +108,8 @@ public void parseCertificate_emptyData_throwsIllegalArgumentException() { assertThrows( IllegalArgumentException.class, () -> CertificateIdentityPoolSubjectTokenSupplier.parseCertificate(new byte[0])); - assertEquals("Invalid certificate data: empty or null input", exception.getMessage()); + assertEquals( + "Invalid certificate data: Certificate file is empty or null.", exception.getMessage()); } @Test @@ -117,7 +118,8 @@ public void parseCertificate_nullData_throwsIllegalArgumentException() { assertThrows( IllegalArgumentException.class, () -> CertificateIdentityPoolSubjectTokenSupplier.parseCertificate(null)); - assertEquals("Invalid certificate data: empty or null input", exception.getMessage()); + assertEquals( + "Invalid certificate data: Certificate file is empty or null.", exception.getMessage()); } @Test diff --git a/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java index 27599266d..0b9788e61 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java @@ -1112,7 +1112,8 @@ public void build_withDefaultCertificate_throwsOnTransportInitFailure() { @Test public void build_withCustomProvider_throwsOnGetKeyStore() throws IOException, KeyStoreException, CertificateException, NoSuchAlgorithmException { - // Setup a custom provider configured to throw during getKeyStore(). + // Simulate a scenario where the X509Provider fails to load the KeyStore, typically due to an + // IOException when reading the certificate or private key files. KeyStore keyStore = KeyStore.getInstance("JKS"); keyStore.load(null, null); TestX509Provider x509Provider = new TestX509Provider(keyStore, "/path/to/certificate.json"); @@ -1121,19 +1122,28 @@ public void build_withCustomProvider_throwsOnGetKeyStore() Map certificateMap = new HashMap<>(); certificateMap.put("certificate_config_location", "/path/to/certificate.json"); - // Expect RuntimeException from the custom provider during build. + // Expect RuntimeException because the constructor wraps the IOException. RuntimeException exception = assertThrows( RuntimeException.class, () -> createCredentialsWithCertificate(x509Provider, certificateMap)); - assertEquals("Exception on get keystore", exception.getMessage()); + // Verify the cause is the expected IOException from the mock. + assertNotNull(exception.getCause()); + assertTrue(exception.getCause() instanceof IOException); + assertEquals("Simulated IOException on get keystore", exception.getCause().getMessage()); + + // Verify the wrapper exception message + assertEquals( + "Failed to initialize IdentityPoolCredentials from certificate source due to an I/O error.", + exception.getMessage()); } @Test public void build_withCustomProvider_throwsOnGetCertificatePath() throws IOException, KeyStoreException, CertificateException, NoSuchAlgorithmException { - // Setup a custom provider configured to throw during getCertificatePath(). + // Simulate a scenario where the X509Provider cannot access or read the certificate + // configuration file needed to determine the certificate path, resulting in an IOException. KeyStore keyStore = KeyStore.getInstance("JKS"); keyStore.load(null, null); TestX509Provider x509Provider = new TestX509Provider(keyStore, "/path/to/certificate.json"); @@ -1142,13 +1152,21 @@ public void build_withCustomProvider_throwsOnGetCertificatePath() Map certificateMap = new HashMap<>(); certificateMap.put("certificate_config_location", "/path/to/certificate.json"); - // Expect RuntimeException from the custom provider during build. + // Expect RuntimeException because the constructor wraps the IOException. RuntimeException exception = assertThrows( RuntimeException.class, () -> createCredentialsWithCertificate(x509Provider, certificateMap)); - assertEquals("Exception on get certificate path", exception.getMessage()); + // Verify the cause is the expected IOException from the mock. + assertNotNull(exception.getCause()); + assertTrue(exception.getCause() instanceof IOException); + assertEquals("Simulated IOException on certificate path", exception.getCause().getMessage()); + + // Verify the wrapper exception message + assertEquals( + "Failed to initialize IdentityPoolCredentials from certificate source due to an I/O error.", + exception.getMessage()); } private void createCredentialsWithCertificate( @@ -1261,17 +1279,17 @@ private static class TestX509Provider extends X509Provider { } @Override - public KeyStore getKeyStore() { + public KeyStore getKeyStore() throws IOException { if (shouldThrowOnGetKeyStore) { - throw new RuntimeException("Exception on get keystore"); + throw new IOException("Simulated IOException on get keystore"); } return keyStore; } @Override - public String getCertificatePath() { + public String getCertificatePath() throws IOException { if (shouldThrowOnGetCertificatePath) { - throw new RuntimeException("Exception on get certificate path"); + throw new IOException("Simulated IOException on certificate path"); } return certificatePath; } diff --git a/oauth2_http/testresources/x509_leaf_certificate.pem b/oauth2_http/testresources/x509_leaf_certificate.pem new file mode 100644 index 000000000..4219c2971 --- /dev/null +++ b/oauth2_http/testresources/x509_leaf_certificate.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDIzCCAgugAwIBAgIJAMfISuBQ5m+5MA0GCSqGSIb3DQEBBQUAMBUxEzARBgNV +BAMTCnVuaXQtdGVzdHMwHhcNMTExMjA2MTYyNjAyWhcNMjExMjAzMTYyNjAyWjAV +MRMwEQYDVQQDEwp1bml0LXRlc3RzMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEA4ej0p7bQ7L/r4rVGUz9RN4VQWoej1Bg1mYWIDYslvKrk1gpj7wZgkdmM +7oVK2OfgrSj/FCTkInKPqaCR0gD7K80q+mLBrN3PUkDrJQZpvRZIff3/xmVU1Wer +uQLFJjnFb2dqu0s/FY/2kWiJtBCakXvXEOb7zfbINuayL+MSsCGSdVYsSliS5qQp +gyDap+8b5fpXZVJkq92hrcNtbkg7hCYUJczt8n9hcCTJCfUpApvaFQ18pe+zpyl4 ++WzkP66I28hniMQyUlA1hBiskT7qiouq0m8IOodhv2fagSZKjOTTU2xkSBc//fy3 +ZpsL7WqgsZS7Q+0VRK8gKfqkxg5OYQIDAQABo3YwdDAdBgNVHQ4EFgQU2RQ8yO+O +gN8oVW2SW7RLrfYd9jEwRQYDVR0jBD4wPIAU2RQ8yO+OgN8oVW2SW7RLrfYd9jGh +GaQXMBUxEzARBgNVBAMTCnVuaXQtdGVzdHOCCQDHyErgUOZvuTAMBgNVHRMEBTAD +AQH/MA0GCSqGSIb3DQEBBQUAA4IBAQBRv+M/6+FiVu7KXNjFI5pSN17OcW5QUtPr +odJMlWrJBtynn/TA1oJlYu3yV5clc/71Vr/AxuX5xGP+IXL32YDF9lTUJXG/uUGk ++JETpKmQviPbRsvzYhz4pf6ZIOZMc3/GIcNq92ECbseGO+yAgyWUVKMmZM0HqXC9 +ovNslqe0M8C1sLm1zAR5z/h/litE7/8O2ietija3Q/qtl2TOXJdCA6sgjJX2WUql +ybrC55ct18NKf3qhpcEkGQvFU40rVYApJpi98DiZPYFdx1oBDp/f4uZ3ojpxRVFT +cDwcJLfNRCPUhormsY7fDS9xSyThiHsW9mjJYdcaKQkwYZ0F11yB +-----END CERTIFICATE----- \ No newline at end of file From e6a32c91ec1253cdacb87d2b11b9c865682a3195 Mon Sep 17 00:00:00 2001 From: nbayati <99771966+nbayati@users.noreply.github.com> Date: Mon, 5 May 2025 13:46:44 -0700 Subject: [PATCH 11/12] Remove certificate caching from x509 provider. --- .../com/google/auth/mtls/X509Provider.java | 17 ++------- ...icateIdentityPoolSubjectTokenSupplier.java | 18 ++++----- .../FileIdentityPoolSubjectTokenSupplier.java | 4 +- .../auth/oauth2/IdentityPoolCredentials.java | 38 +++++++++---------- .../IdentityPoolSubjectTokenSupplier.java | 4 +- .../UrlIdentityPoolSubjectTokenSupplier.java | 4 +- .../oauth2/IdentityPoolCredentialsTest.java | 3 +- 7 files changed, 38 insertions(+), 50 deletions(-) diff --git a/oauth2_http/java/com/google/auth/mtls/X509Provider.java b/oauth2_http/java/com/google/auth/mtls/X509Provider.java index bbece4838..cb08c2229 100644 --- a/oauth2_http/java/com/google/auth/mtls/X509Provider.java +++ b/oauth2_http/java/com/google/auth/mtls/X509Provider.java @@ -52,7 +52,6 @@ public class X509Provider { static final String CERTIFICATE_CONFIGURATION_ENV_VARIABLE = "GOOGLE_API_CERTIFICATE_CONFIG"; static final String WELL_KNOWN_CERTIFICATE_CONFIG_FILE = "certificate_config.json"; static final String CLOUDSDK_CONFIG_DIRECTORY = "gcloud"; - private WorkloadCertificateConfiguration loadedConfig; private final String certConfigPathOverride; @@ -90,12 +89,7 @@ public X509Provider() { * @throws CertificateSourceUnavailableException if the configuration file is not found. */ public String getCertificatePath() throws IOException { - if (loadedConfig == null) { - // Attempt to load the configuration. This call might throw IOException or - // CertificateSourceUnavailableException if loading fails. - loadedConfig = getWorkloadCertificateConfiguration(); - } - String certPath = loadedConfig.getCertPath(); + String certPath = getWorkloadCertificateConfiguration().getCertPath(); if (Strings.isNullOrEmpty(certPath)) { // Ensure the loaded configuration actually contains the required path. throw new CertificateSourceUnavailableException( @@ -119,17 +113,14 @@ public String getCertificatePath() throws IOException { * @throws IOException if there is an error retrieving the certificate configuration. */ public KeyStore getKeyStore() throws IOException { - if (loadedConfig == null) { - loadedConfig = getWorkloadCertificateConfiguration(); - } - + WorkloadCertificateConfiguration workloadCertConfig = getWorkloadCertificateConfiguration(); InputStream certStream = null; InputStream privateKeyStream = null; SequenceInputStream certAndPrivateKeyStream = null; try { // Read the certificate and private key file paths into separate streams. - File certFile = new File(loadedConfig.getCertPath()); - File privateKeyFile = new File(loadedConfig.getPrivateKeyPath()); + File certFile = new File(workloadCertConfig.getCertPath()); + File privateKeyFile = new File(workloadCertConfig.getPrivateKeyPath()); certStream = createInputStream(certFile); privateKeyStream = createInputStream(privateKeyFile); diff --git a/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java b/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java index e65fbbfed..4a33a6479 100644 --- a/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java +++ b/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java @@ -34,9 +34,6 @@ import static com.google.common.base.Preconditions.checkNotNull; import com.google.common.annotations.VisibleForTesting; -import com.google.gson.Gson; -import com.google.gson.JsonArray; -import com.google.gson.JsonPrimitive; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; @@ -49,15 +46,14 @@ import java.util.Base64; /** - * Provider for retrieving subject tokens for {@link IdentityPoolCredentials} by reading an X.509 - * certificate from the filesystem. The certificate file (e.g., PEM or DER encoded) is read, the - * leaf certificate is base64-encoded (DER format), wrapped in a JSON array, and used as the subject - * token for STS exchange. + * Provider for retrieving the subject tokens for {@link IdentityPoolCredentials} by reading an + * X.509 certificate from the filesystem. The certificate file (e.g., PEM or DER encoded) is read, + * the leaf certificate is base64-encoded (DER format), wrapped in a JSON array, and used as the + * subject token for STS exchange. */ public class CertificateIdentityPoolSubjectTokenSupplier implements IdentityPoolSubjectTokenSupplier { - private static final Gson GSON = new Gson(); private final IdentityPoolCredentialSource credentialSource; CertificateIdentityPoolSubjectTokenSupplier(IdentityPoolCredentialSource credentialSource) { @@ -118,10 +114,10 @@ public String getSubjectToken(ExternalAccountSupplierContext context) throws IOE X509Certificate leafCert = loadLeafCertificate(credentialSource.getCredentialLocation()); String encodedLeafCert = encodeCert(leafCert); - JsonArray certChain = new JsonArray(); - certChain.add(new JsonPrimitive(encodedLeafCert)); + java.util.List certChain = new java.util.ArrayList<>(); + certChain.add(encodedLeafCert); - return GSON.toJson(certChain); + return OAuth2Utils.JSON_FACTORY.toString(certChain); } catch (CertificateException e) { // Catch CertificateException to provide a more specific error message including // the path of the file that failed to parse, and re-throw as IOException diff --git a/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplier.java b/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplier.java index cf0b7d242..a939507d4 100644 --- a/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplier.java +++ b/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplier.java @@ -46,8 +46,8 @@ import java.nio.file.Paths; /** - * Internal provider for retrieving subject tokens for {@link IdentityPoolCredentials} to exchange - * for GCP access tokens via a local file. + * Internal provider for retrieving the subject tokens for {@link IdentityPoolCredentials} to + * exchange for GCP access tokens via a local file. */ class FileIdentityPoolSubjectTokenSupplier implements IdentityPoolSubjectTokenSupplier { diff --git a/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java b/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java index 204725012..ada5b765e 100644 --- a/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java @@ -93,7 +93,16 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { this.metricsHeaderValue = URL_METRICS_HEADER_VALUE; } else if (credentialSource.credentialSourceType == IdentityPoolCredentialSourceType.CERTIFICATE) { - this.subjectTokenSupplier = createCertificateSubjectTokenSupplier(builder, credentialSource); + try { + this.subjectTokenSupplier = + createCertificateSubjectTokenSupplier(builder, credentialSource); + } catch (IOException e) { + throw new RuntimeException( + // Wrap IOException in RuntimeException because constructors cannot throw checked + // exceptions. + "Failed to initialize IdentityPoolCredentials from certificate source due to an I/O error.", + e); + } this.metricsHeaderValue = CERTIFICATE_METRICS_HEADER_VALUE; } else { throw new IllegalArgumentException("Source type not supported."); @@ -145,24 +154,15 @@ public static Builder newBuilder(IdentityPoolCredentials identityPoolCredentials } private IdentityPoolSubjectTokenSupplier createCertificateSubjectTokenSupplier( - Builder builder, IdentityPoolCredentialSource credentialSource) { - try { - // Configure the mTLS transport with the x509 keystore. - X509Provider x509Provider = getX509Provider(builder, credentialSource); - KeyStore mtlsKeyStore = x509Provider.getKeyStore(); - this.transportFactory = new MtlsHttpTransportFactory(mtlsKeyStore); - - // Initialize the subject token supplier with the certificate path. - credentialSource.setCredentialLocation(x509Provider.getCertificatePath()); - return new CertificateIdentityPoolSubjectTokenSupplier(credentialSource); - - } catch (IOException e) { - throw new RuntimeException( - // Wrap IOException in RuntimeException because constructors cannot throw checked - // exceptions. - "Failed to initialize IdentityPoolCredentials from certificate source due to an I/O error.", - e); - } + Builder builder, IdentityPoolCredentialSource credentialSource) throws IOException { + // Configure the mTLS transport with the x509 keystore. + X509Provider x509Provider = getX509Provider(builder, credentialSource); + KeyStore mtlsKeyStore = x509Provider.getKeyStore(); + this.transportFactory = new MtlsHttpTransportFactory(mtlsKeyStore); + + // Initialize the subject token supplier with the certificate path. + credentialSource.setCredentialLocation(x509Provider.getCertificatePath()); + return new CertificateIdentityPoolSubjectTokenSupplier(credentialSource); } private X509Provider getX509Provider( diff --git a/oauth2_http/java/com/google/auth/oauth2/IdentityPoolSubjectTokenSupplier.java b/oauth2_http/java/com/google/auth/oauth2/IdentityPoolSubjectTokenSupplier.java index 2e2920c1a..01477f8bb 100644 --- a/oauth2_http/java/com/google/auth/oauth2/IdentityPoolSubjectTokenSupplier.java +++ b/oauth2_http/java/com/google/auth/oauth2/IdentityPoolSubjectTokenSupplier.java @@ -36,8 +36,8 @@ @FunctionalInterface /** - * Provider for retrieving subject tokens for {@Link IdentityPoolCredentials} to exchange for GCP - * access tokens. + * Provider for retrieving the subject tokens for {@Link IdentityPoolCredentials} to exchange for + * GCP access tokens. */ public interface IdentityPoolSubjectTokenSupplier extends Serializable { diff --git a/oauth2_http/java/com/google/auth/oauth2/UrlIdentityPoolSubjectTokenSupplier.java b/oauth2_http/java/com/google/auth/oauth2/UrlIdentityPoolSubjectTokenSupplier.java index af135a01c..3df49ee49 100644 --- a/oauth2_http/java/com/google/auth/oauth2/UrlIdentityPoolSubjectTokenSupplier.java +++ b/oauth2_http/java/com/google/auth/oauth2/UrlIdentityPoolSubjectTokenSupplier.java @@ -42,8 +42,8 @@ import java.io.IOException; /** - * Provider for retrieving subject tokens for {@link IdentityPoolCredentials} to exchange for GCP - * access tokens. The subject token is retrieved by calling a URL that returns the token. + * Provider for retrieving the subject tokens for {@link IdentityPoolCredentials} to exchange for + * GCP access tokens. The subject token is retrieved by calling a URL that returns the token. */ class UrlIdentityPoolSubjectTokenSupplier implements IdentityPoolSubjectTokenSupplier { diff --git a/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java index 0b9788e61..9dfa69c1a 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java @@ -1088,7 +1088,8 @@ public void build_withCertificateSourceAndCustomX509Provider_success() public void build_withDefaultCertificate_throwsOnTransportInitFailure() { // Setup credential source to use default certificate config. Map certificateMap = new HashMap<>(); - certificateMap.put("use_default_certificate_config", true); + certificateMap.put("use_default_certificate_config", false); + certificateMap.put("certificate_config_location", "/non/existing/path/to/certificate.json"); Map credentialSourceMap = new HashMap<>(); credentialSourceMap.put("certificate", certificateMap); IdentityPoolCredentialSource credentialSource = From c963ac339ad12ac781c0280bc45182a39d6caa27 Mon Sep 17 00:00:00 2001 From: nbayati <99771966+nbayati@users.noreply.github.com> Date: Mon, 5 May 2025 15:06:23 -0700 Subject: [PATCH 12/12] Use OAuth2Utils.JSON_FACTORY instead of GSON. --- ...rtificateIdentityPoolSubjectTokenSupplierTest.java | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/oauth2_http/javatests/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplierTest.java b/oauth2_http/javatests/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplierTest.java index b38e88198..18856d23b 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplierTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplierTest.java @@ -35,9 +35,6 @@ import static org.mockito.Mockito.when; import com.google.auth.oauth2.IdentityPoolCredentialSource.CertificateConfig; -import com.google.gson.Gson; -import com.google.gson.JsonArray; -import com.google.gson.JsonPrimitive; import java.io.ByteArrayInputStream; import java.io.File; import java.io.IOException; @@ -70,7 +67,6 @@ public class CertificateIdentityPoolSubjectTokenSupplierTest { @Mock private ExternalAccountSupplierContext mockContext; private CertificateIdentityPoolSubjectTokenSupplier supplier; - private static final Gson GSON = new Gson(); private static final byte[] INVALID_CERT_BYTES = "invalid certificate data".getBytes(StandardCharsets.UTF_8); @@ -137,10 +133,9 @@ public void getSubjectToken_success() throws Exception { CertificateFactory cf = CertificateFactory.getInstance("X.509"); X509Certificate expectedCert = (X509Certificate) cf.generateCertificate(new ByteArrayInputStream(testCertBytesFromFile)); - String expectedEncodedDer = Base64.getEncoder().encodeToString(expectedCert.getEncoded()); - JsonArray expectedJsonArray = new JsonArray(); - expectedJsonArray.add(new JsonPrimitive(expectedEncodedDer)); - String expectedSubjectToken = GSON.toJson(expectedJsonArray); + String expectedEncodedLeaf = Base64.getEncoder().encodeToString(expectedCert.getEncoded()); + String[] expectedCertChainArray = new String[] {expectedEncodedLeaf}; + String expectedSubjectToken = OAuth2Utils.JSON_FACTORY.toString(expectedCertChainArray); // Execute String actualSubjectToken = supplier.getSubjectToken(mockContext);