1818 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
1919 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
2020 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
21- * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
21+ * A PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
2222 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
2323 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
2424 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
3737import com .google .api .client .json .GenericJson ;
3838import com .google .auth .mtls .MtlsHttpTransportFactory ;
3939import 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 ;
40+ import java .io .BufferedReader ;
41+ import java .io .File ;
4642import java .io .FileInputStream ;
4743import java .io .FileOutputStream ;
48- import java .io .IOException ;
4944import java .io .InputStream ;
45+ import java .io .InputStreamReader ;
5046import java .io .OutputStream ;
5147import java .net .InetSocketAddress ;
5248import java .nio .charset .StandardCharsets ;
5349import java .nio .file .Files ;
5450import java .nio .file .Path ;
51+ import java .nio .file .Paths ;
5552import java .nio .file .StandardCopyOption ;
5653import java .security .KeyFactory ;
5754import java .security .KeyStore ;
6663import java .util .HashMap ;
6764import java .util .List ;
6865import java .util .Map ;
66+ import java .util .concurrent .CountDownLatch ;
67+ import java .util .concurrent .TimeUnit ;
6968import java .util .concurrent .atomic .AtomicInteger ;
7069import javax .net .ssl .KeyManagerFactory ;
7170import javax .net .ssl .SSLContext ;
72- import javax .net .ssl .SSLEngine ;
73- import javax .net .ssl .SSLParameters ;
71+ import javax .net .ssl .SSLServerSocket ;
72+ import javax .net .ssl .SSLSocket ;
7473import javax .net .ssl .TrustManagerFactory ;
7574import org .junit .jupiter .api .AfterEach ;
7675import org .junit .jupiter .api .BeforeEach ;
7776import org .junit .jupiter .api .Test ;
7877import org .junit .jupiter .api .io .TempDir ;
7978
8079/**
81- * End-to-end integration and manual verification test infrastructure for :
80+ * End-to-end integration test verifying :
8281 * 1) mTLS Dynamic Certificate Rotation (MtlsHttpTransportFactory + X509Provider)
83- * 2) STS 401 Unauthorized Retry Loop over real local HTTPS sockets requiring mTLS
82+ * 2) STS 401 Unauthorized Retry Interception over real local HTTPS sockets requiring mTLS
83+ *
84+ * Uses raw SSLServerSocket and static pre-generated test resource fixtures in testresources/mtls/
85+ * for zero host process / OpenSSL dependencies and zero JDK module instrumentation warnings.
8486 */
85- public class MtlsCertRotationIntegrationTest {
87+ public class ITMtlsCertRotationTest {
88+
89+ private static final String TESTRESOURCES_DIR = "testresources/mtls/" ;
8690
8791 @ TempDir Path tempDir ;
8892
89- private HttpsServer server ;
93+ private SSLServerSocket serverSocket ;
94+ private Thread serverThread ;
95+ private volatile boolean running = true ;
9096 private int serverPort ;
9197 private final List <String > peerCertificatesReceived = Collections .synchronizedList (new ArrayList <>());
9298 private final AtomicInteger requestCounter = new AtomicInteger (0 );
99+ private final CountDownLatch serverReadyLatch = new CountDownLatch (1 );
93100
94101 private Path certConfigPath ;
95102 private Path activeCertPath ;
@@ -98,12 +105,19 @@ public class MtlsCertRotationIntegrationTest {
98105 private Path key1Path ;
99106 private Path cert2Path ;
100107 private Path key2Path ;
108+ private Path serverCertPath ;
109+ private Path serverKeyPath ;
101110 private String oldTrustStore ;
102111 private String oldTrustStorePassword ;
103112
104113 @ BeforeEach
105114 void setUp () throws Exception {
106- generateCertificates ();
115+ cert1Path = Paths .get (TESTRESOURCES_DIR , "client_v1.crt" );
116+ key1Path = Paths .get (TESTRESOURCES_DIR , "client_v1.pem.key" );
117+ cert2Path = Paths .get (TESTRESOURCES_DIR , "client_v2.crt" );
118+ key2Path = Paths .get (TESTRESOURCES_DIR , "client_v2.pem.key" );
119+ serverCertPath = Paths .get (TESTRESOURCES_DIR , "server.crt" );
120+ serverKeyPath = Paths .get (TESTRESOURCES_DIR , "server.pem.key" );
107121
108122 activeCertPath = tempDir .resolve ("active_client.crt" );
109123 activeKeyPath = tempDir .resolve ("active_client.pem.key" );
@@ -122,28 +136,31 @@ void setUp() throws Exception {
122136 + "}\n " ;
123137 Files .write (certConfigPath , configJson .getBytes (StandardCharsets .UTF_8 ));
124138
125- // Save previous truststore properties and set to our temporary client truststore
126139 oldTrustStore = System .getProperty ("javax.net.ssl.trustStore" );
127140 oldTrustStorePassword = System .getProperty ("javax.net.ssl.trustStorePassword" );
128141
129142 Path clientTrustStorePath = tempDir .resolve ("client_truststore.p12" );
130143 KeyStore clientTrustStore = KeyStore .getInstance ("PKCS12" );
131144 clientTrustStore .load (null , null );
132- addCertToTrustStore (clientTrustStore , tempDir . resolve ( "server.crt" ) , "server" );
145+ addCertToTrustStore (clientTrustStore , serverCertPath , "server" );
133146 try (FileOutputStream fos = new FileOutputStream (clientTrustStorePath .toFile ())) {
134147 clientTrustStore .store (fos , "password" .toCharArray ());
135148 }
136149
137150 System .setProperty ("javax.net.ssl.trustStore" , clientTrustStorePath .toString ());
138151 System .setProperty ("javax.net.ssl.trustStorePassword" , "password" );
139152
140- startLocalMtlsServer ();
153+ startLocalMtlsServerSocket ();
141154 }
142155
143156 @ AfterEach
144157 void tearDown () {
145- if (server != null ) {
146- server .stop (0 );
158+ running = false ;
159+ if (serverSocket != null && !serverSocket .isClosed ()) {
160+ try {
161+ serverSocket .close ();
162+ } catch (Exception ignored ) {
163+ }
147164 }
148165 if (oldTrustStore != null ) {
149166 System .setProperty ("javax.net.ssl.trustStore" , oldTrustStore );
@@ -159,7 +176,7 @@ void tearDown() {
159176
160177 @ Test
161178 void endToEndMtlsCertRotation_on401Retry_reloadsRotatedCertAndSucceeds () throws Exception {
162- System . out . println ( "=== Starting End-to-End mTLS Certificate Rotation Integration Test === " );
179+ assertTrue ( serverReadyLatch . await ( 5 , TimeUnit . SECONDS ), "Server socket failed to start in time " );
163180
164181 X509Provider x509Provider = new X509Provider (certConfigPath .toString ());
165182 MtlsHttpTransportFactory transportFactory = new MtlsHttpTransportFactory (x509Provider );
@@ -193,133 +210,91 @@ void endToEndMtlsCertRotation_on401Retry_reloadsRotatedCertAndSucceeds() throws
193210
194211 assertTrue (peerCertificatesReceived .get (0 ).contains ("CN=client-v1" ));
195212 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- }
242213 }
243214
244- private void startLocalMtlsServer () throws Exception {
245- server = HttpsServer .create (new InetSocketAddress ("127.0.0.1" , 0 ), 0 );
246- serverPort = server .getAddress ().getPort ();
247-
215+ private void startLocalMtlsServerSocket () throws Exception {
248216 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 );
217+ serverSocket = (SSLServerSocket ) serverSslContext .getServerSocketFactory ().createServerSocket ();
218+ serverSocket .bind (new InetSocketAddress ("127.0.0.1" , 0 ));
219+ serverSocket .setNeedClientAuth (true );
220+ serverPort = serverSocket .getLocalPort ();
221+
222+ serverThread =
223+ new Thread (
224+ () -> {
225+ serverReadyLatch .countDown ();
226+ while (running ) {
227+ try (SSLSocket clientSocket = (SSLSocket ) serverSocket .accept ()) {
228+ clientSocket .startHandshake ();
229+ int count = requestCounter .incrementAndGet ();
230+ String peerPrincipalName = "UNKNOWN" ;
231+ Certificate [] certs = clientSocket .getSession ().getPeerCertificates ();
232+ if (certs != null && certs .length > 0 && certs [0 ] instanceof X509Certificate ) {
233+ peerPrincipalName = ((X509Certificate ) certs [0 ]).getSubjectX500Principal ().getName ();
234+ peerCertificatesReceived .add (peerPrincipalName );
235+ }
236+
237+ BufferedReader reader =
238+ new BufferedReader (new InputStreamReader (clientSocket .getInputStream (), StandardCharsets .UTF_8 ));
239+ String line ;
240+ int contentLength = 0 ;
241+ while ((line = reader .readLine ()) != null && !line .isEmpty ()) {
242+ if (line .toLowerCase ().startsWith ("content-length:" )) {
243+ contentLength = Integer .parseInt (line .split (":" )[1 ].trim ());
244+ }
245+ }
246+ if (contentLength > 0 ) {
247+ char [] body = new char [contentLength ];
248+ reader .read (body , 0 , contentLength );
249+ }
250+
251+ OutputStream os = clientSocket .getOutputStream ();
252+ if (peerPrincipalName .contains ("client-v1" )) {
253+ Path tmpCert = tempDir .resolve ("tmp_active_cert.crt" );
254+ Path tmpKey = tempDir .resolve ("tmp_active_key.pem.key" );
255+ Files .copy (cert2Path , tmpCert , StandardCopyOption .REPLACE_EXISTING );
256+ Files .copy (key2Path , tmpKey , StandardCopyOption .REPLACE_EXISTING );
257+ Files .move (tmpCert , activeCertPath , StandardCopyOption .ATOMIC_MOVE );
258+ Files .move (tmpKey , activeKeyPath , StandardCopyOption .ATOMIC_MOVE );
259+
260+ String jsonError =
261+ "{\" error\" : \" invalid_grant\" , \" error_description\" : \" mTLS Certificate Expired\" }" ;
262+ byte [] payload = jsonError .getBytes (StandardCharsets .UTF_8 );
263+ String response =
264+ "HTTP/1.1 401 Unauthorized\r \n "
265+ + "Content-Type: application/json\r \n "
266+ + "Content-Length: " + payload .length + "\r \n "
267+ + "Connection: close\r \n \r \n " ;
268+ os .write (response .getBytes (StandardCharsets .UTF_8 ));
269+ os .write (payload );
270+ os .flush ();
271+ } else {
272+ String jsonOk =
273+ "{\" access_token\" : \" access_token_via_rotated_mtls_cert_v2\" ,"
274+ + " \" issued_token_type\" : \" urn:ietf:params:oauth:token-type:access_token\" ,"
275+ + " \" token_type\" : \" Bearer\" , \" expires_in\" : 3600}" ;
276+ byte [] payload = jsonOk .getBytes (StandardCharsets .UTF_8 );
277+ String response =
278+ "HTTP/1.1 200 OK\r \n "
279+ + "Content-Type: application/json\r \n "
280+ + "Content-Length: " + payload .length + "\r \n "
281+ + "Connection: close\r \n \r \n " ;
282+ os .write (response .getBytes (StandardCharsets .UTF_8 ));
283+ os .write (payload );
284+ os .flush ();
285+ }
286+ } catch (Exception e ) {
287+ if (running ) {
288+ e .printStackTrace ();
289+ }
273290 }
274291 }
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 ();
292+ });
293+ serverThread .setDaemon (true );
294+ serverThread .start ();
317295 }
318296
319297 private SSLContext createServerSslContext () throws Exception {
320- Path serverCertPath = tempDir .resolve ("server.crt" );
321- Path serverKeyPath = tempDir .resolve ("server.pem.key" );
322-
323298 KeyStore keyStore = createKeyStoreFromPem (serverCertPath , serverKeyPath , "server" );
324299 KeyManagerFactory kmf = KeyManagerFactory .getInstance (KeyManagerFactory .getDefaultAlgorithm ());
325300 kmf .init (keyStore , "password" .toCharArray ());
0 commit comments