Skip to content

Commit 58d43fc

Browse files
committed
test(oauth2): add end-to-end local mTLS server test verifying dynamic cert rotation and 401 retry loop
1 parent e8668cf commit 58d43fc

1 file changed

Lines changed: 369 additions & 0 deletions

File tree

Lines changed: 369 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,369 @@
1+
/*
2+
* Copyright 2026 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+
* * Neither the name of Google LLC nor the names of its
15+
* contributors may be used to endorse or promote products derived from
16+
* this software without specific prior written permission.
17+
*
18+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19+
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20+
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
21+
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
22+
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23+
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
24+
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
25+
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
26+
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28+
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29+
*/
30+
31+
package com.google.auth.oauth2;
32+
33+
import static org.junit.jupiter.api.Assertions.assertEquals;
34+
import static org.junit.jupiter.api.Assertions.assertNotNull;
35+
import static org.junit.jupiter.api.Assertions.assertTrue;
36+
37+
import com.google.api.client.json.GenericJson;
38+
import com.google.auth.mtls.MtlsHttpTransportFactory;
39+
import com.google.auth.mtls.X509Provider;
40+
import com.sun.net.httpserver.HttpExchange;
41+
import com.sun.net.httpserver.HttpHandler;
42+
import com.sun.net.httpserver.HttpsConfigurator;
43+
import com.sun.net.httpserver.HttpsExchange;
44+
import com.sun.net.httpserver.HttpsParameters;
45+
import com.sun.net.httpserver.HttpsServer;
46+
import java.io.FileInputStream;
47+
import java.io.FileOutputStream;
48+
import java.io.IOException;
49+
import java.io.InputStream;
50+
import java.io.OutputStream;
51+
import java.net.InetSocketAddress;
52+
import java.nio.charset.StandardCharsets;
53+
import java.nio.file.Files;
54+
import java.nio.file.Path;
55+
import java.nio.file.StandardCopyOption;
56+
import java.security.KeyFactory;
57+
import java.security.KeyStore;
58+
import java.security.PrivateKey;
59+
import java.security.cert.Certificate;
60+
import java.security.cert.CertificateFactory;
61+
import java.security.cert.X509Certificate;
62+
import java.security.spec.PKCS8EncodedKeySpec;
63+
import java.util.ArrayList;
64+
import java.util.Base64;
65+
import java.util.Collections;
66+
import java.util.HashMap;
67+
import java.util.List;
68+
import java.util.Map;
69+
import java.util.concurrent.atomic.AtomicInteger;
70+
import javax.net.ssl.KeyManagerFactory;
71+
import javax.net.ssl.SSLContext;
72+
import javax.net.ssl.SSLEngine;
73+
import javax.net.ssl.SSLParameters;
74+
import javax.net.ssl.TrustManagerFactory;
75+
import org.junit.jupiter.api.AfterEach;
76+
import org.junit.jupiter.api.BeforeEach;
77+
import org.junit.jupiter.api.Test;
78+
import org.junit.jupiter.api.io.TempDir;
79+
80+
/**
81+
* End-to-end integration and manual verification test infrastructure for:
82+
* 1) mTLS Dynamic Certificate Rotation (MtlsHttpTransportFactory + X509Provider)
83+
* 2) STS 401 Unauthorized Retry Loop over real local HTTPS sockets requiring mTLS
84+
*/
85+
public class MtlsCertRotationIntegrationTest {
86+
87+
@TempDir Path tempDir;
88+
89+
private HttpsServer server;
90+
private int serverPort;
91+
private final List<String> peerCertificatesReceived = Collections.synchronizedList(new ArrayList<>());
92+
private final AtomicInteger requestCounter = new AtomicInteger(0);
93+
94+
private Path certConfigPath;
95+
private Path activeCertPath;
96+
private Path activeKeyPath;
97+
private Path cert1Path;
98+
private Path key1Path;
99+
private Path cert2Path;
100+
private Path key2Path;
101+
private String oldTrustStore;
102+
private String oldTrustStorePassword;
103+
104+
@BeforeEach
105+
void setUp() throws Exception {
106+
generateCertificates();
107+
108+
activeCertPath = tempDir.resolve("active_client.crt");
109+
activeKeyPath = tempDir.resolve("active_client.pem.key");
110+
Files.copy(cert1Path, activeCertPath, StandardCopyOption.REPLACE_EXISTING);
111+
Files.copy(key1Path, activeKeyPath, StandardCopyOption.REPLACE_EXISTING);
112+
113+
certConfigPath = tempDir.resolve("certificate_config.json");
114+
String configJson =
115+
"{\n"
116+
+ " \"cert_configs\": {\n"
117+
+ " \"workload\": {\n"
118+
+ " \"cert_path\": \"" + activeCertPath.toString().replace("\\", "/") + "\",\n"
119+
+ " \"key_path\": \"" + activeKeyPath.toString().replace("\\", "/") + "\"\n"
120+
+ " }\n"
121+
+ " }\n"
122+
+ "}\n";
123+
Files.write(certConfigPath, configJson.getBytes(StandardCharsets.UTF_8));
124+
125+
// Save previous truststore properties and set to our temporary client truststore
126+
oldTrustStore = System.getProperty("javax.net.ssl.trustStore");
127+
oldTrustStorePassword = System.getProperty("javax.net.ssl.trustStorePassword");
128+
129+
Path clientTrustStorePath = tempDir.resolve("client_truststore.p12");
130+
KeyStore clientTrustStore = KeyStore.getInstance("PKCS12");
131+
clientTrustStore.load(null, null);
132+
addCertToTrustStore(clientTrustStore, tempDir.resolve("server.crt"), "server");
133+
try (FileOutputStream fos = new FileOutputStream(clientTrustStorePath.toFile())) {
134+
clientTrustStore.store(fos, "password".toCharArray());
135+
}
136+
137+
System.setProperty("javax.net.ssl.trustStore", clientTrustStorePath.toString());
138+
System.setProperty("javax.net.ssl.trustStorePassword", "password");
139+
140+
startLocalMtlsServer();
141+
}
142+
143+
@AfterEach
144+
void tearDown() {
145+
if (server != null) {
146+
server.stop(0);
147+
}
148+
if (oldTrustStore != null) {
149+
System.setProperty("javax.net.ssl.trustStore", oldTrustStore);
150+
} else {
151+
System.clearProperty("javax.net.ssl.trustStore");
152+
}
153+
if (oldTrustStorePassword != null) {
154+
System.setProperty("javax.net.ssl.trustStorePassword", oldTrustStorePassword);
155+
} else {
156+
System.clearProperty("javax.net.ssl.trustStorePassword");
157+
}
158+
}
159+
160+
@Test
161+
void endToEndMtlsCertRotation_on401Retry_reloadsRotatedCertAndSucceeds() throws Exception {
162+
System.out.println("=== Starting End-to-End mTLS Certificate Rotation Integration Test ===");
163+
164+
X509Provider x509Provider = new X509Provider(certConfigPath.toString());
165+
MtlsHttpTransportFactory transportFactory = new MtlsHttpTransportFactory(x509Provider);
166+
167+
GenericJson json = new GenericJson();
168+
json.put("type", "external_account");
169+
json.put(
170+
"audience",
171+
"//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider");
172+
json.put("subject_token_type", "urn:ietf:params:oauth:token-type:id_token");
173+
json.put("token_url", "https://127.0.0.1:" + serverPort + "/sts/token");
174+
175+
Map<String, String> credentialSource = new HashMap<>();
176+
credentialSource.put("file", activeCertPath.toString());
177+
json.put("credential_source", credentialSource);
178+
179+
ExternalAccountCredentials credential =
180+
ExternalAccountCredentials.fromJson(json, transportFactory);
181+
182+
StsTokenExchangeRequest stsRequest =
183+
StsTokenExchangeRequest.newBuilder("subject_token_payload", "urn:ietf:params:oauth:token-type:id_token")
184+
.setAudience("//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider")
185+
.build();
186+
187+
AccessToken accessToken = credential.exchangeExternalCredentialForAccessToken(stsRequest);
188+
189+
assertNotNull(accessToken);
190+
assertEquals("access_token_via_rotated_mtls_cert_v2", accessToken.getTokenValue());
191+
assertEquals(2, requestCounter.get());
192+
assertEquals(2, peerCertificatesReceived.size());
193+
194+
assertTrue(peerCertificatesReceived.get(0).contains("CN=client-v1"));
195+
assertTrue(peerCertificatesReceived.get(1).contains("CN=client-v2"));
196+
197+
System.out.println("=== Verified: Cert V1 -> 401 -> Cert Rotation -> Cert V2 -> 200 OK Token Received! ===");
198+
}
199+
200+
private void generateCertificates() throws Exception {
201+
cert1Path = tempDir.resolve("cert1.crt");
202+
key1Path = tempDir.resolve("cert1.pem.key");
203+
cert2Path = tempDir.resolve("cert2.crt");
204+
key2Path = tempDir.resolve("cert2.pem.key");
205+
Path serverCertPath = tempDir.resolve("server.crt");
206+
Path serverKeyPath = tempDir.resolve("server.pem.key");
207+
208+
runOpenSslCommandWithSan(serverKeyPath, serverCertPath, "/CN=127.0.0.1", "subjectAltName=IP:127.0.0.1,DNS:localhost");
209+
runOpenSslCommand(key1Path, cert1Path, "/CN=client-v1");
210+
runOpenSslCommand(key2Path, cert2Path, "/CN=client-v2");
211+
}
212+
213+
private void runOpenSslCommand(Path keyOut, Path certOut, String subj) throws Exception {
214+
runOpenSslCommandWithSan(keyOut, certOut, subj, null);
215+
}
216+
217+
private void runOpenSslCommandWithSan(Path keyOut, Path certOut, String subj, String sanExt) throws Exception {
218+
List<String> cmd = new ArrayList<>();
219+
cmd.add("openssl");
220+
cmd.add("req");
221+
cmd.add("-x509");
222+
cmd.add("-newkey");
223+
cmd.add("rsa:2048");
224+
cmd.add("-keyout");
225+
cmd.add(keyOut.toString());
226+
cmd.add("-out");
227+
cmd.add(certOut.toString());
228+
cmd.add("-days");
229+
cmd.add("1");
230+
cmd.add("-nodes");
231+
cmd.add("-subj");
232+
cmd.add(subj);
233+
if (sanExt != null) {
234+
cmd.add("-addext");
235+
cmd.add(sanExt);
236+
}
237+
ProcessBuilder pb = new ProcessBuilder(cmd);
238+
int exitCode = pb.start().waitFor();
239+
if (exitCode != 0) {
240+
throw new RuntimeException("OpenSSL cert generation failed for " + subj);
241+
}
242+
}
243+
244+
private void startLocalMtlsServer() throws Exception {
245+
server = HttpsServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
246+
serverPort = server.getAddress().getPort();
247+
248+
SSLContext serverSslContext = createServerSslContext();
249+
server.setHttpsConfigurator(
250+
new HttpsConfigurator(serverSslContext) {
251+
@Override
252+
public void configure(HttpsParameters params) {
253+
SSLEngine engine = serverSslContext.createSSLEngine();
254+
SSLParameters sslParams = serverSslContext.getDefaultSSLParameters();
255+
sslParams.setNeedClientAuth(true);
256+
params.setSSLParameters(sslParams);
257+
}
258+
});
259+
260+
server.createContext(
261+
"/sts/token",
262+
new HttpHandler() {
263+
@Override
264+
public void handle(HttpExchange exchange) throws IOException {
265+
int count = requestCounter.incrementAndGet();
266+
String peerPrincipalName = "UNKNOWN";
267+
try {
268+
if (exchange instanceof HttpsExchange) {
269+
Certificate[] certs = ((HttpsExchange) exchange).getSSLSession().getPeerCertificates();
270+
if (certs != null && certs.length > 0 && certs[0] instanceof X509Certificate) {
271+
peerPrincipalName = ((X509Certificate) certs[0]).getSubjectX500Principal().getName();
272+
peerCertificatesReceived.add(peerPrincipalName);
273+
}
274+
}
275+
} catch (Exception e) {
276+
e.printStackTrace();
277+
}
278+
279+
System.out.printf(
280+
"| Server Handler | Request #%d received peer certificate: %s%n",
281+
count, peerPrincipalName);
282+
283+
if (peerPrincipalName.contains("client-v1")) {
284+
try {
285+
Files.copy(cert2Path, activeCertPath, StandardCopyOption.REPLACE_EXISTING);
286+
Files.copy(key2Path, activeKeyPath, StandardCopyOption.REPLACE_EXISTING);
287+
System.out.println(
288+
"| Server Handler | Simulating cert rotation on disk: active cert is now Client Cert V2");
289+
} catch (Exception e) {
290+
e.printStackTrace();
291+
}
292+
293+
String errorResponse =
294+
"{\"error\": \"invalid_grant\", \"error_description\": \"mTLS Certificate Expired\"}";
295+
byte[] bytes = errorResponse.getBytes(StandardCharsets.UTF_8);
296+
exchange.getResponseHeaders().set("Content-Type", "application/json");
297+
exchange.sendResponseHeaders(401, bytes.length);
298+
try (OutputStream os = exchange.getResponseBody()) {
299+
os.write(bytes);
300+
}
301+
} else {
302+
String tokenResponse =
303+
"{\"access_token\": \"access_token_via_rotated_mtls_cert_v2\","
304+
+ " \"issued_token_type\": \"urn:ietf:params:oauth:token-type:access_token\","
305+
+ " \"token_type\": \"Bearer\", \"expires_in\": 3600}";
306+
byte[] bytes = tokenResponse.getBytes(StandardCharsets.UTF_8);
307+
exchange.getResponseHeaders().set("Content-Type", "application/json");
308+
exchange.sendResponseHeaders(200, bytes.length);
309+
try (OutputStream os = exchange.getResponseBody()) {
310+
os.write(bytes);
311+
}
312+
}
313+
}
314+
});
315+
316+
server.start();
317+
}
318+
319+
private SSLContext createServerSslContext() throws Exception {
320+
Path serverCertPath = tempDir.resolve("server.crt");
321+
Path serverKeyPath = tempDir.resolve("server.pem.key");
322+
323+
KeyStore keyStore = createKeyStoreFromPem(serverCertPath, serverKeyPath, "server");
324+
KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
325+
kmf.init(keyStore, "password".toCharArray());
326+
327+
KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
328+
trustStore.load(null, null);
329+
addCertToTrustStore(trustStore, cert1Path, "client-v1");
330+
addCertToTrustStore(trustStore, cert2Path, "client-v2");
331+
332+
TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
333+
tmf.init(trustStore);
334+
335+
SSLContext sslContext = SSLContext.getInstance("TLS");
336+
sslContext.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null);
337+
return sslContext;
338+
}
339+
340+
private void addCertToTrustStore(KeyStore trustStore, Path certPath, String alias) throws Exception {
341+
try (InputStream in = new FileInputStream(certPath.toFile())) {
342+
CertificateFactory cf = CertificateFactory.getInstance("X.509");
343+
X509Certificate cert = (X509Certificate) cf.generateCertificate(in);
344+
trustStore.setCertificateEntry(alias, cert);
345+
}
346+
}
347+
348+
private KeyStore createKeyStoreFromPem(Path certPath, Path keyPath, String alias) throws Exception {
349+
CertificateFactory cf = CertificateFactory.getInstance("X.509");
350+
X509Certificate cert;
351+
try (InputStream certIn = new FileInputStream(certPath.toFile())) {
352+
cert = (X509Certificate) cf.generateCertificate(certIn);
353+
}
354+
355+
String pemKey = new String(Files.readAllBytes(keyPath), StandardCharsets.UTF_8)
356+
.replace("-----BEGIN PRIVATE KEY-----", "")
357+
.replace("-----END PRIVATE KEY-----", "")
358+
.replaceAll("\\s", "");
359+
byte[] keyBytes = Base64.getDecoder().decode(pemKey);
360+
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes);
361+
KeyFactory kf = KeyFactory.getInstance("RSA");
362+
PrivateKey privateKey = kf.generatePrivate(spec);
363+
364+
KeyStore ks = KeyStore.getInstance("PKCS12");
365+
ks.load(null, null);
366+
ks.setKeyEntry(alias, privateKey, "password".toCharArray(), new Certificate[] {cert});
367+
return ks;
368+
}
369+
}

0 commit comments

Comments
 (0)