Skip to content

Commit b347603

Browse files
authored
feat: Add support for mTLS authentication via X.509 certificates (#1736)
* 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. * Add unit tests for the CertificateIdentityPoolSubjectTokenSupplier class. * Refactor: Enhance error handling and readability per review comments * Add unit tests for IdentityPoolCredentialsSource. * Add unit tests for IdentityPoolCredentials. * Created MtlsHttpTransportFactory class, and added helper methods for IdentityPoolCredentials. * Improves readability: Use boolean flags for source type check * Refactor: make fields private in IdentityPoolCredentialSource and move the certificate content to a file instead of local variable. * Update IdentityPoolCredentialsSourceTest to use getters. * chore: Enhance Javadoc, comments, and test robustness for certificate source. * Remove certificate caching from x509 provider. * Use OAuth2Utils.JSON_FACTORY instead of GSON.
1 parent 7d86aa0 commit b347603

12 files changed

+1092
-92
lines changed
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/*
2+
* Copyright 2025, Google Inc. All rights reserved.
3+
*
4+
* Redistribution and use in source and binary forms, with or without
5+
* modification, are permitted provided that the following conditions are
6+
* met:
7+
*
8+
* * Redistributions of source code must retain the above copyright
9+
* notice, this list of conditions and the following disclaimer.
10+
* * Redistributions in binary form must reproduce the above
11+
* copyright notice, this list of conditions and the following disclaimer
12+
* in the documentation and/or other materials provided with the
13+
* distribution.
14+
*
15+
* * Neither the name of Google Inc. nor the names of its
16+
* contributors may be used to endorse or promote products derived from
17+
* this software without specific prior written permission.
18+
*
19+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
20+
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
21+
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
22+
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
23+
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
24+
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
25+
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
26+
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
27+
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
28+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29+
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30+
*/
31+
32+
package com.google.auth.mtls;
33+
34+
import com.google.api.client.http.javanet.NetHttpTransport;
35+
import com.google.auth.http.HttpTransportFactory;
36+
import java.security.GeneralSecurityException;
37+
import java.security.KeyStore;
38+
import java.util.Objects;
39+
40+
/**
41+
* An HttpTransportFactory that creates {@link NetHttpTransport} instances configured for mTLS
42+
* (mutual TLS) using a specific {@link KeyStore} containing the client's certificate and private
43+
* key.
44+
*
45+
* <p><b>Warning:</b> This class is considered internal and is not intended for direct use by
46+
* library consumers. Its API and behavior may change without notice.
47+
*/
48+
public class MtlsHttpTransportFactory implements HttpTransportFactory {
49+
private final KeyStore mtlsKeyStore;
50+
51+
/**
52+
* Constructs a factory for mTLS transports.
53+
*
54+
* @param mtlsKeyStore The {@link KeyStore} containing the client's X509 certificate and private
55+
* key. This {@link KeyStore} is used for client authentication during the TLS handshake. Must
56+
* not be null.
57+
*/
58+
public MtlsHttpTransportFactory(KeyStore mtlsKeyStore) {
59+
this.mtlsKeyStore = Objects.requireNonNull(mtlsKeyStore, "mtlsKeyStore cannot be null");
60+
}
61+
62+
@Override
63+
public NetHttpTransport create() {
64+
try {
65+
// Build the mTLS transport using the provided KeyStore.
66+
return new NetHttpTransport.Builder().trustCertificates(null, mtlsKeyStore, "").build();
67+
} catch (GeneralSecurityException e) {
68+
// Wrap the checked exception in a RuntimeException because the HttpTransportFactory
69+
// interface's create() method doesn't allow throwing checked exceptions.
70+
throw new RuntimeException("Failed to initialize mTLS transport.", e);
71+
}
72+
}
73+
}

oauth2_http/java/com/google/auth/mtls/X509Provider.java

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ public class X509Provider {
5353
static final String WELL_KNOWN_CERTIFICATE_CONFIG_FILE = "certificate_config.json";
5454
static final String CLOUDSDK_CONFIG_DIRECTORY = "gcloud";
5555

56-
private String certConfigPathOverride;
56+
private final String certConfigPathOverride;
5757

5858
/**
5959
* Creates an X509 provider with an override path for the certificate configuration, bypassing the
@@ -75,6 +75,29 @@ public X509Provider() {
7575
this(null);
7676
}
7777

78+
/**
79+
* Returns the path to the client certificate file specified by the loaded workload certificate
80+
* configuration.
81+
*
82+
* <p>If the configuration has not been loaded yet (e.g., if {@link #getKeyStore()} has not been
83+
* called), this method will attempt to load it first by searching the override path, environment
84+
* variable, and well-known locations.
85+
*
86+
* @return The path to the certificate file.
87+
* @throws IOException if the certificate configuration cannot be found or loaded, or if the
88+
* configuration file does not specify a certificate path.
89+
* @throws CertificateSourceUnavailableException if the configuration file is not found.
90+
*/
91+
public String getCertificatePath() throws IOException {
92+
String certPath = getWorkloadCertificateConfiguration().getCertPath();
93+
if (Strings.isNullOrEmpty(certPath)) {
94+
// Ensure the loaded configuration actually contains the required path.
95+
throw new CertificateSourceUnavailableException(
96+
"Certificate configuration loaded successfully, but does not contain a 'certificate_file' path.");
97+
}
98+
return certPath;
99+
}
100+
78101
/**
79102
* Finds the certificate configuration file, then builds a Keystore using the X.509 certificate
80103
* and private key pointed to by the configuration. This will check the following locations in
@@ -90,9 +113,7 @@ public X509Provider() {
90113
* @throws IOException if there is an error retrieving the certificate configuration.
91114
*/
92115
public KeyStore getKeyStore() throws IOException {
93-
94116
WorkloadCertificateConfiguration workloadCertConfig = getWorkloadCertificateConfiguration();
95-
96117
InputStream certStream = null;
97118
InputStream privateKeyStream = null;
98119
SequenceInputStream certAndPrivateKeyStream = null;
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Redistribution and use in source and binary forms, with or without
5+
* modification, are permitted provided that the following conditions are
6+
* met:
7+
*
8+
* * Redistributions of source code must retain the above copyright
9+
* notice, this list of conditions and the following disclaimer.
10+
* * Redistributions in binary form must reproduce the above
11+
* copyright notice, this list of conditions and the following disclaimer
12+
* in the documentation and/or other materials provided with the
13+
* distribution.
14+
*
15+
* * Neither the name of Google LLC nor the names of its
16+
* contributors may be used to endorse or promote products derived from
17+
* this software without specific prior written permission.
18+
*
19+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
20+
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
21+
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
22+
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
23+
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
24+
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
25+
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
26+
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
27+
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
28+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29+
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30+
*/
31+
32+
package com.google.auth.oauth2;
33+
34+
import static com.google.common.base.Preconditions.checkNotNull;
35+
36+
import com.google.common.annotations.VisibleForTesting;
37+
import java.io.ByteArrayInputStream;
38+
import java.io.IOException;
39+
import java.io.InputStream;
40+
import java.nio.file.Files;
41+
import java.nio.file.Paths;
42+
import java.security.cert.CertificateEncodingException;
43+
import java.security.cert.CertificateException;
44+
import java.security.cert.CertificateFactory;
45+
import java.security.cert.X509Certificate;
46+
import java.util.Base64;
47+
48+
/**
49+
* Provider for retrieving the subject tokens for {@link IdentityPoolCredentials} by reading an
50+
* X.509 certificate from the filesystem. The certificate file (e.g., PEM or DER encoded) is read,
51+
* the leaf certificate is base64-encoded (DER format), wrapped in a JSON array, and used as the
52+
* subject token for STS exchange.
53+
*/
54+
public class CertificateIdentityPoolSubjectTokenSupplier
55+
implements IdentityPoolSubjectTokenSupplier {
56+
57+
private final IdentityPoolCredentialSource credentialSource;
58+
59+
CertificateIdentityPoolSubjectTokenSupplier(IdentityPoolCredentialSource credentialSource) {
60+
this.credentialSource = checkNotNull(credentialSource, "credentialSource cannot be null");
61+
// This check ensures that the credential source was intended for certificate usage.
62+
// IdentityPoolCredentials logic should guarantee credentialLocation is set in this case.
63+
checkNotNull(
64+
credentialSource.getCertificateConfig(),
65+
"credentialSource.certificateConfig cannot be null when creating"
66+
+ " CertificateIdentityPoolSubjectTokenSupplier");
67+
}
68+
69+
private static X509Certificate loadLeafCertificate(String path)
70+
throws IOException, CertificateException {
71+
byte[] leafCertBytes = Files.readAllBytes(Paths.get(path));
72+
return parseCertificate(leafCertBytes);
73+
}
74+
75+
@VisibleForTesting
76+
static X509Certificate parseCertificate(byte[] certData) throws CertificateException {
77+
if (certData == null || certData.length == 0) {
78+
throw new IllegalArgumentException(
79+
"Invalid certificate data: Certificate file is empty or null.");
80+
}
81+
82+
try {
83+
CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
84+
InputStream certificateStream = new ByteArrayInputStream(certData);
85+
return (X509Certificate) certificateFactory.generateCertificate(certificateStream);
86+
} catch (CertificateException e) {
87+
// Catch the original exception to add context about the operation being performed.
88+
// This helps pinpoint the failure point during debugging.
89+
throw new CertificateException("Failed to parse X.509 certificate data.", e);
90+
}
91+
}
92+
93+
private static String encodeCert(X509Certificate certificate)
94+
throws CertificateEncodingException {
95+
return Base64.getEncoder().encodeToString(certificate.getEncoded());
96+
}
97+
98+
/**
99+
* Retrieves the X509 subject token. This method loads the leaf certificate specified by the
100+
* {@code credentialSource.credentialLocation}. The subject token is constructed as a JSON array
101+
* containing the base64-encoded (DER format) leaf certificate. This JSON array serves as the
102+
* subject token for mTLS authentication.
103+
*
104+
* @param context The external account supplier context. This parameter is currently not used in
105+
* this implementation.
106+
* @return The JSON string representation of the base64-encoded leaf certificate in a JSON array.
107+
* @throws IOException If an I/O error occurs while reading the certificate file.
108+
*/
109+
@Override
110+
public String getSubjectToken(ExternalAccountSupplierContext context) throws IOException {
111+
try {
112+
// credentialSource.credentialLocation is expected to be non-null here,
113+
// set during IdentityPoolCredentials construction for certificate type.
114+
X509Certificate leafCert = loadLeafCertificate(credentialSource.getCredentialLocation());
115+
String encodedLeafCert = encodeCert(leafCert);
116+
117+
java.util.List<String> certChain = new java.util.ArrayList<>();
118+
certChain.add(encodedLeafCert);
119+
120+
return OAuth2Utils.JSON_FACTORY.toString(certChain);
121+
} catch (CertificateException e) {
122+
// Catch CertificateException to provide a more specific error message including
123+
// the path of the file that failed to parse, and re-throw as IOException
124+
// as expected by the getSubjectToken method signature for I/O related issues.
125+
throw new IOException(
126+
"Failed to parse certificate(s) from: " + credentialSource.getCredentialLocation(), e);
127+
}
128+
}
129+
}

oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplier.java

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@
3737
import com.google.common.io.CharStreams;
3838
import java.io.BufferedReader;
3939
import java.io.File;
40-
import java.io.FileInputStream;
4140
import java.io.IOException;
4241
import java.io.InputStream;
4342
import java.io.InputStreamReader;
@@ -47,8 +46,8 @@
4746
import java.nio.file.Paths;
4847

4948
/**
50-
* Internal provider for retrieving subject tokens for {@link IdentityPoolCredentials} to exchange
51-
* for GCP access tokens via a local file.
49+
* Internal provider for retrieving the subject tokens for {@link IdentityPoolCredentials} to
50+
* exchange for GCP access tokens via a local file.
5251
*/
5352
class FileIdentityPoolSubjectTokenSupplier implements IdentityPoolSubjectTokenSupplier {
5453

@@ -67,14 +66,15 @@ class FileIdentityPoolSubjectTokenSupplier implements IdentityPoolSubjectTokenSu
6766

6867
@Override
6968
public String getSubjectToken(ExternalAccountSupplierContext context) throws IOException {
70-
String credentialFilePath = this.credentialSource.credentialLocation;
69+
String credentialFilePath = this.credentialSource.getCredentialLocation();
7170
if (!Files.exists(Paths.get(credentialFilePath), LinkOption.NOFOLLOW_LINKS)) {
7271
throw new IOException(
7372
String.format(
7473
"Invalid credential location. The file at %s does not exist.", credentialFilePath));
7574
}
7675
try {
77-
return parseToken(new FileInputStream(new File(credentialFilePath)), this.credentialSource);
76+
return parseToken(
77+
Files.newInputStream(new File(credentialFilePath).toPath()), this.credentialSource);
7878
} catch (IOException e) {
7979
throw new IOException(
8080
"Error when attempting to read the subject token from the credential file.", e);

0 commit comments

Comments
 (0)