diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuthException.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuthException.java
index 975b8bd123d5..0cd177a9a215 100644
--- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuthException.java
+++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuthException.java
@@ -36,7 +36,7 @@
import com.google.api.client.http.HttpResponseException;
import com.google.api.client.json.GenericJson;
import com.google.api.client.json.JsonParser;
-import java.io.IOException;
+import java.util.Map;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
@@ -99,20 +99,67 @@ int getHttpStatusCode() {
return httpStatusCode;
}
- static OAuthException createFromHttpResponseException(HttpResponseException e)
- throws IOException {
- JsonParser parser = OAuth2Utils.JSON_FACTORY.createJsonParser((e).getContent());
- GenericJson errorResponse = parser.parseAndClose(GenericJson.class);
-
- String errorCode = (String) errorResponse.get("error");
- String errorDescription = null;
- String errorUri = null;
- if (errorResponse.containsKey("error_description")) {
- errorDescription = (String) errorResponse.get("error_description");
+ static OAuthException createFromHttpResponseException(HttpResponseException e) {
+ String content = e.getContent();
+ if (content == null || content.trim().isEmpty()) {
+ OAuthException oauthException =
+ new OAuthException(
+ "http_error_" + e.getStatusCode(), e.getStatusMessage(), null, e.getStatusCode());
+ oauthException.initCause(e);
+ return oauthException;
}
- if (errorResponse.containsKey("error_uri")) {
- errorUri = (String) errorResponse.get("error_uri");
+ try {
+ JsonParser parser = OAuth2Utils.JSON_FACTORY.createJsonParser(content);
+ GenericJson errorResponse = parser.parseAndClose(GenericJson.class);
+ if (errorResponse == null) {
+ OAuthException oauthException =
+ new OAuthException(
+ "http_error_" + e.getStatusCode(), e.getStatusMessage(), null, e.getStatusCode());
+ oauthException.initCause(e);
+ return oauthException;
+ }
+
+ String errorCode = null;
+ String errorDescription = null;
+ Object rawError = errorResponse.get("error");
+ if (rawError instanceof String) {
+ errorCode = (String) rawError;
+ } else if (rawError instanceof Map) {
+ Map, ?> errorMap = (Map, ?>) rawError;
+ if (errorMap.get("status") instanceof String) {
+ errorCode = (String) errorMap.get("status");
+ }
+ if (errorMap.get("message") instanceof String) {
+ errorDescription = (String) errorMap.get("message");
+ }
+ }
+ if (errorCode == null) {
+ errorCode = "http_error_" + e.getStatusCode();
+ }
+ String errorUri = null;
+ if (errorResponse.get("error_description") instanceof String) {
+ errorDescription = (String) errorResponse.get("error_description");
+ } else if (errorDescription == null && errorResponse.get("message") instanceof String) {
+ errorDescription = (String) errorResponse.get("message");
+ }
+ if (errorDescription == null && !(rawError instanceof String)) {
+ errorDescription =
+ errorResponse.isEmpty() && e.getStatusMessage() != null
+ ? e.getStatusMessage()
+ : content;
+ }
+ if (errorResponse.get("error_uri") instanceof String) {
+ errorUri = (String) errorResponse.get("error_uri");
+ }
+ OAuthException oauthException =
+ new OAuthException(errorCode, errorDescription, errorUri, e.getStatusCode());
+ oauthException.initCause(e);
+ return oauthException;
+ } catch (Exception parseException) {
+ OAuthException oauthException =
+ new OAuthException("http_error_" + e.getStatusCode(), content, null, e.getStatusCode());
+ oauthException.initCause(e);
+ return oauthException;
}
- return new OAuthException(errorCode, errorDescription, errorUri, e.getStatusCode());
}
}
diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ITWorkloadIdentityFederationTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ITWorkloadIdentityFederationTest.java
index 0f1cdd3092f6..ef38cca471fc 100644
--- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ITWorkloadIdentityFederationTest.java
+++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ITWorkloadIdentityFederationTest.java
@@ -31,13 +31,16 @@
package com.google.auth.oauth2;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
import com.google.api.client.http.GenericUrl;
import com.google.api.client.http.HttpRequest;
import com.google.api.client.http.HttpRequestFactory;
import com.google.api.client.http.HttpResponse;
+import com.google.api.client.http.HttpTransport;
import com.google.api.client.http.UrlEncodedContent;
import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.json.GenericJson;
@@ -46,12 +49,16 @@
import com.google.api.client.json.gson.GsonFactory;
import com.google.api.client.util.GenericData;
import com.google.auth.http.HttpCredentialsAdapter;
+import com.google.auth.http.HttpTransportFactory;
+import com.google.auth.mtls.MtlsHttpTransportFactory;
+import com.google.auth.mtls.X509Provider;
import com.google.auth.oauth2.ExternalAccountCredentials.SubjectTokenTypes;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
+import java.security.KeyStore;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
@@ -281,6 +288,205 @@ void identityPoolCredentials_withProgrammaticAuth() throws IOException {
callGcs(identityPoolCredentials);
}
+ /**
+ * IdentityPoolCredentials (OIDC provider with certificate-bound workload and actor token): Uses
+ * the service account to generate Google ID tokens for subject and actor tokens. Writes both
+ * tokens to a temporary JSON file with subject_token and actor_token field names. Configures
+ * certificate_config_location pointing to the certificate config. Exchanges the tokens over the
+ * mTLS STS endpoint (https://sts.mtls.googleapis.com/v1/token) and calls GCS.
+ */
+ @Test
+ void identityPoolCredentials_withCertificateBoundWorkloadAndActorToken() throws Exception {
+ String certConfigPath = getMtlsCertificateConfigPath();
+ String subjectToken = generateGoogleIdToken(OIDC_AUDIENCE);
+ String actorToken = generateGoogleIdToken(OIDC_AUDIENCE);
+
+ File tokenFile =
+ File.createTempFile(
+ "ITWorkloadIdentityFederation_cert_actor", /* suffix= */ null, /* directory= */ null);
+ tokenFile.deleteOnExit();
+ try {
+ GenericJson tokenJson = new GenericJson();
+ tokenJson.setFactory(OAuth2Utils.JSON_FACTORY);
+ tokenJson.put("subject_token", subjectToken);
+ tokenJson.put("actor_token", actorToken);
+
+ OAuth2Utils.writeInputStreamToFile(
+ new ByteArrayInputStream(tokenJson.toPrettyString().getBytes(StandardCharsets.UTF_8)),
+ tokenFile.getAbsolutePath());
+
+ GenericJson config = new GenericJson();
+ config.put("type", "external_account");
+ config.put("audience", OIDC_AUDIENCE);
+ config.put("subject_token_type", "urn:ietf:params:oauth:token-type:jwt");
+ config.put("actor_token_type", "urn:ietf:params:oauth:token-type:jwt");
+ config.put("token_url", "https://sts.mtls.googleapis.com/v1/token");
+ config.put(
+ "service_account_impersonation_url",
+ String.format(
+ "https://iamcredentials.mtls.googleapis.com/v1/projects/-/serviceAccounts/%s:generateAccessToken",
+ clientEmail));
+
+ GenericJson credentialSource = new GenericJson();
+ credentialSource.put("file", tokenFile.getAbsolutePath());
+
+ GenericJson format = new GenericJson();
+ format.put("type", "json");
+ format.put("subject_token_field_name", "subject_token");
+ format.put("actor_token_field_name", "actor_token");
+ credentialSource.put("format", format);
+
+ GenericJson certificate = new GenericJson();
+ certificate.put("certificate_config_location", certConfigPath);
+ credentialSource.put("certificate", certificate);
+
+ config.put("credential_source", credentialSource);
+
+ IdentityPoolCredentials identityPoolCredentials =
+ (IdentityPoolCredentials)
+ ExternalAccountCredentials.fromJson(config, OAuth2Utils.HTTP_TRANSPORT_FACTORY);
+
+ KeyStore keyStore = new X509Provider(certConfigPath).getKeyStore();
+ HttpTransport mtlsTransport = new MtlsHttpTransportFactory(keyStore).create();
+ callGcs(
+ identityPoolCredentials,
+ mtlsTransport,
+ "https://storage.mtls.googleapis.com/storage/v1/b/");
+ } finally {
+ tokenFile.delete();
+ }
+ }
+
+ /**
+ * IdentityPoolCredentials (OIDC provider with programmatic mTLS and actor token): Uses the
+ * service account to generate Google ID tokens for subject and actor tokens via suppliers.
+ * Configures mTLS transport using MtlsHttpTransportFactory with KeyStore loaded from the mTLS
+ * certificate config. Exchanges the tokens over mTLS STS endpoint and calls GCS.
+ */
+ @Test
+ void identityPoolCredentials_withProgrammaticMtlsAndActorToken() throws Exception {
+ IdentityPoolSubjectTokenSupplier tokenSupplier =
+ context -> generateGoogleIdToken(OIDC_AUDIENCE);
+ IdentityPoolActorTokenSupplier actorSupplier = context -> generateGoogleIdToken(OIDC_AUDIENCE);
+
+ X509Provider x509Provider = new X509Provider(getMtlsCertificateConfigPath());
+ HttpTransportFactory transportFactory =
+ new MtlsHttpTransportFactory(x509Provider.getKeyStore());
+
+ IdentityPoolCredentials credentials =
+ IdentityPoolCredentials.newBuilder()
+ .setSubjectTokenSupplier(tokenSupplier)
+ .setActorTokenSupplier(actorSupplier)
+ .setActorTokenType(SubjectTokenTypes.JWT.value)
+ .setAudience(OIDC_AUDIENCE)
+ .setSubjectTokenType(SubjectTokenTypes.JWT)
+ .setTokenUrl("https://sts.mtls.googleapis.com/v1/token")
+ .setServiceAccountImpersonationUrl(
+ String.format(
+ "https://iamcredentials.mtls.googleapis.com/v1/projects/-/serviceAccounts/%s:generateAccessToken",
+ clientEmail))
+ .setHttpTransportFactory(transportFactory)
+ .build();
+
+ callGcs(
+ credentials,
+ transportFactory.create(),
+ "https://storage.mtls.googleapis.com/storage/v1/b/");
+ }
+
+ /**
+ * IdentityPoolCredentials (OIDC provider with certificate-bound workload and actor token, direct
+ * STS): Exchanges tokens directly over the mTLS STS endpoint
+ * (https://sts.mtls.googleapis.com/v1/token) without service account impersonation.
+ */
+ @Test
+ void identityPoolCredentials_directSts_withCertificateBoundWorkloadAndActorToken()
+ throws IOException {
+ String certConfigPath = getMtlsCertificateConfigPath();
+ String subjectToken = generateGoogleIdToken(OIDC_AUDIENCE);
+ String actorToken = generateGoogleIdToken(OIDC_AUDIENCE);
+
+ File tokenFile =
+ File.createTempFile(
+ "ITWorkloadIdentityFederation_direct_cert_actor",
+ /* suffix= */ null,
+ /* directory= */ null);
+ tokenFile.deleteOnExit();
+ try {
+ GenericJson tokenJson = new GenericJson();
+ tokenJson.setFactory(OAuth2Utils.JSON_FACTORY);
+ tokenJson.put("subject_token", subjectToken);
+ tokenJson.put("actor_token", actorToken);
+
+ OAuth2Utils.writeInputStreamToFile(
+ new ByteArrayInputStream(tokenJson.toPrettyString().getBytes(StandardCharsets.UTF_8)),
+ tokenFile.getAbsolutePath());
+
+ GenericJson config = new GenericJson();
+ config.put("type", "external_account");
+ config.put("audience", OIDC_AUDIENCE);
+ config.put("subject_token_type", "urn:ietf:params:oauth:token-type:jwt");
+ config.put("actor_token_type", "urn:ietf:params:oauth:token-type:jwt");
+ config.put("token_url", "https://sts.mtls.googleapis.com/v1/token");
+
+ GenericJson credentialSource = new GenericJson();
+ credentialSource.put("file", tokenFile.getAbsolutePath());
+
+ GenericJson format = new GenericJson();
+ format.put("type", "json");
+ format.put("subject_token_field_name", "subject_token");
+ format.put("actor_token_field_name", "actor_token");
+ credentialSource.put("format", format);
+
+ GenericJson certificate = new GenericJson();
+ certificate.put("certificate_config_location", certConfigPath);
+ credentialSource.put("certificate", certificate);
+
+ config.put("credential_source", credentialSource);
+
+ IdentityPoolCredentials identityPoolCredentials =
+ (IdentityPoolCredentials)
+ ExternalAccountCredentials.fromJson(config, OAuth2Utils.HTTP_TRANSPORT_FACTORY);
+
+ AccessToken accessToken = identityPoolCredentials.refreshAccessToken();
+ assertNotNull(accessToken);
+ assertNotNull(accessToken.getTokenValue());
+ } finally {
+ tokenFile.delete();
+ }
+ }
+
+ /**
+ * IdentityPoolCredentials (OIDC provider with programmatic mTLS and actor token, direct STS):
+ * Uses suppliers for subject and actor tokens, configuring MtlsHttpTransportFactory. Exchanges
+ * directly over mTLS STS endpoint without service account impersonation.
+ */
+ @Test
+ void identityPoolCredentials_directSts_withProgrammaticMtlsAndActorToken() throws Exception {
+ IdentityPoolSubjectTokenSupplier tokenSupplier =
+ context -> generateGoogleIdToken(OIDC_AUDIENCE);
+ IdentityPoolActorTokenSupplier actorSupplier = context -> generateGoogleIdToken(OIDC_AUDIENCE);
+
+ X509Provider x509Provider = new X509Provider(getMtlsCertificateConfigPath());
+ HttpTransportFactory transportFactory =
+ new MtlsHttpTransportFactory(x509Provider.getKeyStore());
+
+ IdentityPoolCredentials credentials =
+ IdentityPoolCredentials.newBuilder()
+ .setSubjectTokenSupplier(tokenSupplier)
+ .setActorTokenSupplier(actorSupplier)
+ .setActorTokenType(SubjectTokenTypes.JWT.value)
+ .setAudience(OIDC_AUDIENCE)
+ .setSubjectTokenType(SubjectTokenTypes.JWT)
+ .setTokenUrl("https://sts.mtls.googleapis.com/v1/token")
+ .setHttpTransportFactory(transportFactory)
+ .build();
+
+ AccessToken accessToken = credentials.refreshAccessToken();
+ assertNotNull(accessToken);
+ assertNotNull(accessToken.getTokenValue());
+ }
+
private GenericJson buildIdentityPoolCredentialConfig() throws IOException {
String idToken = generateGoogleIdToken(OIDC_AUDIENCE);
@@ -382,24 +588,41 @@ private GenericJson buildAwsCredentialConfig() {
return config;
}
+ private String getMtlsCertificateConfigPath() {
+ String certConfigPath = System.getenv("GOOGLE_API_CERTIFICATE_CONFIG");
+ assumeTrue(
+ certConfigPath != null && !certConfigPath.isEmpty(),
+ "Skipping mTLS test: GOOGLE_API_CERTIFICATE_CONFIG env variable is not set.");
+ return certConfigPath;
+ }
+
private void callGcs(GoogleCredentials credentials) throws IOException {
+ callGcs(credentials, new NetHttpTransport(), "https://storage.googleapis.com/storage/v1/b/");
+ }
+
+ private void callGcs(
+ GoogleCredentials credentials, HttpTransport transport, String storageBaseUrl)
+ throws IOException {
String bucketName = System.getenv("GCS_BUCKET");
if (bucketName == null) {
fail("GCS bucket name not set through GCS_BUCKET env variable.");
}
- String url = "https://storage.googleapis.com/storage/v1/b/" + bucketName;
+ String url = storageBaseUrl + bucketName;
HttpCredentialsAdapter credentialsAdapter = new HttpCredentialsAdapter(credentials);
- HttpRequestFactory requestFactory =
- new NetHttpTransport().createRequestFactory(credentialsAdapter);
+ HttpRequestFactory requestFactory = transport.createRequestFactory(credentialsAdapter);
HttpRequest request = requestFactory.buildGetRequest(new GenericUrl(url));
JsonObjectParser parser = new JsonObjectParser(GsonFactory.getDefaultInstance());
request.setParser(parser);
HttpResponse response = request.execute();
- assertTrue(response.isSuccessStatusCode());
+ try {
+ assertTrue(response.isSuccessStatusCode());
+ } finally {
+ response.disconnect();
+ }
}
/**
diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MtlsPipelineLocalTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MtlsPipelineLocalTest.java
new file mode 100644
index 000000000000..293a3a34a229
--- /dev/null
+++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MtlsPipelineLocalTest.java
@@ -0,0 +1,1366 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ * * Redistributions in binary form must reproduce the above
+ * copyright notice, this list of conditions and the following disclaimer
+ * in the documentation and/or other materials provided with the
+ * distribution.
+ *
+ * * Neither the name of Google LLC nor the names of its
+ * contributors may be used to endorse or promote products derived from
+ * this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package com.google.auth.oauth2;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.google.api.client.json.GenericJson;
+import com.google.api.client.util.SecurityUtils;
+import com.google.auth.http.HttpTransportFactory;
+import com.google.auth.mtls.MtlsHttpTransportFactory;
+import com.google.auth.oauth2.ExternalAccountCredentials.SubjectTokenTypes;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpHandler;
+import com.sun.net.httpserver.HttpsConfigurator;
+import com.sun.net.httpserver.HttpsExchange;
+import com.sun.net.httpserver.HttpsParameters;
+import com.sun.net.httpserver.HttpsServer;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.SequenceInputStream;
+import java.net.InetSocketAddress;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
+import java.security.KeyStore;
+import java.security.SecureRandom;
+import java.security.cert.Certificate;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.Callable;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import javax.net.ssl.HostnameVerifier;
+import javax.net.ssl.HttpsURLConnection;
+import javax.net.ssl.KeyManagerFactory;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLEngine;
+import javax.net.ssl.SSLParameters;
+import javax.net.ssl.SSLSession;
+import javax.net.ssl.TrustManagerFactory;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.api.parallel.Isolated;
+
+/**
+ * Hermetic in-process socket test suite for the mTLS OAuth token exchange pipeline.
+ *
+ *
Spins up an in-process JDK {@link HttpsServer} on {@code localhost} requiring client
+ * authentication ({@code setNeedClientAuth(true)}), validating peer certificates and request
+ * payloads across mTLS token exchanges, 401 retry with cert reloading, concurrent refreshes, and
+ * atomic token reads.
+ */
+@Isolated
+class MtlsPipelineLocalTest {
+
+ private static final String TEST_CERT_PATH = "testresources/mtls/test_cert.pem";
+ private static final String TEST_KEY_PATH = "testresources/mtls/test_key.pem";
+ private static final String TEST_CERT_2_PATH = "testresources/mtls/test_cert_2.pem";
+ private static final String TEST_KEY_2_PATH = "testresources/mtls/test_key_2.pem";
+ private static final String AUDIENCE =
+ "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider";
+ private static final String ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token";
+
+ private static File tempTrustStoreFile;
+ private static HostnameVerifier originalHostnameVerifier;
+ private static String originalTrustStore;
+ private static String originalTrustStorePassword;
+ private static String originalTrustStoreType;
+
+ private HttpsServer server;
+ private int serverPort;
+ private ExecutorService serverExecutor;
+
+ @BeforeAll
+ static void beforeAll() throws Exception {
+ // Initialize the default SSLContext before overriding javax.net.ssl.trustStore below, so it
+ // keeps the JVM's default trust settings.
+ SSLContext.getDefault();
+ originalHostnameVerifier = HttpsURLConnection.getDefaultHostnameVerifier();
+ HttpsURLConnection.setDefaultHostnameVerifier((hostname, session) -> true);
+
+ originalTrustStore = System.getProperty("javax.net.ssl.trustStore");
+ originalTrustStorePassword = System.getProperty("javax.net.ssl.trustStorePassword");
+ originalTrustStoreType = System.getProperty("javax.net.ssl.trustStoreType");
+
+ // Create a truststore containing test_cert.pem so client NetHttpTransport trusts the server
+ KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
+ trustStore.load(null, null);
+ CertificateFactory cf = CertificateFactory.getInstance("X.509");
+ try (FileInputStream fis = new FileInputStream(new File(TEST_CERT_PATH))) {
+ Certificate cert = cf.generateCertificate(fis);
+ trustStore.setCertificateEntry("server-cert", cert);
+ }
+
+ tempTrustStoreFile = File.createTempFile("mtls_test_truststore", ".jks");
+ tempTrustStoreFile.deleteOnExit();
+ try (FileOutputStream fos = new FileOutputStream(tempTrustStoreFile)) {
+ trustStore.store(fos, "changeit".toCharArray());
+ }
+
+ System.setProperty("javax.net.ssl.trustStore", tempTrustStoreFile.getAbsolutePath());
+ System.setProperty("javax.net.ssl.trustStorePassword", "changeit");
+ System.setProperty("javax.net.ssl.trustStoreType", KeyStore.getDefaultType());
+ }
+
+ @AfterAll
+ static void afterAll() {
+ if (originalHostnameVerifier != null) {
+ HttpsURLConnection.setDefaultHostnameVerifier(originalHostnameVerifier);
+ }
+ if (originalTrustStore != null) {
+ System.setProperty("javax.net.ssl.trustStore", originalTrustStore);
+ } else {
+ System.clearProperty("javax.net.ssl.trustStore");
+ }
+ if (originalTrustStorePassword != null) {
+ System.setProperty("javax.net.ssl.trustStorePassword", originalTrustStorePassword);
+ } else {
+ System.clearProperty("javax.net.ssl.trustStorePassword");
+ }
+ if (originalTrustStoreType != null) {
+ System.setProperty("javax.net.ssl.trustStoreType", originalTrustStoreType);
+ } else {
+ System.clearProperty("javax.net.ssl.trustStoreType");
+ }
+ if (tempTrustStoreFile != null && tempTrustStoreFile.exists()) {
+ tempTrustStoreFile.delete();
+ }
+ }
+
+ @BeforeEach
+ void setUp() throws Exception {
+ SSLContext sslContext = createServerSSLContext();
+ server = HttpsServer.create(new InetSocketAddress("localhost", 0), 0);
+ server.setHttpsConfigurator(
+ new HttpsConfigurator(sslContext) {
+ @Override
+ public void configure(HttpsParameters params) {
+ SSLContext context = getSSLContext();
+ SSLEngine engine = context.createSSLEngine();
+ SSLParameters sslParams = context.getDefaultSSLParameters();
+ sslParams.setNeedClientAuth(true);
+ sslParams.setCipherSuites(engine.getEnabledCipherSuites());
+ sslParams.setProtocols(engine.getEnabledProtocols());
+ params.setSSLParameters(sslParams);
+ }
+ });
+
+ serverExecutor = Executors.newCachedThreadPool();
+ server.setExecutor(serverExecutor);
+ serverPort = server.getAddress().getPort();
+ }
+
+ @AfterEach
+ void tearDown() throws InterruptedException {
+ if (server != null) {
+ server.stop(0);
+ }
+ if (serverExecutor != null) {
+ serverExecutor.shutdownNow();
+ serverExecutor.awaitTermination(5, TimeUnit.SECONDS);
+ }
+ }
+
+ private static SSLContext createServerSSLContext() throws Exception {
+ KeyStore serverKeyStore;
+ try (InputStream certStream = new FileInputStream(new File(TEST_CERT_PATH));
+ InputStream keyStream = new FileInputStream(new File(TEST_KEY_PATH));
+ InputStream combined = new SequenceInputStream(certStream, keyStream)) {
+ serverKeyStore = SecurityUtils.createMtlsKeyStore(combined);
+ }
+
+ KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
+ kmf.init(serverKeyStore, "".toCharArray());
+
+ KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
+ trustStore.load(null, null);
+ CertificateFactory cf = CertificateFactory.getInstance("X.509");
+ try (FileInputStream fis = new FileInputStream(new File(TEST_CERT_PATH))) {
+ Certificate cert = cf.generateCertificate(fis);
+ trustStore.setCertificateEntry("client-cert", cert);
+ }
+ try (FileInputStream fis = new FileInputStream(new File(TEST_CERT_2_PATH))) {
+ Certificate cert2 = cf.generateCertificate(fis);
+ trustStore.setCertificateEntry("client-cert-2", cert2);
+ }
+
+ TrustManagerFactory tmf =
+ TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
+ tmf.init(trustStore);
+
+ SSLContext sslContext = SSLContext.getInstance("TLS");
+ sslContext.init(kmf.getKeyManagers(), tmf.getTrustManagers(), new SecureRandom());
+ return sslContext;
+ }
+
+ private static KeyStore createClientKeyStore() throws Exception {
+ try (InputStream certStream = new FileInputStream(new File(TEST_CERT_PATH));
+ InputStream keyStream = new FileInputStream(new File(TEST_KEY_PATH));
+ InputStream combined = new SequenceInputStream(certStream, keyStream)) {
+ return SecurityUtils.createMtlsKeyStore(combined);
+ }
+ }
+
+ private static Map parseFormData(String body) throws Exception {
+ Map params = new HashMap<>();
+ for (String pair : body.split("&")) {
+ int idx = pair.indexOf("=");
+ if (idx > 0) {
+ String key = URLDecoder.decode(pair.substring(0, idx), "UTF-8");
+ String value = URLDecoder.decode(pair.substring(idx + 1), "UTF-8");
+ params.put(key, value);
+ }
+ }
+ return params;
+ }
+
+ private static String readRequestBody(HttpExchange exchange) throws IOException {
+ try (InputStream is = exchange.getRequestBody();
+ ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
+ byte[] buf = new byte[1024];
+ int read;
+ while ((read = is.read(buf)) != -1) {
+ baos.write(buf, 0, read);
+ }
+ return baos.toString(StandardCharsets.UTF_8.name());
+ }
+ }
+
+ private static void sendJsonResponse(HttpExchange exchange, int statusCode, String json)
+ throws IOException {
+ byte[] bytes = json.getBytes(StandardCharsets.UTF_8);
+ exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
+ if (statusCode == 401) {
+ exchange.getResponseHeaders().set("WWW-Authenticate", "Bearer realm=\"oauth\"");
+ }
+ exchange.sendResponseHeaders(statusCode, bytes.length);
+ try (OutputStream os = exchange.getResponseBody()) {
+ os.write(bytes);
+ os.flush();
+ }
+ }
+
+ /**
+ * Scenario A: testMtlsPipeline_verifiesPeerCertAndPayload
+ *
+ * Verify server receives client certificate via SSLSession.getPeerCertificates(), checks token
+ * exchange request body (grant_type, subject_token, actor_token, actor_token_type), and returns
+ * access token.
+ */
+ @Test
+ void testMtlsPipeline_verifiesPeerCertAndPayload(@TempDir Path tempDir) throws Exception {
+ AtomicReference capturedCerts = new AtomicReference<>();
+ AtomicReference