Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
*
* <p><b>Warning:</b> 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);
}
}
}
27 changes: 24 additions & 3 deletions oauth2_http/java/com/google/auth/mtls/X509Provider.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -75,6 +75,29 @@ public X509Provider() {
this(null);
}

/**
* Returns the path to the client certificate file specified by the loaded workload certificate
* configuration.
*
* <p>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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So we previously envisioned X509Provider will implement an "MtlsProvider" interface with a single "getKeyStore()" public method exposed. (SecureConnectProvider will also implement the same interface) As such, it doesn't feel like the getCertificatePath() helper should live inside the X509Provider. I don't see a lot of value with caching the cert config loading (this pattern is not common in client libraries IMO given the low anticipated QPS for auth or setting up an mTLS transport) Furthermore, having 2 separate public methods sharing this internal state could make code execution unpredictable - what if the underlying cert config changed between the call between getCertificatePath and getKeyStore()? And getKeyStore() may be stuck with an outdated loadedConfig in memory as long is the object instance is alive with no way to refresh the internal state. We have to handle cert rotation in the future as well. IMO, it's much safer to call getWorkloadCertificateConfiguration on demand instead of attempting to cache the result. Rest of your PR looks good!

Copy link
Contributor Author

@nbayati nbayati May 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense. I've removed the caching, but I realized that if I want to move getCertificatePath() out of X509Provider, I'd need to move getWorkloadCertificateConfiguration() as well. I've created an issue to refactor the class in a separate PR.

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
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> 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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {

Expand All @@ -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);
Expand Down
Loading
Loading