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> capturedParams = new AtomicReference<>(); + + server.createContext( + "/v1/token", + new HttpHandler() { + @Override + public void handle(HttpExchange exchange) throws IOException { + try { + HttpsExchange httpsExchange = (HttpsExchange) exchange; + SSLSession session = httpsExchange.getSSLSession(); + capturedCerts.set(session.getPeerCertificates()); + + String body = readRequestBody(exchange); + capturedParams.set(parseFormData(body)); + + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("access_token", "test_access_token_payload_verified"); + response.put("token_type", "Bearer"); + response.put("expires_in", 3600); + response.put("issued_token_type", ACCESS_TOKEN_TYPE); + sendJsonResponse(exchange, 200, response.toPrettyString()); + } catch (Exception e) { + sendJsonResponse(exchange, 500, "{\"error\": \"" + e.getMessage() + "\"}"); + } + } + }); + server.start(); + + Path tokenFile = tempDir.resolve("credential.json"); + GenericJson tokenJson = new GenericJson(); + tokenJson.setFactory(OAuth2Utils.JSON_FACTORY); + tokenJson.put("subject_token", "testSubjectTokenPayload123"); + tokenJson.put("actor_token", "testActorTokenPayload456"); + OAuth2Utils.writeInputStreamToFile( + new ByteArrayInputStream(tokenJson.toPrettyString().getBytes(StandardCharsets.UTF_8)), + tokenFile.toString()); + + String configJson = + "{\n" + + " \"type\": \"external_account\",\n" + + " \"audience\": \"" + + AUDIENCE + + "\",\n" + + " \"subject_token_type\": \"urn:ietf:params:oauth:token-type:jwt\",\n" + + " \"actor_token_type\": \"urn:ietf:params:oauth:token-type:jwt\",\n" + + " \"token_url\": \"https://localhost:" + + serverPort + + "/v1/token\",\n" + + " \"credential_source\": {\n" + + " \"file\": \"" + + tokenFile.toString().replace("\\", "\\\\") + + "\",\n" + + " \"format\": {\n" + + " \"type\": \"json\",\n" + + " \"subject_token_field_name\": \"subject_token\",\n" + + " \"actor_token_field_name\": \"actor_token\"\n" + + " },\n" + + " \"certificate\": {\n" + + " \"certificate_config_location\":" + + " \"testresources/mtls/certificate_config.json\"\n" + + " }\n" + + " }\n" + + "}"; + + IdentityPoolCredentials credentials = + (IdentityPoolCredentials) + ExternalAccountCredentials.fromStream( + new ByteArrayInputStream(configJson.getBytes(StandardCharsets.UTF_8))); + + AccessToken accessToken = credentials.refreshAccessToken(); + assertEquals("test_access_token_payload_verified", accessToken.getTokenValue()); + + // Verify peer certificates captured on server + Certificate[] peerCerts = capturedCerts.get(); + assertNotNull(peerCerts); + assertTrue(peerCerts.length > 0); + assertTrue(peerCerts[0] instanceof X509Certificate); + X509Certificate clientCert = (X509Certificate) peerCerts[0]; + assertTrue( + clientCert + .getSubjectX500Principal() + .getName() + .contains("1009120726878.apps.googleusercontent.com")); + + // Verify request payload form parameters + Map params = capturedParams.get(); + assertNotNull(params); + assertEquals("urn:ietf:params:oauth:grant-type:token-exchange", params.get("grant_type")); + assertEquals(AUDIENCE, params.get("audience")); + assertEquals("testSubjectTokenPayload123", params.get("subject_token")); + assertEquals("urn:ietf:params:oauth:token-type:jwt", params.get("subject_token_type")); + assertEquals("testActorTokenPayload456", params.get("actor_token")); + assertEquals("urn:ietf:params:oauth:token-type:jwt", params.get("actor_token_type")); + assertEquals( + "urn:ietf:params:oauth:token-type:access_token", params.get("requested_token_type")); + } + + /** + * Scenario B: testMtlsPipeline_401Retry_reReadsCertFromDisk + * + *

Verify that when server responds with 401 Unauthorized on initial exchange, + * IdentityPoolCredentials catches it, re-reads fresh KeyStore from X509Provider, and retries the + * exchange successfully. + */ + @Test + void testMtlsPipeline_401Retry_reReadsCertFromDisk(@TempDir Path tempDir) throws Exception { + Path dynamicCertFile = tempDir.resolve("dynamic_cert.pem"); + Path dynamicKeyFile = tempDir.resolve("dynamic_key.pem"); + Path certConfigFile = tempDir.resolve("dynamic_cert_config.json"); + + // Write initial cert and key (Cert A) to disk + Files.copy(Paths.get(TEST_CERT_PATH), dynamicCertFile); + Files.copy(Paths.get(TEST_KEY_PATH), dynamicKeyFile); + + String certConfigContent = + "{\n" + + " \"cert_configs\": {\n" + + " \"workload\": {\n" + + " \"cert_path\": \"" + + dynamicCertFile.toString().replace("\\", "\\\\") + + "\",\n" + + " \"key_path\": \"" + + dynamicKeyFile.toString().replace("\\", "\\\\") + + "\"\n" + + " }\n" + + " }\n" + + "}"; + Files.write(certConfigFile, certConfigContent.getBytes(StandardCharsets.UTF_8)); + + AtomicInteger requestCount = new AtomicInteger(0); + List certsPerRequest = Collections.synchronizedList(new ArrayList<>()); + + server.createContext( + "/v1/token", + new HttpHandler() { + @Override + public void handle(HttpExchange exchange) throws IOException { + try { + HttpsExchange httpsExchange = (HttpsExchange) exchange; + SSLSession session = httpsExchange.getSSLSession(); + certsPerRequest.add(session.getPeerCertificates()); + + // Always read and drain the request body + readRequestBody(exchange); + + int count = requestCount.incrementAndGet(); + if (count == 1) { + // Rotate cert files on disk from Cert A to Cert B before returning 401 + Files.write(dynamicCertFile, Files.readAllBytes(Paths.get(TEST_CERT_2_PATH))); + Files.write(dynamicKeyFile, Files.readAllBytes(Paths.get(TEST_KEY_2_PATH))); + + // Initial exchange responds with 401 Unauthorized + GenericJson error = new GenericJson(); + error.setFactory(OAuth2Utils.JSON_FACTORY); + error.put("error", "invalid_client"); + error.put("error_description", "Certificate rotation required"); + sendJsonResponse(exchange, 401, error.toPrettyString()); + } else { + // Second exchange succeeds with 200 OK + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("access_token", "retry_success_token_401_handled"); + response.put("token_type", "Bearer"); + response.put("expires_in", 3600); + response.put("issued_token_type", ACCESS_TOKEN_TYPE); + sendJsonResponse(exchange, 200, response.toPrettyString()); + } + } catch (Exception e) { + sendJsonResponse(exchange, 500, "{\"error\": \"" + e.getMessage() + "\"}"); + } + } + }); + server.start(); + + Path tokenFile = tempDir.resolve("credential.json"); + GenericJson tokenJson = new GenericJson(); + tokenJson.setFactory(OAuth2Utils.JSON_FACTORY); + tokenJson.put("subject_token", "testSubjectToken401"); + tokenJson.put("actor_token", "testActorToken401"); + OAuth2Utils.writeInputStreamToFile( + new ByteArrayInputStream(tokenJson.toPrettyString().getBytes(StandardCharsets.UTF_8)), + tokenFile.toString()); + + String configJson = + "{\n" + + " \"type\": \"external_account\",\n" + + " \"audience\": \"" + + AUDIENCE + + "\",\n" + + " \"subject_token_type\": \"urn:ietf:params:oauth:token-type:jwt\",\n" + + " \"actor_token_type\": \"urn:ietf:params:oauth:token-type:jwt\",\n" + + " \"token_url\": \"https://localhost:" + + serverPort + + "/v1/token\",\n" + + " \"credential_source\": {\n" + + " \"file\": \"" + + tokenFile.toString().replace("\\", "\\\\") + + "\",\n" + + " \"format\": {\n" + + " \"type\": \"json\",\n" + + " \"subject_token_field_name\": \"subject_token\",\n" + + " \"actor_token_field_name\": \"actor_token\"\n" + + " },\n" + + " \"certificate\": {\n" + + " \"certificate_config_location\": \"" + + certConfigFile.toString().replace("\\", "\\\\") + + "\"\n" + + " }\n" + + " }\n" + + "}"; + + IdentityPoolCredentials credentials = + (IdentityPoolCredentials) + ExternalAccountCredentials.fromStream( + new ByteArrayInputStream(configJson.getBytes(StandardCharsets.UTF_8))); + + AccessToken accessToken = credentials.refreshAccessToken(); + assertEquals("retry_success_token_401_handled", accessToken.getTokenValue()); + assertEquals(2, requestCount.get()); + assertEquals(2, certsPerRequest.size()); + assertNotNull(certsPerRequest.get(0)); + assertNotNull(certsPerRequest.get(1)); + assertTrue(certsPerRequest.get(0).length > 0); + assertTrue(certsPerRequest.get(1).length > 0); + assertTrue(certsPerRequest.get(0)[0] instanceof X509Certificate); + assertTrue(certsPerRequest.get(1)[0] instanceof X509Certificate); + assertEquals( + "CN=1009120726878.apps.googleusercontent.com", + ((X509Certificate) certsPerRequest.get(0)[0]).getSubjectX500Principal().getName()); + assertEquals( + "CN=rotated-client.apps.googleusercontent.com", + ((X509Certificate) certsPerRequest.get(1)[0]).getSubjectX500Principal().getName()); + } + + /** + * Scenario B2: testMtlsPipeline_400InvalidGrantRetry_reReadsCertFromDisk + * + *

Live {@code sts.mtls.googleapis.com/v1/token} returns HTTP 400 with {@code "error": + * "invalid_grant"} when the client certificate in the mTLS handshake does not match the leaf + * certificate in {@code subject_token}. Verify that {@link IdentityPoolCredentials} catches this + * {@code invalid_grant} error, reloads the rotated certificate from disk, and succeeds on retry. + */ + @Test + void testMtlsPipeline_400InvalidGrantRetry_reReadsCertFromDisk(@TempDir Path tempDir) + throws Exception { + Path dynamicCertFile = tempDir.resolve("dynamic_cert.pem"); + Path dynamicKeyFile = tempDir.resolve("dynamic_key.pem"); + Files.copy(Paths.get(TEST_CERT_PATH), dynamicCertFile, StandardCopyOption.REPLACE_EXISTING); + Files.copy(Paths.get(TEST_KEY_PATH), dynamicKeyFile, StandardCopyOption.REPLACE_EXISTING); + + Path certConfigFile = tempDir.resolve("dynamic_cert_config.json"); + String certConfigContent = + "{\n" + + " \"cert_configs\": {\n" + + " \"workload\": {\n" + + " \"cert_path\": \"" + + dynamicCertFile.toString().replace("\\", "\\\\") + + "\",\n" + + " \"key_path\": \"" + + dynamicKeyFile.toString().replace("\\", "\\\\") + + "\"\n" + + " }\n" + + " }\n" + + "}"; + Files.write(certConfigFile, certConfigContent.getBytes(StandardCharsets.UTF_8)); + + AtomicInteger requestCount = new AtomicInteger(0); + List certsPerRequest = Collections.synchronizedList(new ArrayList<>()); + + server.createContext( + "/v1/token", + new HttpHandler() { + @Override + public void handle(HttpExchange exchange) throws IOException { + try { + HttpsExchange httpsExchange = (HttpsExchange) exchange; + SSLSession session = httpsExchange.getSSLSession(); + certsPerRequest.add(session.getPeerCertificates()); + + readRequestBody(exchange); + + int count = requestCount.incrementAndGet(); + if (count == 1) { + // Rotate cert files on disk from Cert A to Cert B before returning 400 + // invalid_grant + Files.write(dynamicCertFile, Files.readAllBytes(Paths.get(TEST_CERT_2_PATH))); + Files.write(dynamicKeyFile, Files.readAllBytes(Paths.get(TEST_KEY_2_PATH))); + + GenericJson error = new GenericJson(); + error.setFactory(OAuth2Utils.JSON_FACTORY); + error.put("error", "invalid_grant"); + error.put( + "error_description", "Client cert does not match the cert in mTLS handshake."); + sendJsonResponse(exchange, 400, error.toPrettyString()); + } else { + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("access_token", "retry_success_token_400_invalid_grant_handled"); + response.put("token_type", "Bearer"); + response.put("expires_in", 3600); + response.put("issued_token_type", ACCESS_TOKEN_TYPE); + sendJsonResponse(exchange, 200, response.toPrettyString()); + } + } catch (Exception e) { + sendJsonResponse(exchange, 500, "{\"error\": \"" + e.getMessage() + "\"}"); + } + } + }); + server.start(); + + Path tokenFile = tempDir.resolve("credential.json"); + GenericJson tokenJson = new GenericJson(); + tokenJson.setFactory(OAuth2Utils.JSON_FACTORY); + tokenJson.put("subject_token", "testSubjectToken400"); + tokenJson.put("actor_token", "testActorToken400"); + OAuth2Utils.writeInputStreamToFile( + new ByteArrayInputStream(tokenJson.toPrettyString().getBytes(StandardCharsets.UTF_8)), + tokenFile.toString()); + + String configJson = + "{\n" + + " \"type\": \"external_account\",\n" + + " \"audience\": \"" + + AUDIENCE + + "\",\n" + + " \"subject_token_type\": \"urn:ietf:params:oauth:token-type:jwt\",\n" + + " \"actor_token_type\": \"urn:ietf:params:oauth:token-type:jwt\",\n" + + " \"token_url\": \"https://localhost:" + + serverPort + + "/v1/token\",\n" + + " \"credential_source\": {\n" + + " \"file\": \"" + + tokenFile.toString().replace("\\", "\\\\") + + "\",\n" + + " \"format\": {\n" + + " \"type\": \"json\",\n" + + " \"subject_token_field_name\": \"subject_token\",\n" + + " \"actor_token_field_name\": \"actor_token\"\n" + + " },\n" + + " \"certificate\": {\n" + + " \"certificate_config_location\": \"" + + certConfigFile.toString().replace("\\", "\\\\") + + "\"\n" + + " }\n" + + " }\n" + + "}"; + + IdentityPoolCredentials credentials = + (IdentityPoolCredentials) + ExternalAccountCredentials.fromStream( + new ByteArrayInputStream(configJson.getBytes(StandardCharsets.UTF_8))); + + AccessToken accessToken = credentials.refreshAccessToken(); + assertEquals("retry_success_token_400_invalid_grant_handled", accessToken.getTokenValue()); + assertEquals(2, requestCount.get()); + assertEquals(2, certsPerRequest.size()); + assertEquals( + "CN=1009120726878.apps.googleusercontent.com", + ((X509Certificate) certsPerRequest.get(0)[0]).getSubjectX500Principal().getName()); + assertEquals( + "CN=rotated-client.apps.googleusercontent.com", + ((X509Certificate) certsPerRequest.get(1)[0]).getSubjectX500Principal().getName()); + } + + /** + * Scenario C: testMtlsPipeline_concurrentRefreshes + * + *

Multi-threaded refresh verifying independent transport snapshots per thread without + * socket/cert race conditions. + */ + @Test + void testMtlsPipeline_concurrentRefreshes(@TempDir Path tempDir) throws Exception { + AtomicInteger requestCounter = new AtomicInteger(0); + + server.createContext( + "/v1/token", + new HttpHandler() { + @Override + public void handle(HttpExchange exchange) throws IOException { + try { + // Always read and drain the request body + readRequestBody(exchange); + + int count = requestCounter.incrementAndGet(); + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("access_token", "concurrent_token_" + count); + response.put("token_type", "Bearer"); + response.put("expires_in", 3600); + response.put("issued_token_type", ACCESS_TOKEN_TYPE); + sendJsonResponse(exchange, 200, response.toPrettyString()); + } catch (Exception e) { + sendJsonResponse(exchange, 500, "{\"error\": \"" + e.getMessage() + "\"}"); + } + } + }); + server.start(); + + Path tokenFile = tempDir.resolve("credential.json"); + GenericJson tokenJson = new GenericJson(); + tokenJson.setFactory(OAuth2Utils.JSON_FACTORY); + tokenJson.put("subject_token", "concurrentSubjectToken"); + tokenJson.put("actor_token", "concurrentActorToken"); + OAuth2Utils.writeInputStreamToFile( + new ByteArrayInputStream(tokenJson.toPrettyString().getBytes(StandardCharsets.UTF_8)), + tokenFile.toString()); + + String configJson = + "{\n" + + " \"type\": \"external_account\",\n" + + " \"audience\": \"" + + AUDIENCE + + "\",\n" + + " \"subject_token_type\": \"urn:ietf:params:oauth:token-type:jwt\",\n" + + " \"actor_token_type\": \"urn:ietf:params:oauth:token-type:jwt\",\n" + + " \"token_url\": \"https://localhost:" + + serverPort + + "/v1/token\",\n" + + " \"credential_source\": {\n" + + " \"file\": \"" + + tokenFile.toString().replace("\\", "\\\\") + + "\",\n" + + " \"format\": {\n" + + " \"type\": \"json\",\n" + + " \"subject_token_field_name\": \"subject_token\",\n" + + " \"actor_token_field_name\": \"actor_token\"\n" + + " },\n" + + " \"certificate\": {\n" + + " \"certificate_config_location\":" + + " \"testresources/mtls/certificate_config.json\"\n" + + " }\n" + + " }\n" + + "}"; + + IdentityPoolCredentials credentials = + (IdentityPoolCredentials) + ExternalAccountCredentials.fromStream( + new ByteArrayInputStream(configJson.getBytes(StandardCharsets.UTF_8))); + + int concurrency = 8; + ExecutorService clientExecutor = Executors.newFixedThreadPool(concurrency); + try { + CountDownLatch startLatch = new CountDownLatch(1); + List> futures = new ArrayList<>(); + + for (int i = 0; i < concurrency; i++) { + futures.add( + clientExecutor.submit( + new Callable() { + @Override + public AccessToken call() throws Exception { + startLatch.await(); + return credentials.refreshAccessToken(); + } + })); + } + + // Release all client threads concurrently + startLatch.countDown(); + + for (Future future : futures) { + AccessToken token = future.get(10, TimeUnit.SECONDS); + assertNotNull(token); + assertTrue(token.getTokenValue().startsWith("concurrent_token_")); + } + } finally { + clientExecutor.shutdownNow(); + assertTrue(clientExecutor.awaitTermination(5, TimeUnit.SECONDS)); + } + assertEquals(concurrency, requestCounter.get()); + } + + /** + * Scenario D: testMtlsPipeline_atomicTokenRead + * + *

Verifies that subject and actor tokens configured in the same JSON file share one supplier + * and are both sent in the STS request over mTLS. The single file read is covered by {@code + * IdentityPoolCredentialsTest#refreshAccessToken_subjectAndActorFromSameFileParse}. + */ + @Test + void testMtlsPipeline_atomicTokenRead(@TempDir Path tempDir) throws Exception { + AtomicReference> capturedParams = new AtomicReference<>(); + + server.createContext( + "/v1/token", + new HttpHandler() { + @Override + public void handle(HttpExchange exchange) throws IOException { + try { + String body = readRequestBody(exchange); + capturedParams.set(parseFormData(body)); + + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("access_token", "atomic_token_verified"); + response.put("token_type", "Bearer"); + response.put("expires_in", 3600); + response.put("issued_token_type", ACCESS_TOKEN_TYPE); + sendJsonResponse(exchange, 200, response.toPrettyString()); + } catch (Exception e) { + sendJsonResponse(exchange, 500, "{\"error\": \"" + e.getMessage() + "\"}"); + } + } + }); + server.start(); + + Path tokenFile = tempDir.resolve("credential.json"); + GenericJson tokenJson = new GenericJson(); + tokenJson.setFactory(OAuth2Utils.JSON_FACTORY); + tokenJson.put("subject_token", "atomicSubjectToken_ABC_123"); + tokenJson.put("actor_token", "atomicActorToken_XYZ_789"); + OAuth2Utils.writeInputStreamToFile( + new ByteArrayInputStream(tokenJson.toPrettyString().getBytes(StandardCharsets.UTF_8)), + tokenFile.toString()); + + Map certMap = new HashMap<>(); + certMap.put("certificate_config_location", "testresources/mtls/certificate_config.json"); + + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "subject_token"); + formatMap.put("actor_token_field_name", "actor_token"); + + Map sourceMap = new HashMap<>(); + sourceMap.put("file", tokenFile.toString()); + sourceMap.put("format", formatMap); + sourceMap.put("certificate", certMap); + + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(sourceMap); + KeyStore clientKeyStore = createClientKeyStore(); + HttpTransportFactory transportFactory = new MtlsHttpTransportFactory(clientKeyStore); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setAudience(AUDIENCE) + .setSubjectTokenType(SubjectTokenTypes.JWT) + .setActorTokenType(SubjectTokenTypes.JWT.value) + .setTokenUrl("https://localhost:" + serverPort + "/v1/token") + .setCredentialSource(source) + .setHttpTransportFactory(transportFactory) + .build(); + + // Verify both subject and actor supplier point to the same instance + // (FileIdentityPoolSubjectTokenSupplier) + assertSame( + credentials.getIdentityPoolSubjectTokenSupplier(), + credentials.getIdentityPoolActorTokenSupplier()); + + AccessToken token = credentials.refreshAccessToken(); + assertEquals("atomic_token_verified", token.getTokenValue()); + + Map params = capturedParams.get(); + assertNotNull(params); + assertEquals("atomicSubjectToken_ABC_123", params.get("subject_token")); + assertEquals("atomicActorToken_XYZ_789", params.get("actor_token")); + assertEquals("urn:ietf:params:oauth:token-type:jwt", params.get("subject_token_type")); + assertEquals("urn:ietf:params:oauth:token-type:jwt", params.get("actor_token_type")); + } + + /** + * Scenario E: testMtlsPipeline_withImpersonation_usesSameCertForStsAndIam + * + *

Sets up in-process HttpsServer handlers for both STS (/v1/token) and IAM + * (/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken), both + * requiring client certificates. Executes refreshAccessToken() on IdentityPoolCredentials + * configured with serviceAccountImpersonationUrl, asserting that both STS and IAM receive the + * client X509Certificate from SSLSession, IAM receives the Authorization header from STS, and the + * final target access token is returned. + */ + @Test + void testMtlsPipeline_withImpersonation_usesSameCertForStsAndIam(@TempDir Path tempDir) + throws Exception { + Path dynamicCertFile = tempDir.resolve("dynamic_cert.pem"); + Path dynamicKeyFile = tempDir.resolve("dynamic_key.pem"); + Path certConfigFile = tempDir.resolve("dynamic_cert_config.json"); + + // Write Cert B to disk so the transport stored by fromStream() holds Cert B. Cert A is written + // right before refreshAccessToken(), so the per-cycle pinned transport holds a different cert. + Files.copy(Paths.get(TEST_CERT_2_PATH), dynamicCertFile); + Files.copy(Paths.get(TEST_KEY_2_PATH), dynamicKeyFile); + + String certConfigContent = + "{\n" + + " \"cert_configs\": {\n" + + " \"workload\": {\n" + + " \"cert_path\": \"" + + dynamicCertFile.toString().replace("\\", "\\\\") + + "\",\n" + + " \"key_path\": \"" + + dynamicKeyFile.toString().replace("\\", "\\\\") + + "\"\n" + + " }\n" + + " }\n" + + "}"; + OAuth2Utils.writeInputStreamToFile( + new ByteArrayInputStream(certConfigContent.getBytes(StandardCharsets.UTF_8)), + certConfigFile.toString()); + + AtomicInteger stsCallCount = new AtomicInteger(0); + AtomicReference capturedStsCerts = new AtomicReference<>(); + AtomicReference> capturedStsParams = new AtomicReference<>(); + + server.createContext( + "/v1/token", + new HttpHandler() { + @Override + public void handle(HttpExchange exchange) throws IOException { + try { + HttpsExchange httpsExchange = (HttpsExchange) exchange; + SSLSession session = httpsExchange.getSSLSession(); + capturedStsCerts.set(session.getPeerCertificates()); + + // Rotate certificate on disk (Cert A -> Cert B) immediately after STS reads Cert A + // so that if IAM re-read the disk instead of using the pinned transport, it would + // present Cert B. + Files.copy( + Paths.get(TEST_CERT_2_PATH), + dynamicCertFile, + StandardCopyOption.REPLACE_EXISTING); + Files.copy( + Paths.get(TEST_KEY_2_PATH), dynamicKeyFile, StandardCopyOption.REPLACE_EXISTING); + + String body = readRequestBody(exchange); + capturedStsParams.set(parseFormData(body)); + + stsCallCount.incrementAndGet(); + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("access_token", "intermediate_sts_token_123"); + response.put("token_type", "Bearer"); + response.put("expires_in", 3600); + response.put("issued_token_type", ACCESS_TOKEN_TYPE); + sendJsonResponse(exchange, 200, response.toPrettyString()); + } catch (Exception e) { + sendJsonResponse(exchange, 500, "{\"error\": \"" + e.getMessage() + "\"}"); + } + } + }); + + AtomicInteger iamCallCount = new AtomicInteger(0); + AtomicReference capturedIamCerts = new AtomicReference<>(); + AtomicReference capturedIamAuthHeader = new AtomicReference<>(); + + server.createContext( + "/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken", + new HttpHandler() { + @Override + public void handle(HttpExchange exchange) throws IOException { + try { + HttpsExchange httpsExchange = (HttpsExchange) exchange; + SSLSession session = httpsExchange.getSSLSession(); + capturedIamCerts.set(session.getPeerCertificates()); + + capturedIamAuthHeader.set(exchange.getRequestHeaders().getFirst("Authorization")); + readRequestBody(exchange); + + iamCallCount.incrementAndGet(); + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("accessToken", "final_target_sa_access_token_456"); + response.put("expireTime", "2030-01-01T00:00:00Z"); + sendJsonResponse(exchange, 200, response.toPrettyString()); + } catch (Exception e) { + sendJsonResponse(exchange, 500, "{\"error\": \"" + e.getMessage() + "\"}"); + } + } + }); + + server.start(); + + Path tokenFile = tempDir.resolve("credential.json"); + GenericJson tokenJson = new GenericJson(); + tokenJson.setFactory(OAuth2Utils.JSON_FACTORY); + tokenJson.put("subject_token", "testSubjectTokenImpersonation"); + tokenJson.put("actor_token", "testActorTokenImpersonation"); + OAuth2Utils.writeInputStreamToFile( + new ByteArrayInputStream(tokenJson.toPrettyString().getBytes(StandardCharsets.UTF_8)), + tokenFile.toString()); + + String iamUrl = + "https://localhost:" + + serverPort + + "/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken"; + + String configJson = + "{\n" + + " \"type\": \"external_account\",\n" + + " \"audience\": \"" + + AUDIENCE + + "\",\n" + + " \"subject_token_type\": \"urn:ietf:params:oauth:token-type:jwt\",\n" + + " \"actor_token_type\": \"urn:ietf:params:oauth:token-type:jwt\",\n" + + " \"token_url\": \"https://localhost:" + + serverPort + + "/v1/token\",\n" + + " \"service_account_impersonation_url\": \"" + + iamUrl + + "\",\n" + + " \"credential_source\": {\n" + + " \"file\": \"" + + tokenFile.toString().replace("\\", "\\\\") + + "\",\n" + + " \"format\": {\n" + + " \"type\": \"json\",\n" + + " \"subject_token_field_name\": \"subject_token\",\n" + + " \"actor_token_field_name\": \"actor_token\"\n" + + " },\n" + + " \"certificate\": {\n" + + " \"certificate_config_location\": \"" + + certConfigFile.toString().replace("\\", "\\\\") + + "\"\n" + + " }\n" + + " }\n" + + "}"; + + IdentityPoolCredentials credentials = + (IdentityPoolCredentials) + ExternalAccountCredentials.fromStream( + new ByteArrayInputStream(configJson.getBytes(StandardCharsets.UTF_8))); + + // Overwrite with Cert A so this refresh cycle pins Cert A while the stored transport holds + // Cert B. + Files.copy(Paths.get(TEST_CERT_PATH), dynamicCertFile, StandardCopyOption.REPLACE_EXISTING); + Files.copy(Paths.get(TEST_KEY_PATH), dynamicKeyFile, StandardCopyOption.REPLACE_EXISTING); + + AccessToken accessToken = credentials.refreshAccessToken(); + assertEquals("final_target_sa_access_token_456", accessToken.getTokenValue()); + assertEquals(1, stsCallCount.get()); + assertEquals(1, iamCallCount.get()); + + // Asserts both STS and IAM handlers receive the client's X509Certificate from SSLSession + Certificate[] stsCerts = capturedStsCerts.get(); + assertNotNull(stsCerts); + assertTrue(stsCerts.length > 0); + assertTrue(stsCerts[0] instanceof X509Certificate); + + Certificate[] iamCerts = capturedIamCerts.get(); + assertNotNull(iamCerts); + assertTrue(iamCerts.length > 0); + assertTrue(iamCerts[0] instanceof X509Certificate); + + // Verify both handlers received Cert A (the pinned cert). The stored transport holds Cert B and + // the cert on disk rotates to Cert B inside the STS handler, so IAM would present Cert B if it + // used either of those instead of the pinned transport. + String stsPrincipal = ((X509Certificate) stsCerts[0]).getSubjectX500Principal().getName(); + String iamPrincipal = ((X509Certificate) iamCerts[0]).getSubjectX500Principal().getName(); + assertEquals("CN=1009120726878.apps.googleusercontent.com", stsPrincipal); + assertEquals("CN=1009120726878.apps.googleusercontent.com", iamPrincipal); + + // Asserts IAM handler receives Authorization: Bearer + assertEquals("Bearer intermediate_sts_token_123", capturedIamAuthHeader.get()); + + // Asserts STS received proper token exchange parameters + Map stsParams = capturedStsParams.get(); + assertNotNull(stsParams); + assertEquals("testSubjectTokenImpersonation", stsParams.get("subject_token")); + assertEquals("testActorTokenImpersonation", stsParams.get("actor_token")); + } + + /** + * Scenario F: testMtlsPipeline_withImpersonation_401OnIam_retriesWithFreshCert + * + *

Sets up STS and IAM handlers. On attempt 1, STS succeeds (returning intermediate token 1) + * and IAM returns HTTP 401 Unauthorized. On 401, test updates the cert file on disk (Cert A -> + * Cert B). Verifies IdentityPoolCredentials catches IAM 401, reloads the fresh cert, re-exchanges + * at STS for intermediate token 2 (bound to Cert B), and calls IAM with intermediate token 2 + + * Cert B, succeeding with HTTP 200. + */ + @Test + void testMtlsPipeline_withImpersonation_401OnIam_retriesWithFreshCert(@TempDir Path tempDir) + throws Exception { + Path dynamicCertFile = tempDir.resolve("dynamic_cert.pem"); + Path dynamicKeyFile = tempDir.resolve("dynamic_key.pem"); + Path certConfigFile = tempDir.resolve("dynamic_cert_config.json"); + + // Write initial cert and key (Cert A) to disk + Files.copy(Paths.get(TEST_CERT_PATH), dynamicCertFile); + Files.copy(Paths.get(TEST_KEY_PATH), dynamicKeyFile); + + String certConfigContent = + "{\n" + + " \"cert_configs\": {\n" + + " \"workload\": {\n" + + " \"cert_path\": \"" + + dynamicCertFile.toString().replace("\\", "\\\\") + + "\",\n" + + " \"key_path\": \"" + + dynamicKeyFile.toString().replace("\\", "\\\\") + + "\"\n" + + " }\n" + + " }\n" + + "}"; + Files.write(certConfigFile, certConfigContent.getBytes(StandardCharsets.UTF_8)); + + AtomicInteger stsRequestCount = new AtomicInteger(0); + List stsCertsList = Collections.synchronizedList(new ArrayList<>()); + + server.createContext( + "/v1/token", + new HttpHandler() { + @Override + public void handle(HttpExchange exchange) throws IOException { + try { + HttpsExchange httpsExchange = (HttpsExchange) exchange; + SSLSession session = httpsExchange.getSSLSession(); + stsCertsList.add(session.getPeerCertificates()); + + readRequestBody(exchange); + + int count = stsRequestCount.incrementAndGet(); + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("access_token", "intermediate_sts_token_" + count); + response.put("token_type", "Bearer"); + response.put("expires_in", 3600); + response.put("issued_token_type", ACCESS_TOKEN_TYPE); + sendJsonResponse(exchange, 200, response.toPrettyString()); + } catch (Exception e) { + sendJsonResponse(exchange, 500, "{\"error\": \"" + e.getMessage() + "\"}"); + } + } + }); + + AtomicInteger iamRequestCount = new AtomicInteger(0); + List iamCertsList = Collections.synchronizedList(new ArrayList<>()); + List iamAuthHeaders = Collections.synchronizedList(new ArrayList<>()); + + server.createContext( + "/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken", + new HttpHandler() { + @Override + public void handle(HttpExchange exchange) throws IOException { + try { + HttpsExchange httpsExchange = (HttpsExchange) exchange; + SSLSession session = httpsExchange.getSSLSession(); + iamCertsList.add(session.getPeerCertificates()); + + iamAuthHeaders.add(exchange.getRequestHeaders().getFirst("Authorization")); + readRequestBody(exchange); + + int count = iamRequestCount.incrementAndGet(); + if (count == 1) { + // Rewrite the cert files on disk on 401 to trigger X509Provider reload and retry + Files.write(dynamicCertFile, Files.readAllBytes(Paths.get(TEST_CERT_2_PATH))); + Files.write(dynamicKeyFile, Files.readAllBytes(Paths.get(TEST_KEY_2_PATH))); + + GenericJson error = new GenericJson(); + error.setFactory(OAuth2Utils.JSON_FACTORY); + error.put("error", "invalid_client"); + error.put("error_description", "Certificate rotation required"); + sendJsonResponse(exchange, 401, error.toPrettyString()); + } else { + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("accessToken", "retry_final_target_sa_token_success"); + response.put("expireTime", "2030-01-01T00:00:00Z"); + sendJsonResponse(exchange, 200, response.toPrettyString()); + } + } catch (Exception e) { + sendJsonResponse(exchange, 500, "{\"error\": \"" + e.getMessage() + "\"}"); + } + } + }); + + server.start(); + + Path tokenFile = tempDir.resolve("credential.json"); + GenericJson tokenJson = new GenericJson(); + tokenJson.setFactory(OAuth2Utils.JSON_FACTORY); + tokenJson.put("subject_token", "testSubjectToken401Iam"); + tokenJson.put("actor_token", "testActorToken401Iam"); + OAuth2Utils.writeInputStreamToFile( + new ByteArrayInputStream(tokenJson.toPrettyString().getBytes(StandardCharsets.UTF_8)), + tokenFile.toString()); + + String iamUrl = + "https://localhost:" + + serverPort + + "/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken"; + + String configJson = + "{\n" + + " \"type\": \"external_account\",\n" + + " \"audience\": \"" + + AUDIENCE + + "\",\n" + + " \"subject_token_type\": \"urn:ietf:params:oauth:token-type:jwt\",\n" + + " \"actor_token_type\": \"urn:ietf:params:oauth:token-type:jwt\",\n" + + " \"token_url\": \"https://localhost:" + + serverPort + + "/v1/token\",\n" + + " \"service_account_impersonation_url\": \"" + + iamUrl + + "\",\n" + + " \"credential_source\": {\n" + + " \"file\": \"" + + tokenFile.toString().replace("\\", "\\\\") + + "\",\n" + + " \"format\": {\n" + + " \"type\": \"json\",\n" + + " \"subject_token_field_name\": \"subject_token\",\n" + + " \"actor_token_field_name\": \"actor_token\"\n" + + " },\n" + + " \"certificate\": {\n" + + " \"certificate_config_location\": \"" + + certConfigFile.toString().replace("\\", "\\\\") + + "\"\n" + + " }\n" + + " }\n" + + "}"; + + IdentityPoolCredentials credentials = + (IdentityPoolCredentials) + ExternalAccountCredentials.fromStream( + new ByteArrayInputStream(configJson.getBytes(StandardCharsets.UTF_8))); + + AccessToken accessToken = credentials.refreshAccessToken(); + assertEquals("retry_final_target_sa_token_success", accessToken.getTokenValue()); + + // Verify 2 STS exchanges and 2 IAM calls occurred + assertEquals(2, stsRequestCount.get()); + assertEquals(2, iamRequestCount.get()); + + // Verify certs captured for both attempts + assertEquals(2, stsCertsList.size()); + assertEquals(2, iamCertsList.size()); + assertNotNull(stsCertsList.get(0)); + assertNotNull(stsCertsList.get(1)); + assertNotNull(iamCertsList.get(0)); + assertNotNull(iamCertsList.get(1)); + assertTrue(stsCertsList.get(0)[0] instanceof X509Certificate); + assertTrue(stsCertsList.get(1)[0] instanceof X509Certificate); + assertTrue(iamCertsList.get(0)[0] instanceof X509Certificate); + assertTrue(iamCertsList.get(1)[0] instanceof X509Certificate); + + // Verify in each attempt, STS and IAM received the same peer certificate + assertEquals( + ((X509Certificate) stsCertsList.get(0)[0]).getSubjectX500Principal(), + ((X509Certificate) iamCertsList.get(0)[0]).getSubjectX500Principal()); + assertEquals( + ((X509Certificate) stsCertsList.get(1)[0]).getSubjectX500Principal(), + ((X509Certificate) iamCertsList.get(1)[0]).getSubjectX500Principal()); + + // Verify Attempt 0 presented Cert A and Attempt 1 presented rotated Cert B + assertEquals( + "CN=1009120726878.apps.googleusercontent.com", + ((X509Certificate) iamCertsList.get(0)[0]).getSubjectX500Principal().getName()); + assertEquals( + "CN=rotated-client.apps.googleusercontent.com", + ((X509Certificate) iamCertsList.get(1)[0]).getSubjectX500Principal().getName()); + + // Verify IAM received intermediate tokens 1 and 2 respectively + assertEquals(2, iamAuthHeaders.size()); + assertEquals("Bearer intermediate_sts_token_1", iamAuthHeaders.get(0)); + assertEquals("Bearer intermediate_sts_token_2", iamAuthHeaders.get(1)); + } + + /** + * Scenario G: testMtlsPipeline_programmaticMtlsTransportFactory_usedForStsAndIam + * + *

Builds credentials through the public programmatic path only: subject and actor token + * suppliers plus a caller-supplied {@link MtlsHttpTransportFactory}, with no certificate config. + * Verifies the caller-supplied client certificate is presented to both STS and IAM. + */ + @Test + void testMtlsPipeline_programmaticMtlsTransportFactory_usedForStsAndIam() throws Exception { + AtomicReference capturedStsCerts = new AtomicReference<>(); + AtomicReference> capturedStsParams = new AtomicReference<>(); + + server.createContext( + "/v1/token", + new HttpHandler() { + @Override + public void handle(HttpExchange exchange) throws IOException { + try { + HttpsExchange httpsExchange = (HttpsExchange) exchange; + capturedStsCerts.set(httpsExchange.getSSLSession().getPeerCertificates()); + capturedStsParams.set(parseFormData(readRequestBody(exchange))); + + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("access_token", "intermediate_sts_token_programmatic"); + response.put("token_type", "Bearer"); + response.put("expires_in", 3600); + response.put("issued_token_type", ACCESS_TOKEN_TYPE); + sendJsonResponse(exchange, 200, response.toPrettyString()); + } catch (Exception e) { + sendJsonResponse(exchange, 500, "{\"error\": \"" + e.getMessage() + "\"}"); + } + } + }); + + AtomicReference capturedIamCerts = new AtomicReference<>(); + AtomicReference capturedIamAuthHeader = new AtomicReference<>(); + + server.createContext( + "/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken", + new HttpHandler() { + @Override + public void handle(HttpExchange exchange) throws IOException { + try { + HttpsExchange httpsExchange = (HttpsExchange) exchange; + capturedIamCerts.set(httpsExchange.getSSLSession().getPeerCertificates()); + capturedIamAuthHeader.set(exchange.getRequestHeaders().getFirst("Authorization")); + readRequestBody(exchange); + + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("accessToken", "final_target_sa_access_token_programmatic"); + response.put("expireTime", "2030-01-01T00:00:00Z"); + sendJsonResponse(exchange, 200, response.toPrettyString()); + } catch (Exception e) { + sendJsonResponse(exchange, 500, "{\"error\": \"" + e.getMessage() + "\"}"); + } + } + }); + + server.start(); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(context -> "testSubjectTokenProgrammatic") + .setActorTokenSupplier(context -> "testActorTokenProgrammatic") + .setActorTokenType(SubjectTokenTypes.JWT.value) + .setAudience(AUDIENCE) + .setSubjectTokenType(SubjectTokenTypes.JWT) + .setTokenUrl("https://localhost:" + serverPort + "/v1/token") + .setServiceAccountImpersonationUrl( + "https://localhost:" + + serverPort + + "/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken") + .setHttpTransportFactory(new MtlsHttpTransportFactory(createClientKeyStore())) + .build(); + + AccessToken accessToken = credentials.refreshAccessToken(); + assertEquals("final_target_sa_access_token_programmatic", accessToken.getTokenValue()); + + // The caller-supplied KeyStore holds Cert A, and no certificate config exists, so both STS and + // IAM must present Cert A from the supplied transport factory. + Certificate[] stsCerts = capturedStsCerts.get(); + assertNotNull(stsCerts); + assertEquals( + "CN=1009120726878.apps.googleusercontent.com", + ((X509Certificate) stsCerts[0]).getSubjectX500Principal().getName()); + Certificate[] iamCerts = capturedIamCerts.get(); + assertNotNull(iamCerts); + assertEquals( + "CN=1009120726878.apps.googleusercontent.com", + ((X509Certificate) iamCerts[0]).getSubjectX500Principal().getName()); + + assertEquals("Bearer intermediate_sts_token_programmatic", capturedIamAuthHeader.get()); + Map stsParams = capturedStsParams.get(); + assertNotNull(stsParams); + assertEquals("testSubjectTokenProgrammatic", stsParams.get("subject_token")); + assertEquals("testActorTokenProgrammatic", stsParams.get("actor_token")); + } +} diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuthExceptionTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuthExceptionTest.java index 3b50ae9d3b6b..ebad147ed3d2 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuthExceptionTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuthExceptionTest.java @@ -33,9 +33,16 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import com.google.api.client.http.HttpHeaders; +import com.google.api.client.http.HttpResponseException; import com.google.auth.TestUtils; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; import java.io.ObjectStreamClass; import org.junit.jupiter.api.Test; @@ -132,9 +139,216 @@ void createFromHttpResponseException_baseFormat() throws IOException { } @Test - void serialVersionUID_matchesReleasedVersions() { + void createFromHttpResponseException_nullContent() { + HttpResponseException httpException = + new HttpResponseException.Builder( + /* statusCode= */ 401, /* statusMessage= */ "Unauthorized", new HttpHeaders()) + .setContent(null) + .build(); + + OAuthException e = OAuthException.createFromHttpResponseException(httpException); + + assertEquals("http_error_401", e.getErrorCode()); + assertEquals("Unauthorized", e.getErrorDescription()); + assertNull(e.getErrorUri()); + assertEquals(401, e.getHttpStatusCode()); + assertSame(httpException, e.getCause()); + } + + @Test + void createFromHttpResponseException_emptyContent() { + HttpResponseException httpException = + new HttpResponseException.Builder( + /* statusCode= */ 401, /* statusMessage= */ "Unauthorized", new HttpHeaders()) + .setContent(" ") + .build(); + + OAuthException e = OAuthException.createFromHttpResponseException(httpException); + + assertEquals("http_error_401", e.getErrorCode()); + assertEquals("Unauthorized", e.getErrorDescription()); + assertNull(e.getErrorUri()); + assertEquals(401, e.getHttpStatusCode()); + assertSame(httpException, e.getCause()); + } + + @Test + void createFromHttpResponseException_nonJsonContent() { + HttpResponseException httpException = + new HttpResponseException.Builder( + /* statusCode= */ 502, /* statusMessage= */ "Bad Gateway", new HttpHeaders()) + .setContent("Bad Gateway") + .build(); + + OAuthException e = OAuthException.createFromHttpResponseException(httpException); + + assertEquals("http_error_502", e.getErrorCode()); + assertEquals("Bad Gateway", e.getErrorDescription()); + assertNull(e.getErrorUri()); + assertEquals(502, e.getHttpStatusCode()); + assertSame(httpException, e.getCause()); + } + + @Test + void createFromHttpResponseException_missingErrorField() { + HttpResponseException httpException = + new HttpResponseException.Builder( + /* statusCode= */ 400, /* statusMessage= */ "Bad Request", new HttpHeaders()) + .setContent("{\"error_description\": \"some description\"}") + .build(); + + OAuthException e = OAuthException.createFromHttpResponseException(httpException); + + assertEquals("http_error_400", e.getErrorCode()); + assertEquals("some description", e.getErrorDescription()); + assertNull(e.getErrorUri()); + assertEquals(400, e.getHttpStatusCode()); + assertSame(httpException, e.getCause()); + } + + @Test + void createFromHttpResponseException_googleApiJsonErrorObject() { + HttpResponseException httpException = + new HttpResponseException.Builder( + /* statusCode= */ 401, /* statusMessage= */ null, new HttpHeaders()) + .setContent( + "{\"error\": {\"code\": 401, \"message\": \"Request had invalid authentication" + + " credentials.\", \"status\": \"UNAUTHENTICATED\"}}") + .build(); + + OAuthException e = OAuthException.createFromHttpResponseException(httpException); + + assertEquals("UNAUTHENTICATED", e.getErrorCode()); + assertEquals("Request had invalid authentication credentials.", e.getErrorDescription()); + assertNull(e.getErrorUri()); + assertEquals(401, e.getHttpStatusCode()); + assertSame(httpException, e.getCause()); + } + + @Test + void createFromHttpResponseException_jsonLiteralNull() { + HttpResponseException httpException = + new HttpResponseException.Builder( + /* statusCode= */ 500, + /* statusMessage= */ "Internal Server Error", + new HttpHeaders()) + .setContent("null") + .build(); + + OAuthException e = OAuthException.createFromHttpResponseException(httpException); + + assertEquals("http_error_500", e.getErrorCode()); + assertEquals("Internal Server Error", e.getErrorDescription()); + assertNull(e.getErrorUri()); + assertEquals(500, e.getHttpStatusCode()); + assertSame(httpException, e.getCause()); + } + + @Test + void createFromHttpResponseException_emptyJsonObject_fallsBackToStatusMessage() { + HttpResponseException httpException = + new HttpResponseException.Builder( + /* statusCode= */ 401, /* statusMessage= */ "Unauthorized", new HttpHeaders()) + .setContent("{}") + .build(); + + OAuthException e = OAuthException.createFromHttpResponseException(httpException); + + assertEquals("http_error_401", e.getErrorCode()); + assertEquals("Unauthorized", e.getErrorDescription()); + assertNull(e.getErrorUri()); + assertEquals(401, e.getHttpStatusCode()); + assertSame(httpException, e.getCause()); + } + + @Test + void createFromHttpResponseException_topLevelMessage_extractsMessage() { + HttpResponseException httpException = + new HttpResponseException.Builder( + /* statusCode= */ 400, /* statusMessage= */ "Bad Request", new HttpHeaders()) + .setContent("{\"message\": \"Certificate expired\"}") + .build(); + + OAuthException e = OAuthException.createFromHttpResponseException(httpException); + + assertEquals("http_error_400", e.getErrorCode()); + assertEquals("Certificate expired", e.getErrorDescription()); + assertNull(e.getErrorUri()); + assertEquals(400, e.getHttpStatusCode()); + assertSame(httpException, e.getCause()); + } + + @Test + void createFromHttpResponseException_nullErrorField_fallsBackToContent() { + String content = "{\"error\": null}"; + HttpResponseException httpException = + new HttpResponseException.Builder( + /* statusCode= */ 400, /* statusMessage= */ "Bad Request", new HttpHeaders()) + .setContent(content) + .build(); + + OAuthException e = OAuthException.createFromHttpResponseException(httpException); + + assertEquals("http_error_400", e.getErrorCode()); + assertEquals(content, e.getErrorDescription()); + assertEquals(400, e.getHttpStatusCode()); + assertSame(httpException, e.getCause()); + } + + @Test + void createFromHttpResponseException_arrayErrorField_fallsBackToContent() { + String content = "{\"error\": []}"; + HttpResponseException httpException = + new HttpResponseException.Builder( + /* statusCode= */ 400, /* statusMessage= */ "Bad Request", new HttpHeaders()) + .setContent(content) + .build(); + + OAuthException e = OAuthException.createFromHttpResponseException(httpException); + + assertEquals("http_error_400", e.getErrorCode()); + assertEquals(content, e.getErrorDescription()); + assertEquals(400, e.getHttpStatusCode()); + assertSame(httpException, e.getCause()); + } + + @Test + void createFromHttpResponseException_errorObjectWithoutMessage_fallsBackToContent() { + String content = "{\"error\": {\"code\": 400}}"; + HttpResponseException httpException = + new HttpResponseException.Builder( + /* statusCode= */ 400, /* statusMessage= */ "Bad Request", new HttpHeaders()) + .setContent(content) + .build(); + + OAuthException e = OAuthException.createFromHttpResponseException(httpException); + + assertEquals("http_error_400", e.getErrorCode()); + assertEquals(content, e.getErrorDescription()); + assertEquals(400, e.getHttpStatusCode()); + assertSame(httpException, e.getCause()); + } + + @Test + void serialVersionUID_matchesReleasedUidAndRoundTrips() throws Exception { assertEquals( -5276727039237496975L, ObjectStreamClass.lookup(OAuthException.class).getSerialVersionUID()); + + OAuthException original = + new OAuthException("invalid_client", "Certificate mismatch", "https://example.com", 401); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ObjectOutputStream oos = new ObjectOutputStream(baos)) { + oos.writeObject(original); + } + OAuthException deserialized; + try (ObjectInputStream ois = + new ObjectInputStream(new ByteArrayInputStream(baos.toByteArray()))) { + deserialized = (OAuthException) ois.readObject(); + } + assertEquals("invalid_client", deserialized.getErrorCode()); + assertEquals("Certificate mismatch", deserialized.getErrorDescription()); + assertEquals("https://example.com", deserialized.getErrorUri()); + assertEquals(401, deserialized.getHttpStatusCode()); } } diff --git a/google-auth-library-java/oauth2_http/testresources/mtls/test_cert.pem b/google-auth-library-java/oauth2_http/testresources/mtls/test_cert.pem index 17fcf0227c80..6b59dc4c9723 100644 --- a/google-auth-library-java/oauth2_http/testresources/mtls/test_cert.pem +++ b/google-auth-library-java/oauth2_http/testresources/mtls/test_cert.pem @@ -1,14 +1,20 @@ -----BEGIN CERTIFICATE----- -MIICGzCCAYSgAwIBAgIIWrt6xtmHPs4wDQYJKoZIhvcNAQEFBQAwMzExMC8GA1UE -AxMoMTAwOTEyMDcyNjg3OC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbTAeFw0x -MjEyMDExNjEwNDRaFw0yMjExMjkxNjEwNDRaMDMxMTAvBgNVBAMTKDEwMDkxMjA3 -MjY4NzguYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20wgZ8wDQYJKoZIhvcNAQEB -BQADgY0AMIGJAoGBAL1SdY8jTUVU7O4/XrZLYTw0ON1lV6MQRGajFDFCqD2Fd9tQ -GLW8Iftx9wfXe1zuaehJSgLcyCxazfyJoN3RiONBihBqWY6d3lQKqkgsRTNZkdFJ -Wdzl/6CxhK9sojh2p0r3tydtv9iwq5fuuWIvtODtT98EgphhncQAqkKoF3zVAgMB -AAGjODA2MAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBYGA1UdJQEB/wQM -MAoGCCsGAQUFBwMCMA0GCSqGSIb3DQEBBQUAA4GBAD8XQEqzGePa9VrvtEGpf+R4 -fkxKbcYAzqYq202nKu0kfjhIYkYSBj6gi348YaxE64yu60TVl42l5HThmswUheW4 -uQIaq36JvwvsDP5Zoj5BgiNSnDAFQp+jJFBRUA5vooJKgKgMDf/r/DCOsbO6VJF1 -kWwa9n19NFiV0z3m6isj +MIIDSTCCAjGgAwIBAgIULHknJr/h6bKI9J/iji5DKRumlsgwDQYJKoZIhvcNAQEL +BQAwMzExMC8GA1UEAwwoMTAwOTEyMDcyNjg3OC5hcHBzLmdvb2dsZXVzZXJjb250 +ZW50LmNvbTAgFw0yNjA5MjIwMjAyMDJaGA8yMTI2MDgyOTAyMDIwMlowMzExMC8G +A1UEAwwoMTAwOTEyMDcyNjg3OC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbTCC +ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKDe3cQIOSKdJBCHHHxedeRg +7SlxL25n0rEY0IuiHvYak/tRNGV8fHcQtpEwFScgT3oKYpC0eZ/crVk4cppZ21xN +ZT81JmVtA6BbK5MHZMaimYtBngD3awwC//E5cKBC3wEr79OpV1gyERCawe6lcGVC +QzY2G3k321PH2Yv9/5b0qFpJ/51KGAaLC2AMIZOWBEcWMbxUU+iQjxFo4ZxmRvne +s9ctwqD0bAHmFUTsUHwwSMAmICAopJoEP/8AJ+l6QR4OQADvTVsBo31ZT7M/n5tO +yhCnAWVxORrq5rtkx/AL41+qklz17VvVW9V04tO4W5w9iRaqKUb7z/Z7lm9RytsC +AwEAAaNTMFEwHQYDVR0OBBYEFO+baurDiMbGFs0RW/oWP6F+FvuIMB8GA1UdIwQY +MBaAFO+baurDiMbGFs0RW/oWP6F+FvuIMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI +hvcNAQELBQADggEBAD0Lmd3K6jvXqhMofzFg+mlMWrtrvt1Tpo86mLBHmvLwk97F +dUvWam5k1cKQTuR2f58euuKcAOhYLhyYA0iAb3T35CRYQymrAyVjqfFwDpT9FnxD +uz/mYWL+lD8mi3QOB7K3JOgDLrrzZgKOo2LIliHGAYJbYYUR/vwVm34VFbtll8u1 +AxOPtL6HZbIy7Df2fnrcXhgTwRXX//BPvDeG8KzlA8283STsswmcjaySvhQvt7D8 +zaIKJkEEDHlafuVmrs8RnEKaQ2Oa8grXIwGtACAUKD05YzsjVqyNGTwEysXW2Gil +EwBTaKkIG/naV4/zAA4214ioovHmdhzRMGd+SlA= -----END CERTIFICATE----- diff --git a/google-auth-library-java/oauth2_http/testresources/mtls/test_key.pem b/google-auth-library-java/oauth2_http/testresources/mtls/test_key.pem index c6a91c3ce623..6c9d3f0c2d9c 100644 --- a/google-auth-library-java/oauth2_http/testresources/mtls/test_key.pem +++ b/google-auth-library-java/oauth2_http/testresources/mtls/test_key.pem @@ -1,16 +1,28 @@ -----BEGIN PRIVATE KEY----- -MIICdQIBADANBgkqhkiG9w0BAQEFAASCAl8wggJbAgEAAoGBAL1SdY8jTUVU7O4/ -XrZLYTw0ON1lV6MQRGajFDFCqD2Fd9tQGLW8Iftx9wfXe1zuaehJSgLcyCxazfyJ -oN3RiONBihBqWY6d3lQKqkgsRTNZkdFJWdzl/6CxhK9sojh2p0r3tydtv9iwq5fu -uWIvtODtT98EgphhncQAqkKoF3zVAgMBAAECgYB51B9cXe4yiGTzJ4pOKpHGySAy -sC1F/IjXt2eeD3PuKv4m/hL4l7kScpLx0+NJuQ4j8U2UK/kQOdrGANapB1ZbMZAK -/q0xmIUzdNIDiGSoTXGN2mEfdsEpQ/Xiv0lyhYBBPC/K4sYIpHccnhSRQUZlWLLY -lE5cFNKC9b7226mNvQJBAPt0hfCNIN0kUYOA9jdLtx7CE4ySGMPf5KPBuzPd8ty1 -fxaFm9PB7B76VZQYmHcWy8rT5XjoLJHrmGW1ZvP+iDsCQQDAvnKoarPOGb5iJfkq -RrA4flf1TOlf+1+uqIOJ94959jkkJeb0gv/TshDnm6/bWn+1kJylQaKygCizwPwB -Z84vAkA0Duur4YvsPJijoQ9YY1SGCagCcjyuUKwFOxaGpmyhRPIKt56LOJqpzyno -fy8ReKa4VyYq4eZYT249oFCwMwIBAkAROPNF2UL3x5UbcAkznd1hLujtIlI4IV4L -XUNjsJtBap7we/KHJq11XRPlniO4lf2TW7iji5neGVWJulTKS1xBAkAerktk4Hsw -ErUaUG1s/d+Sgc8e/KMeBElV+NxGhcWEeZtfHMn/6VOlbzY82JyvC9OKC80A5CAE -VUV6b25kqrcu +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCg3t3ECDkinSQQ +hxx8XnXkYO0pcS9uZ9KxGNCLoh72GpP7UTRlfHx3ELaRMBUnIE96CmKQtHmf3K1Z +OHKaWdtcTWU/NSZlbQOgWyuTB2TGopmLQZ4A92sMAv/xOXCgQt8BK+/TqVdYMhEQ +msHupXBlQkM2Nht5N9tTx9mL/f+W9KhaSf+dShgGiwtgDCGTlgRHFjG8VFPokI8R +aOGcZkb53rPXLcKg9GwB5hVE7FB8MEjAJiAgKKSaBD//ACfpekEeDkAA701bAaN9 +WU+zP5+bTsoQpwFlcTka6ua7ZMfwC+NfqpJc9e1b1VvVdOLTuFucPYkWqilG+8/2 +e5ZvUcrbAgMBAAECggEABBCdme57QxylZKWFWr8oS3UHiRyLDkchU8hEvMKD9cQG +KTuMD2Rt4LRTaKfGYQ6382V/yBhHWnrBBpgejTfoCxUJkedN6RIwHUUwfK6bJUnG +h3ZI/81ArzUEMpw49FO8PtNVZAvLYTcip0DB1b3ocSaTfDMxzoZSzHMoVBk+9BmA +PYFuYakcoLyeknlVRqrZr6sZi8qK+qrikpkUS42n6l3Em0SiLONXRTd6jt5M9BRT +toANspvmH5Qch7WxTYkdd59XCiQGrNGG2tcVszHgedEv3Uzk8t7GMoqIxM+NtrtE +cKpr3vwbLWt4kTVxmu0ypqOQORI0nZkiEIsmvtbIwQKBgQDb+4+FAyjIiy/NNYxZ +aeVrbkldwi0SVnmYx+24F4vZWDhclThHdZPzl9iE5VYVL+0zIT8RryxqtGg7Qw+a +ql6NV43JgOt/RmRSHtfAZmKVUGgSJ9NfhYrVtWXB/NMKn3O4QHET/K5+61S87Ywg +JvS1zeFgxk59vgDLbqG4oVHEVwKBgQC7NaiQYo5n7i2XWYJLQSr6KHJcL+/LRvrx +urG/rpmVM/Cz2eIWwqPYyrXKwSeSvJrsXz9zcL0KXTmuz2IsSKDNoeTX+L0vpoQE +BItet0lNvaifaLWafZ+6//8RDufrVqBVzIpqXwu8Hdn5swm3fLenr7fZrzWwgyUe +PhQaGJW7HQKBgGa1iqn48qbHzdbLRK9OsS46vQiIq9CfDYEW/9fvLn2ZIh+gEDnV +HPIkGJTcGxgjW2V/BVp1ZpCfkPbydauqFiF6GppVlh7Dt/1bpzKO20KJ2z122MsF +p+/mFQ/Awqx4DGiftew2EybxK4xWgRFV/vWPncY4cNdmyioxilKRB4NbAoGAQ5yU +fVjq34Q4uQGdufoWQHSdIMg7TWocNXNcwMKZOzagBwR40AmJzUEguNJbrXYqaZXY +v6OG0phqvcSuSxoL4VhH4uw1v2DfKuhNQW7mrYoCvVe+xsX6Czprb0i339nQXExb +7SDYhx8s2y6bIsaLOaePubG6rT4gCLUY43ffk80CgYEAhIiMuWMA7S8Ecf81OjFp +w50GO/AyUcnkM/Z7itfoYdH9LM/SvmrcXeHMk6vUNsNpXIdGJhY5sTy26nsxcx01 +q5oWLr/pHpsWKaa2aJsJ08fx/D7+EX+a+jpBTutiEjEhBo7zQfD3ai6rqwhmFKWm +9Te3km56VkF+dYukuKNS56k= -----END PRIVATE KEY-----