Skip to content

Commit 6d05be8

Browse files
authored
feat: Return X509 certificate chain as the subject token. (#1746)
* feat: Return X.509 certificate chain as subject token. The CertificateIdentityPoolSubjectTokenSupplier's subjectToken function now returns the full X.509 certificate chain, including the leaf certificate and any provided trust chain certificates, as a JSON array of base64-encoded strings. This chain is used as the subject token for mTLS authentication. * Fix: Update failing unit test to compare paths in a platform-agnostic way. * Fix: Applied formatting to resolve lint check * Added comments for clarity, and moved the exception handling of leaf certificate to the private helper method. * Refactor: Use Strings.isNullOrEmpty * Refactor: Use com.google.common.base.Strings * Refactor: move the trust chain logic into a helper private method. * Extend wildcard import with specific static imports.
1 parent b347603 commit 6d05be8

File tree

6 files changed

+653
-24
lines changed

6 files changed

+653
-24
lines changed

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

Lines changed: 187 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -34,16 +34,24 @@
3434
import static com.google.common.base.Preconditions.checkNotNull;
3535

3636
import com.google.common.annotations.VisibleForTesting;
37+
import com.google.common.base.Strings;
3738
import java.io.ByteArrayInputStream;
3839
import java.io.IOException;
3940
import java.io.InputStream;
41+
import java.nio.charset.StandardCharsets;
4042
import java.nio.file.Files;
43+
import java.nio.file.NoSuchFileException;
4144
import java.nio.file.Paths;
45+
import java.security.cert.Certificate;
4246
import java.security.cert.CertificateEncodingException;
4347
import java.security.cert.CertificateException;
4448
import java.security.cert.CertificateFactory;
4549
import java.security.cert.X509Certificate;
50+
import java.util.ArrayList;
4651
import java.util.Base64;
52+
import java.util.List;
53+
import java.util.regex.Matcher;
54+
import java.util.regex.Pattern;
4755

4856
/**
4957
* Provider for retrieving the subject tokens for {@link IdentityPoolCredentials} by reading an
@@ -56,6 +64,9 @@ public class CertificateIdentityPoolSubjectTokenSupplier
5664

5765
private final IdentityPoolCredentialSource credentialSource;
5866

67+
private static final Pattern PEM_CERT_PATTERN =
68+
Pattern.compile("-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----", Pattern.DOTALL);
69+
5970
CertificateIdentityPoolSubjectTokenSupplier(IdentityPoolCredentialSource credentialSource) {
6071
this.credentialSource = checkNotNull(credentialSource, "credentialSource cannot be null");
6172
// This check ensures that the credential source was intended for certificate usage.
@@ -66,10 +77,21 @@ public class CertificateIdentityPoolSubjectTokenSupplier
6677
+ " CertificateIdentityPoolSubjectTokenSupplier");
6778
}
6879

69-
private static X509Certificate loadLeafCertificate(String path)
70-
throws IOException, CertificateException {
71-
byte[] leafCertBytes = Files.readAllBytes(Paths.get(path));
72-
return parseCertificate(leafCertBytes);
80+
private static String loadAndEncodeLeafCertificate(String path) throws IOException {
81+
try {
82+
byte[] leafCertBytes = Files.readAllBytes(Paths.get(path));
83+
X509Certificate leafCert = parseCertificate(leafCertBytes);
84+
return encodeCert(leafCert);
85+
} catch (NoSuchFileException e) {
86+
throw new IOException(String.format("Leaf certificate file not found: %s", path), e);
87+
} catch (CertificateException e) {
88+
throw new IOException(
89+
String.format("Failed to parse leaf certificate from file: %s", path), e);
90+
} catch (IOException e) {
91+
// This catches any other general I/O errors during leaf certificate file reading (e.g.,
92+
// permissions).
93+
throw new IOException(String.format("Failed to read leaf certificate file: %s", path), e);
94+
}
7395
}
7496

7597
@VisibleForTesting
@@ -97,33 +119,177 @@ private static String encodeCert(X509Certificate certificate)
97119

98120
/**
99121
* 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.
122+
* {@code credentialSource.credentialLocation}. If a trust chain path is configured in the {@code
123+
* credentialSource.certificateConfig}, it also loads and includes the trust chain certificates.
124+
* The subject token is constructed as a JSON array containing the base64-encoded (DER format)
125+
* leaf certificate, followed by the base64-encoded (DER format) certificates in the trust chain.
126+
* This JSON array serves as the subject token for mTLS authentication.
103127
*
104128
* @param context The external account supplier context. This parameter is currently not used in
105129
* 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.
130+
* @return The JSON string representation of the base64-encoded certificate chain (leaf
131+
* certificate followed by the trust chain, if present).
132+
* @throws IOException If an I/O error occurs while reading the certificate file(s).
108133
*/
109134
@Override
110135
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);
136+
String leafCertPath = credentialSource.getCredentialLocation();
137+
String trustChainPath = null;
138+
if (credentialSource.getCertificateConfig() != null) {
139+
trustChainPath = credentialSource.getCertificateConfig().getTrustChainPath();
140+
}
141+
142+
// Load and encode the leaf certificate.
143+
String encodedLeafCert = loadAndEncodeLeafCertificate(leafCertPath);
116144

117-
java.util.List<String> certChain = new java.util.ArrayList<>();
118-
certChain.add(encodedLeafCert);
145+
// Initialize the certificate chain for the subject token. The Security Token Service (STS)
146+
// requires that the leaf certificate (the one used for authenticating this workload) must be
147+
// the first certificate in this chain.
148+
List<String> certChain = new ArrayList<>();
149+
certChain.add(encodedLeafCert);
150+
151+
// Handle trust chain loading and processing.
152+
try {
153+
// Read the trust chain.
154+
List<X509Certificate> trustChainCerts = readTrustChain(trustChainPath);
119155

120-
return OAuth2Utils.JSON_FACTORY.toString(certChain);
156+
// Process the trust chain certificates read from the file.
157+
if (!trustChainCerts.isEmpty()) {
158+
populateCertChainFromTrustChain(certChain, trustChainCerts, encodedLeafCert);
159+
}
160+
} catch (IllegalArgumentException e) {
161+
// This catches the specific error for misconfigured trust chain (e.g., leaf in wrong place).
162+
throw new IOException("Trust chain misconfiguration: " + e.getMessage(), e);
163+
} catch (NoSuchFileException e) {
164+
throw new IOException(String.format("Trust chain file not found: %s", trustChainPath), e);
121165
} 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.
125166
throw new IOException(
126-
"Failed to parse certificate(s) from: " + credentialSource.getCredentialLocation(), e);
167+
String.format("Failed to parse certificate(s) from trust chain file: %s", trustChainPath),
168+
e);
169+
} catch (IOException e) {
170+
// This catches any other general I/O errors during trust chain file reading (e.g.,
171+
// permissions).
172+
throw new IOException(
173+
String.format("Failed to read trust chain file: %s", trustChainPath), e);
174+
}
175+
176+
return OAuth2Utils.JSON_FACTORY.toString(certChain);
177+
}
178+
179+
/**
180+
* Extends {@code certChainToPopulate} with encoded certificates from {@code trustChainCerts},
181+
* applying validation rules for the leaf certificate's presence and order within the trust chain.
182+
*
183+
* @param certChainToPopulate The list of encoded certificate strings to populate.
184+
* @param trustChainCerts The list of X509Certificates from the trust chain file (non-empty).
185+
* @param encodedLeafCert The Base64-encoded leaf certificate.
186+
* @throws CertificateEncodingException If an error occurs during certificate encoding.
187+
* @throws IllegalArgumentException If the leaf certificate is found in an invalid position in the
188+
* trust chain.
189+
*/
190+
private void populateCertChainFromTrustChain(
191+
List<String> certChainToPopulate,
192+
List<X509Certificate> trustChainCerts,
193+
String encodedLeafCert)
194+
throws CertificateEncodingException, IllegalArgumentException {
195+
196+
// Get the first certificate from the user-provided trust chain file.
197+
X509Certificate firstTrustCert = trustChainCerts.get(0);
198+
String encodedFirstTrustCert = encodeCert(firstTrustCert);
199+
200+
// If the first certificate in the user-provided trust chain file is *not* the leaf
201+
// certificate (which has already been added as the first element to `certChainToPopulate`),
202+
// then add this certificate. This handles cases where the user's trust chain file
203+
// starts with an intermediate certificate. If the first certificate in the trust chain file
204+
// *is* the leaf certificate, this means the user has explicitly included the leaf in their
205+
// trust chain file. In this case, we skip adding it again to prevent duplication, as the
206+
// leaf is already at the beginning of `certChainToPopulate`.
207+
if (!encodedFirstTrustCert.equals(encodedLeafCert)) {
208+
certChainToPopulate.add(encodedFirstTrustCert);
209+
}
210+
211+
// Iterate over the remaining certificates in the trust chain.
212+
for (int i = 1; i < trustChainCerts.size(); i++) {
213+
X509Certificate currentCert = trustChainCerts.get(i);
214+
String encodedCurrentCert = encodeCert(currentCert);
215+
216+
// Throw an error if the current certificate (from the user-provided trust chain file,
217+
// at an index beyond the first) is the same as the leaf certificate.
218+
// This enforces that if the leaf certificate is included in the trust chain file by the
219+
// user, it must be the very first certificate in that file. It should not appear
220+
// elsewhere in the chain.
221+
if (encodedCurrentCert.equals(encodedLeafCert)) {
222+
throw new IllegalArgumentException(
223+
"The leaf certificate should only appear at the beginning of the trust chain file, or be omitted entirely.");
224+
}
225+
226+
// Add the current certificate to the chain.
227+
certChainToPopulate.add(encodedCurrentCert);
228+
}
229+
}
230+
231+
/**
232+
* Reads a file containing PEM-encoded X509 certificates and returns a list of parsed
233+
* certificates. It splits the file content based on PEM headers and parses each certificate.
234+
* Returns an empty list if the trust chain path is empty.
235+
*
236+
* @param trustChainPath The path to the trust chain file.
237+
* @return A list of parsed X509 certificates.
238+
* @throws IOException If an error occurs while reading the file.
239+
* @throws CertificateException If an error occurs while parsing a certificate.
240+
*/
241+
@VisibleForTesting
242+
static List<X509Certificate> readTrustChain(String trustChainPath)
243+
throws IOException, CertificateException {
244+
List<X509Certificate> certificateTrustChain = new ArrayList<>();
245+
246+
// If no trust chain path is provided, return an empty list.
247+
if (Strings.isNullOrEmpty(trustChainPath)) {
248+
return certificateTrustChain;
127249
}
250+
251+
// initialize certificate factory to retrieve x509 certificates.
252+
CertificateFactory cf = CertificateFactory.getInstance("X.509");
253+
254+
// Read the trust chain file.
255+
byte[] trustChainData;
256+
trustChainData = Files.readAllBytes(Paths.get(trustChainPath));
257+
258+
// Split the file content into PEM certificate blocks.
259+
String content = new String(trustChainData, StandardCharsets.UTF_8);
260+
261+
Matcher matcher = PEM_CERT_PATTERN.matcher(content);
262+
263+
while (matcher.find()) {
264+
String pemCertBlock = matcher.group(0);
265+
try (InputStream certStream =
266+
new ByteArrayInputStream(pemCertBlock.getBytes(StandardCharsets.UTF_8))) {
267+
// Parse the certificate data.
268+
Certificate cert = cf.generateCertificate(certStream);
269+
270+
// Append the certificate to the trust chain.
271+
if (cert instanceof X509Certificate) {
272+
certificateTrustChain.add((X509Certificate) cert);
273+
} else {
274+
throw new CertificateException(
275+
"Found non-X.509 certificate in trust chain file: " + trustChainPath);
276+
}
277+
} catch (CertificateException e) {
278+
// If parsing an individual PEM block fails, re-throw with more context.
279+
throw new CertificateException(
280+
"Error loading PEM certificates from the trust chain file: "
281+
+ trustChainPath
282+
+ " - "
283+
+ e.getMessage(),
284+
e);
285+
}
286+
}
287+
288+
if (trustChainData.length > 0 && certificateTrustChain.isEmpty()) {
289+
throw new CertificateException(
290+
"Trust chain file was not empty but no PEM certificates were found: " + trustChainPath);
291+
}
292+
293+
return certificateTrustChain;
128294
}
129295
}

0 commit comments

Comments
 (0)