|
| 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