-
Notifications
You must be signed in to change notification settings - Fork 265
feat: Add support for mTLS authentication via X.509 certificates #1736
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
fb3d375
b7e8ec6
7cd3529
dd83a66
91ec285
faa0a9e
82442dc
701168a
26c3998
3e9d88d
c53de82
2eb5de3
1559054
c9a67cb
e6a32c9
c963ac3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
| * | ||
| * <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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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!
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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; | ||
|
|
||
| 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 { | ||
nbayati marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| try { | ||
| // credentialSource.credentialLocation is expected to be non-null here, | ||
| // set during IdentityPoolCredentials construction for certificate type. | ||
| X509Certificate leafCert = loadLeafCertificate(credentialSource.getCredentialLocation()); | ||
nbayati marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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); | ||
| } | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.