Skip to content

Commit fb3d375

Browse files
committed
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.
1 parent 8a75ccd commit fb3d375

File tree

4 files changed

+377
-11
lines changed

4 files changed

+377
-11
lines changed

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

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,9 @@ public class X509Provider {
5252
static final String CERTIFICATE_CONFIGURATION_ENV_VARIABLE = "GOOGLE_API_CERTIFICATE_CONFIG";
5353
static final String WELL_KNOWN_CERTIFICATE_CONFIG_FILE = "certificate_config.json";
5454
static final String CLOUDSDK_CONFIG_DIRECTORY = "gcloud";
55+
private WorkloadCertificateConfiguration loadedConfig;
5556

56-
private String certConfigPathOverride;
57+
private final String certConfigPathOverride;
5758

5859
/**
5960
* Creates an X509 provider with an override path for the certificate configuration, bypassing the
@@ -75,6 +76,34 @@ public X509Provider() {
7576
this(null);
7677
}
7778

79+
/**
80+
* Returns the path to the client certificate file specified by the loaded workload certificate
81+
* configuration.
82+
*
83+
* <p>If the configuration has not been loaded yet (e.g., if {@link #getKeyStore()} has not been
84+
* called), this method will attempt to load it first by searching the override path, environment
85+
* variable, and well-known locations.
86+
*
87+
* @return The path to the certificate file.
88+
* @throws IOException if the certificate configuration cannot be found or loaded, or if the
89+
* configuration file does not specify a certificate path.
90+
* @throws CertificateSourceUnavailableException if the configuration file is not found.
91+
*/
92+
public String getCertificatePath() throws IOException {
93+
if (loadedConfig == null) {
94+
// Attempt to load the configuration. This call might throw IOException or
95+
// CertificateSourceUnavailableException if loading fails.
96+
loadedConfig = getWorkloadCertificateConfiguration();
97+
}
98+
String certPath = loadedConfig.getCertPath();
99+
if (Strings.isNullOrEmpty(certPath)) {
100+
// Ensure the loaded configuration actually contains the required path.
101+
throw new IOException(
102+
"Certificate configuration loaded successfully, but does not contain a 'certificate_file' path.");
103+
}
104+
return certPath;
105+
}
106+
78107
/**
79108
* Finds the certificate configuration file, then builds a Keystore using the X.509 certificate
80109
* and private key pointed to by the configuration. This will check the following locations in
@@ -90,8 +119,10 @@ public X509Provider() {
90119
* @throws IOException if there is an error retrieving the certificate configuration.
91120
*/
92121
public KeyStore getKeyStore() throws IOException {
93-
94-
WorkloadCertificateConfiguration workloadCertConfig = getWorkloadCertificateConfiguration();
122+
if (loadedConfig == null) {
123+
loadedConfig = getWorkloadCertificateConfiguration();
124+
}
125+
WorkloadCertificateConfiguration workloadCertConfig = loadedConfig;
95126

96127
InputStream certStream = null;
97128
InputStream privateKeyStream = null;
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
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.gson.Gson;
37+
import com.google.gson.JsonArray;
38+
import com.google.gson.JsonPrimitive;
39+
import java.io.ByteArrayInputStream;
40+
import java.io.IOException;
41+
import java.io.InputStream;
42+
import java.nio.file.Files;
43+
import java.nio.file.InvalidPathException;
44+
import java.nio.file.Paths;
45+
import java.security.cert.CertificateEncodingException;
46+
import java.security.cert.CertificateException;
47+
import java.security.cert.CertificateFactory;
48+
import java.security.cert.X509Certificate;
49+
import java.util.Base64;
50+
51+
/**
52+
* Provider for retrieving subject tokens for {@link IdentityPoolCredentials} by reading an X.509
53+
* certificate from the filesystem. The certificate file (e.g., PEM or DER encoded) is read, the
54+
* leaf certificate is base64-encoded (DER format), wrapped in a JSON array, and used as the subject
55+
* token for STS exchange.
56+
*/
57+
public class CertificateIdentityPoolSubjectTokenSupplier
58+
implements IdentityPoolSubjectTokenSupplier {
59+
60+
private static final Gson GSON = new Gson();
61+
private final IdentityPoolCredentialSource credentialSource;
62+
63+
CertificateIdentityPoolSubjectTokenSupplier(IdentityPoolCredentialSource credentialSource) {
64+
this.credentialSource = checkNotNull(credentialSource, "credentialSource cannot be null");
65+
// This check ensures that the credential source was intended for certificate usage.
66+
// IdentityPoolCredentials logic should guarantee credentialLocation is set in this case.
67+
checkNotNull(
68+
credentialSource.certificateConfig,
69+
"credentialSource.certificateConfig cannot be null when creating"
70+
+ " CertificateIdentityPoolSubjectTokenSupplier");
71+
}
72+
73+
private static X509Certificate loadLeafCertificate(String path)
74+
throws IOException, CertificateException {
75+
byte[] leafCertBytes;
76+
try {
77+
// IdentityPoolCredentials should have already validated the path exists via X509Provider.
78+
leafCertBytes = Files.readAllBytes(Paths.get(path));
79+
} catch (InvalidPathException e) {
80+
throw new IOException("Invalid certificate file path provided: " + path, e);
81+
}
82+
// Files.readAllBytes throws IOException for other read errors.
83+
return parseCertificate(leafCertBytes);
84+
}
85+
86+
private static X509Certificate parseCertificate(byte[] certData) throws CertificateException {
87+
if (certData == null || certData.length == 0) {
88+
throw new IllegalArgumentException("Invalid certificate data: empty or null input");
89+
}
90+
91+
try {
92+
CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
93+
InputStream certificateStream = new ByteArrayInputStream(certData);
94+
return (X509Certificate) certificateFactory.generateCertificate(certificateStream);
95+
} catch (CertificateException e) {
96+
throw new CertificateException("Failed to parse X.509 certificate data.", e);
97+
}
98+
}
99+
100+
private static String encodeCert(X509Certificate certificate)
101+
throws CertificateEncodingException {
102+
return Base64.getEncoder().encodeToString(certificate.getEncoded());
103+
}
104+
105+
@Override
106+
public String getSubjectToken(ExternalAccountSupplierContext context) throws IOException {
107+
try {
108+
// credentialSource.credentialLocation is expected to be non-null here,
109+
// set during IdentityPoolCredentials construction for certificate type.
110+
X509Certificate leafCert = loadLeafCertificate(credentialSource.credentialLocation);
111+
String encodedCert = encodeCert(leafCert);
112+
113+
JsonArray certChain = new JsonArray();
114+
certChain.add(new JsonPrimitive(encodedCert));
115+
116+
return GSON.toJson(certChain);
117+
} catch (CertificateException e) {
118+
throw new IOException(
119+
"Failed to parse certificate from: " + credentialSource.credentialLocation, e);
120+
}
121+
}
122+
}

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

Lines changed: 166 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131

3232
package com.google.auth.oauth2;
3333

34+
import static com.google.common.base.Preconditions.checkArgument;
35+
3436
import java.util.HashMap;
3537
import java.util.Locale;
3638
import java.util.Map;
@@ -48,6 +50,157 @@ public class IdentityPoolCredentialSource extends ExternalAccountCredentials.Cre
4850
String credentialLocation;
4951
@Nullable String subjectTokenFieldName;
5052
@Nullable Map<String, String> headers;
53+
@Nullable CertificateConfig certificateConfig;
54+
55+
/**
56+
* Extracts and configures the {@link CertificateConfig} from the provided credential source.
57+
*
58+
* @param credentialSourceMap A map containing the certificate configuration.
59+
* @return A new {@link CertificateConfig} instance.
60+
* @throws IllegalArgumentException if the 'certificate' entry is not a Map or if required fields
61+
* within the certificate configuration have invalid types.
62+
*/
63+
private CertificateConfig getCertificateConfig(Map<String, Object> credentialSourceMap) {
64+
Object certValue = credentialSourceMap.get("certificate");
65+
if (!(certValue instanceof Map)) {
66+
throw new IllegalArgumentException(
67+
"The 'certificate' credential source must be a JSON object (Map).");
68+
}
69+
Map<String, Object> certificateMap = (Map<String, Object>) certValue;
70+
71+
Boolean useDefaultCertificateConfig =
72+
getOptionalBoolean(certificateMap, "use_default_certificate_config");
73+
String trustChain = getOptionalString(certificateMap, "trust_chain_path");
74+
String certificateConfigLocation =
75+
getOptionalString(certificateMap, "certificate_config_location");
76+
77+
return new CertificateConfig(
78+
useDefaultCertificateConfig, certificateConfigLocation, trustChain);
79+
}
80+
81+
/**
82+
* Retrieves an optional boolean value from a map.
83+
*
84+
* @param map The map to retrieve from.
85+
* @param key The key of the boolean value.
86+
* @return The boolean value if present and of the correct type, otherwise null.
87+
* @throws IllegalArgumentException if the value is present but not a boolean.
88+
*/
89+
private @Nullable Boolean getOptionalBoolean(Map<String, Object> map, String key) {
90+
Object value = map.get(key);
91+
if (value == null) {
92+
return null;
93+
}
94+
if (!(value instanceof Boolean)) {
95+
throw new IllegalArgumentException(
96+
String.format(
97+
"Invalid type for '%s' in certificate configuration: expected Boolean, got %s.",
98+
key, value.getClass().getSimpleName()));
99+
}
100+
return (Boolean) value;
101+
}
102+
103+
/**
104+
* Retrieves an optional string value from a map.
105+
*
106+
* @param map The map to retrieve from.
107+
* @param key The key of the string value.
108+
* @return The string value if present and of the correct type, otherwise null.
109+
* @throws IllegalArgumentException if the value is present but not a string.
110+
*/
111+
private @Nullable String getOptionalString(Map<String, Object> map, String key) {
112+
Object value = map.get(key);
113+
if (value == null) {
114+
return null;
115+
}
116+
if (!(value instanceof String)) {
117+
throw new IllegalArgumentException(
118+
String.format(
119+
"Invalid type for '%s' in certificate configuration: expected String, got %s.",
120+
key, value.getClass().getSimpleName()));
121+
}
122+
return (String) value;
123+
}
124+
/**
125+
* Represents the configuration options for X.509-based workload credentials (mTLS). It specifies
126+
* how to locate and use the client certificate, private key, and optional trust chain for mutual
127+
* TLS authentication.
128+
*/
129+
public static class CertificateConfig implements java.io.Serializable {
130+
private static final long serialVersionUID = 1L;
131+
132+
/**
133+
* If true, attempts to load the default certificate configuration. It checks the
134+
* GOOGLE_API_CERTIFICATE_CONFIG environment variable first, then a conventional default file
135+
* location. Cannot be true if {@code certificateConfigLocation} is set.
136+
*/
137+
private final boolean useDefaultCertificateConfig;
138+
139+
/**
140+
* Specifies the path to the client certificate and private key file. This is used when {@code
141+
* useDefaultCertificateConfig} is false or unset. Must be set if {@code
142+
* useDefaultCertificateConfig} is false.
143+
*/
144+
@Nullable private final String certificateConfigLocation;
145+
146+
/**
147+
* Specifies the path to a PEM-formatted file containing the X.509 certificate trust chain. This
148+
* file should contain any intermediate certificates required to complete the trust chain
149+
* between the leaf certificate (used for mTLS) and the root certificate(s) in your workload
150+
* identity pool's trust store. The leaf certificate and any certificates already present in the
151+
* workload identity pool's trust store are optional in this file. Certificates should be
152+
* ordered with the leaf certificate (or the certificate which signed the leaf) first.
153+
*/
154+
@Nullable private final String trustChainPath;
155+
156+
/**
157+
* Constructor for {@code CertificateConfig}.
158+
*
159+
* @param useDefaultCertificateConfig Whether to use the default certificate configuration.
160+
* @param certificateConfigLocation Path to the client certificate and private key file.
161+
* @param trustChainPath Path to the trust chain file.
162+
* @throws IllegalArgumentException if the configuration is invalid (e.g., neither default nor
163+
* location is specified, or both are specified).
164+
*/
165+
CertificateConfig(
166+
@Nullable Boolean useDefaultCertificateConfig,
167+
@Nullable String certificateConfigLocation,
168+
@Nullable String trustChainPath) {
169+
170+
boolean useDefault = useDefaultCertificateConfig != null && useDefaultCertificateConfig;
171+
boolean locationIsPresent =
172+
certificateConfigLocation != null && !certificateConfigLocation.isEmpty();
173+
174+
checkArgument(
175+
!(!useDefault && !locationIsPresent),
176+
"credentials: \"certificate\" object must either specify a certificate_config_location or use_default_certificate_config should be true");
177+
178+
checkArgument(
179+
!(useDefault && locationIsPresent),
180+
"credentials: \"certificate\" object cannot specify both a certificate_config_location and use_default_certificate_config=true");
181+
182+
this.useDefaultCertificateConfig = useDefault;
183+
this.certificateConfigLocation = certificateConfigLocation;
184+
this.trustChainPath = trustChainPath;
185+
}
186+
187+
/** Returns whether the default certificate configuration should be used. */
188+
public boolean useDefaultCertificateConfig() {
189+
return useDefaultCertificateConfig;
190+
}
191+
192+
/** Returns the path to the client certificate file, or null if not set. */
193+
@Nullable
194+
public String getCertificateConfigLocation() {
195+
return certificateConfigLocation;
196+
}
197+
198+
/** Returns the path to the trust chain file, or null if not set. */
199+
@Nullable
200+
public String getTrustChainPath() {
201+
return trustChainPath;
202+
}
203+
}
51204

52205
/**
53206
* The source of the 3P credential.
@@ -69,9 +222,15 @@ public class IdentityPoolCredentialSource extends ExternalAccountCredentials.Cre
69222
public IdentityPoolCredentialSource(Map<String, Object> credentialSourceMap) {
70223
super(credentialSourceMap);
71224

72-
if (credentialSourceMap.containsKey("file") && credentialSourceMap.containsKey("url")) {
225+
boolean filePresent = credentialSourceMap.containsKey("file");
226+
boolean urlPresent = credentialSourceMap.containsKey("url");
227+
boolean certificatePresent = credentialSourceMap.containsKey("certificate");
228+
229+
if ((filePresent && urlPresent)
230+
|| (filePresent && certificatePresent)
231+
|| (urlPresent && certificatePresent)) {
73232
throw new IllegalArgumentException(
74-
"Only one credential source type can be set, either file or url.");
233+
"Only one credential source type can be set: 'file', 'url', or 'certificate'.");
75234
}
76235

77236
if (credentialSourceMap.containsKey("file")) {
@@ -80,6 +239,9 @@ public IdentityPoolCredentialSource(Map<String, Object> credentialSourceMap) {
80239
} else if (credentialSourceMap.containsKey("url")) {
81240
credentialLocation = (String) credentialSourceMap.get("url");
82241
credentialSourceType = IdentityPoolCredentialSourceType.URL;
242+
} else if (credentialSourceMap.containsKey("certificate")) {
243+
credentialSourceType = IdentityPoolCredentialSourceType.CERTIFICATE;
244+
this.certificateConfig = getCertificateConfig(credentialSourceMap);
83245
} else {
84246
throw new IllegalArgumentException(
85247
"Missing credential source file location or URL. At least one must be specified.");
@@ -121,7 +283,8 @@ boolean hasHeaders() {
121283

122284
enum IdentityPoolCredentialSourceType {
123285
FILE,
124-
URL
286+
URL,
287+
CERTIFICATE
125288
}
126289

127290
enum CredentialFormatType {

0 commit comments

Comments
 (0)