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..fe4c14209 --- /dev/null +++ b/oauth2_http/java/com/google/auth/mtls/MtlsHttpTransportFactory.java @@ -0,0 +1,73 @@ +/* + * 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. + * + *

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; + + /** + * 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/mtls/X509Provider.java b/oauth2_http/java/com/google/auth/mtls/X509Provider.java index 704f85bdd..cb08c2229 100644 --- a/oauth2_http/java/com/google/auth/mtls/X509Provider.java +++ b/oauth2_http/java/com/google/auth/mtls/X509Provider.java @@ -53,7 +53,7 @@ public class X509Provider { static final String WELL_KNOWN_CERTIFICATE_CONFIG_FILE = "certificate_config.json"; static final String CLOUDSDK_CONFIG_DIRECTORY = "gcloud"; - private String certConfigPathOverride; + private final String certConfigPathOverride; /** * Creates an X509 provider with an override path for the certificate configuration, bypassing the @@ -75,6 +75,29 @@ 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 { + String certPath = getWorkloadCertificateConfiguration().getCertPath(); + if (Strings.isNullOrEmpty(certPath)) { + // Ensure the loaded configuration actually contains the required path. + throw new CertificateSourceUnavailableException( + "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,9 +113,7 @@ public X509Provider() { * @throws IOException if there is an error retrieving the certificate configuration. */ public KeyStore getKeyStore() throws IOException { - WorkloadCertificateConfiguration workloadCertConfig = getWorkloadCertificateConfiguration(); - InputStream certStream = null; InputStream privateKeyStream = null; SequenceInputStream certAndPrivateKeyStream = 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..4a33a6479 --- /dev/null +++ b/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java @@ -0,0 +1,129 @@ +/* + * 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.common.annotations.VisibleForTesting; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +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 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 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.getCertificateConfig(), + "credentialSource.certificateConfig cannot be null when creating" + + " CertificateIdentityPoolSubjectTokenSupplier"); + } + + private static X509Certificate loadLeafCertificate(String path) + throws IOException, CertificateException { + byte[] leafCertBytes = Files.readAllBytes(Paths.get(path)); + return parseCertificate(leafCertBytes); + } + + @VisibleForTesting + static X509Certificate parseCertificate(byte[] certData) throws CertificateException { + if (certData == null || certData.length == 0) { + throw new IllegalArgumentException( + "Invalid certificate data: Certificate file is empty or null."); + } + + try { + CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509"); + 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); + } + } + + private static String encodeCert(X509Certificate certificate) + throws CertificateEncodingException { + 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 { + // credentialSource.credentialLocation is expected to be non-null here, + // set during IdentityPoolCredentials construction for certificate type. + X509Certificate leafCert = loadLeafCertificate(credentialSource.getCredentialLocation()); + String encodedLeafCert = encodeCert(leafCert); + + java.util.List certChain = new java.util.ArrayList<>(); + certChain.add(encodedLeafCert); + + 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 + // as expected by the getSubjectToken method signature for I/O related issues. + throw new IOException( + "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..a939507d4 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; @@ -47,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 { @@ -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 6fa9e6f41..4ab63b8ed 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; @@ -45,9 +47,193 @@ 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 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. + * + * @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 certificateConfigFromSourceMap( + 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), + "Invalid 'certificate' configuration in credential source: Must specify either 'certificate_config_location' or set 'use_default_certificate_config' to true."); + + checkArgument( + !(useDefault && locationIsPresent), + "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; + 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,20 +255,29 @@ 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")) { + 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 (certificatePresent) { + credentialSourceType = IdentityPoolCredentialSourceType.CERTIFICATE; + this.certificateConfig = certificateConfigFromSourceMap(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"); @@ -121,7 +316,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..ada5b765e 100644 --- a/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java @@ -32,9 +32,13 @@ package com.google.auth.oauth2; 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.KeyStore; import java.util.ArrayList; import java.util.Collection; import java.util.Map; @@ -48,6 +52,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"; + private static final long serialVersionUID = 2471046175477275881L; private final IdentityPoolSubjectTokenSupplier subjectTokenSupplier; private final ExternalAccountSupplierContext supplierContext; @@ -63,6 +69,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 +79,33 @@ 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) { + 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."); } } @@ -119,8 +142,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() { @@ -131,9 +153,40 @@ public static Builder newBuilder(IdentityPoolCredentials identityPoolCredentials return new Builder(identityPoolCredentials); } + private IdentityPoolSubjectTokenSupplier createCertificateSubjectTokenSupplier( + 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( + Builder builder, IdentityPoolCredentialSource credentialSource) { + final IdentityPoolCredentialSource.CertificateConfig certConfig = + credentialSource.getCertificateConfig(); + + // 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; + private X509Provider x509Provider; Builder() {} @@ -144,6 +197,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 + 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/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 788911a6c..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 { @@ -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 new file mode 100644 index 000000000..18856d23b --- /dev/null +++ b/oauth2_http/javatests/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplierTest.java @@ -0,0 +1,146 @@ +/* + * 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 static org.mockito.Mockito.when; + +import com.google.auth.oauth2.IdentityPoolCredentialSource.CertificateConfig; +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; +import java.util.Base64; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +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(); + + @Mock private IdentityPoolCredentialSource mockCredentialSource; + @Mock private CertificateConfig mockCertificateConfig; + @Mock private ExternalAccountSupplierContext mockContext; + + private CertificateIdentityPoolSubjectTokenSupplier supplier; + + private static final byte[] INVALID_CERT_BYTES = + "invalid certificate data".getBytes(StandardCharsets.UTF_8); + + private byte[] testCertBytesFromFile; + + @Before + 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(testCertBytesFromFile); + assertNotNull(cert); + } + + @Test + public void parseCertificate_emptyData_throwsIllegalArgumentException() { + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> CertificateIdentityPoolSubjectTokenSupplier.parseCertificate(new byte[0])); + assertEquals( + "Invalid certificate data: Certificate file is empty or null.", exception.getMessage()); + } + + @Test + public void parseCertificate_nullData_throwsIllegalArgumentException() { + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> CertificateIdentityPoolSubjectTokenSupplier.parseCertificate(null)); + assertEquals( + "Invalid certificate data: Certificate file is empty or null.", 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 based on the file content. + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + X509Certificate expectedCert = + (X509Certificate) cf.generateCertificate(new ByteArrayInputStream(testCertBytesFromFile)); + 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); + + // Verify + assertEquals(expectedSubjectToken, actualSubjectToken); + } +} 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..2ee80c3d3 --- /dev/null +++ b/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsSourceTest.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.IdentityPoolCredentialSourceType; +import java.util.HashMap; +import java.util.Map; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** 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.getCertificateConfig()); + assertFalse(credentialSource.getCertificateConfig().useDefaultCertificateConfig()); + assertEquals( + "/path/to/certificate", + credentialSource.getCertificateConfig().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.getCertificateConfig()); + assertTrue(credentialSource.getCertificateConfig().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)); + 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() { + 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)); + + 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() { + 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.getCertificateConfig()); + assertEquals( + "path/to/trust/chain", credentialSource.getCertificateConfig().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)); + + 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..9dfa69c1a 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,154 @@ 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", false); + certificateMap.put("certificate_config_location", "/non/existing/path/to/certificate.json"); + 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 IdentityPoolCredentials from certificate source due to an I/O error.", + exception.getMessage()); + } + + @Test + public void build_withCustomProvider_throwsOnGetKeyStore() + throws IOException, KeyStoreException, CertificateException, NoSuchAlgorithmException { + // 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"); + x509Provider.setShouldThrowOnGetKeyStore(true); // Configure to throw + + Map certificateMap = new HashMap<>(); + certificateMap.put("certificate_config_location", "/path/to/certificate.json"); + + // Expect RuntimeException because the constructor wraps the IOException. + RuntimeException exception = + assertThrows( + RuntimeException.class, + () -> createCredentialsWithCertificate(x509Provider, certificateMap)); + + // 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 { + // 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"); + x509Provider.setShouldThrowOnGetCertificatePath(true); // Configure to throw + + Map certificateMap = new HashMap<>(); + certificateMap.put("certificate_config_location", "/path/to/certificate.json"); + + // Expect RuntimeException because the constructor wraps the IOException. + RuntimeException exception = + assertThrows( + RuntimeException.class, + () -> createCredentialsWithCertificate(x509Provider, certificateMap)); + + // 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( + 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 +1232,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() throws IOException { + if (shouldThrowOnGetKeyStore) { + throw new IOException("Simulated IOException on get keystore"); + } + return keyStore; + } + + @Override + public String getCertificatePath() throws IOException { + if (shouldThrowOnGetCertificatePath) { + throw new IOException("Simulated IOException on certificate path"); + } + return certificatePath; + } + + void setShouldThrowOnGetKeyStore(boolean shouldThrow) { + this.shouldThrowOnGetKeyStore = shouldThrow; + } + + void setShouldThrowOnGetCertificatePath(boolean shouldThrow) { + this.shouldThrowOnGetCertificatePath = shouldThrow; + } + } } 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