From dadeda68ee819c9efcb041c861062c45ab18005b Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Thu, 17 Sep 2026 03:17:32 +0000 Subject: [PATCH 01/20] feat(oauth2): implement IAM impersonation mTLS transport pinning and 401 recovery - Pin mTLS HttpTransportFactory across multi-step STS and IAM token exchanges so both requests use the exact same certificate snapshot within a single refresh cycle. - Add 401 Unauthorized recovery with automatic certificate reload from X509Provider and single-retry coordination in IdentityPoolCredentials and ImpersonatedCredentials. - Preserve custom non-default HttpTransportFactory instances when X509Provider is configured. - Add comprehensive unit tests across IdentityPoolCredentialsTest, ImpersonatedCredentialsTest, and OAuth2UtilsTest. --- .../google/auth/oauth2/AwsCredentials.java | 8 +- .../oauth2/ExternalAccountCredentials.java | 15 +- .../auth/oauth2/IdentityPoolCredentials.java | 79 ++- .../auth/oauth2/ImpersonatedCredentials.java | 83 ++- .../com/google/auth/oauth2/OAuth2Utils.java | 24 + .../auth/oauth2/PluggableAuthCredentials.java | 8 +- .../oauth2/IdentityPoolCredentialsTest.java | 605 +++++++++++++++++- .../oauth2/ImpersonatedCredentialsTest.java | 103 ++- ...ckExternalAccountCredentialsTransport.java | 18 + .../google/auth/oauth2/OAuth2UtilsTest.java | 56 ++ 10 files changed, 921 insertions(+), 78 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java index 548008d4bab6..2abcd114dab5 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java @@ -120,6 +120,11 @@ public class AwsCredentials extends ExternalAccountCredentials { @Override public AccessToken refreshAccessToken() throws IOException { + return refreshAccessToken(this.transportFactory); + } + + @Override + public AccessToken refreshAccessToken(HttpTransportFactory transportFactory) throws IOException { StsTokenExchangeRequest.Builder stsTokenExchangeRequest = StsTokenExchangeRequest.newBuilder(retrieveSubjectToken(), getSubjectTokenType()) .setAudience(getAudience()); @@ -130,7 +135,8 @@ public AccessToken refreshAccessToken() throws IOException { stsTokenExchangeRequest.setScopes(new ArrayList<>(scopes)); } - return exchangeExternalCredentialForAccessToken(stsTokenExchangeRequest.build()); + return exchangeExternalCredentialForAccessToken( + stsTokenExchangeRequest.build(), transportFactory); } @Override diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java index 7191be5ca3fc..d39dddf98287 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java @@ -526,6 +526,19 @@ private boolean shouldBuildImpersonatedCredential() { return this.serviceAccountImpersonationUrl != null && this.impersonatedCredentials == null; } + /** + * Refreshes the access token using the specified transport factory. Default implementation + * delegates to {@link #refreshAccessToken()}. Subclasses should override this method if they + * support transport pinning per refresh cycle. + * + * @param transportFactory the HTTP transport factory to use for this refresh cycle + * @return the refreshed access token + * @throws IOException if the token refresh fails + */ + public AccessToken refreshAccessToken(HttpTransportFactory transportFactory) throws IOException { + return refreshAccessToken(); + } + /** * Exchanges the external credential for a Google Cloud access token. * @@ -556,7 +569,7 @@ protected AccessToken exchangeExternalCredentialForAccessToken( this.impersonatedCredentials = this.buildImpersonatedCredentials(); } if (this.impersonatedCredentials != null) { - return this.impersonatedCredentials.refreshAccessToken(); + return this.impersonatedCredentials.refreshAccessToken(cycleTransportFactory); } StsRequestHandler.Builder requestHandler = diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java index e6846eaee550..ec80d1d8439e 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java @@ -115,7 +115,7 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { || builder.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY || builder.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory || builder.transportFactory.getClass() == MtlsHttpTransportFactory.class) { - this.transportFactory = new MtlsHttpTransportFactory(mtlsKeyStore); + this.transportFactory = createMtlsTransportFactory(mtlsKeyStore); } else if (!(builder.transportFactory instanceof MtlsHttpTransportFactory)) { LOGGER_PROVIDER .getLogger() @@ -182,7 +182,7 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { if (this.actorTokenSupplier != null && !isMtlsConfigured()) { throw new IllegalArgumentException( "Actor tokens are only supported for mTLS token exchanges. Please configure a certificate" - + " source or MtlsHttpTransportFactory."); + + " configuration in the credential source or provide an mTLS-enabled transport."); } if (this.actorTokenSupplier != null) { @@ -228,15 +228,35 @@ private boolean isMtlsConfigured() { && ((MtlsHttpTransportFactory) this.transportFactory).hasKeyStore()); } + private boolean shouldUseMtlsTransportFactory() { + return this.transportFactory == null + || this.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY + || this.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory + || this.transportFactory instanceof MtlsHttpTransportFactory; + } + @Override public AccessToken refreshAccessToken() throws IOException { // Per-cycle cert pinning: snapshot the KeyStore at the start of each refresh cycle. HttpTransportFactory cycleTransportFactory = this.transportFactory; - if (this.x509Provider != null && this.transportFactory instanceof MtlsHttpTransportFactory) { + if (this.x509Provider != null && shouldUseMtlsTransportFactory()) { KeyStore pinnedKeyStore = this.x509Provider.getKeyStore(); - cycleTransportFactory = new MtlsHttpTransportFactory(pinnedKeyStore); + cycleTransportFactory = createMtlsTransportFactory(pinnedKeyStore); } + return refreshWithRetry(cycleTransportFactory, true); + } + @Override + public AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) + throws IOException { + // Retry is intentionally disabled when an explicit cycleTransportFactory is supplied to + // ensure transport synchronization across multi-step token exchanges (e.g. STS and IAM) + // and prevent nested retry amplification. Outer callers manage retry coordination. + return refreshWithRetry(cycleTransportFactory, false); + } + + private AccessToken refreshWithRetry( + HttpTransportFactory cycleTransportFactory, boolean allowRetry) throws IOException { // Read subject and actor tokens, atomically if from the same file supplier. String subjectToken; String actorToken = null; @@ -270,22 +290,35 @@ public AccessToken refreshAccessToken() throws IOException { try { return exchangeExternalCredentialForAccessToken( stsTokenExchangeRequest.build(), cycleTransportFactory); - } catch (OAuthException e) { - if (e.getHttpStatusCode() == 401 + } catch (Exception e) { + if (allowRetry + && OAuth2Utils.isUnauthorizedException(e) && this.x509Provider != null - && this.transportFactory instanceof MtlsHttpTransportFactory) { + && shouldUseMtlsTransportFactory()) { + KeyStore freshKeyStore; try { - // On 401, re-read from X509Provider for fresh certs and retry once. - KeyStore freshKeyStore = this.x509Provider.getKeyStore(); - HttpTransportFactory retryTransportFactory = new MtlsHttpTransportFactory(freshKeyStore); - return exchangeExternalCredentialForAccessToken( - stsTokenExchangeRequest.build(), retryTransportFactory); - } catch (IOException retryException) { - retryException.addSuppressed(e); - throw retryException; + // On 401, re-read from X509Provider for fresh certs. + freshKeyStore = this.x509Provider.getKeyStore(); + } catch (IOException reloadException) { + reloadException.addSuppressed(e); + throw reloadException; + } catch (Exception reloadException) { + IOException ioException = + new IOException("Failed to reload certificate on retry", reloadException); + ioException.addSuppressed(e); + throw ioException; } + + HttpTransportFactory retryTransportFactory = createMtlsTransportFactory(freshKeyStore); + return refreshWithRetry(retryTransportFactory, false); } - throw e; + if (e instanceof IOException) { + throw (IOException) e; + } + if (e instanceof RuntimeException) { + throw (RuntimeException) e; + } + throw new IOException(e); } } @@ -324,6 +357,11 @@ HttpTransportFactory getTransportFactory() { return this.x509Provider; } + @VisibleForTesting + HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { + return new MtlsHttpTransportFactory(keyStore); + } + /** Clones the IdentityPoolCredentials with the specified scopes. */ @Override public IdentityPoolCredentials createScoped(Collection newScopes) { @@ -353,7 +391,7 @@ private IdentityPoolSubjectTokenSupplier createCertificateSubjectTokenSupplier( || builder.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY || builder.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory || builder.transportFactory.getClass() == MtlsHttpTransportFactory.class) { - this.transportFactory = new MtlsHttpTransportFactory(mtlsKeyStore); + this.transportFactory = createMtlsTransportFactory(mtlsKeyStore); } else if (!(builder.transportFactory instanceof MtlsHttpTransportFactory)) { LOGGER_PROVIDER .getLogger() @@ -395,7 +433,12 @@ private void readObject(ObjectInputStream input) throws IOException, ClassNotFou new X509Provider(getEnvironmentProvider(), getPropertyProvider(), explicitCertConfigPath); try { KeyStore mtlsKeyStore = this.x509Provider.getKeyStore(); - this.transportFactory = new MtlsHttpTransportFactory(mtlsKeyStore); + if (this.transportFactory == null + || this.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY + || this.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory + || this.transportFactory instanceof MtlsHttpTransportFactory) { + this.transportFactory = createMtlsTransportFactory(mtlsKeyStore); + } } catch (Exception e) { // Cert loading failure will be handled on refreshAccessToken() } diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java index ad8a2468afe9..81ffc6d533fe 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java @@ -46,6 +46,7 @@ import com.google.api.client.util.GenericData; import com.google.api.core.ObsoleteApi; import com.google.auth.CredentialTypeForMetrics; +import com.google.auth.Credentials; import com.google.auth.ServiceAccountSigner; import com.google.auth.http.HttpCredentialsAdapter; import com.google.auth.http.HttpTransportFactory; @@ -580,31 +581,70 @@ public String getUniverseDomain() throws IOException { @Override public AccessToken refreshAccessToken() throws IOException { - if (this.sourceCredentials.getAccessToken() == null) { - // Apply the `CLOUD_PLATFORM_SCOPE` to access the iamcredentials endpoint - this.sourceCredentials = - this.sourceCredentials.createScoped( - Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE)); - } - - // skip for SA with SSJ flow because it uses self-signed JWT - // and will get refreshed at initialize request step - // run for other source credential types or SA with GDU assert flow - if (!(this.sourceCredentials instanceof ServiceAccountCredentials) - || (isDefaultUniverseDomain() - && ((ServiceAccountCredentials) this.sourceCredentials) - .shouldUseAssertionFlowForGdu())) { - try { - this.sourceCredentials.refreshIfExpired(); - } catch (IOException e) { - throw new IOException("Unable to refresh sourceCredentials", e); + return refreshAccessToken(this.transportFactory); + } + + /** + * Refreshes the access token using the specified transport factory. + * + *

This package-private method is intended for internal transport pinning by {@link + * ExternalAccountCredentials} during service account impersonation. For mTLS Workload Identity + * Federation with impersonation, applications should configure {@code + * setServiceAccountImpersonationUrl} directly on {@code IdentityPoolCredentials}, which manages + * the certificate lifecycle and 401 recovery. + * + * @param transportFactory the HTTP transport factory to use + * @return the refreshed access token + * @throws IOException if token refresh fails + */ + AccessToken refreshAccessToken(HttpTransportFactory transportFactory) throws IOException { + HttpTransportFactory effectiveTransportFactory = + transportFactory != null + ? transportFactory + : (this.transportFactory != null + ? this.transportFactory + : OAuth2Utils.HTTP_TRANSPORT_FACTORY); + HttpCredentialsAdapter adapter; + if (this.sourceCredentials instanceof ExternalAccountCredentials) { + AccessToken intermediateAccessToken = + (transportFactory == null + || transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY + || transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory) + ? ((ExternalAccountCredentials) this.sourceCredentials).refreshAccessToken() + : ((ExternalAccountCredentials) this.sourceCredentials) + .refreshAccessToken(effectiveTransportFactory); + Credentials authCredentials = + intermediateAccessToken != null + ? OAuth2Credentials.create(intermediateAccessToken) + : this.sourceCredentials; + adapter = new HttpCredentialsAdapter(authCredentials); + } else { + if (this.sourceCredentials.getAccessToken() == null) { + // Apply the `CLOUD_PLATFORM_SCOPE` to access the iamcredentials endpoint + this.sourceCredentials = + this.sourceCredentials.createScoped( + Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE)); } + + // skip for SA with SSJ flow because it uses self-signed JWT + // and will get refreshed at initialize request step + // run for other source credential types or SA with GDU assert flow + if (!(this.sourceCredentials instanceof ServiceAccountCredentials) + || (isDefaultUniverseDomain() + && ((ServiceAccountCredentials) this.sourceCredentials) + .shouldUseAssertionFlowForGdu())) { + try { + this.sourceCredentials.refreshIfExpired(); + } catch (IOException e) { + throw new IOException("Unable to refresh sourceCredentials", e); + } + } + adapter = new HttpCredentialsAdapter(sourceCredentials); } - HttpTransport httpTransport = this.transportFactory.create(); + HttpTransport httpTransport = effectiveTransportFactory.create(); JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY); - HttpCredentialsAdapter adapter = new HttpCredentialsAdapter(sourceCredentials); HttpRequestFactory requestFactory = httpTransport.createRequestFactory(); String endpointUrl = @@ -627,6 +667,9 @@ public AccessToken refreshAccessToken() throws IOException { // Client Library Debug Logging via LoggingUtils is used instead. request.setLoggingEnabled(false); adapter.initialize(request); + if (this.sourceCredentials instanceof ExternalAccountCredentials) { + request.setUnsuccessfulResponseHandler(null); + } request.setParser(parser); MetricsUtils.setMetricsHeader( request, diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java index f740dd980e73..61d58bc55316 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java @@ -32,6 +32,7 @@ package com.google.auth.oauth2; import com.google.api.client.http.HttpHeaders; +import com.google.api.client.http.HttpResponseException; import com.google.api.client.http.HttpTransport; import com.google.api.client.http.javanet.NetHttpTransport; import com.google.api.client.json.GenericJson; @@ -324,5 +325,28 @@ static String generateBasicAuthHeader(String username, String password) { return "Basic " + encodedCredentials; } + /** + * Returns whether the given throwable or any exception in its causal chain represents a 401 + * Unauthorized error (either an {@link OAuthException} or {@link HttpResponseException} with + * status code 401). + */ + static boolean isUnauthorizedException(@Nullable Throwable t) { + while (t != null) { + if (t instanceof OAuthException && ((OAuthException) t).getHttpStatusCode() == 401) { + return true; + } + if (t instanceof HttpResponseException + && ((HttpResponseException) t).getStatusCode() == 401) { + return true; + } + Throwable cause = t.getCause(); + if (cause == t) { + break; + } + t = cause; + } + return false; + } + private OAuth2Utils() {} } diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java index 10ab650c77e5..ae76abe63093 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java @@ -121,6 +121,11 @@ public class PluggableAuthCredentials extends ExternalAccountCredentials { @Override public AccessToken refreshAccessToken() throws IOException { + return refreshAccessToken(this.transportFactory); + } + + @Override + public AccessToken refreshAccessToken(HttpTransportFactory transportFactory) throws IOException { String credential = retrieveSubjectToken(); StsTokenExchangeRequest.Builder stsTokenExchangeRequest = StsTokenExchangeRequest.newBuilder(credential, getSubjectTokenType()) @@ -130,7 +135,8 @@ public AccessToken refreshAccessToken() throws IOException { if (scopes != null && !scopes.isEmpty()) { stsTokenExchangeRequest.setScopes(new ArrayList<>(scopes)); } - return exchangeExternalCredentialForAccessToken(stsTokenExchangeRequest.build()); + return exchangeExternalCredentialForAccessToken( + stsTokenExchangeRequest.build(), transportFactory); } /** diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java index a081814a9020..09748ee07de6 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java @@ -45,7 +45,13 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import com.google.api.client.http.HttpTransport; +import com.google.api.client.http.LowLevelHttpRequest; +import com.google.api.client.http.LowLevelHttpResponse; import com.google.api.client.json.GenericJson; +import com.google.api.client.json.Json; +import com.google.api.client.testing.http.MockHttpTransport; +import com.google.api.client.testing.http.MockLowLevelHttpRequest; +import com.google.api.client.testing.http.MockLowLevelHttpResponse; import com.google.api.client.util.Clock; import com.google.api.client.util.SecurityUtils; import com.google.auth.TestUtils; @@ -68,6 +74,7 @@ import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; import java.security.cert.CertificateException; +import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; import java.util.Collections; @@ -1400,7 +1407,7 @@ public String getActorToken(ExternalAccountSupplierContext context) { assertEquals( "Actor tokens are only supported for mTLS token exchanges. Please configure a certificate" - + " source or MtlsHttpTransportFactory.", + + " configuration in the credential source or provide an mTLS-enabled transport.", e.getMessage()); } @@ -1810,6 +1817,116 @@ void mtlsHttpTransportFactory_hasKeyStore_noArg_returnsFalse() { assertFalse(factory.hasKeyStore()); } + @Test + void builder_actorToken_plainPublicTokenUrl_throws() throws Exception { + KeyStore ks = createPopulatedKeyStore(); + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); + + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setActorTokenSupplier(testActorSupplier) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setHttpTransportFactory(mtlsTransport) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.googleapis.com/v1/token") + .build()); + assertTrue( + e.getMessage() + .contains( + "cannot be used with actor tokens because it is a plain public Google API" + + " endpoint")); + } + + @Test + void builder_actorToken_plainPublicImpersonationUrl_throws() throws Exception { + KeyStore ks = createPopulatedKeyStore(); + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); + + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setActorTokenSupplier(testActorSupplier) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setHttpTransportFactory(mtlsTransport) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .setServiceAccountImpersonationUrl( + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@test.iam.gserviceaccount.com:generateAccessToken") + .build()); + assertTrue( + e.getMessage() + .contains( + "cannot be used with actor tokens because it is a plain public Google API" + + " endpoint")); + } + + @Test + void builder_actorToken_mtlsEndpoints_succeeds() throws Exception { + KeyStore ks = createPopulatedKeyStore(); + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setActorTokenSupplier(testActorSupplier) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setHttpTransportFactory(mtlsTransport) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .setServiceAccountImpersonationUrl( + "https://iamcredentials.mtls.googleapis.com/v1/projects/-/serviceAccounts/test@test.iam.gserviceaccount.com:generateAccessToken") + .build(); + assertNotNull(credentials); + } + + @Test + void builder_actorToken_pscEndpoints_succeeds() throws Exception { + KeyStore ks = createPopulatedKeyStore(); + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setActorTokenSupplier(testActorSupplier) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setHttpTransportFactory(mtlsTransport) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.p.googleapis.com/v1/token") + .setServiceAccountImpersonationUrl( + "https://iamcredentials.p.googleapis.com/v1/projects/-/serviceAccounts/test@test.iam.gserviceaccount.com:generateAccessToken") + .build(); + assertNotNull(credentials); + } + + @Test + void builder_actorToken_customNonGoogleHost_succeeds() throws Exception { + KeyStore ks = createPopulatedKeyStore(); + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setActorTokenSupplier(testActorSupplier) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setHttpTransportFactory(mtlsTransport) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://custom-auth-proxy.internal.corp/token") + .build(); + assertNotNull(credentials); + } + // ================================================================================== // Section A: Cert Pinning & Transport Factory Tests // ================================================================================== @@ -1951,6 +2068,56 @@ public KeyStore getKeyStore() { assertEquals(2, credential.getExchangeCallCount()); } + @Test + void refreshAccessToken_401Retry_viaHttpTransport_retriesAndSucceeds() throws Exception { + KeyStore ksA = createPopulatedKeyStore(); + KeyStore ksB = createPopulatedKeyStore(); + + AtomicInteger callCount = new AtomicInteger(0); + X509Provider rotatingProvider = + new X509Provider() { + @Override + public KeyStore getKeyStore() { + return callCount.getAndIncrement() == 0 ? ksA : ksB; + } + }; + + MockExternalAccountCredentialsTransport transport = + new MockExternalAccountCredentialsTransport(); + // 1st STS call returns 401 Unauthorized, 2nd STS call returns 200 OK + transport.addStsStatusCodeSequence(401, 200); + + List usedKeyStores = new ArrayList<>(); + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(rotatingProvider) + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl(transport.getStsUrl())) { + @Override + HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { + usedKeyStores.add(keyStore); + return () -> transport; + } + }; + + AccessToken token = credential.refreshAccessToken(); + assertNotNull(token); + assertEquals("accessToken", token.getTokenValue()); + + // Verify 2 calls to X509Provider: 1st for initial snapshot, 2nd on 401 reload + assertEquals(2, callCount.get()); + + // Verify 2 STS requests were executed over HTTP + assertEquals(2, transport.getRequests().size()); + + // Verify initial cycle used ksA, and retry used ksB + assertEquals(java.util.Arrays.asList(ksA, ksB), usedKeyStores); + } + @Test void refreshAccessToken_401Retry_nonMtls_bubblesUp() throws Exception { // When x509Provider is null (non-mTLS), a 401 should bubble up, not retry. @@ -2315,6 +2482,7 @@ public KeyStore getKeyStore() { // A credential that rotates the cert DURING the exchange call, then captures // the transport factory to verify it's still the original pinned one. AtomicReference capturedFactory = new AtomicReference<>(); + AtomicInteger exchangeCallCount = new AtomicInteger(0); IdentityPoolCredentials credential = new IdentityPoolCredentials( IdentityPoolCredentials.newBuilder() @@ -2330,59 +2498,49 @@ protected AccessToken exchangeExternalCredentialForAccessToken( StsTokenExchangeRequest stsTokenExchangeRequest, HttpTransportFactory cycleTransportFactory) throws IOException { - // Rotate the cert on the provider DURING the exchange. - // This simulates a cert rotation happening while STS/IAM is in-flight. - currentKeyStore.set(ksRotated); - // Capture the factory that was passed — it should be the original pinned one. + int call = exchangeCallCount.incrementAndGet(); + if (call == 1) { + // Rotate the cert on the provider DURING the exchange. + // This simulates a cert rotation happening while STS/IAM is in-flight. + currentKeyStore.set(ksRotated); + } capturedFactory.set(cycleTransportFactory); - return new AccessToken("pinnedCertToken", null); + return new AccessToken("token-" + call, null); } }; // Call refresh — this will snapshot ksOriginal, then during exchange, rotate to ksRotated. AccessToken token = credential.refreshAccessToken(); assertNotNull(token); + assertEquals("token-1", token.getTokenValue()); // Snapshot was taken exactly once (at the start of the cycle) assertEquals(1, snapshotCount.get()); // The transport factory used in exchange should be an MtlsHttpTransportFactory // built from the ORIGINAL snapshot, not the rotated cert. - assertNotNull(capturedFactory.get()); + HttpTransportFactory firstCycleFactory = capturedFactory.get(); + assertNotNull(firstCycleFactory); assertTrue( - capturedFactory.get() instanceof MtlsHttpTransportFactory, + firstCycleFactory instanceof MtlsHttpTransportFactory, "Exchange should use MtlsHttpTransportFactory pinned to original cert"); - // Verify that a SECOND refresh picks up the rotated cert (ksRotated). - AtomicReference secondCapturedFactory = new AtomicReference<>(); - IdentityPoolCredentials credential2 = - new IdentityPoolCredentials( - IdentityPoolCredentials.newBuilder() - .setSubjectTokenSupplier(testProvider) - .setX509Provider(provider) - .setAudience( - "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") - .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") - .setTokenUrl(transportFactory.transport.getStsUrl()) - .setHttpTransportFactory(mtlsTransport)) { - @Override - protected AccessToken exchangeExternalCredentialForAccessToken( - StsTokenExchangeRequest stsTokenExchangeRequest, - HttpTransportFactory cycleTransportFactory) - throws IOException { - secondCapturedFactory.set(cycleTransportFactory); - return new AccessToken("rotatedCertToken", null); - } - }; - - AccessToken token2 = credential2.refreshAccessToken(); + // Verify that a SECOND refresh on the SAME instance picks up the rotated cert (ksRotated). + AccessToken token2 = credential.refreshAccessToken(); assertNotNull(token2); + assertEquals("token-2", token2.getTokenValue()); // Second refresh should have taken a new snapshot assertEquals(2, snapshotCount.get()); + HttpTransportFactory secondCycleFactory = capturedFactory.get(); + assertNotNull(secondCycleFactory); + assertTrue( + secondCycleFactory instanceof MtlsHttpTransportFactory, + "Second exchange should use MtlsHttpTransportFactory pinned to rotated cert"); + // The two factories should be different instances (different cert snapshots) assertNotSame( - capturedFactory.get(), - secondCapturedFactory.get(), + firstCycleFactory, + secondCycleFactory, "Each refresh cycle should create a distinct transport factory from its cert snapshot"); } @@ -2419,6 +2577,24 @@ void serialize_deserialize_withActorTokenConfig_roundTrips() throws Exception { assertEquals(credentials.getActorTokenType(), deserialized.getActorTokenType()); } + @Test + void serialize_deserialize_withCustomTransportFactory_preservesCustomTransport() + throws Exception { + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(new MockHttpTransportFactory()) + .setSubjectTokenSupplier(testProvider) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build(); + + IdentityPoolCredentials deserialized = serializeAndDeserialize(credentials); + assertTrue( + deserialized.getTransportFactory() instanceof MockHttpTransportFactory, + "Custom transport factory should be preserved across serialization"); + } + private static final String PRE_PR_SERIALIZED_BYTES_BASE64 = "rO0ABXNyAC5jb20uZ29vZ2xlLmF1dGgub2F1dGgyLklkZW50aXR5UG9vbENyZWRlbnRpYWxzIkrrZ4jpHOkCAANMABJtZXRy" + "aWNzSGVhZGVyVmFsdWV0ABJMamF2YS9sYW5nL1N0cmluZztMABRzdWJqZWN0VG9rZW5TdXBwbGllcnQAOUxjb20vZ29vZ2xl" @@ -3164,4 +3340,367 @@ java.util.List getCapturedFactories() { return capturedFactories; } } + + // ================================================================================== + // Section: IAM Impersonation mTLS Transport Pinning & Retry Tests + // ================================================================================== + + @Test + void refreshAccessToken_impersonation_pinsTransportForBothStsAndIam() throws Exception { + KeyStore ks = createPopulatedKeyStore(); + AtomicInteger getKeyStoreCallCount = new AtomicInteger(0); + X509Provider x509Provider = + new X509Provider() { + @Override + public KeyStore getKeyStore() { + getKeyStoreCallCount.incrementAndGet(); + return ks; + } + }; + + AtomicInteger stsCallCount = new AtomicInteger(0); + AtomicInteger iamCallCount = new AtomicInteger(0); + List iamAuthHeaders = Collections.synchronizedList(new ArrayList<>()); + + MockHttpTransport mockTransport = + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() { + if (url.contains("/v1/token")) { + int count = stsCallCount.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", "urn:ietf:params:oauth:token-type:access_token"); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } else if (url.contains(":generateAccessToken")) { + int count = iamCallCount.incrementAndGet(); + iamAuthHeaders.add(getFirstHeaderValue("Authorization")); + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("accessToken", "final-iam-token-" + count); + response.put("expireTime", "2030-01-01T00:00:00Z"); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } + return new MockLowLevelHttpResponse().setStatusCode(404); + } + }; + } + }; + + List usedKeyStores = new ArrayList<>(); + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(x509Provider) + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .setServiceAccountImpersonationUrl( + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken")) { + @Override + HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { + usedKeyStores.add(keyStore); + return () -> mockTransport; + } + }; + + AccessToken token = credential.refreshAccessToken(); + assertNotNull(token); + assertEquals("final-iam-token-1", token.getTokenValue()); + + // Verify MtlsHttpTransportFactory was constructed with the pinned KeyStore. + assertEquals(Collections.singletonList(ks), usedKeyStores); + + // getKeyStore() should be called exactly once per refresh cycle. + assertEquals(1, getKeyStoreCallCount.get()); + + // Both STS and IAM should have been called once on the transport. + assertEquals(1, stsCallCount.get()); + assertEquals(1, iamCallCount.get()); + + // Verify the IAM request received Authorization: Bearer . + assertEquals(1, iamAuthHeaders.size()); + assertEquals("Bearer intermediate-sts-token-1", iamAuthHeaders.get(0)); + } + + @Test + void refreshAccessToken_impersonation_401OnIam_retriesBothStsAndIamWithFreshCert() + throws Exception { + KeyStore ks1 = createPopulatedKeyStore(); + KeyStore ks2 = createPopulatedKeyStore(); + AtomicInteger getKeyStoreCallCount = new AtomicInteger(0); + X509Provider x509Provider = + new X509Provider() { + @Override + public KeyStore getKeyStore() { + int count = getKeyStoreCallCount.incrementAndGet(); + return count == 1 ? ks1 : ks2; + } + }; + + AtomicInteger stsCallCount = new AtomicInteger(0); + AtomicInteger iamCallCount = new AtomicInteger(0); + List iamAuthHeaders = Collections.synchronizedList(new ArrayList<>()); + + MockHttpTransport mockTransport = + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() { + if (url.contains("/v1/token")) { + int count = stsCallCount.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", "urn:ietf:params:oauth:token-type:access_token"); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } else if (url.contains(":generateAccessToken")) { + int count = iamCallCount.incrementAndGet(); + iamAuthHeaders.add(getFirstHeaderValue("Authorization")); + if (count == 1) { + return new MockLowLevelHttpResponse() + .setStatusCode(401) + .setContentType(Json.MEDIA_TYPE) + .setContent("{\"error\": {\"code\": 401, \"message\": \"Unauthorized\"}}"); + } + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("accessToken", "final-iam-token-" + count); + response.put("expireTime", "2030-01-01T00:00:00Z"); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } + return new MockLowLevelHttpResponse().setStatusCode(404); + } + }; + } + }; + + List usedKeyStores = new ArrayList<>(); + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(x509Provider) + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .setServiceAccountImpersonationUrl( + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken")) { + @Override + HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { + usedKeyStores.add(keyStore); + return () -> mockTransport; + } + }; + + AccessToken token = credential.refreshAccessToken(); + assertNotNull(token); + assertEquals("final-iam-token-2", token.getTokenValue()); + + // Verify initial cycle used ks1, and 401 retry used ks2 (fresh cert). + assertEquals(java.util.Arrays.asList(ks1, ks2), usedKeyStores); + + // 1st call for initial cycle + 2nd call on 401 retry. + assertEquals(2, getKeyStoreCallCount.get()); + + // STS called twice (once on original cycle, once on retry with fresh cert). + assertEquals(2, stsCallCount.get()); + + // IAM called twice (once failed with 401, once succeeded on retry). + assertEquals(2, iamCallCount.get()); + + // IAM retry should have used the new intermediate STS token. + assertEquals(2, iamAuthHeaders.size()); + assertEquals("Bearer intermediate-sts-token-1", iamAuthHeaders.get(0)); + assertEquals("Bearer intermediate-sts-token-2", iamAuthHeaders.get(1)); + } + + @Test + void refreshAccessToken_impersonation_401OnIam_certLoadFailure_preservesOriginalError() + throws Exception { + KeyStore ks = createPopulatedKeyStore(); + AtomicInteger getKeyStoreCallCount = new AtomicInteger(0); + X509Provider x509Provider = + new X509Provider() { + @Override + public KeyStore getKeyStore() throws IOException { + int count = getKeyStoreCallCount.incrementAndGet(); + if (count == 1) { + return ks; + } + throw new IOException("Cert rotation reload disk error"); + } + }; + + MockHttpTransport mockTransport = + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() { + if (url.contains("/v1/token")) { + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("access_token", "intermediate-sts-token-1"); + response.put("token_type", "Bearer"); + response.put("expires_in", 3600); + response.put( + "issued_token_type", "urn:ietf:params:oauth:token-type:access_token"); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } else if (url.contains(":generateAccessToken")) { + return new MockLowLevelHttpResponse() + .setStatusCode(401) + .setContentType(Json.MEDIA_TYPE) + .setContent("{\"error\": {\"code\": 401, \"message\": \"Unauthorized\"}}"); + } + return new MockLowLevelHttpResponse().setStatusCode(404); + } + }; + } + }; + + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(x509Provider) + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .setServiceAccountImpersonationUrl( + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken")) { + @Override + HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { + return () -> mockTransport; + } + }; + + IOException thrown = assertThrows(IOException.class, credential::refreshAccessToken); + assertEquals("Cert rotation reload disk error", thrown.getMessage()); + assertEquals(2, getKeyStoreCallCount.get()); + + Throwable[] suppressed = thrown.getSuppressed(); + assertTrue(suppressed.length > 0); + assertTrue(OAuth2Utils.isUnauthorizedException(suppressed[0])); + } + + @Test + void refreshAccessToken_impersonation_certRotationBetweenCycles_usesNewCert() throws Exception { + KeyStore ksA = createPopulatedKeyStore(); + KeyStore ksB = createPopulatedKeyStore(); + AtomicInteger getKeyStoreCallCount = new AtomicInteger(0); + X509Provider x509Provider = + new X509Provider() { + @Override + public KeyStore getKeyStore() { + int count = getKeyStoreCallCount.incrementAndGet(); + return count == 1 ? ksA : ksB; + } + }; + + AtomicInteger stsCallCount = new AtomicInteger(0); + AtomicInteger iamCallCount = new AtomicInteger(0); + List iamAuthHeaders = Collections.synchronizedList(new ArrayList<>()); + + MockHttpTransport mockTransport = + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() { + if (url.contains("/v1/token")) { + int count = stsCallCount.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", "urn:ietf:params:oauth:token-type:access_token"); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } else if (url.contains(":generateAccessToken")) { + int count = iamCallCount.incrementAndGet(); + iamAuthHeaders.add(getFirstHeaderValue("Authorization")); + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("accessToken", "final-iam-token-" + count); + response.put("expireTime", "2030-01-01T00:00:00Z"); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } + return new MockLowLevelHttpResponse().setStatusCode(404); + } + }; + } + }; + + List usedKeyStores = new ArrayList<>(); + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(x509Provider) + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .setServiceAccountImpersonationUrl( + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken")) { + @Override + HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { + usedKeyStores.add(keyStore); + return () -> mockTransport; + } + }; + + // Refresh cycle 1 + AccessToken token1 = credential.refreshAccessToken(); + assertNotNull(token1); + assertEquals("final-iam-token-1", token1.getTokenValue()); + assertEquals(1, getKeyStoreCallCount.get()); + assertEquals(1, stsCallCount.get()); + assertEquals(1, iamCallCount.get()); + assertEquals("Bearer intermediate-sts-token-1", iamAuthHeaders.get(0)); + + // Refresh cycle 2 + AccessToken token2 = credential.refreshAccessToken(); + assertNotNull(token2); + assertEquals("final-iam-token-2", token2.getTokenValue()); + assertEquals(2, getKeyStoreCallCount.get()); + assertEquals(2, stsCallCount.get()); + assertEquals(2, iamCallCount.get()); + assertEquals("Bearer intermediate-sts-token-2", iamAuthHeaders.get(1)); + assertEquals(java.util.Arrays.asList(ksA, ksB), usedKeyStores); + } } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java index cc95fbe5b575..fcd682a4b820 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java @@ -93,8 +93,8 @@ class ImpersonatedCredentialsTest extends BaseSerializationTest { + "SXagBxzp8PecbaCHjzNRSQE2in81qYnrAFNB4o3DpHyMMY6s5ALLeHKscEWnqP8Ur6X4PvzZecCWU9BKAZAkAut" + "LPknAuxSCsUOvUfS1i87ex77Ot+w6POp34pEX+UWb+u5iFn2cQacDTHLV1LtE80L8jVLSbrbrlH43H0DjU5AkEA" + "gidhycxS86dxpEljnOMCw8CKoUBd5I880IUahEiUltk7OLJYS/Ts1wbn3kPOVX3wyJs8WBDtBkFrDHW2ezth2QJ" - + "ADj3e1YhMVdjJW5jqwlD/VNddGjgzyunmiZg0uOXsHXbytYmsA545S8KRQFaJKFXYYFo2kOjqOiC1T2cAzMDjCQ" - + "==\n-----END PRIVATE KEY-----\n"; + + "ADj3e1YhMVdjJW5jqwlD/VNddGjgzyunmiZg0uOXsHXbytYmsA545S8KRQFaJKFXYYFo2kOjqOiC1T2cAzMDjCQ==\n" + + "-----END PRIVATE KEY-----\n"; // Id Token provided by the default IAM API that does not include the "email" claim public static final String STANDARD_ID_TOKEN = @@ -1090,8 +1090,8 @@ void universeDomain_whenExplicit_notAllowedIfNotMatchToSourceUD() { IllegalStateException illegalStateException = assertThrows(IllegalStateException.class, builder::build); assertEquals( - "Universe domain source.domain.xyz in source credentials" - + " does not match explicit.domain.com universe domain set for impersonated credentials.", + "Universe domain source.domain.xyz in source credentials does not match explicit.domain.com" + + " universe domain set for impersonated credentials.", illegalStateException.getMessage()); } @@ -1373,4 +1373,99 @@ static InputStream writeImpersonationCredentialsStream( buildImpersonationCredentialsJson(impersonationUrl, delegates, quotaProjectId, scopes); return TestUtils.jsonToInputStream(json); } + + @Test + void refreshAccessToken_withExternalAccountSource_usesProvidedTransportFactory() + throws IOException { + MockIAMCredentialsServiceTransportFactory customTransportFactory = + new MockIAMCredentialsServiceTransportFactory(); + customTransportFactory.getTransport().setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL); + customTransportFactory.getTransport().setAccessToken("final-iam-token"); + customTransportFactory.getTransport().setExpireTime(getDefaultExpireTime()); + customTransportFactory + .getTransport() + .addStatusCodeAndMessage(HttpStatusCodes.STATUS_CODE_OK, ""); + + java.util.concurrent.atomic.AtomicReference capturedSourceTransport = + new java.util.concurrent.atomic.AtomicReference<>(); + ExternalAccountCredentials mockExternalAccountCredentials = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setSubjectTokenSupplier(context -> "token") + .setTokenUrl("https://sts.googleapis.com/v1/token")) { + @Override + public AccessToken refreshAccessToken(HttpTransportFactory transportFactory) { + capturedSourceTransport.set(transportFactory); + return new AccessToken("intermediate-sts-token-xyz", null); + } + }; + + ImpersonatedCredentials credentials = + ImpersonatedCredentials.newBuilder() + .setSourceCredentials(mockExternalAccountCredentials) + .setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL) + .setScopes(IMMUTABLE_SCOPES_LIST) + .setLifetime(VALID_LIFETIME) + .setHttpTransportFactory(mockTransportFactory) + .build(); + + AccessToken token = credentials.refreshAccessToken(customTransportFactory); + assertEquals("final-iam-token", token.getTokenValue()); + assertSame(customTransportFactory, capturedSourceTransport.get()); + assertEquals( + "Bearer intermediate-sts-token-xyz", + customTransportFactory.getTransport().getRequest().getFirstHeaderValue("Authorization")); + } + + @Test + void refreshAccessToken_nullTransportFactory_fallsBackToCredentialsTransport() + throws IOException { + MockIAMCredentialsServiceTransportFactory credentialsTransportFactory = + new MockIAMCredentialsServiceTransportFactory(); + credentialsTransportFactory.getTransport().setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL); + credentialsTransportFactory.getTransport().setAccessToken("final-iam-token-null-transport"); + credentialsTransportFactory.getTransport().setExpireTime(getDefaultExpireTime()); + credentialsTransportFactory + .getTransport() + .addStatusCodeAndMessage(HttpStatusCodes.STATUS_CODE_OK, ""); + + java.util.concurrent.atomic.AtomicBoolean sourceRefreshed = + new java.util.concurrent.atomic.AtomicBoolean(false); + ExternalAccountCredentials mockExternalAccountCredentials = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setSubjectTokenSupplier(context -> "token") + .setTokenUrl("https://sts.googleapis.com/v1/token")) { + @Override + public AccessToken refreshAccessToken() { + sourceRefreshed.set(true); + return new AccessToken("intermediate-sts-token-null", null); + } + }; + + ImpersonatedCredentials credentials = + ImpersonatedCredentials.newBuilder() + .setSourceCredentials(mockExternalAccountCredentials) + .setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL) + .setScopes(IMMUTABLE_SCOPES_LIST) + .setLifetime(VALID_LIFETIME) + .setHttpTransportFactory(credentialsTransportFactory) + .build(); + + AccessToken token = credentials.refreshAccessToken(null); + assertEquals("final-iam-token-null-transport", token.getTokenValue()); + assertTrue(sourceRefreshed.get()); + assertEquals( + "Bearer intermediate-sts-token-null", + credentialsTransportFactory + .getTransport() + .getRequest() + .getFirstHeaderValue("Authorization")); + } } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java index 85dff97bc270..e37c8d835f20 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java @@ -89,11 +89,16 @@ public class MockExternalAccountCredentialsTransport extends MockHttpTransport { private final Queue responseErrorSequence = new ArrayDeque<>(); private final Queue refreshTokenSequence = new ArrayDeque<>(); private final Queue> scopeSequence = new ArrayDeque<>(); + private final Queue stsStatusCodeSequence = new ArrayDeque<>(); private final List requests = new ArrayList<>(); private String expireTime; private String metadataServerContentType; private String stsContent; + public void addStsStatusCodeSequence(Integer... statusCodes) { + Collections.addAll(stsStatusCodeSequence, statusCodes); + } + public void addResponseErrorSequence(IOException... errors) { Collections.addAll(responseErrorSequence, errors); } @@ -174,6 +179,19 @@ public LowLevelHttpResponse execute() throws IOException { // Store STS content as multiple calls are made using this transport. stsContent = getContentAsString(); + int statusCode = + !stsStatusCodeSequence.isEmpty() ? stsStatusCodeSequence.poll() : 200; + if (statusCode != 200) { + GenericJson errorResponse = new GenericJson(); + errorResponse.setFactory(JSON_FACTORY); + errorResponse.put("error", "invalid_token"); + errorResponse.put("error_description", "Invalid or expired client certificate."); + return new MockLowLevelHttpResponse() + .setStatusCode(statusCode) + .setContentType(Json.MEDIA_TYPE) + .setContent(errorResponse.toPrettyString()); + } + assertEquals(EXPECTED_GRANT_TYPE, query.get("grant_type")); assertNotNull(query.get("subject_token_type")); assertNotNull(query.get("subject_token")); diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuth2UtilsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuth2UtilsTest.java index f540ac41d2b9..e043b235c50c 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuth2UtilsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuth2UtilsTest.java @@ -98,4 +98,60 @@ void testNullPassword_throws() { generateBasicAuthHeader(username, password); }); } + + @Test + void isUnauthorizedException_null_returnsFalse() { + org.junit.jupiter.api.Assertions.assertFalse(OAuth2Utils.isUnauthorizedException(null)); + } + + @Test + void isUnauthorizedException_genericIOException_returnsFalse() { + org.junit.jupiter.api.Assertions.assertFalse( + OAuth2Utils.isUnauthorizedException(new java.io.IOException("Network error"))); + } + + @Test + void isUnauthorizedException_oauthException401_returnsTrue() { + OAuthException ex = new OAuthException("invalid_client", "Unauthorized", null, 401); + org.junit.jupiter.api.Assertions.assertTrue(OAuth2Utils.isUnauthorizedException(ex)); + } + + @Test + void isUnauthorizedException_oauthExceptionNon401_returnsFalse() { + OAuthException ex = new OAuthException("bad_request", "Bad Request", null, 400); + org.junit.jupiter.api.Assertions.assertFalse(OAuth2Utils.isUnauthorizedException(ex)); + } + + @Test + void isUnauthorizedException_httpResponseException401_returnsTrue() { + com.google.api.client.http.HttpResponseException ex = + new com.google.api.client.http.HttpResponseException.Builder( + 401, "Unauthorized", new com.google.api.client.http.HttpHeaders()) + .build(); + org.junit.jupiter.api.Assertions.assertTrue(OAuth2Utils.isUnauthorizedException(ex)); + } + + @Test + void isUnauthorizedException_httpResponseExceptionNon401_returnsFalse() { + com.google.api.client.http.HttpResponseException ex = + new com.google.api.client.http.HttpResponseException.Builder( + 403, "Forbidden", new com.google.api.client.http.HttpHeaders()) + .build(); + org.junit.jupiter.api.Assertions.assertFalse(OAuth2Utils.isUnauthorizedException(ex)); + } + + @Test + void isUnauthorizedException_wrappedInExceptionChain_returnsTrue() { + OAuthException oauthEx = new OAuthException("invalid_client", "Unauthorized", null, 401); + java.io.IOException wrapped = new java.io.IOException("Wrapped failure", oauthEx); + org.junit.jupiter.api.Assertions.assertTrue(OAuth2Utils.isUnauthorizedException(wrapped)); + + com.google.api.client.http.HttpResponseException httpEx = + new com.google.api.client.http.HttpResponseException.Builder( + 401, "Unauthorized", new com.google.api.client.http.HttpHeaders()) + .build(); + java.io.IOException wrappedHttp = + new java.io.IOException("Outer", new java.io.IOException("Inner", httpEx)); + org.junit.jupiter.api.Assertions.assertTrue(OAuth2Utils.isUnauthorizedException(wrappedHttp)); + } } From 5cc87e0add2cc0dfeafa115ac166b9378cf4cdc6 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Thu, 17 Sep 2026 14:37:01 +0000 Subject: [PATCH 02/20] fix(oauth2): enforce CLOUD_PLATFORM_SCOPE on impersonation source credentials and address review findings - Explicitly scope inner sourceCredentials to CLOUD_PLATFORM_SCOPE in ExternalAccountCredentials.buildImpersonatedCredentials and ImpersonatedCredentials.refreshAccessToken so STS issues tokens authorized to call IAM generateAccessToken even when downstream target scopes are configured via createScoped. - Ensure public no-arg ImpersonatedCredentials.refreshAccessToken delegates without overriding source credential transport settings. - Preserve custom actorTokenSupplier in IdentityPoolCredentials.Builder copy constructor when credentialSource is present. - Ensure HTTP response is closed in a finally block in ImpersonatedCredentials.refreshAccessToken. - Attach initial 401 exception as suppressed when the 401 retry attempt fails in IdentityPoolCredentials.refreshWithRetry. - Add unit tests in IdentityPoolCredentialsTest and ImpersonatedCredentialsTest covering scoped impersonation, custom actorTokenSupplier preservation, and retry exception chaining. --- .../oauth2/ExternalAccountCredentials.java | 6 +- .../auth/oauth2/IdentityPoolCredentials.java | 37 ++++-- .../auth/oauth2/ImpersonatedCredentials.java | 35 ++++-- .../oauth2/IdentityPoolCredentialsTest.java | 112 ++++++++++++++++++ .../oauth2/ImpersonatedCredentialsTest.java | 66 ++++++++++- ...ckExternalAccountCredentialsTransport.java | 9 +- 6 files changed, 235 insertions(+), 30 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java index d39dddf98287..96fe81866ce9 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java @@ -95,7 +95,7 @@ public abstract class ExternalAccountCredentials extends GoogleCredentials { protected transient HttpTransportFactory transportFactory; - protected @Nullable ImpersonatedCredentials impersonatedCredentials; + protected volatile @Nullable ImpersonatedCredentials impersonatedCredentials; private final EnvironmentProvider environmentProvider; private final PropertyProvider propertyProvider; @@ -292,16 +292,19 @@ protected ExternalAccountCredentials(ExternalAccountCredentials.Builder builder) sourceCredentials = AwsCredentials.newBuilder((AwsCredentials) this) .setServiceAccountImpersonationUrl(null) + .setScopes(Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE)) .build(); } else if (this instanceof PluggableAuthCredentials) { sourceCredentials = PluggableAuthCredentials.newBuilder((PluggableAuthCredentials) this) .setServiceAccountImpersonationUrl(null) + .setScopes(Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE)) .build(); } else { sourceCredentials = IdentityPoolCredentials.newBuilder((IdentityPoolCredentials) this) .setServiceAccountImpersonationUrl(null) + .setScopes(Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE)) .build(); } @@ -639,6 +642,7 @@ private void readObject(ObjectInputStream input) throws IOException, ClassNotFou // Properly deserialize the transient transportFactory. input.defaultReadObject(); transportFactory = newInstance(transportFactoryClassName); + impersonatedCredentials = null; } public @Nullable String getServiceAccountImpersonationUrl() { diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java index ec80d1d8439e..fc838343cf6c 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java @@ -72,7 +72,7 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { private final @Nullable String actorTokenType; // Transient: not serialized directly. Reconstructed in readObject() from the credentialSource // certificate config so deserialized credentials remain usable for mTLS and refresh. - private transient @Nullable X509Provider x509Provider; + private transient volatile @Nullable X509Provider x509Provider; private final ExternalAccountSupplierContext supplierContext; private final String metricsHeaderValue; @@ -232,7 +232,7 @@ private boolean shouldUseMtlsTransportFactory() { return this.transportFactory == null || this.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY || this.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory - || this.transportFactory instanceof MtlsHttpTransportFactory; + || this.transportFactory.getClass() == MtlsHttpTransportFactory.class; } @Override @@ -300,17 +300,34 @@ && shouldUseMtlsTransportFactory()) { // On 401, re-read from X509Provider for fresh certs. freshKeyStore = this.x509Provider.getKeyStore(); } catch (IOException reloadException) { - reloadException.addSuppressed(e); + if (reloadException != e) { + reloadException.addSuppressed(e); + } throw reloadException; } catch (Exception reloadException) { IOException ioException = new IOException("Failed to reload certificate on retry", reloadException); - ioException.addSuppressed(e); + if (ioException != e) { + ioException.addSuppressed(e); + } throw ioException; } HttpTransportFactory retryTransportFactory = createMtlsTransportFactory(freshKeyStore); - return refreshWithRetry(retryTransportFactory, false); + try { + return refreshWithRetry(retryTransportFactory, false); + } catch (Exception retryException) { + if (retryException != e) { + retryException.addSuppressed(e); + } + if (retryException instanceof IOException) { + throw (IOException) retryException; + } + if (retryException instanceof RuntimeException) { + throw (RuntimeException) retryException; + } + throw new IOException(retryException); + } } if (e instanceof IOException) { throw (IOException) e; @@ -433,10 +450,7 @@ private void readObject(ObjectInputStream input) throws IOException, ClassNotFou new X509Provider(getEnvironmentProvider(), getPropertyProvider(), explicitCertConfigPath); try { KeyStore mtlsKeyStore = this.x509Provider.getKeyStore(); - if (this.transportFactory == null - || this.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY - || this.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory - || this.transportFactory instanceof MtlsHttpTransportFactory) { + if (shouldUseMtlsTransportFactory()) { this.transportFactory = createMtlsTransportFactory(mtlsKeyStore); } } catch (Exception e) { @@ -484,8 +498,11 @@ public static class Builder extends ExternalAccountCredentials.Builder { if (this.credentialSource == null) { this.subjectTokenSupplier = credentials.subjectTokenSupplier; this.actorTokenSupplier = credentials.actorTokenSupplier; + } else if (credentials.actorTokenSupplier != credentials.subjectTokenSupplier) { + this.actorTokenSupplier = credentials.actorTokenSupplier; } - // Note: when credentialSource is present, subjectTokenSupplier and actorTokenSupplier + // Note: when credentialSource is present, subjectTokenSupplier and file-based + // actorTokenSupplier // are intentionally NOT copied here. They will be reconstructed from credentialSource // during build(), which ensures they share the same FileIdentityPoolSubjectTokenSupplier // instance for atomic token reads. diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java index 81ffc6d533fe..7c7966101584 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java @@ -107,7 +107,7 @@ public class ImpersonatedCredentials extends GoogleCredentials private static final long serialVersionUID = -2133257318957488431L; private static final int TWELVE_HOURS_IN_SECONDS = 43200; private static final int DEFAULT_LIFETIME_IN_SECONDS = 3600; - private GoogleCredentials sourceCredentials; + private volatile GoogleCredentials sourceCredentials; private final String targetPrincipal; private List delegates; private final List scopes; @@ -117,7 +117,7 @@ public class ImpersonatedCredentials extends GoogleCredentials private static final LoggerProvider LOGGER_PROVIDER = LoggerProvider.forClazz(ImpersonatedCredentials.class); - private transient HttpTransportFactory transportFactory; + private transient volatile HttpTransportFactory transportFactory; private transient @Nullable Calendar calendar; @@ -581,7 +581,7 @@ public String getUniverseDomain() throws IOException { @Override public AccessToken refreshAccessToken() throws IOException { - return refreshAccessToken(this.transportFactory); + return refreshAccessToken(null); } /** @@ -593,11 +593,13 @@ public AccessToken refreshAccessToken() throws IOException { * setServiceAccountImpersonationUrl} directly on {@code IdentityPoolCredentials}, which manages * the certificate lifecycle and 401 recovery. * - * @param transportFactory the HTTP transport factory to use + * @param transportFactory the HTTP transport factory to use, or {@code null} to use this + * instance's configured transport factory without overriding source credential transport * @return the refreshed access token * @throws IOException if token refresh fails */ - AccessToken refreshAccessToken(HttpTransportFactory transportFactory) throws IOException { + AccessToken refreshAccessToken(@Nullable HttpTransportFactory transportFactory) + throws IOException { HttpTransportFactory effectiveTransportFactory = transportFactory != null ? transportFactory @@ -606,10 +608,15 @@ AccessToken refreshAccessToken(HttpTransportFactory transportFactory) throws IOE : OAuth2Utils.HTTP_TRANSPORT_FACTORY); HttpCredentialsAdapter adapter; if (this.sourceCredentials instanceof ExternalAccountCredentials) { + Collection currentScopes = + ((ExternalAccountCredentials) this.sourceCredentials).getScopes(); + if (currentScopes == null || !currentScopes.contains(OAuth2Utils.CLOUD_PLATFORM_SCOPE)) { + this.sourceCredentials = + this.sourceCredentials.createScoped( + Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE)); + } AccessToken intermediateAccessToken = - (transportFactory == null - || transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY - || transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory) + (transportFactory == null) ? ((ExternalAccountCredentials) this.sourceCredentials).refreshAccessToken() : ((ExternalAccountCredentials) this.sourceCredentials) .refreshAccessToken(effectiveTransportFactory); @@ -686,10 +693,14 @@ AccessToken refreshAccessToken(HttpTransportFactory transportFactory) throws IOE throw new IOException("Error requesting access token", e); } - GenericData responseData = response.parseAs(GenericData.class); - LoggingUtils.logResponsePayload( - responseData, LOGGER_PROVIDER, "Response payload for access token"); - response.disconnect(); + GenericData responseData; + try { + responseData = response.parseAs(GenericData.class); + LoggingUtils.logResponsePayload( + responseData, LOGGER_PROVIDER, "Response payload for access token"); + } finally { + response.disconnect(); + } String accessToken = OAuth2Utils.validateString(responseData, "accessToken", "Expected to find an accessToken"); diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java index 09748ee07de6..cbf22907d9ec 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java @@ -3703,4 +3703,116 @@ HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { assertEquals("Bearer intermediate-sts-token-2", iamAuthHeaders.get(1)); assertEquals(java.util.Arrays.asList(ksA, ksB), usedKeyStores); } + + @Test + void + refreshAccessToken_impersonation_createScoped_passesCloudPlatformScopeToStsAndTargetScopeToIam() + throws Exception { + MockExternalAccountCredentialsTransport transport = + new MockExternalAccountCredentialsTransport(); + transport.setExpireTime(TestUtils.getDefaultExpireTime()); + + IdentityPoolCredentials baseCredential = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl(transport.getStsUrl()) + .setServiceAccountImpersonationUrl(transport.getServiceAccountImpersonationUrl()) + .setHttpTransportFactory(() -> transport) + .build(); + + List targetScopes = + Collections.singletonList("https://www.googleapis.com/auth/devstorage.read_only"); + transport.setExpectedIamScope("https://www.googleapis.com/auth/devstorage.read_only"); + IdentityPoolCredentials scopedCredential = baseCredential.createScoped(targetScopes); + + AccessToken token = scopedCredential.refreshAccessToken(); + assertNotNull(token); + assertEquals(transport.getServiceAccountAccessToken(), token.getTokenValue()); + + // Request 0 is STS token exchange from sourceCredentials; verify it requested cloud-platform + // scope + String stsRequestContent = transport.getRequests().get(0).getContentAsString(); + Map stsParams = TestUtils.parseQuery(stsRequestContent); + assertEquals(OAuth2Utils.CLOUD_PLATFORM_SCOPE, stsParams.get("scope")); + + // Request 1 is IAM generateAccessToken; verify it requested the downstream target scope + String iamRequestContent = transport.getRequests().get(1).getContentAsString(); + try (com.google.api.client.json.JsonParser parser = + OAuth2Utils.JSON_FACTORY.createJsonParser(iamRequestContent)) { + GenericJson iamBody = parser.parseAndClose(GenericJson.class); + assertEquals(targetScopes, iamBody.get("scope")); + } + } + + @Test + void createScoped_withCredentialSourceAndCustomActorTokenSupplier_preservesActorTokenSupplier() + throws Exception { + IdentityPoolCredentialSource credentialSource = + (IdentityPoolCredentialSource) createBaseFileSourcedCredentials().getCredentialSource(); + + IdentityPoolActorTokenSupplier customActorSupplier = ctx -> "custom-actor-token"; + KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); + ks.load(null, null); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setCredentialSource(credentialSource) + .setActorTokenSupplier(customActorSupplier) + .setActorTokenType("urn:ietf:params:oauth:token-type:access_token") + .setX509Provider( + new X509Provider(null) { + @Override + public KeyStore getKeyStore() { + return ks; + } + }) + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build(); + + IdentityPoolCredentials scoped = + credentials.createScoped( + Collections.singletonList("https://www.googleapis.com/auth/cloud-platform")); + assertEquals(customActorSupplier, scoped.getIdentityPoolActorTokenSupplier()); + assertEquals("urn:ietf:params:oauth:token-type:access_token", scoped.getActorTokenType()); + } + + @Test + void refreshAccessToken_401RetryFailureOnSecondAttempt_attachesInitial401AsSuppressed() + throws Exception { + KeyStore ksA = KeyStore.getInstance(KeyStore.getDefaultType()); + ksA.load(null, null); + KeyStore ksB = KeyStore.getInstance(KeyStore.getDefaultType()); + ksB.load(null, null); + AtomicInteger callCount = new AtomicInteger(0); + X509Provider rotatingProvider = + new X509Provider(null) { + @Override + public KeyStore getKeyStore() { + return callCount.getAndIncrement() == 0 ? ksA : ksB; + } + }; + + TestableIdentityPoolCredentials credential = + new TestableIdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(rotatingProvider) + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token"), + /* failOnFirstExchange= */ true, + /* failOnAllExchanges= */ true); + + OAuthException thrown = + assertThrows(OAuthException.class, () -> credential.refreshAccessToken()); + assertEquals(1, thrown.getSuppressed().length); + assertTrue(thrown.getSuppressed()[0] instanceof OAuthException); + } } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java index fcd682a4b820..4559dcff8155 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java @@ -90,10 +90,7 @@ class ImpersonatedCredentialsTest extends BaseSerializationTest { + "4Az2ZkmeuN6Fk/y9H+Lcb2pskJIXjrL533vrDWGOC48LrsThMQPv8cxBky8HFSEklPpkfTF95tpD43iVwJRB/Gr" + "CtGTw65IfJ4/tI09h6zGc4yqvIo1cHX/LQ+SxKLGyir/dQM925rGt/VojxY5ryJR7GLbCzxPnJm/oQJBANwOCO6" + "D2hy1LQYJhXh7O+RLtA/tSnT1xyMQsGT+uUCMiKS2bSKx2wxo9k7h3OegNJIu1q6nZ6AbxDK8H3+d0dUCQQDTrP" - + "SXagBxzp8PecbaCHjzNRSQE2in81qYnrAFNB4o3DpHyMMY6s5ALLeHKscEWnqP8Ur6X4PvzZecCWU9BKAZAkAut" - + "LPknAuxSCsUOvUfS1i87ex77Ot+w6POp34pEX+UWb+u5iFn2cQacDTHLV1LtE80L8jVLSbrbrlH43H0DjU5AkEA" - + "gidhycxS86dxpEljnOMCw8CKoUBd5I880IUahEiUltk7OLJYS/Ts1wbn3kPOVX3wyJs8WBDtBkFrDHW2ezth2QJ" - + "ADj3e1YhMVdjJW5jqwlD/VNddGjgzyunmiZg0uOXsHXbytYmsA545S8KRQFaJKFXYYFo2kOjqOiC1T2cAzMDjCQ==\n" + + "SXagBxzp8PecbaCHjzNRSQE2in81qYnrAFNB4o3DpHyMMY6s5ALLeHKscEWnqP8Ur6X4PvzZecCWU9BKAZAkAutLPknAuxSCsUOvUfS1i87ex77Ot+w6POp34pEX+UWb+u5iFn2cQacDTHLV1LtE80L8jVLSbrbrlH43H0DjU5AkEAgidhycxS86dxpEljnOMCw8CKoUBd5I880IUahEiUltk7OLJYS/Ts1wbn3kPOVX3wyJs8WBDtBkFrDHW2ezth2QJADj3e1YhMVdjJW5jqwlD/VNddGjgzyunmiZg0uOXsHXbytYmsA545S8KRQFaJKFXYYFo2kOjqOiC1T2cAzMDjCQ==\n" + "-----END PRIVATE KEY-----\n"; // Id Token provided by the default IAM API that does not include the "email" claim @@ -1467,5 +1464,66 @@ public AccessToken refreshAccessToken() { .getTransport() .getRequest() .getFirstHeaderValue("Authorization")); + + // Also verify public no-arg refreshAccessToken() delegates without overriding source transport + sourceRefreshed.set(false); + credentialsTransportFactory + .getTransport() + .addStatusCodeAndMessage(HttpStatusCodes.STATUS_CODE_OK, ""); + AccessToken token2 = credentials.refreshAccessToken(); + assertEquals("final-iam-token-null-transport", token2.getTokenValue()); + assertTrue(sourceRefreshed.get()); + } + + @Test + void + refreshAccessToken_externalAccountSource_appliesCloudPlatformScopeToSourceAndTargetScopeToIam() + throws IOException { + MockExternalAccountCredentialsTransport stsTransport = + new MockExternalAccountCredentialsTransport(); + stsTransport.setExpireTime(getDefaultExpireTime()); + + MockIAMCredentialsServiceTransportFactory iamTransportFactory = + new MockIAMCredentialsServiceTransportFactory(); + iamTransportFactory.getTransport().setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL); + iamTransportFactory.getTransport().setAccessToken("final-iam-token"); + iamTransportFactory.getTransport().setExpireTime(getDefaultExpireTime()); + iamTransportFactory.getTransport().addStatusCodeAndMessage(HttpStatusCodes.STATUS_CODE_OK, ""); + + IdentityPoolCredentials sourceCredentials = + IdentityPoolCredentials.newBuilder() + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setSubjectTokenSupplier(context -> "subject-token") + .setTokenUrl(stsTransport.getStsUrl()) + .setHttpTransportFactory(() -> stsTransport) + .build(); + + List targetScopes = Arrays.asList("https://www.googleapis.com/auth/bigquery"); + ImpersonatedCredentials impersonated = + ImpersonatedCredentials.newBuilder() + .setSourceCredentials(sourceCredentials) + .setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL) + .setScopes(targetScopes) + .setLifetime(VALID_LIFETIME) + .setHttpTransportFactory(iamTransportFactory) + .build(); + + AccessToken token = impersonated.refreshAccessToken(); + assertEquals("final-iam-token", token.getTokenValue()); + + // Verify STS request received cloud-platform scope + String stsContent = stsTransport.getRequests().get(0).getContentAsString(); + Map stsParams = TestUtils.parseQuery(stsContent); + assertEquals(OAuth2Utils.CLOUD_PLATFORM_SCOPE, stsParams.get("scope")); + + // Verify IAM request received the target bigquery scope + assertTrue( + iamTransportFactory + .getTransport() + .getRequest() + .getContentAsString() + .contains("https://www.googleapis.com/auth/bigquery")); } } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java index e37c8d835f20..873b12f0a1b3 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java @@ -94,6 +94,11 @@ public class MockExternalAccountCredentialsTransport extends MockHttpTransport { private String expireTime; private String metadataServerContentType; private String stsContent; + private String expectedIamScope = OAuth2Utils.CLOUD_PLATFORM_SCOPE; + + public void setExpectedIamScope(String expectedIamScope) { + this.expectedIamScope = expectedIamScope; + } public void addStsStatusCodeSequence(Integer... statusCodes) { Collections.addAll(stsStatusCodeSequence, statusCodes); @@ -219,9 +224,7 @@ public LowLevelHttpResponse execute() throws IOException { OAuth2Utils.JSON_FACTORY .createJsonParser(getContentAsString()) .parseAndClose(GenericJson.class); - assertEquals( - OAuth2Utils.CLOUD_PLATFORM_SCOPE, - ((ArrayList) query.get("scope")).get(0)); + assertEquals(expectedIamScope, ((ArrayList) query.get("scope")).get(0)); assertEquals(1, getHeaders().get("authorization").size()); assertTrue(getHeaders().containsKey("authorization")); assertNotNull(getHeaders().get("authorization").get(0)); From 73c5a0af7623034ec09dcff5af32c6351430565e Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Thu, 17 Sep 2026 18:09:55 +0000 Subject: [PATCH 03/20] fix(oauth2): address PR review feedback for IAM mTLS transport pinning --- .../google/auth/oauth2/AwsCredentials.java | 9 +- .../oauth2/ExternalAccountCredentials.java | 28 +- .../auth/oauth2/IdentityPoolCredentials.java | 98 ++-- .../auth/oauth2/ImpersonatedCredentials.java | 50 +- .../com/google/auth/oauth2/OAuth2Utils.java | 47 ++ .../auth/oauth2/PluggableAuthCredentials.java | 9 +- .../auth/oauth2/AwsCredentialsTest.java | 4 +- .../oauth2/IdentityPoolCredentialsTest.java | 503 +++++++++++++----- .../oauth2/ImpersonatedCredentialsTest.java | 36 +- ...ckExternalAccountCredentialsTransport.java | 8 +- .../google/auth/oauth2/OAuth2UtilsTest.java | 58 +- 11 files changed, 588 insertions(+), 262 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java index 2abcd114dab5..6dec8364ea4e 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java @@ -124,7 +124,12 @@ public AccessToken refreshAccessToken() throws IOException { } @Override - public AccessToken refreshAccessToken(HttpTransportFactory transportFactory) throws IOException { + AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) throws IOException { + ImpersonatedCredentials impersonated = getImpersonatedCredentials(); + if (impersonated != null) { + return impersonated.refreshAccessToken(cycleTransportFactory); + } + StsTokenExchangeRequest.Builder stsTokenExchangeRequest = StsTokenExchangeRequest.newBuilder(retrieveSubjectToken(), getSubjectTokenType()) .setAudience(getAudience()); @@ -136,7 +141,7 @@ public AccessToken refreshAccessToken(HttpTransportFactory transportFactory) thr } return exchangeExternalCredentialForAccessToken( - stsTokenExchangeRequest.build(), transportFactory); + stsTokenExchangeRequest.build(), cycleTransportFactory); } @Override diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java index 96fe81866ce9..e9b78230499e 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java @@ -529,16 +529,26 @@ private boolean shouldBuildImpersonatedCredential() { return this.serviceAccountImpersonationUrl != null && this.impersonatedCredentials == null; } + @Nullable + ImpersonatedCredentials getImpersonatedCredentials() { + if (this.shouldBuildImpersonatedCredential()) { + this.impersonatedCredentials = this.buildImpersonatedCredentials(); + } + return this.impersonatedCredentials; + } + /** - * Refreshes the access token using the specified transport factory. Default implementation - * delegates to {@link #refreshAccessToken()}. Subclasses should override this method if they - * support transport pinning per refresh cycle. + * Refreshes the access token using the specified transport factory for per-cycle transport + * pinning. Internal subclasses ({@link IdentityPoolCredentials}, {@link AwsCredentials}, {@link + * PluggableAuthCredentials}) delegate {@link #refreshAccessToken()} into this method. This + * default implementation delegates back to {@link #refreshAccessToken()} for any custom + * subclasses that do not override this method. * - * @param transportFactory the HTTP transport factory to use for this refresh cycle + * @param cycleTransportFactory the HTTP transport factory to use for this refresh cycle * @return the refreshed access token * @throws IOException if the token refresh fails */ - public AccessToken refreshAccessToken(HttpTransportFactory transportFactory) throws IOException { + AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) throws IOException { return refreshAccessToken(); } @@ -568,11 +578,9 @@ protected AccessToken exchangeExternalCredentialForAccessToken( StsTokenExchangeRequest stsTokenExchangeRequest, HttpTransportFactory cycleTransportFactory) throws IOException { // Handle service account impersonation if necessary. - if (this.shouldBuildImpersonatedCredential()) { - this.impersonatedCredentials = this.buildImpersonatedCredentials(); - } - if (this.impersonatedCredentials != null) { - return this.impersonatedCredentials.refreshAccessToken(cycleTransportFactory); + ImpersonatedCredentials impersonated = getImpersonatedCredentials(); + if (impersonated != null) { + return impersonated.refreshAccessToken(cycleTransportFactory); } StsRequestHandler.Builder requestHandler = diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java index fc838343cf6c..7c7e58a40a9a 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java @@ -239,58 +239,66 @@ private boolean shouldUseMtlsTransportFactory() { public AccessToken refreshAccessToken() throws IOException { // Per-cycle cert pinning: snapshot the KeyStore at the start of each refresh cycle. HttpTransportFactory cycleTransportFactory = this.transportFactory; + KeyStore pinnedKeyStore = null; if (this.x509Provider != null && shouldUseMtlsTransportFactory()) { - KeyStore pinnedKeyStore = this.x509Provider.getKeyStore(); + pinnedKeyStore = this.x509Provider.getKeyStore(); cycleTransportFactory = createMtlsTransportFactory(pinnedKeyStore); } - return refreshWithRetry(cycleTransportFactory, true); + return refreshWithRetry(cycleTransportFactory, pinnedKeyStore, true); } @Override - public AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) - throws IOException { + AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) throws IOException { // Retry is intentionally disabled when an explicit cycleTransportFactory is supplied to // ensure transport synchronization across multi-step token exchanges (e.g. STS and IAM) // and prevent nested retry amplification. Outer callers manage retry coordination. - return refreshWithRetry(cycleTransportFactory, false); + return refreshWithRetry(cycleTransportFactory, null, false); } private AccessToken refreshWithRetry( - HttpTransportFactory cycleTransportFactory, boolean allowRetry) throws IOException { - // Read subject and actor tokens, atomically if from the same file supplier. - String subjectToken; - String actorToken = null; - if (this.subjectTokenSupplier instanceof FileIdentityPoolSubjectTokenSupplier - && this.actorTokenSupplier == this.subjectTokenSupplier) { - FileIdentityPoolSubjectTokenSupplier.TokenPair tokens = - ((FileIdentityPoolSubjectTokenSupplier) this.subjectTokenSupplier) - .readTokens(supplierContext); - subjectToken = tokens.subject; - actorToken = tokens.actor; - } else { - subjectToken = retrieveSubjectToken(); - if (this.actorTokenSupplier != null) { - actorToken = this.actorTokenSupplier.getActorToken(supplierContext); + HttpTransportFactory cycleTransportFactory, + @Nullable KeyStore pinnedKeyStore, + boolean allowRetry) + throws IOException { + try { + ImpersonatedCredentials impersonated = getImpersonatedCredentials(); + if (impersonated != null) { + return impersonated.refreshAccessToken(cycleTransportFactory); } - } - StsTokenExchangeRequest.Builder stsTokenExchangeRequest = - StsTokenExchangeRequest.newBuilder(subjectToken, getSubjectTokenType()) - .setAudience(getAudience()); + // Read subject and actor tokens, atomically if from the same file supplier. + String subjectToken; + String actorToken = null; + if (this.subjectTokenSupplier instanceof FileIdentityPoolSubjectTokenSupplier + && this.actorTokenSupplier == this.subjectTokenSupplier) { + FileIdentityPoolSubjectTokenSupplier.TokenPair tokens = + ((FileIdentityPoolSubjectTokenSupplier) this.subjectTokenSupplier) + .readTokens(supplierContext); + subjectToken = tokens.subject; + actorToken = tokens.actor; + } else { + subjectToken = retrieveSubjectToken(); + if (this.actorTokenSupplier != null) { + actorToken = this.actorTokenSupplier.getActorToken(supplierContext); + } + } - if (actorToken != null && this.actorTokenType != null) { - stsTokenExchangeRequest.setActingParty(new ActingParty(actorToken, this.actorTokenType)); - } + StsTokenExchangeRequest.Builder stsTokenExchangeRequest = + StsTokenExchangeRequest.newBuilder(subjectToken, getSubjectTokenType()) + .setAudience(getAudience()); - Collection scopes = getScopes(); - if (scopes != null && !scopes.isEmpty()) { - stsTokenExchangeRequest.setScopes(new ArrayList<>(scopes)); - } + if (actorToken != null && this.actorTokenType != null) { + stsTokenExchangeRequest.setActingParty(new ActingParty(actorToken, this.actorTokenType)); + } + + Collection scopes = getScopes(); + if (scopes != null && !scopes.isEmpty()) { + stsTokenExchangeRequest.setScopes(new ArrayList<>(scopes)); + } - try { return exchangeExternalCredentialForAccessToken( stsTokenExchangeRequest.build(), cycleTransportFactory); - } catch (Exception e) { + } catch (IOException | RuntimeException e) { if (allowRetry && OAuth2Utils.isUnauthorizedException(e) && this.x509Provider != null @@ -313,29 +321,21 @@ && shouldUseMtlsTransportFactory()) { throw ioException; } + if (!OAuth2Utils.hasCertificateChanged(pinnedKeyStore, freshKeyStore)) { + throw e; + } + HttpTransportFactory retryTransportFactory = createMtlsTransportFactory(freshKeyStore); try { - return refreshWithRetry(retryTransportFactory, false); - } catch (Exception retryException) { + return refreshWithRetry(retryTransportFactory, freshKeyStore, false); + } catch (IOException | RuntimeException retryException) { if (retryException != e) { retryException.addSuppressed(e); } - if (retryException instanceof IOException) { - throw (IOException) retryException; - } - if (retryException instanceof RuntimeException) { - throw (RuntimeException) retryException; - } - throw new IOException(retryException); + throw retryException; } } - if (e instanceof IOException) { - throw (IOException) e; - } - if (e instanceof RuntimeException) { - throw (RuntimeException) e; - } - throw new IOException(e); + throw e; } } diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java index 7c7966101584..02ab0908af99 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java @@ -80,6 +80,11 @@ * Also, the target service account must grant the originating principal the "Service Account Token * Creator" IAM role. * + *

Note: For mTLS Workload Identity Federation with service account impersonation, applications + * should configure {@link IdentityPoolCredentials.Builder#setServiceAccountImpersonationUrl} + * directly on {@link IdentityPoolCredentials}, which manages per-cycle mTLS certificate pinning and + * 401 recovery across both STS and IAM token exchanges. + * *

Usage: * *

@@ -585,24 +590,19 @@ public AccessToken refreshAccessToken() throws IOException {
   }
 
   /**
-   * Refreshes the access token using the specified transport factory.
-   *
-   * 

This package-private method is intended for internal transport pinning by {@link - * ExternalAccountCredentials} during service account impersonation. For mTLS Workload Identity - * Federation with impersonation, applications should configure {@code - * setServiceAccountImpersonationUrl} directly on {@code IdentityPoolCredentials}, which manages - * the certificate lifecycle and 401 recovery. + * Refreshes the access token using the specified transport factory for per-cycle transport + * pinning. * - * @param transportFactory the HTTP transport factory to use, or {@code null} to use this + * @param cycleTransportFactory the HTTP transport factory to use, or {@code null} to use this * instance's configured transport factory without overriding source credential transport * @return the refreshed access token * @throws IOException if token refresh fails */ - AccessToken refreshAccessToken(@Nullable HttpTransportFactory transportFactory) + AccessToken refreshAccessToken(@Nullable HttpTransportFactory cycleTransportFactory) throws IOException { HttpTransportFactory effectiveTransportFactory = - transportFactory != null - ? transportFactory + cycleTransportFactory != null + ? cycleTransportFactory : (this.transportFactory != null ? this.transportFactory : OAuth2Utils.HTTP_TRANSPORT_FACTORY); @@ -615,15 +615,29 @@ AccessToken refreshAccessToken(@Nullable HttpTransportFactory transportFactory) this.sourceCredentials.createScoped( Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE)); } - AccessToken intermediateAccessToken = - (transportFactory == null) - ? ((ExternalAccountCredentials) this.sourceCredentials).refreshAccessToken() - : ((ExternalAccountCredentials) this.sourceCredentials) + AccessToken intermediateAccessToken; + try { + if (cycleTransportFactory == null) { + this.sourceCredentials.refreshIfExpired(); + intermediateAccessToken = this.sourceCredentials.getAccessToken(); + } else { + intermediateAccessToken = + ((ExternalAccountCredentials) this.sourceCredentials) .refreshAccessToken(effectiveTransportFactory); + } + } catch (IOException e) { + throw new IOException("Unable to refresh sourceCredentials", e); + } Credentials authCredentials = - intermediateAccessToken != null - ? OAuth2Credentials.create(intermediateAccessToken) - : this.sourceCredentials; + new GoogleCredentials( + GoogleCredentials.newBuilder() + .setQuotaProjectId(this.sourceCredentials.getQuotaProjectId()) + .setUniverseDomain(this.sourceCredentials.getUniverseDomain())) { + @Override + public AccessToken refreshAccessToken() { + return intermediateAccessToken; + } + }; adapter = new HttpCredentialsAdapter(authCredentials); } else { if (this.sourceCredentials.getAccessToken() == null) { diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java index 61d58bc55316..efab5f66e092 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java @@ -59,12 +59,18 @@ import java.net.URI; import java.nio.charset.StandardCharsets; import java.security.KeyFactory; +import java.security.KeyStore; +import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; +import java.security.cert.Certificate; import java.security.spec.InvalidKeySpecException; import java.security.spec.PKCS8EncodedKeySpec; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; +import java.util.Collections; +import java.util.Enumeration; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -348,5 +354,46 @@ static boolean isUnauthorizedException(@Nullable Throwable t) { return false; } + /** + * Returns whether the certificate chain in {@code newKeyStore} differs from {@code oldKeyStore}. + * Used on 401 retry recovery to avoid retrying when the reloaded certificate is unchanged. + */ + static boolean hasCertificateChanged( + @Nullable KeyStore oldKeyStore, @Nullable KeyStore newKeyStore) { + if (oldKeyStore == newKeyStore) { + return false; + } + if (oldKeyStore == null || newKeyStore == null) { + return true; + } + List oldCerts = getCertificates(oldKeyStore); + List newCerts = getCertificates(newKeyStore); + return !oldCerts.equals(newCerts); + } + + private static List getCertificates(KeyStore keyStore) { + List certs = new ArrayList<>(); + try { + Enumeration aliases = keyStore.aliases(); + if (aliases != null) { + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + Certificate[] chain = keyStore.getCertificateChain(alias); + if (chain != null && chain.length > 0) { + Collections.addAll(certs, chain); + } else { + Certificate cert = keyStore.getCertificate(alias); + if (cert != null) { + certs.add(cert); + } + } + } + } + } catch (KeyStoreException e) { + // If a KeyStore cannot be inspected, treat its certificates as empty + } + return certs; + } + private OAuth2Utils() {} } diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java index ae76abe63093..7eaffdd1c253 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java @@ -125,7 +125,12 @@ public AccessToken refreshAccessToken() throws IOException { } @Override - public AccessToken refreshAccessToken(HttpTransportFactory transportFactory) throws IOException { + AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) throws IOException { + ImpersonatedCredentials impersonated = getImpersonatedCredentials(); + if (impersonated != null) { + return impersonated.refreshAccessToken(cycleTransportFactory); + } + String credential = retrieveSubjectToken(); StsTokenExchangeRequest.Builder stsTokenExchangeRequest = StsTokenExchangeRequest.newBuilder(credential, getSubjectTokenType()) @@ -136,7 +141,7 @@ public AccessToken refreshAccessToken(HttpTransportFactory transportFactory) thr stsTokenExchangeRequest.setScopes(new ArrayList<>(scopes)); } return exchangeExternalCredentialForAccessToken( - stsTokenExchangeRequest.build(), transportFactory); + stsTokenExchangeRequest.build(), cycleTransportFactory); } /** diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java index c7556c0ac3c6..3064d993a4e9 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java @@ -167,7 +167,7 @@ void refreshAccessToken_withServiceAccountImpersonation() throws IOException { // Validate metrics header is set correctly on the sts request. Map> headers = - transportFactory.transport.getRequests().get(6).getHeaders(); + transportFactory.transport.getRequests().get(3).getHeaders(); ExternalAccountCredentialsTest.validateMetricsHeader(headers, "aws", true, false); } @@ -206,7 +206,7 @@ void refreshAccessToken_withServiceAccountImpersonationOptions() throws IOExcept // Validate metrics header is set correctly on the sts request. Map> headers = - transportFactory.transport.getRequests().get(6).getHeaders(); + transportFactory.transport.getRequests().get(3).getHeaders(); ExternalAccountCredentialsTest.validateMetricsHeader(headers, "aws", true, true); } } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java index cbf22907d9ec..494871761c33 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java @@ -106,6 +106,65 @@ class IdentityPoolCredentialsTest extends BaseSerializationTest { private static final IdentityPoolActorTokenSupplier testActorSupplier = (ExternalAccountSupplierContext context) -> "testActorToken"; + private static final String ROTATED_CERT_AND_KEY_PEM = + "-----BEGIN CERTIFICATE-----\n" + + "MIIDDzCCAfegAwIBAgIUcbzNP4BjFtH2pLfSr1KMZClf5eQwDQYJKoZIhvcNAQEL\n" + + "BQAwFzEVMBMGA1UEAwwMcm90YXRlZC1jZXJ0MB4XDTI2MDkxNzE2NDk1NFoXDTM2\n" + + "MDkxNDE2NDk1NFowFzEVMBMGA1UEAwwMcm90YXRlZC1jZXJ0MIIBIjANBgkqhkiG\n" + + "9w0BAQEFAAOCAQ8AMIIBCgKCAQEAi3vzaruGAex4T/FSHqzh+80RT//gWhGpm/JG\n" + + "tyK2hr54ExO5kzSeZDo+VzIJBhTdg9lf8USPTgsXcC3SNatMtWRBOMu9hg/NKLrg\n" + + "S+bCYw0iw6Wzy59XuWn+XcphD/SNUsO3Oas9vg1uj6H3BNWUuLsrPgfDYyIBtBrN\n" + + "6HEWHH7fl7/Nz8lUyj0Pv/uiAKF7bZyMeDv8Jwlv8yRVaEFpjlImWhKb+bCqPUYh\n" + + "adLI33aHF1npy1Jg1LWxecTP+VhvoFY6HJscIDJm47ENUtBSmrNKN2WJUVU7nhHw\n" + + "MYOKwXivm5J6HwxhK9rw2ifAJPStwGW0SNn0wSajvp66i5TINQIDAQABo1MwUTAd\n" + + "BgNVHQ4EFgQUI+rMQW4pBZOnwo51UrCXVFWlJ3swHwYDVR0jBBgwFoAUI+rMQW4p\n" + + "BZOnwo51UrCXVFWlJ3swDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOC\n" + + "AQEAL6LIJbZec8PNCaA176J6C7QW03ZWCgp2GSxb5V42kjgVMyqn5mrez7DQy1UY\n" + + "aDi4/n+OMOAWiJ1qWyYPe8xEKcYtG2sPkAs53wRoY8cbKYOxHr1JQkWh2v7gAwr0\n" + + "WpYsW60mGqAFjiqZz6S2xBdVRwTZ2dvONFMuJBw4JlJFFdxGU5XT3/XvGcvx5UK5\n" + + "2MzYuXkGDr3zTaLMwyBgi3paRs+46POtPZX/i4zUtpaGSG7HDAkCVWK4JMcbKiPk\n" + + "I/vV55YKOblwu8hk6qOyxbX4sSsaCXllH7YWryiyTwBOQjlUqNqdwfxe/jezdeGG\n" + + "OLTM9LO1/oNvD/2RpCH/5D2+fw==\n" + + "-----END CERTIFICATE-----\n" + + "-----BEGIN PRIVATE KEY-----\n" + + "MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCLe/Nqu4YB7HhP\n" + + "8VIerOH7zRFP/+BaEamb8ka3IraGvngTE7mTNJ5kOj5XMgkGFN2D2V/xRI9OCxdw\n" + + "LdI1q0y1ZEE4y72GD80ouuBL5sJjDSLDpbPLn1e5af5dymEP9I1Sw7c5qz2+DW6P\n" + + "ofcE1ZS4uys+B8NjIgG0Gs3ocRYcft+Xv83PyVTKPQ+/+6IAoXttnIx4O/wnCW/z\n" + + "JFVoQWmOUiZaEpv5sKo9RiFp0sjfdocXWenLUmDUtbF5xM/5WG+gVjocmxwgMmbj\n" + + "sQ1S0FKas0o3ZYlRVTueEfAxg4rBeK+bknofDGEr2vDaJ8Ak9K3AZbRI2fTBJqO+\n" + + "nrqLlMg1AgMBAAECggEAOetS5Q+QMk1MmjmFVYqJXiNFnJgOQ6hQ6xocBiDKdUIz\n" + + "HwzSQs+XM9xBlbiHqbhRUVYaslc7QHd3mJPWVYXXmPzT3m8vuDLoiJCs4aelMTc7\n" + + "p80vTw7QAQSD5NNMIbF1W5g8hZxXS4tNTSQ+rAm6M0k5SA02M3xkA7MbrHkE6vig\n" + + "/NgJ/9qZTMLIbSgQnflPKsGkv8kaXAdh/6APXnIM0pfBf5Fu7SXDUucsLPLRPkiS\n" + + "CmI062OW5/MEKehof1nuzzgXbR80yjuttIDRN1g4XSRJav2WePDxet2hTjnMaOxL\n" + + "hB8BDUMoUw5wi23nAzZgjHaxCpVDD+crBiflR5crkwKBgQDDvpY1mALyHr2Z2a0u\n" + + "bapwN+xhIv5MAq5zAt/mxQ4u8lxlJfemX1ulN4ZVxgqMsyGHHdFXS3dnhgs299Y0\n" + + "cAT6Fd0rxorRo/S/0F6G+iGbZQbFCO7HB3tpQZ06VWEF16xow10jgSoOIU05iitl\n" + + "sJbB2BuNjrHYuV6RpkcXme4UTwKBgQC2a9ZxKlJmMbZ9g+dS13t3VZISOxX/hvol\n" + + "fN1+vg2tkTnKaPgYxA0A/W28k++cgW4syS7ysNe9X9NApmERvSVJoI1g8BlQLVuh\n" + + "AZXHXK5cZknSB7iBZxuT/Ag55QE3gA0FipJHYSLDHYeoLXskiWXBUq1MHPOsSC4q\n" + + "pQHkNK/GOwKBgQCfy62aUN9OwvOrbj1nopU6CR1KayPH74R0VYttO78JakctN6KF\n" + + "SmFpbfuXeBXSqMWdJSVpqyzt8UqkdAyFQFF/y2uDuhBHdh5unG8ep4HZ9s5g+Zrc\n" + + "FeqUkcEGBv8uotOXrq0RN/eaE2uUpowo9tELrB1KIYxkTWe7ZU+yH7JxFwKBgQCH\n" + + "PQcrul6AGNbb0pAKIGoOHEhAb8FtQNnuNNXYgnmNdZ7MammTorSpSTizl1EKTAIr\n" + + "/bJqhaRLZuEsiqxoBDvCi96EQTvi7t2BTbWGqTUylzqfFM46UQBnA2/ty9LNHIeK\n" + + "1iJ//IlS8W+CxMUIXzwqyGplhQk5bgGb59yxHEY7xQKBgQCVaR/0DYDsmryP5Ntt\n" + + "uQjSYMKRCv/7ABegPcocQdLNbr+KvzB8dQUm+QRdBXUMWS69eTgwb4f7F5ilMCi6\n" + + "oPJwwyKpnYzxSxWaQQOFRB3L6b1w7MFMO7TV+5ZcFvIpTRkGqi1NwMFUMdlwQcjG\n" + + "7dyMDd4JN8ac/jwHngxJidcNGg==\n" + + "-----END PRIVATE KEY-----\n"; + + private static final String ROTATED_CERT_PEM = + ROTATED_CERT_AND_KEY_PEM.substring( + 0, + ROTATED_CERT_AND_KEY_PEM.indexOf("-----END CERTIFICATE-----") + + "-----END CERTIFICATE-----\n".length()); + + private static final String ROTATED_KEY_PEM = + ROTATED_CERT_AND_KEY_PEM.substring( + ROTATED_CERT_AND_KEY_PEM.indexOf("-----BEGIN PRIVATE KEY-----")); + private static KeyStore createPopulatedKeyStore() { try (InputStream certStream = new FileInputStream(new File("testresources/mtls/test_cert.pem")); @@ -117,6 +176,15 @@ private static KeyStore createPopulatedKeyStore() { } } + private static KeyStore createRotatedPopulatedKeyStore() { + try (InputStream stream = + new ByteArrayInputStream(ROTATED_CERT_AND_KEY_PEM.getBytes(StandardCharsets.UTF_8))) { + return SecurityUtils.createMtlsKeyStore(stream); + } catch (Exception e) { + throw new RuntimeException("Failed to create rotated test KeyStore", e); + } + } + @Test void createdScoped_clonedCredentialWithAddedScopes() { IdentityPoolCredentials credentials = @@ -491,7 +559,7 @@ void refreshAccessToken_withServiceAccountImpersonation() throws IOException { // Validate metrics header is set correctly on the sts request. Map> headers = - transportFactory.transport.getRequests().get(2).getHeaders(); + transportFactory.transport.getRequests().get(1).getHeaders(); ExternalAccountCredentialsTest.validateMetricsHeader(headers, "url", true, false); } @@ -532,7 +600,7 @@ void refreshAccessToken_withServiceAccountImpersonationOptions() throws IOExcept // Validate metrics header is set correctly on the sts request. Map> headers = - transportFactory.transport.getRequests().get(2).getHeaders(); + transportFactory.transport.getRequests().get(1).getHeaders(); ExternalAccountCredentialsTest.validateMetricsHeader(headers, "url", true, true); } @@ -1817,116 +1885,6 @@ void mtlsHttpTransportFactory_hasKeyStore_noArg_returnsFalse() { assertFalse(factory.hasKeyStore()); } - @Test - void builder_actorToken_plainPublicTokenUrl_throws() throws Exception { - KeyStore ks = createPopulatedKeyStore(); - MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); - - IllegalArgumentException e = - assertThrows( - IllegalArgumentException.class, - () -> - IdentityPoolCredentials.newBuilder() - .setSubjectTokenSupplier(testProvider) - .setActorTokenSupplier(testActorSupplier) - .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") - .setHttpTransportFactory(mtlsTransport) - .setAudience("audience") - .setSubjectTokenType("subjectTokenType") - .setTokenUrl("https://sts.googleapis.com/v1/token") - .build()); - assertTrue( - e.getMessage() - .contains( - "cannot be used with actor tokens because it is a plain public Google API" - + " endpoint")); - } - - @Test - void builder_actorToken_plainPublicImpersonationUrl_throws() throws Exception { - KeyStore ks = createPopulatedKeyStore(); - MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); - - IllegalArgumentException e = - assertThrows( - IllegalArgumentException.class, - () -> - IdentityPoolCredentials.newBuilder() - .setSubjectTokenSupplier(testProvider) - .setActorTokenSupplier(testActorSupplier) - .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") - .setHttpTransportFactory(mtlsTransport) - .setAudience("audience") - .setSubjectTokenType("subjectTokenType") - .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") - .setServiceAccountImpersonationUrl( - "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@test.iam.gserviceaccount.com:generateAccessToken") - .build()); - assertTrue( - e.getMessage() - .contains( - "cannot be used with actor tokens because it is a plain public Google API" - + " endpoint")); - } - - @Test - void builder_actorToken_mtlsEndpoints_succeeds() throws Exception { - KeyStore ks = createPopulatedKeyStore(); - MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); - - IdentityPoolCredentials credentials = - IdentityPoolCredentials.newBuilder() - .setSubjectTokenSupplier(testProvider) - .setActorTokenSupplier(testActorSupplier) - .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") - .setHttpTransportFactory(mtlsTransport) - .setAudience("audience") - .setSubjectTokenType("subjectTokenType") - .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") - .setServiceAccountImpersonationUrl( - "https://iamcredentials.mtls.googleapis.com/v1/projects/-/serviceAccounts/test@test.iam.gserviceaccount.com:generateAccessToken") - .build(); - assertNotNull(credentials); - } - - @Test - void builder_actorToken_pscEndpoints_succeeds() throws Exception { - KeyStore ks = createPopulatedKeyStore(); - MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); - - IdentityPoolCredentials credentials = - IdentityPoolCredentials.newBuilder() - .setSubjectTokenSupplier(testProvider) - .setActorTokenSupplier(testActorSupplier) - .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") - .setHttpTransportFactory(mtlsTransport) - .setAudience("audience") - .setSubjectTokenType("subjectTokenType") - .setTokenUrl("https://sts.p.googleapis.com/v1/token") - .setServiceAccountImpersonationUrl( - "https://iamcredentials.p.googleapis.com/v1/projects/-/serviceAccounts/test@test.iam.gserviceaccount.com:generateAccessToken") - .build(); - assertNotNull(credentials); - } - - @Test - void builder_actorToken_customNonGoogleHost_succeeds() throws Exception { - KeyStore ks = createPopulatedKeyStore(); - MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); - - IdentityPoolCredentials credentials = - IdentityPoolCredentials.newBuilder() - .setSubjectTokenSupplier(testProvider) - .setActorTokenSupplier(testActorSupplier) - .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") - .setHttpTransportFactory(mtlsTransport) - .setAudience("audience") - .setSubjectTokenType("subjectTokenType") - .setTokenUrl("https://custom-auth-proxy.internal.corp/token") - .build(); - assertNotNull(credentials); - } - // ================================================================================== // Section A: Cert Pinning & Transport Factory Tests // ================================================================================== @@ -1981,7 +1939,7 @@ public KeyStore getKeyStore() { void refreshAccessToken_certRotationBetweenCycles_usesNewCert() throws Exception { // First refresh uses cert A, rotate the provider, second refresh uses cert B. KeyStore ksA = createPopulatedKeyStore(); - KeyStore ksB = createPopulatedKeyStore(); + KeyStore ksB = createRotatedPopulatedKeyStore(); AtomicInteger callCount = new AtomicInteger(0); X509Provider rotatingProvider = @@ -2030,7 +1988,7 @@ public KeyStore getKeyStore() { void refreshAccessToken_401Retry_reReadsFromDisk() throws Exception { // On 401, the code should re-read from X509Provider to get fresh certs and retry. KeyStore ksA = createPopulatedKeyStore(); - KeyStore ksB = createPopulatedKeyStore(); + KeyStore ksB = createRotatedPopulatedKeyStore(); AtomicInteger callCount = new AtomicInteger(0); X509Provider rotatingProvider = @@ -2071,7 +2029,7 @@ public KeyStore getKeyStore() { @Test void refreshAccessToken_401Retry_viaHttpTransport_retriesAndSucceeds() throws Exception { KeyStore ksA = createPopulatedKeyStore(); - KeyStore ksB = createPopulatedKeyStore(); + KeyStore ksB = createRotatedPopulatedKeyStore(); AtomicInteger callCount = new AtomicInteger(0); X509Provider rotatingProvider = @@ -2115,7 +2073,7 @@ HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { assertEquals(2, transport.getRequests().size()); // Verify initial cycle used ksA, and retry used ksB - assertEquals(java.util.Arrays.asList(ksA, ksB), usedKeyStores); + assertEquals(Arrays.asList(ksA, ksB), usedKeyStores); } @Test @@ -2143,21 +2101,23 @@ void refreshAccessToken_401Retry_nonMtls_bubblesUp() throws Exception { @Test void refreshAccessToken_401Retry_secondAttemptFails_throws() throws Exception { - // 401 → retry → retry also fails → exception propagates. - KeyStore ks = createPopulatedKeyStore(); + // 401 → retry with rotated cert → retry also fails → exception propagates. + KeyStore ksA = createPopulatedKeyStore(); + KeyStore ksB = createRotatedPopulatedKeyStore(); + AtomicInteger callCount = new AtomicInteger(0); X509Provider provider = new X509Provider() { @Override public KeyStore getKeyStore() { - return ks; + return callCount.getAndIncrement() == 0 ? ksA : ksB; } }; MockExternalAccountCredentialsTransportFactory transportFactory = new MockExternalAccountCredentialsTransportFactory(); - MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ksA); // Testable credential that always throws 401 (both first and retry). TestableIdentityPoolCredentials credential = @@ -2179,6 +2139,45 @@ public KeyStore getKeyStore() { assertEquals(2, credential.getExchangeCallCount()); } + @Test + void refreshAccessToken_401Retry_unchangedCert_doesNotRetry() throws Exception { + // When X509Provider returns a KeyStore containing the exact same certificate on 401, + // refreshWithRetry should NOT retry. + KeyStore ks1 = createPopulatedKeyStore(); + KeyStore ks2SameCert = createPopulatedKeyStore(); + AtomicInteger callCount = new AtomicInteger(0); + + X509Provider provider = + new X509Provider() { + @Override + public KeyStore getKeyStore() { + return callCount.getAndIncrement() == 0 ? ks1 : ks2SameCert; + } + }; + + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks1); + + TestableIdentityPoolCredentials credential = + new TestableIdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(provider) + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl(transportFactory.transport.getStsUrl()) + .setHttpTransportFactory(mtlsTransport), + /* failOnFirstExchange= */ true); + + OAuthException e = assertThrows(OAuthException.class, credential::refreshAccessToken); + assertEquals(401, e.getHttpStatusCode()); + assertEquals(2, callCount.get()); + // Because the certificate in ks2SameCert did not change, no retry exchange was performed! + assertEquals(1, credential.getExchangeCallCount()); + } + @Test void refreshAccessToken_401Retry_certLoadFailure_preservesOriginalError() throws Exception { // When a 401 triggers retry but X509Provider.getKeyStore() throws on the retry, @@ -2304,7 +2303,7 @@ void refreshAccessToken_concurrent_eachGetOwnSnapshot() throws Exception { // Two threads refresh simultaneously. Each should get their own KeyStore snapshot. AtomicInteger getKeyStoreCount = new AtomicInteger(0); KeyStore ks1 = createPopulatedKeyStore(); - KeyStore ks2 = createPopulatedKeyStore(); + KeyStore ks2 = createRotatedPopulatedKeyStore(); X509Provider countingProvider = new X509Provider() { @@ -2370,7 +2369,7 @@ void refreshAccessToken_concurrent_401OnOneThread_doesNotAffectOther() throws Ex // Verify that Thread B's retry (re-read from X509Provider) does not affect Thread A's // transport — each thread has its own local cycleTransportFactory. KeyStore ksInitial = createPopulatedKeyStore(); - KeyStore ksRetry = createPopulatedKeyStore(); + KeyStore ksRetry = createRotatedPopulatedKeyStore(); AtomicInteger getKeyStoreCount = new AtomicInteger(0); X509Provider provider = @@ -2460,7 +2459,7 @@ void refreshAccessToken_certRotationDuringRefresh_pinnedCertUsed() throws Except // Verify the transport factory used in exchange is the one pinned at snapshot time, // not the rotated cert. KeyStore ksOriginal = createPopulatedKeyStore(); - KeyStore ksRotated = createPopulatedKeyStore(); + KeyStore ksRotated = createRotatedPopulatedKeyStore(); AtomicReference currentKeyStore = new AtomicReference<>(ksOriginal); AtomicInteger snapshotCount = new AtomicInteger(0); @@ -2580,10 +2579,19 @@ void serialize_deserialize_withActorTokenConfig_roundTrips() throws Exception { @Test void serialize_deserialize_withCustomTransportFactory_preservesCustomTransport() throws Exception { + Map certificateMap = new HashMap<>(); + certificateMap.put("use_default_certificate_config", false); + certificateMap.put("certificate_config_location", "testresources/mtls/certificate_config.json"); + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", "testresources/mtls/certificate_config.json"); + credentialSourceMap.put("certificate", certificateMap); + IdentityPoolCredentialSource credentialSource = + new IdentityPoolCredentialSource(credentialSourceMap); + IdentityPoolCredentials credentials = IdentityPoolCredentials.newBuilder() .setHttpTransportFactory(new MockHttpTransportFactory()) - .setSubjectTokenSupplier(testProvider) + .setCredentialSource(credentialSource) .setAudience("audience") .setSubjectTokenType("subjectTokenType") .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") @@ -3107,6 +3115,27 @@ void fromStream_fileCredentialSource_certRotation_401Retry_succeeds(@TempDir Pat new ByteArrayInputStream(tokenJson.toPrettyString().getBytes(StandardCharsets.UTF_8)), tokenFile.toString()); + Path certFile = tempDir.resolve("cert.pem"); + Path keyFile = tempDir.resolve("key.pem"); + Files.copy(new File("testresources/mtls/test_cert.pem").toPath(), certFile); + Files.copy(new File("testresources/mtls/test_key.pem").toPath(), keyFile); + + Path certConfigFile = tempDir.resolve("certificate_config.json"); + String certConfigJson = + "{\n" + + " \"cert_configs\": {\n" + + " \"workload\": {\n" + + " \"cert_path\": \"" + + certFile.toString() + + "\",\n" + + " \"key_path\": \"" + + keyFile.toString() + + "\"\n" + + " }\n" + + " }\n" + + "}"; + Files.write(certConfigFile, certConfigJson.getBytes(StandardCharsets.UTF_8)); + String configJson = "{\n" + " \"type\": \"external_account\",\n" @@ -3123,8 +3152,9 @@ void fromStream_fileCredentialSource_certRotation_401Retry_succeeds(@TempDir Pat + " \"subject_token_field_name\": \"subject_token\"\n" + " },\n" + " \"certificate\": {\n" - + " \"certificate_config_location\":" - + " \"testresources/mtls/certificate_config.json\"\n" + + " \"certificate_config_location\": \"" + + certConfigFile.toString() + + "\"\n" + " }\n" + " }\n" + "}"; @@ -3146,6 +3176,8 @@ protected AccessToken exchangeExternalCredentialForAccessToken( HttpTransportFactory cycleTransportFactory) throws IOException { if (exchangeCount.incrementAndGet() == 1) { + Files.write(certFile, ROTATED_CERT_PEM.getBytes(StandardCharsets.UTF_8)); + Files.write(keyFile, ROTATED_KEY_PEM.getBytes(StandardCharsets.UTF_8)); throw new OAuthException("invalid_client", "Unauthorized", null, 401); } return new AccessToken("rotatedRetryToken", null); @@ -3399,6 +3431,7 @@ public LowLevelHttpResponse execute() { }; List usedKeyStores = new ArrayList<>(); + List requestKeyStores = Collections.synchronizedList(new ArrayList<>()); IdentityPoolCredentials credential = new IdentityPoolCredentials( IdentityPoolCredentials.newBuilder() @@ -3413,7 +3446,15 @@ public LowLevelHttpResponse execute() { @Override HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { usedKeyStores.add(keyStore); - return () -> mockTransport; + return () -> + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) + throws IOException { + requestKeyStores.add(keyStore); + return mockTransport.buildRequest(method, url); + } + }; } }; @@ -3423,6 +3464,7 @@ HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { // Verify MtlsHttpTransportFactory was constructed with the pinned KeyStore. assertEquals(Collections.singletonList(ks), usedKeyStores); + assertEquals(Arrays.asList(ks, ks), requestKeyStores); // getKeyStore() should be called exactly once per refresh cycle. assertEquals(1, getKeyStoreCallCount.get()); @@ -3440,7 +3482,7 @@ HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { void refreshAccessToken_impersonation_401OnIam_retriesBothStsAndIamWithFreshCert() throws Exception { KeyStore ks1 = createPopulatedKeyStore(); - KeyStore ks2 = createPopulatedKeyStore(); + KeyStore ks2 = createRotatedPopulatedKeyStore(); AtomicInteger getKeyStoreCallCount = new AtomicInteger(0); X509Provider x509Provider = new X509Provider() { @@ -3498,6 +3540,7 @@ public LowLevelHttpResponse execute() { }; List usedKeyStores = new ArrayList<>(); + List requestKeyStores = Collections.synchronizedList(new ArrayList<>()); IdentityPoolCredentials credential = new IdentityPoolCredentials( IdentityPoolCredentials.newBuilder() @@ -3512,7 +3555,15 @@ public LowLevelHttpResponse execute() { @Override HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { usedKeyStores.add(keyStore); - return () -> mockTransport; + return () -> + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) + throws IOException { + requestKeyStores.add(keyStore); + return mockTransport.buildRequest(method, url); + } + }; } }; @@ -3521,7 +3572,8 @@ HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { assertEquals("final-iam-token-2", token.getTokenValue()); // Verify initial cycle used ks1, and 401 retry used ks2 (fresh cert). - assertEquals(java.util.Arrays.asList(ks1, ks2), usedKeyStores); + assertEquals(Arrays.asList(ks1, ks2), usedKeyStores); + assertEquals(Arrays.asList(ks1, ks1, ks2, ks2), requestKeyStores); // 1st call for initial cycle + 2nd call on 401 retry. assertEquals(2, getKeyStoreCallCount.get()); @@ -3614,7 +3666,7 @@ HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { @Test void refreshAccessToken_impersonation_certRotationBetweenCycles_usesNewCert() throws Exception { KeyStore ksA = createPopulatedKeyStore(); - KeyStore ksB = createPopulatedKeyStore(); + KeyStore ksB = createRotatedPopulatedKeyStore(); AtomicInteger getKeyStoreCallCount = new AtomicInteger(0); X509Provider x509Provider = new X509Provider() { @@ -3666,6 +3718,7 @@ public LowLevelHttpResponse execute() { }; List usedKeyStores = new ArrayList<>(); + List requestKeyStores = Collections.synchronizedList(new ArrayList<>()); IdentityPoolCredentials credential = new IdentityPoolCredentials( IdentityPoolCredentials.newBuilder() @@ -3680,7 +3733,15 @@ public LowLevelHttpResponse execute() { @Override HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { usedKeyStores.add(keyStore); - return () -> mockTransport; + return () -> + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) + throws IOException { + requestKeyStores.add(keyStore); + return mockTransport.buildRequest(method, url); + } + }; } }; @@ -3701,7 +3762,177 @@ HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { assertEquals(2, stsCallCount.get()); assertEquals(2, iamCallCount.get()); assertEquals("Bearer intermediate-sts-token-2", iamAuthHeaders.get(1)); - assertEquals(java.util.Arrays.asList(ksA, ksB), usedKeyStores); + assertEquals(Arrays.asList(ksA, ksB), usedKeyStores); + assertEquals(Arrays.asList(ksA, ksA, ksB, ksB), requestKeyStores); + } + + @Test + void refreshAccessToken_impersonation_persistent401OnIam_throwsWithSuppressed() throws Exception { + KeyStore ks1 = createPopulatedKeyStore(); + KeyStore ks2 = createRotatedPopulatedKeyStore(); + AtomicInteger getKeyStoreCallCount = new AtomicInteger(0); + X509Provider x509Provider = + new X509Provider() { + @Override + public KeyStore getKeyStore() { + int count = getKeyStoreCallCount.incrementAndGet(); + return count == 1 ? ks1 : ks2; + } + }; + + AtomicInteger stsCallCount = new AtomicInteger(0); + AtomicInteger iamCallCount = new AtomicInteger(0); + + MockHttpTransport mockTransport = + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() { + if (url.contains("/v1/token")) { + int count = stsCallCount.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", "urn:ietf:params:oauth:token-type:access_token"); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } else if (url.contains(":generateAccessToken")) { + iamCallCount.incrementAndGet(); + return new MockLowLevelHttpResponse() + .setStatusCode(401) + .setContentType(Json.MEDIA_TYPE) + .setContent("{\"error\": {\"code\": 401, \"message\": \"Unauthorized\"}}"); + } + return new MockLowLevelHttpResponse().setStatusCode(404); + } + }; + } + }; + + List requestKeyStores = Collections.synchronizedList(new ArrayList<>()); + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(x509Provider) + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .setServiceAccountImpersonationUrl( + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken")) { + @Override + HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { + return () -> + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) + throws IOException { + requestKeyStores.add(keyStore); + return mockTransport.buildRequest(method, url); + } + }; + } + }; + + IOException thrown = assertThrows(IOException.class, credential::refreshAccessToken); + assertTrue(OAuth2Utils.isUnauthorizedException(thrown)); + assertEquals(1, thrown.getSuppressed().length); + assertTrue(OAuth2Utils.isUnauthorizedException(thrown.getSuppressed()[0])); + assertEquals(2, getKeyStoreCallCount.get()); + assertEquals(2, stsCallCount.get()); + assertEquals(2, iamCallCount.get()); + assertEquals(Arrays.asList(ks1, ks1, ks2, ks2), requestKeyStores); + } + + @Test + void refreshAccessToken_impersonation_non401OnIam_doesNotRetry() throws Exception { + KeyStore ks1 = createPopulatedKeyStore(); + AtomicInteger getKeyStoreCallCount = new AtomicInteger(0); + X509Provider x509Provider = + new X509Provider() { + @Override + public KeyStore getKeyStore() { + getKeyStoreCallCount.incrementAndGet(); + return ks1; + } + }; + + AtomicInteger stsCallCount = new AtomicInteger(0); + AtomicInteger iamCallCount = new AtomicInteger(0); + + MockHttpTransport mockTransport = + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() { + if (url.contains("/v1/token")) { + int count = stsCallCount.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", "urn:ietf:params:oauth:token-type:access_token"); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } else if (url.contains(":generateAccessToken")) { + iamCallCount.incrementAndGet(); + return new MockLowLevelHttpResponse() + .setStatusCode(500) + .setContentType(Json.MEDIA_TYPE) + .setContent( + "{\"error\": {\"code\": 500, \"message\": \"Internal Server Error\"}}"); + } + return new MockLowLevelHttpResponse().setStatusCode(404); + } + }; + } + }; + + List requestKeyStores = Collections.synchronizedList(new ArrayList<>()); + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(x509Provider) + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .setServiceAccountImpersonationUrl( + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken")) { + @Override + HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { + return () -> + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) + throws IOException { + requestKeyStores.add(keyStore); + return mockTransport.buildRequest(method, url); + } + }; + } + }; + + IOException thrown = assertThrows(IOException.class, credential::refreshAccessToken); + assertFalse(OAuth2Utils.isUnauthorizedException(thrown)); + assertEquals(0, thrown.getSuppressed().length); + assertEquals(1, getKeyStoreCallCount.get()); + assertEquals(1, stsCallCount.get()); + assertEquals(1, iamCallCount.get()); + assertEquals(Arrays.asList(ks1, ks1), requestKeyStores); } @Test @@ -3785,10 +4016,8 @@ public KeyStore getKeyStore() { @Test void refreshAccessToken_401RetryFailureOnSecondAttempt_attachesInitial401AsSuppressed() throws Exception { - KeyStore ksA = KeyStore.getInstance(KeyStore.getDefaultType()); - ksA.load(null, null); - KeyStore ksB = KeyStore.getInstance(KeyStore.getDefaultType()); - ksB.load(null, null); + KeyStore ksA = createPopulatedKeyStore(); + KeyStore ksB = createRotatedPopulatedKeyStore(); AtomicInteger callCount = new AtomicInteger(0); X509Provider rotatingProvider = new X509Provider(null) { diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java index 4559dcff8155..43308ce7356c 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java @@ -73,6 +73,8 @@ import java.util.Date; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -90,8 +92,11 @@ class ImpersonatedCredentialsTest extends BaseSerializationTest { + "4Az2ZkmeuN6Fk/y9H+Lcb2pskJIXjrL533vrDWGOC48LrsThMQPv8cxBky8HFSEklPpkfTF95tpD43iVwJRB/Gr" + "CtGTw65IfJ4/tI09h6zGc4yqvIo1cHX/LQ+SxKLGyir/dQM925rGt/VojxY5ryJR7GLbCzxPnJm/oQJBANwOCO6" + "D2hy1LQYJhXh7O+RLtA/tSnT1xyMQsGT+uUCMiKS2bSKx2wxo9k7h3OegNJIu1q6nZ6AbxDK8H3+d0dUCQQDTrP" - + "SXagBxzp8PecbaCHjzNRSQE2in81qYnrAFNB4o3DpHyMMY6s5ALLeHKscEWnqP8Ur6X4PvzZecCWU9BKAZAkAutLPknAuxSCsUOvUfS1i87ex77Ot+w6POp34pEX+UWb+u5iFn2cQacDTHLV1LtE80L8jVLSbrbrlH43H0DjU5AkEAgidhycxS86dxpEljnOMCw8CKoUBd5I880IUahEiUltk7OLJYS/Ts1wbn3kPOVX3wyJs8WBDtBkFrDHW2ezth2QJADj3e1YhMVdjJW5jqwlD/VNddGjgzyunmiZg0uOXsHXbytYmsA545S8KRQFaJKFXYYFo2kOjqOiC1T2cAzMDjCQ==\n" - + "-----END PRIVATE KEY-----\n"; + + "SXagBxzp8PecbaCHjzNRSQE2in81qYnrAFNB4o3DpHyMMY6s5ALLeHKscEWnqP8Ur6X4PvzZecCWU9BKAZAkAut" + + "LPknAuxSCsUOvUfS1i87ex77Ot+w6POp34pEX+UWb+u5iFn2cQacDTHLV1LtE80L8jVLSbrbrlH43H0DjU5AkEA" + + "gidhycxS86dxpEljnOMCw8CKoUBd5I880IUahEiUltk7OLJYS/Ts1wbn3kPOVX3wyJs8WBDtBkFrDHW2ezth2QJ" + + "ADj3e1YhMVdjJW5jqwlD/VNddGjgzyunmiZg0uOXsHXbytYmsA545S8KRQFaJKFXYYFo2kOjqOiC1T2cAzMDjCQ" + + "==\n-----END PRIVATE KEY-----\n"; // Id Token provided by the default IAM API that does not include the "email" claim public static final String STANDARD_ID_TOKEN = @@ -1087,8 +1092,8 @@ void universeDomain_whenExplicit_notAllowedIfNotMatchToSourceUD() { IllegalStateException illegalStateException = assertThrows(IllegalStateException.class, builder::build); assertEquals( - "Universe domain source.domain.xyz in source credentials does not match explicit.domain.com" - + " universe domain set for impersonated credentials.", + "Universe domain source.domain.xyz in source credentials" + + " does not match explicit.domain.com universe domain set for impersonated credentials.", illegalStateException.getMessage()); } @@ -1383,8 +1388,7 @@ void refreshAccessToken_withExternalAccountSource_usesProvidedTransportFactory() .getTransport() .addStatusCodeAndMessage(HttpStatusCodes.STATUS_CODE_OK, ""); - java.util.concurrent.atomic.AtomicReference capturedSourceTransport = - new java.util.concurrent.atomic.AtomicReference<>(); + AtomicReference capturedSourceTransport = new AtomicReference<>(); ExternalAccountCredentials mockExternalAccountCredentials = new IdentityPoolCredentials( IdentityPoolCredentials.newBuilder() @@ -1392,10 +1396,11 @@ void refreshAccessToken_withExternalAccountSource_usesProvidedTransportFactory() "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") .setSubjectTokenSupplier(context -> "token") + .setQuotaProjectId("test-quota-project") .setTokenUrl("https://sts.googleapis.com/v1/token")) { @Override - public AccessToken refreshAccessToken(HttpTransportFactory transportFactory) { - capturedSourceTransport.set(transportFactory); + AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) { + capturedSourceTransport.set(cycleTransportFactory); return new AccessToken("intermediate-sts-token-xyz", null); } }; @@ -1415,10 +1420,16 @@ public AccessToken refreshAccessToken(HttpTransportFactory transportFactory) { assertEquals( "Bearer intermediate-sts-token-xyz", customTransportFactory.getTransport().getRequest().getFirstHeaderValue("Authorization")); + assertEquals( + "test-quota-project", + customTransportFactory + .getTransport() + .getRequest() + .getFirstHeaderValue("x-goog-user-project")); } @Test - void refreshAccessToken_nullTransportFactory_fallsBackToCredentialsTransport() + void refreshAccessToken_nullTransportFactory_fallsBackToCredentialsTransportAndUsesCache() throws IOException { MockIAMCredentialsServiceTransportFactory credentialsTransportFactory = new MockIAMCredentialsServiceTransportFactory(); @@ -1429,8 +1440,7 @@ void refreshAccessToken_nullTransportFactory_fallsBackToCredentialsTransport() .getTransport() .addStatusCodeAndMessage(HttpStatusCodes.STATUS_CODE_OK, ""); - java.util.concurrent.atomic.AtomicBoolean sourceRefreshed = - new java.util.concurrent.atomic.AtomicBoolean(false); + AtomicBoolean sourceRefreshed = new AtomicBoolean(false); ExternalAccountCredentials mockExternalAccountCredentials = new IdentityPoolCredentials( IdentityPoolCredentials.newBuilder() @@ -1465,14 +1475,14 @@ public AccessToken refreshAccessToken() { .getRequest() .getFirstHeaderValue("Authorization")); - // Also verify public no-arg refreshAccessToken() delegates without overriding source transport + // Verify subsequent no-arg refreshAccessToken() uses refreshIfExpired() and reuses cached source token sourceRefreshed.set(false); credentialsTransportFactory .getTransport() .addStatusCodeAndMessage(HttpStatusCodes.STATUS_CODE_OK, ""); AccessToken token2 = credentials.refreshAccessToken(); assertEquals("final-iam-token-null-transport", token2.getTokenValue()); - assertTrue(sourceRefreshed.get()); + assertFalse(sourceRefreshed.get()); } @Test diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java index 873b12f0a1b3..6aad11e38704 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java @@ -184,6 +184,10 @@ public LowLevelHttpResponse execute() throws IOException { // Store STS content as multiple calls are made using this transport. stsContent = getContentAsString(); + assertEquals(EXPECTED_GRANT_TYPE, query.get("grant_type")); + assertNotNull(query.get("subject_token_type")); + assertNotNull(query.get("subject_token")); + int statusCode = !stsStatusCodeSequence.isEmpty() ? stsStatusCodeSequence.poll() : 200; if (statusCode != 200) { @@ -197,10 +201,6 @@ public LowLevelHttpResponse execute() throws IOException { .setContent(errorResponse.toPrettyString()); } - assertEquals(EXPECTED_GRANT_TYPE, query.get("grant_type")); - assertNotNull(query.get("subject_token_type")); - assertNotNull(query.get("subject_token")); - GenericJson response = new GenericJson(); response.setFactory(JSON_FACTORY); response.put("token_type", TOKEN_TYPE); diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuth2UtilsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuth2UtilsTest.java index e043b235c50c..96b55fab01d7 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuth2UtilsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuth2UtilsTest.java @@ -33,8 +33,14 @@ import static com.google.auth.oauth2.OAuth2Utils.generateBasicAuthHeader; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import com.google.api.client.http.HttpHeaders; +import com.google.api.client.http.HttpResponseException; +import java.io.IOException; +import java.security.KeyStore; import org.junit.jupiter.api.Test; /** Tests for {@link OAuth2Utils}. */ @@ -101,57 +107,59 @@ void testNullPassword_throws() { @Test void isUnauthorizedException_null_returnsFalse() { - org.junit.jupiter.api.Assertions.assertFalse(OAuth2Utils.isUnauthorizedException(null)); + assertFalse(OAuth2Utils.isUnauthorizedException(null)); } @Test void isUnauthorizedException_genericIOException_returnsFalse() { - org.junit.jupiter.api.Assertions.assertFalse( - OAuth2Utils.isUnauthorizedException(new java.io.IOException("Network error"))); + assertFalse(OAuth2Utils.isUnauthorizedException(new IOException("Network error"))); } @Test void isUnauthorizedException_oauthException401_returnsTrue() { OAuthException ex = new OAuthException("invalid_client", "Unauthorized", null, 401); - org.junit.jupiter.api.Assertions.assertTrue(OAuth2Utils.isUnauthorizedException(ex)); + assertTrue(OAuth2Utils.isUnauthorizedException(ex)); } @Test void isUnauthorizedException_oauthExceptionNon401_returnsFalse() { OAuthException ex = new OAuthException("bad_request", "Bad Request", null, 400); - org.junit.jupiter.api.Assertions.assertFalse(OAuth2Utils.isUnauthorizedException(ex)); + assertFalse(OAuth2Utils.isUnauthorizedException(ex)); } @Test void isUnauthorizedException_httpResponseException401_returnsTrue() { - com.google.api.client.http.HttpResponseException ex = - new com.google.api.client.http.HttpResponseException.Builder( - 401, "Unauthorized", new com.google.api.client.http.HttpHeaders()) - .build(); - org.junit.jupiter.api.Assertions.assertTrue(OAuth2Utils.isUnauthorizedException(ex)); + HttpResponseException ex = + new HttpResponseException.Builder(401, "Unauthorized", new HttpHeaders()).build(); + assertTrue(OAuth2Utils.isUnauthorizedException(ex)); } @Test void isUnauthorizedException_httpResponseExceptionNon401_returnsFalse() { - com.google.api.client.http.HttpResponseException ex = - new com.google.api.client.http.HttpResponseException.Builder( - 403, "Forbidden", new com.google.api.client.http.HttpHeaders()) - .build(); - org.junit.jupiter.api.Assertions.assertFalse(OAuth2Utils.isUnauthorizedException(ex)); + HttpResponseException ex = + new HttpResponseException.Builder(403, "Forbidden", new HttpHeaders()).build(); + assertFalse(OAuth2Utils.isUnauthorizedException(ex)); } @Test void isUnauthorizedException_wrappedInExceptionChain_returnsTrue() { OAuthException oauthEx = new OAuthException("invalid_client", "Unauthorized", null, 401); - java.io.IOException wrapped = new java.io.IOException("Wrapped failure", oauthEx); - org.junit.jupiter.api.Assertions.assertTrue(OAuth2Utils.isUnauthorizedException(wrapped)); - - com.google.api.client.http.HttpResponseException httpEx = - new com.google.api.client.http.HttpResponseException.Builder( - 401, "Unauthorized", new com.google.api.client.http.HttpHeaders()) - .build(); - java.io.IOException wrappedHttp = - new java.io.IOException("Outer", new java.io.IOException("Inner", httpEx)); - org.junit.jupiter.api.Assertions.assertTrue(OAuth2Utils.isUnauthorizedException(wrappedHttp)); + IOException wrapped = new IOException("Wrapped failure", oauthEx); + assertTrue(OAuth2Utils.isUnauthorizedException(wrapped)); + + HttpResponseException httpEx = + new HttpResponseException.Builder(401, "Unauthorized", new HttpHeaders()).build(); + IOException wrappedHttp = new IOException("Outer", new IOException("Inner", httpEx)); + assertTrue(OAuth2Utils.isUnauthorizedException(wrappedHttp)); + } + + @Test + void hasCertificateChanged_nullOrSameReference_returnsFalse() throws Exception { + assertFalse(OAuth2Utils.hasCertificateChanged(null, null)); + KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); + ks.load(null, null); + assertFalse(OAuth2Utils.hasCertificateChanged(ks, ks)); + assertTrue(OAuth2Utils.hasCertificateChanged(null, ks)); + assertTrue(OAuth2Utils.hasCertificateChanged(ks, null)); } } From 8034525277d390c31e79a1d8efebe132c14d0480 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Thu, 17 Sep 2026 19:28:37 +0000 Subject: [PATCH 04/20] fix(oauth2): address post-review audit edge cases for mTLS pinning --- .../oauth2/ExternalAccountCredentials.java | 5 +- .../auth/oauth2/IdentityPoolCredentials.java | 16 ++-- .../auth/oauth2/ImpersonatedCredentials.java | 45 ++++++----- .../com/google/auth/oauth2/OAuth2Utils.java | 5 +- .../oauth2/IdentityPoolCredentialsTest.java | 74 ++++++++++--------- .../oauth2/ImpersonatedCredentialsTest.java | 63 +++++++++++++++- 6 files changed, 141 insertions(+), 67 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java index e9b78230499e..3f19b0bd22fc 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java @@ -529,8 +529,7 @@ private boolean shouldBuildImpersonatedCredential() { return this.serviceAccountImpersonationUrl != null && this.impersonatedCredentials == null; } - @Nullable - ImpersonatedCredentials getImpersonatedCredentials() { + @Nullable ImpersonatedCredentials getImpersonatedCredentials() { if (this.shouldBuildImpersonatedCredential()) { this.impersonatedCredentials = this.buildImpersonatedCredentials(); } @@ -541,7 +540,7 @@ ImpersonatedCredentials getImpersonatedCredentials() { * Refreshes the access token using the specified transport factory for per-cycle transport * pinning. Internal subclasses ({@link IdentityPoolCredentials}, {@link AwsCredentials}, {@link * PluggableAuthCredentials}) delegate {@link #refreshAccessToken()} into this method. This - * default implementation delegates back to {@link #refreshAccessToken()} for any custom + * default implementation delegates back to {@link #refreshAccessToken()} for any package-private * subclasses that do not override this method. * * @param cycleTransportFactory the HTTP transport factory to use for this refresh cycle diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java index 7c7e58a40a9a..de0a992dad2e 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java @@ -73,6 +73,7 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { // Transient: not serialized directly. Reconstructed in readObject() from the credentialSource // certificate config so deserialized credentials remain usable for mTLS and refresh. private transient volatile @Nullable X509Provider x509Provider; + private transient @Nullable HttpTransportFactory defaultMtlsTransportFactory; private final ExternalAccountSupplierContext supplierContext; private final String metricsHeaderValue; @@ -114,8 +115,9 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { if (builder.transportFactory == null || builder.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY || builder.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory - || builder.transportFactory.getClass() == MtlsHttpTransportFactory.class) { + || builder.transportFactory instanceof MtlsHttpTransportFactory) { this.transportFactory = createMtlsTransportFactory(mtlsKeyStore); + this.defaultMtlsTransportFactory = this.transportFactory; } else if (!(builder.transportFactory instanceof MtlsHttpTransportFactory)) { LOGGER_PROVIDER .getLogger() @@ -232,7 +234,9 @@ private boolean shouldUseMtlsTransportFactory() { return this.transportFactory == null || this.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY || this.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory - || this.transportFactory.getClass() == MtlsHttpTransportFactory.class; + || this.transportFactory instanceof MtlsHttpTransportFactory + || (this.defaultMtlsTransportFactory != null + && this.transportFactory == this.defaultMtlsTransportFactory); } @Override @@ -315,7 +319,7 @@ && shouldUseMtlsTransportFactory()) { } catch (Exception reloadException) { IOException ioException = new IOException("Failed to reload certificate on retry", reloadException); - if (ioException != e) { + if (reloadException != e) { ioException.addSuppressed(e); } throw ioException; @@ -325,8 +329,8 @@ && shouldUseMtlsTransportFactory()) { throw e; } - HttpTransportFactory retryTransportFactory = createMtlsTransportFactory(freshKeyStore); try { + HttpTransportFactory retryTransportFactory = createMtlsTransportFactory(freshKeyStore); return refreshWithRetry(retryTransportFactory, freshKeyStore, false); } catch (IOException | RuntimeException retryException) { if (retryException != e) { @@ -407,8 +411,9 @@ private IdentityPoolSubjectTokenSupplier createCertificateSubjectTokenSupplier( if (builder.transportFactory == null || builder.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY || builder.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory - || builder.transportFactory.getClass() == MtlsHttpTransportFactory.class) { + || builder.transportFactory instanceof MtlsHttpTransportFactory) { this.transportFactory = createMtlsTransportFactory(mtlsKeyStore); + this.defaultMtlsTransportFactory = this.transportFactory; } else if (!(builder.transportFactory instanceof MtlsHttpTransportFactory)) { LOGGER_PROVIDER .getLogger() @@ -452,6 +457,7 @@ private void readObject(ObjectInputStream input) throws IOException, ClassNotFou KeyStore mtlsKeyStore = this.x509Provider.getKeyStore(); if (shouldUseMtlsTransportFactory()) { this.transportFactory = createMtlsTransportFactory(mtlsKeyStore); + this.defaultMtlsTransportFactory = this.transportFactory; } } catch (Exception e) { // Cert loading failure will be handled on refreshAccessToken() diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java index 02ab0908af99..8419e16f59ea 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java @@ -318,7 +318,7 @@ public String getAccount() { } @VisibleForTesting - String getIamEndpointOverride() { + @Nullable String getIamEndpointOverride() { return this.iamEndpointOverride; } @@ -615,30 +615,34 @@ AccessToken refreshAccessToken(@Nullable HttpTransportFactory cycleTransportFact this.sourceCredentials.createScoped( Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE)); } - AccessToken intermediateAccessToken; - try { - if (cycleTransportFactory == null) { + if (cycleTransportFactory == null) { + try { this.sourceCredentials.refreshIfExpired(); - intermediateAccessToken = this.sourceCredentials.getAccessToken(); - } else { + } catch (IOException e) { + throw new IOException("Unable to refresh sourceCredentials", e); + } + adapter = new HttpCredentialsAdapter(this.sourceCredentials); + } else { + AccessToken intermediateAccessToken; + try { intermediateAccessToken = ((ExternalAccountCredentials) this.sourceCredentials) .refreshAccessToken(effectiveTransportFactory); + } catch (IOException e) { + throw new IOException("Unable to refresh sourceCredentials", e); } - } catch (IOException e) { - throw new IOException("Unable to refresh sourceCredentials", e); + Credentials authCredentials = + new GoogleCredentials( + GoogleCredentials.newBuilder() + .setQuotaProjectId(this.sourceCredentials.getQuotaProjectId()) + .setUniverseDomain(this.sourceCredentials.getUniverseDomain())) { + @Override + public AccessToken refreshAccessToken() { + return intermediateAccessToken; + } + }; + adapter = new HttpCredentialsAdapter(authCredentials); } - Credentials authCredentials = - new GoogleCredentials( - GoogleCredentials.newBuilder() - .setQuotaProjectId(this.sourceCredentials.getQuotaProjectId()) - .setUniverseDomain(this.sourceCredentials.getUniverseDomain())) { - @Override - public AccessToken refreshAccessToken() { - return intermediateAccessToken; - } - }; - adapter = new HttpCredentialsAdapter(authCredentials); } else { if (this.sourceCredentials.getAccessToken() == null) { // Apply the `CLOUD_PLATFORM_SCOPE` to access the iamcredentials endpoint @@ -688,7 +692,8 @@ public AccessToken refreshAccessToken() { // Client Library Debug Logging via LoggingUtils is used instead. request.setLoggingEnabled(false); adapter.initialize(request); - if (this.sourceCredentials instanceof ExternalAccountCredentials) { + if (cycleTransportFactory != null + && this.sourceCredentials instanceof ExternalAccountCredentials) { request.setUnsuccessfulResponseHandler(null); } request.setParser(parser); diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java index efab5f66e092..8549ff90fb8f 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java @@ -376,8 +376,9 @@ private static List getCertificates(KeyStore keyStore) { try { Enumeration aliases = keyStore.aliases(); if (aliases != null) { - while (aliases.hasMoreElements()) { - String alias = aliases.nextElement(); + List aliasList = Collections.list(aliases); + Collections.sort(aliasList); + for (String alias : aliasList) { Certificate[] chain = keyStore.getCertificateChain(alias); if (chain != null && chain.length > 0) { Collections.addAll(certs, chain); diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java index 494871761c33..d3a96f07459e 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java @@ -49,6 +49,7 @@ import com.google.api.client.http.LowLevelHttpResponse; import com.google.api.client.json.GenericJson; import com.google.api.client.json.Json; +import com.google.api.client.json.JsonParser; import com.google.api.client.testing.http.MockHttpTransport; import com.google.api.client.testing.http.MockLowLevelHttpRequest; import com.google.api.client.testing.http.MockLowLevelHttpResponse; @@ -3352,8 +3353,8 @@ int getExchangeCallCount() { * without making real HTTP calls. */ private static class TransportCapturingCredentials extends IdentityPoolCredentials { - private final java.util.List capturedFactories = - java.util.Collections.synchronizedList(new java.util.ArrayList<>()); + private final List capturedFactories = + Collections.synchronizedList(new ArrayList<>()); TransportCapturingCredentials(IdentityPoolCredentials.Builder builder) { super(builder); @@ -3368,7 +3369,7 @@ protected AccessToken exchangeExternalCredentialForAccessToken( return new AccessToken("capturedAccessToken", null); } - java.util.List getCapturedFactories() { + List getCapturedFactories() { return capturedFactories; } } @@ -3607,36 +3608,7 @@ public KeyStore getKeyStore() throws IOException { } }; - MockHttpTransport mockTransport = - new MockHttpTransport() { - @Override - public LowLevelHttpRequest buildRequest(String method, String url) { - return new MockLowLevelHttpRequest(url) { - @Override - public LowLevelHttpResponse execute() { - if (url.contains("/v1/token")) { - GenericJson response = new GenericJson(); - response.setFactory(OAuth2Utils.JSON_FACTORY); - response.put("access_token", "intermediate-sts-token-1"); - response.put("token_type", "Bearer"); - response.put("expires_in", 3600); - response.put( - "issued_token_type", "urn:ietf:params:oauth:token-type:access_token"); - return new MockLowLevelHttpResponse() - .setContentType(Json.MEDIA_TYPE) - .setContent(response.toString()); - } else if (url.contains(":generateAccessToken")) { - return new MockLowLevelHttpResponse() - .setStatusCode(401) - .setContentType(Json.MEDIA_TYPE) - .setContent("{\"error\": {\"code\": 401, \"message\": \"Unauthorized\"}}"); - } - return new MockLowLevelHttpResponse().setStatusCode(404); - } - }; - } - }; - + List requestKeyStores = new ArrayList<>(); IdentityPoolCredentials credential = new IdentityPoolCredentials( IdentityPoolCredentials.newBuilder() @@ -3650,13 +3622,44 @@ public LowLevelHttpResponse execute() { "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken")) { @Override HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { - return () -> mockTransport; + return () -> + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + requestKeyStores.add(keyStore); + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() { + if (url.contains("/v1/token")) { + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("access_token", "intermediate-sts-token-1"); + response.put("token_type", "Bearer"); + response.put("expires_in", 3600); + response.put( + "issued_token_type", "urn:ietf:params:oauth:token-type:access_token"); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } else if (url.contains(":generateAccessToken")) { + return new MockLowLevelHttpResponse() + .setStatusCode(401) + .setContentType(Json.MEDIA_TYPE) + .setContent( + "{\"error\": {\"code\": 401, \"message\": \"Unauthorized\"}}"); + } + return new MockLowLevelHttpResponse().setStatusCode(404); + } + }; + } + }; } }; IOException thrown = assertThrows(IOException.class, credential::refreshAccessToken); assertEquals("Cert rotation reload disk error", thrown.getMessage()); assertEquals(2, getKeyStoreCallCount.get()); + assertEquals(Arrays.asList(ks, ks), requestKeyStores); Throwable[] suppressed = thrown.getSuppressed(); assertTrue(suppressed.length > 0); @@ -3971,8 +3974,7 @@ public LowLevelHttpRequest buildRequest(String method, String url) // Request 1 is IAM generateAccessToken; verify it requested the downstream target scope String iamRequestContent = transport.getRequests().get(1).getContentAsString(); - try (com.google.api.client.json.JsonParser parser = - OAuth2Utils.JSON_FACTORY.createJsonParser(iamRequestContent)) { + try (JsonParser parser = OAuth2Utils.JSON_FACTORY.createJsonParser(iamRequestContent)) { GenericJson iamBody = parser.parseAndClose(GenericJson.class); assertEquals(targetScopes, iamBody.get("scope")); } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java index 43308ce7356c..a9420ee1fe1c 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java @@ -70,10 +70,13 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Calendar; +import java.util.Collection; +import java.util.Collections; import java.util.Date; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -1475,7 +1478,8 @@ public AccessToken refreshAccessToken() { .getRequest() .getFirstHeaderValue("Authorization")); - // Verify subsequent no-arg refreshAccessToken() uses refreshIfExpired() and reuses cached source token + // Verify subsequent no-arg refreshAccessToken() uses refreshIfExpired() and reuses cached + // source token sourceRefreshed.set(false); credentialsTransportFactory .getTransport() @@ -1536,4 +1540,61 @@ public AccessToken refreshAccessToken() { .getContentAsString() .contains("https://www.googleapis.com/auth/bigquery")); } + + @Test + void refreshAccessToken_standaloneExternalAccountSource_retriesOn401FromIam() throws IOException { + AtomicInteger sourceRefreshCount = new AtomicInteger(0); + ExternalAccountCredentials mockExternalAccountCredentials = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setSubjectTokenSupplier(context -> "token") + .setScopes(Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE)) + .setTokenUrl("https://sts.googleapis.com/v1/token")) { + @Override + public AccessToken refreshAccessToken() { + int count = sourceRefreshCount.incrementAndGet(); + return new AccessToken("intermediate-sts-token-" + count, null); + } + + @Override + public IdentityPoolCredentials createScoped(Collection scopes) { + return this; + } + }; + + MockIAMCredentialsServiceTransportFactory credentialsTransportFactory = + new MockIAMCredentialsServiceTransportFactory(); + credentialsTransportFactory.getTransport().setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL); + credentialsTransportFactory.getTransport().setAccessToken("final-iam-token-after-retry"); + credentialsTransportFactory.getTransport().setExpireTime(getDefaultExpireTime()); + // First IAM call returns 401 Unauthorized, second returns 200 OK + credentialsTransportFactory + .getTransport() + .addStatusCodeAndMessage(HttpStatusCodes.STATUS_CODE_UNAUTHORIZED, "Unauthorized"); + credentialsTransportFactory + .getTransport() + .addStatusCodeAndMessage(HttpStatusCodes.STATUS_CODE_OK, ""); + + ImpersonatedCredentials credentials = + ImpersonatedCredentials.newBuilder() + .setSourceCredentials(mockExternalAccountCredentials) + .setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL) + .setScopes(IMMUTABLE_SCOPES_LIST) + .setLifetime(VALID_LIFETIME) + .setHttpTransportFactory(credentialsTransportFactory) + .build(); + + AccessToken token = credentials.refreshAccessToken(); + assertEquals("final-iam-token-after-retry", token.getTokenValue()); + assertEquals(2, sourceRefreshCount.get()); + assertEquals( + "Bearer intermediate-sts-token-2", + credentialsTransportFactory + .getTransport() + .getRequest() + .getFirstHeaderValue("Authorization")); + } } From 17aae1f65b153eb44e1919f7dcbbbd38d5c9db03 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Thu, 17 Sep 2026 19:59:02 +0000 Subject: [PATCH 05/20] fix(oauth2): preserve exact class check for MtlsHttpTransportFactory in IdentityPoolCredentials --- .../google/auth/oauth2/IdentityPoolCredentials.java | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java index de0a992dad2e..4c483ebc0439 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java @@ -115,7 +115,9 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { if (builder.transportFactory == null || builder.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY || builder.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory - || builder.transportFactory instanceof MtlsHttpTransportFactory) { + || builder.transportFactory.getClass() == MtlsHttpTransportFactory.class + || (builder.defaultMtlsTransportFactory != null + && builder.transportFactory == builder.defaultMtlsTransportFactory)) { this.transportFactory = createMtlsTransportFactory(mtlsKeyStore); this.defaultMtlsTransportFactory = this.transportFactory; } else if (!(builder.transportFactory instanceof MtlsHttpTransportFactory)) { @@ -234,7 +236,7 @@ private boolean shouldUseMtlsTransportFactory() { return this.transportFactory == null || this.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY || this.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory - || this.transportFactory instanceof MtlsHttpTransportFactory + || this.transportFactory.getClass() == MtlsHttpTransportFactory.class || (this.defaultMtlsTransportFactory != null && this.transportFactory == this.defaultMtlsTransportFactory); } @@ -411,7 +413,9 @@ private IdentityPoolSubjectTokenSupplier createCertificateSubjectTokenSupplier( if (builder.transportFactory == null || builder.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY || builder.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory - || builder.transportFactory instanceof MtlsHttpTransportFactory) { + || builder.transportFactory.getClass() == MtlsHttpTransportFactory.class + || (builder.defaultMtlsTransportFactory != null + && builder.transportFactory == builder.defaultMtlsTransportFactory)) { this.transportFactory = createMtlsTransportFactory(mtlsKeyStore); this.defaultMtlsTransportFactory = this.transportFactory; } else if (!(builder.transportFactory instanceof MtlsHttpTransportFactory)) { @@ -496,6 +500,7 @@ public static class Builder extends ExternalAccountCredentials.Builder { private @Nullable IdentityPoolActorTokenSupplier actorTokenSupplier; private @Nullable String actorTokenType; private @Nullable X509Provider x509Provider; + private @Nullable HttpTransportFactory defaultMtlsTransportFactory; Builder() {} @@ -514,6 +519,7 @@ public static class Builder extends ExternalAccountCredentials.Builder { // instance for atomic token reads. this.actorTokenType = credentials.actorTokenType; this.x509Provider = credentials.x509Provider; + this.defaultMtlsTransportFactory = credentials.defaultMtlsTransportFactory; } /** From f2d752ed18a1027b4599f32caaa7d0a7d01eb94b Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Thu, 17 Sep 2026 20:15:09 +0000 Subject: [PATCH 06/20] test(oauth2): add coverage for custom MtlsHttpTransportFactory subclass and FILE cert pinning --- .../oauth2/IdentityPoolCredentialsTest.java | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java index d3a96f07459e..7cfbc5d75249 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java @@ -4046,4 +4046,149 @@ public KeyStore getKeyStore() { assertEquals(1, thrown.getSuppressed().length); assertTrue(thrown.getSuppressed()[0] instanceof OAuthException); } + + public static class CustomMtlsHttpTransportFactory extends MtlsHttpTransportFactory { + public CustomMtlsHttpTransportFactory() { + super(); + } + } + + @Test + void customMtlsHttpTransportFactorySubclass_preservedInConstructorAndRefreshAndDeserialization() + throws Exception { + Map certificateMap = new HashMap<>(); + certificateMap.put("use_default_certificate_config", false); + certificateMap.put("certificate_config_location", "testresources/mtls/certificate_config.json"); + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", "testresources/mtls/certificate_config.json"); + credentialSourceMap.put("certificate", certificateMap); + IdentityPoolCredentialSource credentialSource = + new IdentityPoolCredentialSource(credentialSourceMap); + + CustomMtlsHttpTransportFactory customFactory = new CustomMtlsHttpTransportFactory(); + KeyStore ks = createPopulatedKeyStore(); + X509Provider x509Provider = new TestX509Provider(ks, "certificate_config_location"); + + List capturedCycleFactories = new ArrayList<>(); + IdentityPoolCredentials credentials = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(customFactory) + .setCredentialSource(credentialSource) + .setX509Provider(x509Provider) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token")) { + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) { + capturedCycleFactories.add(cycleTransportFactory); + return new AccessToken("token", null); + } + }; + + assertSame( + customFactory, + credentials.getTransportFactory(), + "Constructor must preserve custom subclass of MtlsHttpTransportFactory"); + + credentials.refreshAccessToken(); + assertEquals(1, capturedCycleFactories.size()); + assertSame( + customFactory, + capturedCycleFactories.get(0), + "refreshAccessToken must use custom MtlsHttpTransportFactory subclass without overwriting"); + + IdentityPoolCredentials regularCredentials = + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(customFactory) + .setCredentialSource(credentialSource) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build(); + IdentityPoolCredentials deserialized = serializeAndDeserialize(regularCredentials); + assertTrue( + deserialized.getTransportFactory() instanceof CustomMtlsHttpTransportFactory, + "readObject must preserve custom subclass of MtlsHttpTransportFactory"); + } + + @Test + void fileCredentialSourceWithCertConfig_overriddenCreateMtlsTransportFactory_rotatesPerCycle() + throws Exception { + File tokenFile = File.createTempFile("subject_token", ".txt"); + tokenFile.deleteOnExit(); + Files.write(tokenFile.toPath(), "test-subject-token".getBytes(StandardCharsets.UTF_8)); + + Map certificateMap = new HashMap<>(); + certificateMap.put("use_default_certificate_config", false); + certificateMap.put("certificate_config_location", "testresources/mtls/certificate_config.json"); + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", tokenFile.getAbsolutePath()); + credentialSourceMap.put("certificate", certificateMap); + IdentityPoolCredentialSource credentialSource = + new IdentityPoolCredentialSource(credentialSourceMap); + + KeyStore ksA = createPopulatedKeyStore(); + KeyStore ksB = createRotatedPopulatedKeyStore(); + AtomicInteger getKeyStoreCount = new AtomicInteger(0); + X509Provider rotatingProvider = + new X509Provider() { + @Override + public KeyStore getKeyStore() { + // Call 1: constructor; Call 2: initial refresh attempt; Call 3: 401 retry + int count = getKeyStoreCount.incrementAndGet(); + return count <= 2 ? ksA : ksB; + } + }; + + List requestKeyStores = new ArrayList<>(); + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setCredentialSource(credentialSource) + .setX509Provider(rotatingProvider) + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token")) { + @Override + HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { + return () -> + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + requestKeyStores.add(keyStore); + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() { + if (keyStore == ksA) { + return new MockLowLevelHttpResponse() + .setStatusCode(401) + .setContentType(Json.MEDIA_TYPE) + .setContent( + "{\"error\": \"invalid_client\", \"error_description\": \"Unauthorized\"}"); + } + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("access_token", "rotated-sts-token"); + response.put("token_type", "Bearer"); + response.put("expires_in", 3600); + response.put( + "issued_token_type", "urn:ietf:params:oauth:token-type:access_token"); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } + }; + } + }; + } + }; + + AccessToken token = credential.refreshAccessToken(); + assertEquals("rotated-sts-token", token.getTokenValue()); + assertEquals(Arrays.asList(ksA, ksB), requestKeyStores); + } } From a5e69ed61bdc50ab3ba4dd719951608babafafa8 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Fri, 18 Sep 2026 01:39:13 +0000 Subject: [PATCH 07/20] fix(oauth2): annotate refreshAccessToken(HttpTransportFactory) with @InternalExtensionOnly --- .../google/auth/oauth2/AwsCredentials.java | 5 ++- .../oauth2/ExternalAccountCredentials.java | 7 ++-- .../auth/oauth2/IdentityPoolCredentials.java | 5 ++- .../auth/oauth2/PluggableAuthCredentials.java | 5 ++- .../oauth2/IdentityPoolCredentialsTest.java | 32 ++++++++----------- .../oauth2/ImpersonatedCredentialsTest.java | 2 +- 6 files changed, 32 insertions(+), 24 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java index 6dec8364ea4e..6be30db02b88 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java @@ -32,6 +32,7 @@ package com.google.auth.oauth2; import com.google.api.client.json.GenericJson; +import com.google.api.core.InternalExtensionOnly; import com.google.auth.http.HttpTransportFactory; import com.google.common.annotations.VisibleForTesting; import com.google.errorprone.annotations.CanIgnoreReturnValue; @@ -123,8 +124,10 @@ public AccessToken refreshAccessToken() throws IOException { return refreshAccessToken(this.transportFactory); } + @InternalExtensionOnly @Override - AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) throws IOException { + public AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) + throws IOException { ImpersonatedCredentials impersonated = getImpersonatedCredentials(); if (impersonated != null) { return impersonated.refreshAccessToken(cycleTransportFactory); diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java index 3f19b0bd22fc..61aa5aa34949 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java @@ -36,6 +36,7 @@ import com.google.api.client.http.HttpHeaders; import com.google.api.client.json.GenericJson; import com.google.api.client.util.Data; +import com.google.api.core.InternalExtensionOnly; import com.google.auth.RequestMetadataCallback; import com.google.auth.http.HttpTransportFactory; import com.google.common.base.MoreObjects; @@ -540,14 +541,16 @@ private boolean shouldBuildImpersonatedCredential() { * Refreshes the access token using the specified transport factory for per-cycle transport * pinning. Internal subclasses ({@link IdentityPoolCredentials}, {@link AwsCredentials}, {@link * PluggableAuthCredentials}) delegate {@link #refreshAccessToken()} into this method. This - * default implementation delegates back to {@link #refreshAccessToken()} for any package-private + * default implementation delegates back to {@link #refreshAccessToken()} for any custom * subclasses that do not override this method. * * @param cycleTransportFactory the HTTP transport factory to use for this refresh cycle * @return the refreshed access token * @throws IOException if the token refresh fails */ - AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) throws IOException { + @InternalExtensionOnly + public AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) + throws IOException { return refreshAccessToken(); } diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java index 4c483ebc0439..ff1c41ed08c2 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java @@ -31,6 +31,7 @@ package com.google.auth.oauth2; +import com.google.api.core.InternalExtensionOnly; import com.google.auth.http.HttpTransportFactory; import com.google.auth.mtls.MtlsHttpTransportFactory; import com.google.auth.mtls.MtlsUtils; @@ -253,8 +254,10 @@ public AccessToken refreshAccessToken() throws IOException { return refreshWithRetry(cycleTransportFactory, pinnedKeyStore, true); } + @InternalExtensionOnly @Override - AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) throws IOException { + public AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) + throws IOException { // Retry is intentionally disabled when an explicit cycleTransportFactory is supplied to // ensure transport synchronization across multi-step token exchanges (e.g. STS and IAM) // and prevent nested retry amplification. Outer callers manage retry coordination. diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java index 7eaffdd1c253..3ff3bf19bf4e 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java @@ -31,6 +31,7 @@ package com.google.auth.oauth2; +import com.google.api.core.InternalExtensionOnly; import com.google.auth.http.HttpTransportFactory; import com.google.auth.oauth2.ExecutableHandler.ExecutableOptions; import com.google.common.annotations.VisibleForTesting; @@ -124,8 +125,10 @@ public AccessToken refreshAccessToken() throws IOException { return refreshAccessToken(this.transportFactory); } + @InternalExtensionOnly @Override - AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) throws IOException { + public AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) + throws IOException { ImpersonatedCredentials impersonated = getImpersonatedCredentials(); if (impersonated != null) { return impersonated.refreshAccessToken(cycleTransportFactory); diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java index 7cfbc5d75249..215628d48fe8 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java @@ -2373,14 +2373,22 @@ void refreshAccessToken_concurrent_401OnOneThread_doesNotAffectOther() throws Ex KeyStore ksRetry = createRotatedPopulatedKeyStore(); AtomicInteger getKeyStoreCount = new AtomicInteger(0); + CyclicBarrier barrier = new CyclicBarrier(2); X509Provider provider = new X509Provider() { @Override - public KeyStore getKeyStore() { + public KeyStore getKeyStore() throws IOException { int count = getKeyStoreCount.incrementAndGet(); - // First two calls are for the two threads' initial snapshots, - // third call is for Thread B's retry after 401. - return count <= 2 ? ksInitial : ksRetry; + if (count <= 2) { + try { + barrier.await(5, TimeUnit.SECONDS); + } catch (Exception e) { + throw new IOException(e); + } + return ksInitial; + } + // Third call is for Thread B's retry after 401. + return ksRetry; } }; @@ -2392,7 +2400,6 @@ public KeyStore getKeyStore() { // Use a credential where one thread gets a 401 (first exchange fails) and the other // succeeds. The AtomicInteger tracks per-thread exchange behavior. AtomicInteger exchangeCallCount = new AtomicInteger(0); - CyclicBarrier barrier = new CyclicBarrier(2); // Subclass that alternates: first exchange call throws 401, all others succeed. IdentityPoolCredentials credential = @@ -2422,19 +2429,8 @@ protected AccessToken exchangeExternalCredentialForAccessToken( ExecutorService executor = Executors.newFixedThreadPool(2); try { - Future futureA = - executor.submit( - () -> { - barrier.await(5, TimeUnit.SECONDS); - return credential.refreshAccessToken(); - }); - - Future futureB = - executor.submit( - () -> { - barrier.await(5, TimeUnit.SECONDS); - return credential.refreshAccessToken(); - }); + Future futureA = executor.submit(() -> credential.refreshAccessToken()); + Future futureB = executor.submit(() -> credential.refreshAccessToken()); AccessToken tokenA = futureA.get(10, TimeUnit.SECONDS); AccessToken tokenB = futureB.get(10, TimeUnit.SECONDS); diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java index a9420ee1fe1c..d16f8677bd30 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java @@ -1402,7 +1402,7 @@ void refreshAccessToken_withExternalAccountSource_usesProvidedTransportFactory() .setQuotaProjectId("test-quota-project") .setTokenUrl("https://sts.googleapis.com/v1/token")) { @Override - AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) { + public AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) { capturedSourceTransport.set(cycleTransportFactory); return new AccessToken("intermediate-sts-token-xyz", null); } From 031a59405c8507cc3f8a47b61951cd0e9877a8ff Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Sat, 29 Aug 2026 03:01:32 +0000 Subject: [PATCH 08/20] test(oauth2): add mTLS in-process socket tests and live GCP WIF integration tests - Add MtlsPipelineLocalTest providing hermetic in-process socket tests over JDK HttpsServer with client certificate authentication (peer cert verification, 401 retry with cert rotation, concurrent refreshes, atomic token read, IAM impersonation mTLS transport pinning, and 401 retry with fresh cert). - Add ITWorkloadIdentityFederationTest extensions for certificate-bound workload + actor token JSON config and programmatic mTLS token suppliers covering both direct STS and Service Account Impersonation. - Fix OAuthException to safely handle null HTTP error response content. --- .../google/auth/oauth2/OAuthException.java | 36 +- .../ITWorkloadIdentityFederationTest.java | 228 ++++ .../auth/oauth2/MtlsPipelineLocalTest.java | 1049 +++++++++++++++++ .../auth/oauth2/OAuthExceptionTest.java | 66 ++ 4 files changed, 1367 insertions(+), 12 deletions(-) create mode 100644 google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MtlsPipelineLocalTest.java 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 76d7fc60aa3c..d7c01b0df54d 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 @@ -11,7 +11,6 @@ * 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. @@ -98,18 +97,31 @@ int getHttpStatusCode() { 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"); + String content = e.getContent(); + if (content == null || content.trim().isEmpty()) { + return new OAuthException( + "http_error_" + e.getStatusCode(), e.getStatusMessage(), null, e.getStatusCode()); } - 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); + + String errorCode = (String) errorResponse.get("error"); + if (errorCode == null) { + errorCode = "http_error_" + e.getStatusCode(); + } + String errorDescription = null; + String errorUri = null; + if (errorResponse.containsKey("error_description")) { + errorDescription = (String) errorResponse.get("error_description"); + } + if (errorResponse.containsKey("error_uri")) { + errorUri = (String) errorResponse.get("error_uri"); + } + return new OAuthException(errorCode, errorDescription, errorUri, e.getStatusCode()); + } catch (Exception parseException) { + return new OAuthException( + "http_error_" + e.getStatusCode(), e.getStatusMessage(), null, e.getStatusCode()); } - 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..4e7f426dd164 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,6 +31,7 @@ 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; @@ -45,13 +46,19 @@ import com.google.api.client.json.JsonObjectParser; import com.google.api.client.json.gson.GsonFactory; import com.google.api.client.util.GenericData; +import com.google.api.client.util.SecurityUtils; import com.google.auth.http.HttpCredentialsAdapter; +import com.google.auth.http.HttpTransportFactory; +import com.google.auth.mtls.MtlsHttpTransportFactory; 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.io.InputStream; +import java.io.SequenceInputStream; import java.nio.charset.StandardCharsets; +import java.security.KeyStore; import java.time.Instant; import java.util.HashMap; import java.util.Map; @@ -281,6 +288,227 @@ 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 IOException { + String subjectToken = generateGoogleIdToken(OIDC_AUDIENCE); + String actorToken = generateGoogleIdToken(OIDC_AUDIENCE); + + File tokenFile = + File.createTempFile( + "ITWorkloadIdentityFederation_cert_actor", /* suffix= */ null, /* directory= */ null); + tokenFile.deleteOnExit(); + + 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", "testresources/mtls/certificate_config.json"); + credentialSource.put("certificate", certificate); + + config.put("credential_source", credentialSource); + + IdentityPoolCredentials identityPoolCredentials = + (IdentityPoolCredentials) + ExternalAccountCredentials.fromJson(config, OAuth2Utils.HTTP_TRANSPORT_FACTORY); + + callGcs(identityPoolCredentials); + } + + /** + * 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 test + * certificate resources. Exchanges the tokens over mTLS STS endpoint and calls GCS. + */ + @Test + void identityPoolCredentials_withProgrammaticMtlsAndActorToken() throws Exception { + IdentityPoolSubjectTokenSupplier tokenSupplier = + (ExternalAccountSupplierContext context) -> { + try { + return generateGoogleIdToken(OIDC_AUDIENCE); + } catch (IOException e) { + throw new RuntimeException(e); + } + }; + + IdentityPoolActorTokenSupplier actorSupplier = + (ExternalAccountSupplierContext context) -> { + try { + return generateGoogleIdToken(OIDC_AUDIENCE); + } catch (IOException e) { + throw new RuntimeException(e); + } + }; + + KeyStore keyStore; + try (InputStream certStream = + new FileInputStream(new File("testresources/mtls/test_cert.pem")); + InputStream keyStream = new FileInputStream(new File("testresources/mtls/test_key.pem")); + InputStream combined = new SequenceInputStream(certStream, keyStream)) { + keyStore = SecurityUtils.createMtlsKeyStore(combined); + } + HttpTransportFactory transportFactory = new MtlsHttpTransportFactory(keyStore); + + 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); + } + + /** + * 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 subjectToken = generateGoogleIdToken(OIDC_AUDIENCE); + String actorToken = generateGoogleIdToken(OIDC_AUDIENCE); + + File tokenFile = + File.createTempFile( + "ITWorkloadIdentityFederation_direct_cert_actor", + /* suffix= */ null, + /* directory= */ null); + tokenFile.deleteOnExit(); + + 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", "testresources/mtls/certificate_config.json"); + 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()); + } + + /** + * 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 = + (ExternalAccountSupplierContext context) -> { + try { + return generateGoogleIdToken(OIDC_AUDIENCE); + } catch (IOException e) { + throw new RuntimeException(e); + } + }; + + IdentityPoolActorTokenSupplier actorSupplier = + (ExternalAccountSupplierContext context) -> { + try { + return generateGoogleIdToken(OIDC_AUDIENCE); + } catch (IOException e) { + throw new RuntimeException(e); + } + }; + + KeyStore keyStore; + try (InputStream certStream = + new FileInputStream(new File("testresources/mtls/test_cert.pem")); + InputStream keyStream = new FileInputStream(new File("testresources/mtls/test_key.pem")); + InputStream combined = new SequenceInputStream(certStream, keyStream)) { + keyStore = SecurityUtils.createMtlsKeyStore(combined); + } + HttpTransportFactory transportFactory = new MtlsHttpTransportFactory(keyStore); + + 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); 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..90fe23df65e8 --- /dev/null +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MtlsPipelineLocalTest.java @@ -0,0 +1,1049 @@ +/* + * 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.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; + +/** + * 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. + */ +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 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 { + 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) { + try { + 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); + } catch (Exception e) { + throw new RuntimeException("Failed to configure HttpsServer mTLS", e); + } + } + }); + + serverExecutor = Executors.newCachedThreadPool(); + server.setExecutor(serverExecutor); + } + + @AfterEach + void tearDown() { + if (server != null) { + server.stop(0); + } + if (serverExecutor != null) { + serverExecutor.shutdownNow(); + } + } + + 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); + } + + 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(); + serverPort = server.getAddress().getPort(); + + 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() + + "\",\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 { + AtomicInteger requestCount = new AtomicInteger(0); + List certsPerRequest = 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(); + synchronized (certsPerRequest) { + certsPerRequest.add(session.getPeerCertificates()); + } + + // Always read and drain the request body + String body = readRequestBody(exchange); + + int count = requestCount.incrementAndGet(); + if (count == 1) { + // 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(); + serverPort = server.getAddress().getPort(); + + 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() + + "\",\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("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( + ((X509Certificate) certsPerRequest.get(0)[0]).getSubjectX500Principal(), + ((X509Certificate) certsPerRequest.get(1)[0]).getSubjectX500Principal()); + } + + /** + * 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 { + HttpsExchange httpsExchange = (HttpsExchange) exchange; + SSLSession session = httpsExchange.getSSLSession(); + Certificate[] certs = session.getPeerCertificates(); + if (certs == null || certs.length == 0) { + sendJsonResponse(exchange, 403, "{\"error\": \"missing_peer_cert\"}"); + return; + } + + // Always read and drain the request body + String 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(); + serverPort = server.getAddress().getPort(); + + 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() + + "\",\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); + 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_")); + } + + clientExecutor.shutdown(); + assertTrue(clientExecutor.awaitTermination(5, TimeUnit.SECONDS)); + assertEquals(concurrency, requestCounter.get()); + } + + /** + * Scenario D: testMtlsPipeline_atomicTokenRead + * + *

Verify single-pass file read of subject + actor tokens from the same JSON file. + */ + @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(); + serverPort = server.getAddress().getPort(); + + 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 { + 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()); + + 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(); + serverPort = server.getAddress().getPort(); + + 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() + + "\",\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("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 the exact same client certificate principal + assertEquals( + ((X509Certificate) stsCerts[0]).getSubjectX500Principal(), + ((X509Certificate) iamCerts[0]).getSubjectX500Principal()); + + // 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() + + "\",\n" + + " \"key_path\": \"" + + dynamicKeyFile.toString() + + "\"\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) { + // Update the cert files on disk on 401 (Cert A -> Cert B) + Files.write(dynamicCertFile, Files.readAllBytes(Paths.get(TEST_CERT_PATH))); + Files.write(dynamicKeyFile, Files.readAllBytes(Paths.get(TEST_KEY_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(); + serverPort = server.getAddress().getPort(); + + 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() + + "\",\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() + + "\"\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 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)); + } +} 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 e70ba6851574..bec00cc3ffdd 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 @@ -34,6 +34,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; +import com.google.api.client.http.HttpHeaders; +import com.google.api.client.http.HttpResponseException; import com.google.auth.TestUtils; import java.io.IOException; import org.junit.jupiter.api.Test; @@ -129,4 +131,68 @@ void createFromHttpResponseException_baseFormat() throws IOException { String expectedMessage = String.format(BASE_MESSAGE_FORMAT, "errorCode"); assertEquals(expectedMessage, e.getMessage()); } + + @Test + void createFromHttpResponseException_nullContent() throws IOException { + 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()); + } + + @Test + void createFromHttpResponseException_emptyContent() throws IOException { + 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()); + } + + @Test + void createFromHttpResponseException_nonJsonContent() throws IOException { + 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()); + } + + @Test + void createFromHttpResponseException_missingErrorField() throws IOException { + 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()); + } } From fdfea4f12b803e6f191331cb68ff98bdb9d03d44 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Tue, 1 Sep 2026 02:04:57 +0000 Subject: [PATCH 09/20] test(oauth2): escape Windows path backslashes in MtlsPipelineLocalTest JSON templates --- .../auth/oauth2/MtlsPipelineLocalTest.java | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) 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 index 90fe23df65e8..b742e36853b8 100644 --- 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 @@ -347,7 +347,7 @@ public void handle(HttpExchange exchange) throws IOException { + "/v1/token\",\n" + " \"credential_source\": {\n" + " \"file\": \"" - + tokenFile.toString() + + tokenFile.toString().replace("\\", "\\\\") + "\",\n" + " \"format\": {\n" + " \"type\": \"json\",\n" @@ -355,7 +355,8 @@ public void handle(HttpExchange exchange) throws IOException { + " \"actor_token_field_name\": \"actor_token\"\n" + " },\n" + " \"certificate\": {\n" - + " \"certificate_config_location\": \"testresources/mtls/certificate_config.json\"\n" + + " \"certificate_config_location\":" + + " \"testresources/mtls/certificate_config.json\"\n" + " }\n" + " }\n" + "}"; @@ -468,7 +469,7 @@ public void handle(HttpExchange exchange) throws IOException { + "/v1/token\",\n" + " \"credential_source\": {\n" + " \"file\": \"" - + tokenFile.toString() + + tokenFile.toString().replace("\\", "\\\\") + "\",\n" + " \"format\": {\n" + " \"type\": \"json\",\n" @@ -476,7 +477,8 @@ public void handle(HttpExchange exchange) throws IOException { + " \"actor_token_field_name\": \"actor_token\"\n" + " },\n" + " \"certificate\": {\n" - + " \"certificate_config_location\": \"testresources/mtls/certificate_config.json\"\n" + + " \"certificate_config_location\":" + + " \"testresources/mtls/certificate_config.json\"\n" + " }\n" + " }\n" + "}"; @@ -566,7 +568,7 @@ public void handle(HttpExchange exchange) throws IOException { + "/v1/token\",\n" + " \"credential_source\": {\n" + " \"file\": \"" - + tokenFile.toString() + + tokenFile.toString().replace("\\", "\\\\") + "\",\n" + " \"format\": {\n" + " \"type\": \"json\",\n" @@ -574,7 +576,8 @@ public void handle(HttpExchange exchange) throws IOException { + " \"actor_token_field_name\": \"actor_token\"\n" + " },\n" + " \"certificate\": {\n" - + " \"certificate_config_location\": \"testresources/mtls/certificate_config.json\"\n" + + " \"certificate_config_location\":" + + " \"testresources/mtls/certificate_config.json\"\n" + " }\n" + " }\n" + "}"; @@ -807,7 +810,7 @@ public void handle(HttpExchange exchange) throws IOException { + "\",\n" + " \"credential_source\": {\n" + " \"file\": \"" - + tokenFile.toString() + + tokenFile.toString().replace("\\", "\\\\") + "\",\n" + " \"format\": {\n" + " \"type\": \"json\",\n" @@ -815,7 +818,8 @@ public void handle(HttpExchange exchange) throws IOException { + " \"actor_token_field_name\": \"actor_token\"\n" + " },\n" + " \"certificate\": {\n" - + " \"certificate_config_location\": \"testresources/mtls/certificate_config.json\"\n" + + " \"certificate_config_location\":" + + " \"testresources/mtls/certificate_config.json\"\n" + " }\n" + " }\n" + "}"; @@ -881,10 +885,10 @@ void testMtlsPipeline_withImpersonation_401OnIam_retriesWithFreshCert(@TempDir P + " \"cert_configs\": {\n" + " \"workload\": {\n" + " \"cert_path\": \"" - + dynamicCertFile.toString() + + dynamicCertFile.toString().replace("\\", "\\\\") + "\",\n" + " \"key_path\": \"" - + dynamicKeyFile.toString() + + dynamicKeyFile.toString().replace("\\", "\\\\") + "\"\n" + " }\n" + " }\n" @@ -994,7 +998,7 @@ public void handle(HttpExchange exchange) throws IOException { + "\",\n" + " \"credential_source\": {\n" + " \"file\": \"" - + tokenFile.toString() + + tokenFile.toString().replace("\\", "\\\\") + "\",\n" + " \"format\": {\n" + " \"type\": \"json\",\n" @@ -1003,7 +1007,7 @@ public void handle(HttpExchange exchange) throws IOException { + " },\n" + " \"certificate\": {\n" + " \"certificate_config_location\": \"" - + certConfigFile.toString() + + certConfigFile.toString().replace("\\", "\\\\") + "\"\n" + " }\n" + " }\n" From 4266196b9d9cd3816c9bee6fc4ac878e66b50916 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Wed, 16 Sep 2026 01:46:19 +0000 Subject: [PATCH 10/20] test(oauth2): ensure executor shutdown and temp file cleanup in mTLS integration tests --- .../ITWorkloadIdentityFederationTest.java | 168 +++++++++--------- .../auth/oauth2/MtlsPipelineLocalTest.java | 50 +++--- 2 files changed, 113 insertions(+), 105 deletions(-) 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 4e7f426dd164..eb9f7227c590 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 @@ -304,48 +304,51 @@ void identityPoolCredentials_withCertificateBoundWorkloadAndActorToken() throws File.createTempFile( "ITWorkloadIdentityFederation_cert_actor", /* suffix= */ null, /* directory= */ null); tokenFile.deleteOnExit(); - - 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", "testresources/mtls/certificate_config.json"); - credentialSource.put("certificate", certificate); - - config.put("credential_source", credentialSource); - - IdentityPoolCredentials identityPoolCredentials = - (IdentityPoolCredentials) - ExternalAccountCredentials.fromJson(config, OAuth2Utils.HTTP_TRANSPORT_FACTORY); - - callGcs(identityPoolCredentials); + 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", "testresources/mtls/certificate_config.json"); + credentialSource.put("certificate", certificate); + + config.put("credential_source", credentialSource); + + IdentityPoolCredentials identityPoolCredentials = + (IdentityPoolCredentials) + ExternalAccountCredentials.fromJson(config, OAuth2Utils.HTTP_TRANSPORT_FACTORY); + + callGcs(identityPoolCredentials); + } finally { + tokenFile.delete(); + } } /** @@ -418,45 +421,48 @@ void identityPoolCredentials_directSts_withCertificateBoundWorkloadAndActorToken /* suffix= */ null, /* directory= */ null); tokenFile.deleteOnExit(); - - 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", "testresources/mtls/certificate_config.json"); - 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()); + 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", "testresources/mtls/certificate_config.json"); + 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(); + } } /** 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 index b742e36853b8..15277b0b1fb7 100644 --- 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 @@ -589,32 +589,34 @@ public void handle(HttpExchange exchange) throws IOException { int concurrency = 8; ExecutorService clientExecutor = Executors.newFixedThreadPool(concurrency); - 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(); - } - })); - } + 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(); + // 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_")); + 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)); } - - clientExecutor.shutdown(); - assertTrue(clientExecutor.awaitTermination(5, TimeUnit.SECONDS)); assertEquals(concurrency, requestCounter.get()); } @@ -943,7 +945,7 @@ public void handle(HttpExchange exchange) throws IOException { int count = iamRequestCount.incrementAndGet(); if (count == 1) { - // Update the cert files on disk on 401 (Cert A -> Cert B) + // Rewrite the cert files on disk on 401 to trigger X509Provider reload and retry Files.write(dynamicCertFile, Files.readAllBytes(Paths.get(TEST_CERT_PATH))); Files.write(dynamicKeyFile, Files.readAllBytes(Paths.get(TEST_KEY_PATH))); From 7ac9e637af198cb38c0c659186164012aec68638 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Fri, 18 Sep 2026 20:55:55 +0000 Subject: [PATCH 11/20] test(oauth2): verify real Cert A -> Cert B rotation on 401 and harden OAuthException --- .../google/auth/oauth2/OAuthException.java | 25 ++++++-- .../ITWorkloadIdentityFederationTest.java | 6 +- .../auth/oauth2/MtlsPipelineLocalTest.java | 63 ++++++++++++++++--- .../auth/oauth2/OAuthExceptionTest.java | 18 ++++++ .../testresources/mtls/test_cert_2.pem | 20 ++++++ .../testresources/mtls/test_key_2.pem | 28 +++++++++ 6 files changed, 144 insertions(+), 16 deletions(-) create mode 100644 google-auth-library-java/oauth2_http/testresources/mtls/test_cert_2.pem create mode 100644 google-auth-library-java/oauth2_http/testresources/mtls/test_key_2.pem 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 d7c01b0df54d..13853eb25f51 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 @@ -46,6 +46,8 @@ @NullMarked class OAuthException extends GoogleAuthException { + private static final long serialVersionUID = 1L; + private final String errorCode; @Nullable private final String errorDescription; @Nullable private final String errorUri; @@ -106,22 +108,35 @@ static OAuthException createFromHttpResponseException(HttpResponseException e) JsonParser parser = OAuth2Utils.JSON_FACTORY.createJsonParser(content); GenericJson errorResponse = parser.parseAndClose(GenericJson.class); - String errorCode = (String) errorResponse.get("error"); + String errorCode = null; + String errorDescription = null; + Object rawError = errorResponse.get("error"); + if (rawError instanceof String) { + errorCode = (String) rawError; + } else if (rawError instanceof java.util.Map) { + java.util.Map errorMap = (java.util.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 errorDescription = null; String errorUri = null; - if (errorResponse.containsKey("error_description")) { + if (errorResponse.get("error_description") instanceof String) { errorDescription = (String) errorResponse.get("error_description"); } - if (errorResponse.containsKey("error_uri")) { + if (errorResponse.get("error_uri") instanceof String) { errorUri = (String) errorResponse.get("error_uri"); } return new OAuthException(errorCode, errorDescription, errorUri, e.getStatusCode()); } catch (Exception parseException) { + String fallbackDescription = e.getStatusMessage() != null ? e.getStatusMessage() : content; return new OAuthException( - "http_error_" + e.getStatusCode(), e.getStatusMessage(), null, e.getStatusCode()); + "http_error_" + e.getStatusCode(), fallbackDescription, null, 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 eb9f7227c590..ee097ab58a2e 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 @@ -633,7 +633,11 @@ private void callGcs(GoogleCredentials credentials) throws IOException { 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 index 15277b0b1fb7..86f0f2e8323e 100644 --- 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 @@ -106,6 +106,8 @@ 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"; @@ -228,6 +230,10 @@ private static SSLContext createServerSSLContext() throws Exception { 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()); @@ -403,8 +409,31 @@ public void handle(HttpExchange exchange) throws IOException { */ @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 = new ArrayList<>(); + List certsPerRequest = Collections.synchronizedList(new ArrayList<>()); server.createContext( "/v1/token", @@ -414,15 +443,17 @@ public void handle(HttpExchange exchange) throws IOException { try { HttpsExchange httpsExchange = (HttpsExchange) exchange; SSLSession session = httpsExchange.getSSLSession(); - synchronized (certsPerRequest) { - certsPerRequest.add(session.getPeerCertificates()); - } + certsPerRequest.add(session.getPeerCertificates()); // Always read and drain the request body String 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); @@ -477,8 +508,9 @@ public void handle(HttpExchange exchange) throws IOException { + " \"actor_token_field_name\": \"actor_token\"\n" + " },\n" + " \"certificate\": {\n" - + " \"certificate_config_location\":" - + " \"testresources/mtls/certificate_config.json\"\n" + + " \"certificate_config_location\": \"" + + certConfigFile.toString().replace("\\", "\\\\") + + "\"\n" + " }\n" + " }\n" + "}"; @@ -499,8 +531,11 @@ public void handle(HttpExchange exchange) throws IOException { assertTrue(certsPerRequest.get(0)[0] instanceof X509Certificate); assertTrue(certsPerRequest.get(1)[0] instanceof X509Certificate); assertEquals( - ((X509Certificate) certsPerRequest.get(0)[0]).getSubjectX500Principal(), - ((X509Certificate) certsPerRequest.get(1)[0]).getSubjectX500Principal()); + "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()); } /** @@ -946,8 +981,8 @@ public void handle(HttpExchange exchange) throws IOException { 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_PATH))); - Files.write(dynamicKeyFile, Files.readAllBytes(Paths.get(TEST_KEY_PATH))); + 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); @@ -1047,6 +1082,14 @@ public void handle(HttpExchange exchange) throws IOException { ((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)); 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 bec00cc3ffdd..20b430a12a11 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 @@ -195,4 +195,22 @@ void createFromHttpResponseException_missingErrorField() throws IOException { assertNull(e.getErrorUri()); assertEquals(400, e.getHttpStatusCode()); } + + @Test + void createFromHttpResponseException_googleApiJsonErrorObject() throws IOException { + 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()); + } } diff --git a/google-auth-library-java/oauth2_http/testresources/mtls/test_cert_2.pem b/google-auth-library-java/oauth2_http/testresources/mtls/test_cert_2.pem new file mode 100644 index 000000000000..3198bfa7cc82 --- /dev/null +++ b/google-auth-library-java/oauth2_http/testresources/mtls/test_cert_2.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDSzCCAjOgAwIBAgIUGdqGf10PtKM9CrhxJjJWo1bwBrowDQYJKoZIhvcNAQEL +BQAwNDEyMDAGA1UEAwwpcm90YXRlZC1jbGllbnQuYXBwcy5nb29nbGV1c2VyY29u +dGVudC5jb20wIBcNMjYwOTE4MTk1NjM4WhgPMjEyNjA4MjUxOTU2MzhaMDQxMjAw +BgNVBAMMKXJvdGF0ZWQtY2xpZW50LmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29t +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyKMqPCwcqeoVtrrdfhsf +wDldPteZyTi2e9dRWQB+H0GGoTfwI5edvXBKc+ACIpoyl/fEsBLsGYhZqIyGYl90 +pVnHnLYoP4gppoeR9L1wIfY/t8AFCQb+ko9c31gVJLlHCmtPuBf9B/yRrnmwJDXe +BNEuPALdcq9y8ZklDoF+QWXiw0SoZ7HbhhgumK3juNfeOqqxaGt7JGWoZ5ub+Nv/ +fEikINB9gHyUeW33FPoUDIhwDQYRLuyNytKX6tnTHf1o5qIF9ELBBpu6T4U9/1F+ +jD9Wx0XPW59wvcu6f7k/mOswxBdJKNsTfqWeJ0iOn0mZgePmuZJWCsC3P0QPDrwe +KwIDAQABo1MwUTAdBgNVHQ4EFgQUDoOQ6j/nPz2bPi9DzMKijXnQYg0wHwYDVR0j +BBgwFoAUDoOQ6j/nPz2bPi9DzMKijXnQYg0wDwYDVR0TAQH/BAUwAwEB/zANBgkq +hkiG9w0BAQsFAAOCAQEAXf3anEUWyQfoet0e/diBeSvBAbMtG58nxB8q8JVzp4bD +4A+P9AkdS2fNTcOyuVQSQep+cviDXGTFQaAdP+6IHWUFus7bTtGV1VYWOuyTrlsh +8+PKOoAHASloJUUR8aXT0jRE+CEToZH0YnRrXr3d0UePd7BSD6lDlcpvmWpRuAbM +kJZTfOmmDavAt8SWq6fHqCt15PGrCQvyxbOnmMfDHARxH/ysJrs6dCzzNzcBqMAl +XqL8Mzcc+SwqfY4yX5RGDzFfPcwQ8yQKsM4nRYVTL1oGQDbaJXbd34OeIh5VdCmz +pgiAwbu3ubifmfGPBgtuL5FOBGYiXx++EX/+i3QskQ== +-----END CERTIFICATE----- diff --git a/google-auth-library-java/oauth2_http/testresources/mtls/test_key_2.pem b/google-auth-library-java/oauth2_http/testresources/mtls/test_key_2.pem new file mode 100644 index 000000000000..a7e7f18f50b1 --- /dev/null +++ b/google-auth-library-java/oauth2_http/testresources/mtls/test_key_2.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDIoyo8LByp6hW2 +ut1+Gx/AOV0+15nJOLZ711FZAH4fQYahN/Ajl529cEpz4AIimjKX98SwEuwZiFmo +jIZiX3SlWcectig/iCmmh5H0vXAh9j+3wAUJBv6Sj1zfWBUkuUcKa0+4F/0H/JGu +ebAkNd4E0S48At1yr3LxmSUOgX5BZeLDRKhnsduGGC6YreO41946qrFoa3skZahn +m5v42/98SKQg0H2AfJR5bfcU+hQMiHANBhEu7I3K0pfq2dMd/WjmogX0QsEGm7pP +hT3/UX6MP1bHRc9bn3C9y7p/uT+Y6zDEF0ko2xN+pZ4nSI6fSZmB4+a5klYKwLc/ +RA8OvB4rAgMBAAECggEATUph5k09+4zUMZD5628Kg7P1glRvB0/EKJ/xU/53F5ZY +BTo11Zg+KXCdFddpKyhIrjdB+2xGrK1UkLQVvsaz+lDEL1AS4CqVlPPr26KwlDy3 +HhOoPOqHXjFBHz7g4kVHozFkw+/lx1JaUqLJIqHnteZZT7/wzBHfj0emOVjUKN+m +RdtZrl23pP/QI3xVqFlfbzkA+S3RFQ0sxJx2x/P4P8XCTlwE1vqhQ5kB4UzeT68U +iF3hLvGLQ3mWnByPbHOGMOhizdLc2YNw/hyuM37K885BGDYMsNduSUGCe/4E5IHi +QgaRNHr2bZ10TgYIOYb0gEHNLxqHHyAV6VZEPR8JYQKBgQD2tp9Mq4u4vK9i5m79 +FWVcDRSyHuNYm+UR/hlaHKsb+Ps3suEaDcHBTd70Q3xUc0+ELyktw28pfLwNt1bm +do/AcdF6DZ3v7CceqmT9qKkCYb9hd0Fh9qBfysphVdCA9ADLhqTt1VhBlMi5gjop +qoq5xSppm3i2B5oKRVLd/MRdCwKBgQDQMIugrgj7tHXj30M5ZPWI81zQzwB9PK19 +SEMtY2U8CbK7UADojRgZt48jUvi+6JGwoRMGgZKNS738d5HLXomP2ixptOkP/Ojb +saRfYq3f/laEWmTmhKWuPpDRJomeBgN9RIlObCzHG8nXQpkpv3Pxquz+3XsNmqVc +EeVpzLa3YQKBgQDvFxxaGQVXCsSNe/OjoNCR3LtpwzVf8fSU80NuaMoKWchbuicc +MKjMxMKExcH/taAh4fJuPy/DbPZx9HNq9Qtq3OK8/eVJVfqzZT5p9MGVZVUrB399 +iJzzOHvYyGlADzeWBe5f7pmRNLcx0Fq9dmDql4D6x4i8H7HQsk4Ug5lo8QKBgQCQ +ieOmvq1F81gVyJ+nbUx6tFswLUctq77XSCA7ltJmCGWqXjUDP1IIKMSbxbMk99qO +QoYu8i6JIpjYezshcpUW5mYHTa6xhukz/fUctCn7ZV0zrn52/Ij6nD12t8a7i3lI +sxiQ9dFwuv47EOW9ckpQ8lZv69zO6Rr8/P/FoHcKgQKBgBwkBRCqIN+E9BP+NcKi +fIbmEJ3pyrO4FpzMIUF59cGgUZT0GqyU/GHUShk/I36LXL2wMZYPrRubUbz6ibXB ++vHChi14J+JSGHNK5nzapQGJgKvrRlHMOfy0kljnRphTGCuU33ZeRBsFts6tHYEL +dc8QzzRoO2+cylKxetiq45RE +-----END PRIVATE KEY----- From 13e8be00345c1adf4783ed3ed40ed0838893c6d7 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Mon, 21 Sep 2026 20:59:47 +0000 Subject: [PATCH 12/20] fix(oauth2): address review feedback on mTLS transport pinning and 401 recovery --- .../google/auth/oauth2/AwsCredentials.java | 3 +- .../oauth2/ExternalAccountCredentials.java | 13 +++++-- .../auth/oauth2/IdentityPoolCredentials.java | 10 +++-- .../auth/oauth2/ImpersonatedCredentials.java | 3 ++ .../auth/oauth2/PluggableAuthCredentials.java | 3 +- .../ExternalAccountCredentialsTest.java | 27 +++++++++++++ .../oauth2/IdentityPoolCredentialsTest.java | 38 +++++++++++-------- .../oauth2/ImpersonatedCredentialsTest.java | 13 +++---- .../google/auth/oauth2/OAuth2UtilsTest.java | 10 +++++ .../oauth2/PluggableAuthCredentialsTest.java | 11 +++++- 10 files changed, 94 insertions(+), 37 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java index 6be30db02b88..847eaa1cd421 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java @@ -126,8 +126,7 @@ public AccessToken refreshAccessToken() throws IOException { @InternalExtensionOnly @Override - public AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) - throws IOException { + AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) throws IOException { ImpersonatedCredentials impersonated = getImpersonatedCredentials(); if (impersonated != null) { return impersonated.refreshAccessToken(cycleTransportFactory); diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java index 61aa5aa34949..073524254b9e 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java @@ -86,6 +86,7 @@ public abstract class ExternalAccountCredentials extends GoogleCredentials { private final @Nullable String tokenInfoUrl; private final @Nullable String serviceAccountImpersonationUrl; + private transient @Nullable String targetServiceAccountEmail; private final @Nullable String clientId; private final @Nullable String clientSecret; @@ -96,7 +97,7 @@ public abstract class ExternalAccountCredentials extends GoogleCredentials { protected transient HttpTransportFactory transportFactory; - protected volatile @Nullable ImpersonatedCredentials impersonatedCredentials; + protected transient volatile @Nullable ImpersonatedCredentials impersonatedCredentials; private final EnvironmentProvider environmentProvider; private final PropertyProvider propertyProvider; @@ -237,6 +238,7 @@ protected ExternalAccountCredentials(ExternalAccountCredentials.Builder builder) this.credentialSource = builder.credentialSource; this.tokenInfoUrl = builder.tokenInfoUrl; this.serviceAccountImpersonationUrl = builder.serviceAccountImpersonationUrl; + this.targetServiceAccountEmail = builder.targetServiceAccountEmail; this.clientId = builder.clientId; this.clientSecret = builder.clientSecret; @@ -311,6 +313,7 @@ protected ExternalAccountCredentials(ExternalAccountCredentials.Builder builder) String targetPrincipal = ImpersonatedCredentials.extractTargetPrincipal(serviceAccountImpersonationUrl); + sourceCredentials.targetServiceAccountEmail = targetPrincipal; return ImpersonatedCredentials.newBuilder() .setSourceCredentials(sourceCredentials) .setHttpTransportFactory(transportFactory) @@ -549,8 +552,7 @@ private boolean shouldBuildImpersonatedCredential() { * @throws IOException if the token refresh fails */ @InternalExtensionOnly - public AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) - throws IOException { + AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) throws IOException { return refreshAccessToken(); } @@ -664,7 +666,7 @@ private void readObject(ObjectInputStream input) throws IOException, ClassNotFou */ public @Nullable String getServiceAccountEmail() { if (serviceAccountImpersonationUrl == null || serviceAccountImpersonationUrl.isEmpty()) { - return null; + return targetServiceAccountEmail; } return ImpersonatedCredentials.extractTargetPrincipal(serviceAccountImpersonationUrl); } @@ -816,6 +818,7 @@ public abstract static class Builder extends GoogleCredentials.Builder { protected @Nullable HttpTransportFactory transportFactory; protected @Nullable String serviceAccountImpersonationUrl; + private @Nullable String targetServiceAccountEmail; protected @Nullable String clientId; protected @Nullable String clientSecret; protected @Nullable Collection scopes; @@ -840,6 +843,7 @@ protected Builder(ExternalAccountCredentials credentials) { this.tokenUrl = credentials.tokenUrl; this.tokenInfoUrl = credentials.tokenInfoUrl; this.serviceAccountImpersonationUrl = credentials.serviceAccountImpersonationUrl; + this.targetServiceAccountEmail = credentials.targetServiceAccountEmail; this.credentialSource = credentials.credentialSource; this.clientId = credentials.clientId; this.clientSecret = credentials.clientSecret; @@ -938,6 +942,7 @@ public Builder setCredentialSource(CredentialSource credentialSource) { public Builder setServiceAccountImpersonationUrl( @Nullable String serviceAccountImpersonationUrl) { this.serviceAccountImpersonationUrl = serviceAccountImpersonationUrl; + this.targetServiceAccountEmail = null; return this; } diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java index ff1c41ed08c2..eef4414bbf5f 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java @@ -187,7 +187,8 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { if (this.actorTokenSupplier != null && !isMtlsConfigured()) { throw new IllegalArgumentException( "Actor tokens are only supported for mTLS token exchanges. Please configure a certificate" - + " configuration in the credential source or provide an mTLS-enabled transport."); + + " configuration in the credential source or provide an MtlsHttpTransportFactory" + + " constructed with a KeyStore."); } if (this.actorTokenSupplier != null) { @@ -256,8 +257,7 @@ public AccessToken refreshAccessToken() throws IOException { @InternalExtensionOnly @Override - public AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) - throws IOException { + AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) throws IOException { // Retry is intentionally disabled when an explicit cycleTransportFactory is supplied to // ensure transport synchronization across multi-step token exchanges (e.g. STS and IAM) // and prevent nested retry amplification. Outer callers manage retry coordination. @@ -462,7 +462,9 @@ private void readObject(ObjectInputStream input) throws IOException, ClassNotFou new X509Provider(getEnvironmentProvider(), getPropertyProvider(), explicitCertConfigPath); try { KeyStore mtlsKeyStore = this.x509Provider.getKeyStore(); - if (shouldUseMtlsTransportFactory()) { + if (shouldUseMtlsTransportFactory() + || (this.transportFactory instanceof MtlsHttpTransportFactory + && !((MtlsHttpTransportFactory) this.transportFactory).hasKeyStore())) { this.transportFactory = createMtlsTransportFactory(mtlsKeyStore); this.defaultMtlsTransportFactory = this.transportFactory; } diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java index 8419e16f59ea..e079a1641cb1 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java @@ -694,6 +694,9 @@ public AccessToken refreshAccessToken() { adapter.initialize(request); if (cycleTransportFactory != null && this.sourceCredentials instanceof ExternalAccountCredentials) { + // Disable HttpCredentialsAdapter's default 401 retry so 401 responses propagate to the + // caller (e.g. IdentityPoolCredentials) to re-snapshot the certificate and retry the full + // cycle with a newly pinned transport. request.setUnsuccessfulResponseHandler(null); } request.setParser(parser); diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java index 3ff3bf19bf4e..4c928ffb40e0 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java @@ -127,8 +127,7 @@ public AccessToken refreshAccessToken() throws IOException { @InternalExtensionOnly @Override - public AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) - throws IOException { + AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) throws IOException { ImpersonatedCredentials impersonated = getImpersonatedCredentials(); if (impersonated != null) { return impersonated.refreshAccessToken(cycleTransportFactory); diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java index 5532a97b3447..cc6b2cc280c7 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java @@ -1286,6 +1286,33 @@ void validateServiceAccountImpersonationUrls_invalidUrls() { } } + @Test + void + serialize_deserialize_withServiceAccountImpersonation_rebuildsTransientImpersonatedCredentials() + throws Exception { + IdentityPoolCredentials credential = + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(new OAuth2Utils.DefaultHttpTransportFactory()) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl(STS_URL) + .setServiceAccountImpersonationUrl(SERVICE_ACCOUNT_IMPERSONATION_URL) + .setCredentialSource(new IdentityPoolCredentialSource(FILE_CREDENTIAL_SOURCE_MAP)) + .build(); + + assertNotNull(credential.getImpersonatedCredentials()); + assertNotNull(credential.impersonatedCredentials); + + IdentityPoolCredentials deserialized = serializeAndDeserialize(credential); + assertNull(deserialized.impersonatedCredentials); + + ImpersonatedCredentials rebuilt = deserialized.getImpersonatedCredentials(); + assertNotNull(rebuilt); + assertEquals( + credential.getServiceAccountEmail(), + ((ExternalAccountCredentials) rebuilt.getSourceCredentials()).getServiceAccountEmail()); + } + private GenericJson buildJsonIdentityPoolCredential() { GenericJson json = new GenericJson(); json.put( diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java index 215628d48fe8..26b92cafe45c 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java @@ -166,7 +166,7 @@ class IdentityPoolCredentialsTest extends BaseSerializationTest { ROTATED_CERT_AND_KEY_PEM.substring( ROTATED_CERT_AND_KEY_PEM.indexOf("-----BEGIN PRIVATE KEY-----")); - private static KeyStore createPopulatedKeyStore() { + static KeyStore createPopulatedKeyStore() { try (InputStream certStream = new FileInputStream(new File("testresources/mtls/test_cert.pem")); InputStream keyStream = new FileInputStream(new File("testresources/mtls/test_key.pem")); @@ -177,7 +177,7 @@ private static KeyStore createPopulatedKeyStore() { } } - private static KeyStore createRotatedPopulatedKeyStore() { + static KeyStore createRotatedPopulatedKeyStore() { try (InputStream stream = new ByteArrayInputStream(ROTATED_CERT_AND_KEY_PEM.getBytes(StandardCharsets.UTF_8))) { return SecurityUtils.createMtlsKeyStore(stream); @@ -1476,7 +1476,8 @@ public String getActorToken(ExternalAccountSupplierContext context) { assertEquals( "Actor tokens are only supported for mTLS token exchanges. Please configure a certificate" - + " configuration in the credential source or provide an mTLS-enabled transport.", + + " configuration in the credential source or provide an MtlsHttpTransportFactory" + + " constructed with a KeyStore.", e.getMessage()); } @@ -2041,10 +2042,13 @@ public KeyStore getKeyStore() { } }; - MockExternalAccountCredentialsTransport transport = + MockExternalAccountCredentialsTransport transportA = + new MockExternalAccountCredentialsTransport(); + transportA.addStsStatusCodeSequence(401); + + MockExternalAccountCredentialsTransport transportB = new MockExternalAccountCredentialsTransport(); - // 1st STS call returns 401 Unauthorized, 2nd STS call returns 200 OK - transport.addStsStatusCodeSequence(401, 200); + transportB.addStsStatusCodeSequence(200); List usedKeyStores = new ArrayList<>(); IdentityPoolCredentials credential = @@ -2055,11 +2059,11 @@ public KeyStore getKeyStore() { .setAudience( "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") - .setTokenUrl(transport.getStsUrl())) { + .setTokenUrl(transportA.getStsUrl())) { @Override HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { usedKeyStores.add(keyStore); - return () -> transport; + return () -> keyStore == ksA ? transportA : transportB; } }; @@ -2070,8 +2074,9 @@ HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { // Verify 2 calls to X509Provider: 1st for initial snapshot, 2nd on 401 reload assertEquals(2, callCount.get()); - // Verify 2 STS requests were executed over HTTP - assertEquals(2, transport.getRequests().size()); + // Verify 1st STS request executed over transportA (ksA) and 2nd over transportB (ksB) + assertEquals(1, transportA.getRequests().size()); + assertEquals(1, transportB.getRequests().size()); // Verify initial cycle used ksA, and retry used ksB assertEquals(Arrays.asList(ksA, ksB), usedKeyStores); @@ -2439,12 +2444,10 @@ protected AccessToken exchangeExternalCredentialForAccessToken( assertNotNull(tokenB); // Both threads did initial snapshots (2 calls), plus Thread B's retry (1 more) - assertTrue( - getKeyStoreCount.get() >= 3, - "Expected at least 3 getKeyStore calls (2 initial + 1 retry), got " - + getKeyStoreCount.get()); + assertEquals(3, getKeyStoreCount.get()); // 3 exchange calls total: one 401 + one retry success + one normal success assertEquals(3, exchangeCallCount.get()); + assertSame(mtlsTransport, credential.getTransportFactory()); } finally { executor.shutdownNow(); } @@ -4106,8 +4109,11 @@ protected AccessToken exchangeExternalCredentialForAccessToken( .build(); IdentityPoolCredentials deserialized = serializeAndDeserialize(regularCredentials); assertTrue( - deserialized.getTransportFactory() instanceof CustomMtlsHttpTransportFactory, - "readObject must preserve custom subclass of MtlsHttpTransportFactory"); + deserialized.getTransportFactory() instanceof MtlsHttpTransportFactory, + "readObject must restore an MtlsHttpTransportFactory"); + assertTrue( + ((MtlsHttpTransportFactory) deserialized.getTransportFactory()).hasKeyStore(), + "readObject must restore a KeyStore-backed MtlsHttpTransportFactory"); } @Test diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java index d16f8677bd30..883b9e8a5460 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java @@ -70,7 +70,6 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Calendar; -import java.util.Collection; import java.util.Collections; import java.util.Date; import java.util.List; @@ -1402,7 +1401,7 @@ void refreshAccessToken_withExternalAccountSource_usesProvidedTransportFactory() .setQuotaProjectId("test-quota-project") .setTokenUrl("https://sts.googleapis.com/v1/token")) { @Override - public AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) { + AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) { capturedSourceTransport.set(cycleTransportFactory); return new AccessToken("intermediate-sts-token-xyz", null); } @@ -1510,6 +1509,8 @@ public AccessToken refreshAccessToken() { "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") .setSubjectTokenSupplier(context -> "subject-token") + .setScopes( + Collections.singletonList("https://www.googleapis.com/auth/devstorage.read_only")) .setTokenUrl(stsTransport.getStsUrl()) .setHttpTransportFactory(() -> stsTransport) .build(); @@ -1542,7 +1543,8 @@ public AccessToken refreshAccessToken() { } @Test - void refreshAccessToken_standaloneExternalAccountSource_retriesOn401FromIam() throws IOException { + void refreshAccessToken_withoutCycleTransportFactory_externalAccountSourceRetriesOn401FromIam() + throws IOException { AtomicInteger sourceRefreshCount = new AtomicInteger(0); ExternalAccountCredentials mockExternalAccountCredentials = new IdentityPoolCredentials( @@ -1558,11 +1560,6 @@ public AccessToken refreshAccessToken() { int count = sourceRefreshCount.incrementAndGet(); return new AccessToken("intermediate-sts-token-" + count, null); } - - @Override - public IdentityPoolCredentials createScoped(Collection scopes) { - return this; - } }; MockIAMCredentialsServiceTransportFactory credentialsTransportFactory = diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuth2UtilsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuth2UtilsTest.java index 96b55fab01d7..b5f2c8f9917a 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuth2UtilsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuth2UtilsTest.java @@ -162,4 +162,14 @@ void hasCertificateChanged_nullOrSameReference_returnsFalse() throws Exception { assertTrue(OAuth2Utils.hasCertificateChanged(null, ks)); assertTrue(OAuth2Utils.hasCertificateChanged(ks, null)); } + + @Test + void hasCertificateChanged_distinctKeyStoreInstances_comparesCertificates() { + KeyStore ks1 = IdentityPoolCredentialsTest.createPopulatedKeyStore(); + KeyStore ks2 = IdentityPoolCredentialsTest.createPopulatedKeyStore(); + KeyStore ksRotated = IdentityPoolCredentialsTest.createRotatedPopulatedKeyStore(); + + assertFalse(OAuth2Utils.hasCertificateChanged(ks1, ks2)); + assertTrue(OAuth2Utils.hasCertificateChanged(ks1, ksRotated)); + } } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java index a07d9450de35..0675d4430f6d 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java @@ -225,13 +225,22 @@ void refreshAccessToken_withServiceAccountImpersonation() throws IOException { .setHttpTransportFactory(transportFactory) .build(); + final ExecutableOptions[] providedOptions = {null}; credential = PluggableAuthCredentials.newBuilder(credential) - .setExecutableHandler(options -> "pluggableAuthToken") + .setExecutableHandler( + options -> { + providedOptions[0] = options; + return "pluggableAuthToken"; + }) .build(); AccessToken accessToken = credential.refreshAccessToken(); + assertEquals( + credential.getServiceAccountEmail(), + providedOptions[0].getEnvironmentMap().get("GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL")); + assertEquals( transportFactory.transport.getServiceAccountAccessToken(), accessToken.getTokenValue()); From 44ca6d7e99c76b7846416633a160d2cf72915ad3 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Tue, 22 Sep 2026 03:30:27 +0000 Subject: [PATCH 13/20] fix(oauth2): address PR #14212 round 2 review feedback --- .../google/auth/oauth2/AwsCredentials.java | 4 +- .../oauth2/ExternalAccountCredentials.java | 65 +++-- .../auth/oauth2/IdentityPoolCredentials.java | 67 ++---- .../auth/oauth2/ImpersonatedCredentials.java | 42 ++-- .../com/google/auth/oauth2/OAuth2Utils.java | 44 +++- .../auth/oauth2/PluggableAuthCredentials.java | 4 +- .../auth/oauth2/AwsCredentialsTest.java | 14 +- .../oauth2/IdentityPoolCredentialsTest.java | 225 ++++++++++++------ .../oauth2/ImpersonatedCredentialsTest.java | 6 +- .../google/auth/oauth2/OAuth2UtilsTest.java | 32 +++ .../oauth2/PluggableAuthCredentialsTest.java | 8 + .../testresources/mtls/test_cert_2.pem | 20 ++ .../testresources/mtls/test_key_2.pem | 28 +++ 13 files changed, 362 insertions(+), 197 deletions(-) create mode 100644 google-auth-library-java/oauth2_http/testresources/mtls/test_cert_2.pem create mode 100644 google-auth-library-java/oauth2_http/testresources/mtls/test_key_2.pem diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java index 847eaa1cd421..a1d2fe07b3ca 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java @@ -32,7 +32,6 @@ package com.google.auth.oauth2; import com.google.api.client.json.GenericJson; -import com.google.api.core.InternalExtensionOnly; import com.google.auth.http.HttpTransportFactory; import com.google.common.annotations.VisibleForTesting; import com.google.errorprone.annotations.CanIgnoreReturnValue; @@ -124,12 +123,11 @@ public AccessToken refreshAccessToken() throws IOException { return refreshAccessToken(this.transportFactory); } - @InternalExtensionOnly @Override AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) throws IOException { ImpersonatedCredentials impersonated = getImpersonatedCredentials(); if (impersonated != null) { - return impersonated.refreshAccessToken(cycleTransportFactory); + return impersonated.refreshAccessToken(null); } StsTokenExchangeRequest.Builder stsTokenExchangeRequest = diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java index 073524254b9e..4c12124dc95e 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java @@ -36,7 +36,6 @@ import com.google.api.client.http.HttpHeaders; import com.google.api.client.json.GenericJson; import com.google.api.client.util.Data; -import com.google.api.core.InternalExtensionOnly; import com.google.auth.RequestMetadataCallback; import com.google.auth.http.HttpTransportFactory; import com.google.common.base.MoreObjects; @@ -86,7 +85,7 @@ public abstract class ExternalAccountCredentials extends GoogleCredentials { private final @Nullable String tokenInfoUrl; private final @Nullable String serviceAccountImpersonationUrl; - private transient @Nullable String targetServiceAccountEmail; + private final @Nullable String targetServiceAccountEmail; private final @Nullable String clientId; private final @Nullable String clientSecret; @@ -198,6 +197,7 @@ protected ExternalAccountCredentials( this.credentialSource = checkNotNull(credentialSource); this.tokenInfoUrl = tokenInfoUrl; this.serviceAccountImpersonationUrl = serviceAccountImpersonationUrl; + this.targetServiceAccountEmail = null; this.clientId = clientId; this.clientSecret = clientSecret; this.scopes = @@ -289,31 +289,22 @@ protected ExternalAccountCredentials(ExternalAccountCredentials.Builder builder) if (serviceAccountImpersonationUrl == null) { return null; } + String targetPrincipal = + ImpersonatedCredentials.extractTargetPrincipal(serviceAccountImpersonationUrl); // Create a copy of this instance without service account impersonation. - ExternalAccountCredentials sourceCredentials; + ExternalAccountCredentials.Builder sourceBuilder; if (this instanceof AwsCredentials) { - sourceCredentials = - AwsCredentials.newBuilder((AwsCredentials) this) - .setServiceAccountImpersonationUrl(null) - .setScopes(Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE)) - .build(); + sourceBuilder = AwsCredentials.newBuilder((AwsCredentials) this); } else if (this instanceof PluggableAuthCredentials) { - sourceCredentials = - PluggableAuthCredentials.newBuilder((PluggableAuthCredentials) this) - .setServiceAccountImpersonationUrl(null) - .setScopes(Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE)) - .build(); + sourceBuilder = PluggableAuthCredentials.newBuilder((PluggableAuthCredentials) this); } else { - sourceCredentials = - IdentityPoolCredentials.newBuilder((IdentityPoolCredentials) this) - .setServiceAccountImpersonationUrl(null) - .setScopes(Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE)) - .build(); + sourceBuilder = IdentityPoolCredentials.newBuilder((IdentityPoolCredentials) this); } - - String targetPrincipal = - ImpersonatedCredentials.extractTargetPrincipal(serviceAccountImpersonationUrl); - sourceCredentials.targetServiceAccountEmail = targetPrincipal; + sourceBuilder + .setServiceAccountImpersonationUrl(null) + .setScopes(Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE)); + sourceBuilder.targetServiceAccountEmail = targetPrincipal; + ExternalAccountCredentials sourceCredentials = sourceBuilder.build(); return ImpersonatedCredentials.newBuilder() .setSourceCredentials(sourceCredentials) .setHttpTransportFactory(transportFactory) @@ -529,29 +520,34 @@ private static boolean isAwsCredential(Map credentialSource) { && ((String) credentialSource.get("environment_id")).startsWith("aws"); } - private boolean shouldBuildImpersonatedCredential() { - return this.serviceAccountImpersonationUrl != null && this.impersonatedCredentials == null; - } - @Nullable ImpersonatedCredentials getImpersonatedCredentials() { - if (this.shouldBuildImpersonatedCredential()) { - this.impersonatedCredentials = this.buildImpersonatedCredentials(); + if (this.serviceAccountImpersonationUrl == null) { + return null; + } + ImpersonatedCredentials local = this.impersonatedCredentials; + if (local == null) { + synchronized (this) { + local = this.impersonatedCredentials; + if (local == null) { + local = this.buildImpersonatedCredentials(); + this.impersonatedCredentials = local; + } + } } - return this.impersonatedCredentials; + return local; } /** * Refreshes the access token using the specified transport factory for per-cycle transport - * pinning. Internal subclasses ({@link IdentityPoolCredentials}, {@link AwsCredentials}, {@link - * PluggableAuthCredentials}) delegate {@link #refreshAccessToken()} into this method. This - * default implementation delegates back to {@link #refreshAccessToken()} for any custom - * subclasses that do not override this method. + * pinning. {@link AwsCredentials} and {@link PluggableAuthCredentials} delegate {@link + * #refreshAccessToken()} into this method, while {@link IdentityPoolCredentials} coordinates + * per-cycle transport pinning and retries directly. This default implementation delegates back to + * {@link #refreshAccessToken()} for any custom subclasses that do not override this method. * * @param cycleTransportFactory the HTTP transport factory to use for this refresh cycle * @return the refreshed access token * @throws IOException if the token refresh fails */ - @InternalExtensionOnly AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) throws IOException { return refreshAccessToken(); } @@ -654,7 +650,6 @@ private void readObject(ObjectInputStream input) throws IOException, ClassNotFou // Properly deserialize the transient transportFactory. input.defaultReadObject(); transportFactory = newInstance(transportFactoryClassName); - impersonatedCredentials = null; } public @Nullable String getServiceAccountImpersonationUrl() { diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java index eef4414bbf5f..8b8286e985af 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java @@ -31,7 +31,6 @@ package com.google.auth.oauth2; -import com.google.api.core.InternalExtensionOnly; import com.google.auth.http.HttpTransportFactory; import com.google.auth.mtls.MtlsHttpTransportFactory; import com.google.auth.mtls.MtlsUtils; @@ -100,8 +99,9 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { "A subjectTokenSupplier or a credentialSource must be provided."); } - // Store the x509Provider for per-cycle cert pinning. + // Store the x509Provider and defaultMtlsTransportFactory for per-cycle cert pinning. this.x509Provider = builder.x509Provider; + this.defaultMtlsTransportFactory = builder.defaultMtlsTransportFactory; // Initialize based on the source type if (builder.subjectTokenSupplier != null) { @@ -110,25 +110,7 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { } else if (credentialSource.credentialSourceType == IdentityPoolCredentialSourceType.FILE) { if (credentialSource.getCertificateConfig() != null) { try { - X509Provider x509Provider = getX509Provider(builder, credentialSource); - this.x509Provider = x509Provider; - KeyStore mtlsKeyStore = x509Provider.getKeyStore(); - if (builder.transportFactory == null - || builder.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY - || builder.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory - || builder.transportFactory.getClass() == MtlsHttpTransportFactory.class - || (builder.defaultMtlsTransportFactory != null - && builder.transportFactory == builder.defaultMtlsTransportFactory)) { - this.transportFactory = createMtlsTransportFactory(mtlsKeyStore); - this.defaultMtlsTransportFactory = this.transportFactory; - } else if (!(builder.transportFactory instanceof MtlsHttpTransportFactory)) { - LOGGER_PROVIDER - .getLogger() - .debug( - "Custom HttpTransportFactory provided with certificate configuration; skipping" - + " automatic MtlsHttpTransportFactory upgrade. Ensure the custom transport" - + " factory is configured for mTLS if required by the token endpoint."); - } + initializeMtlsTransport(builder, credentialSource); } catch (Exception e) { throw new RuntimeException( "Failed to initialize mTLS transport for file credential source due to certificate" @@ -239,6 +221,8 @@ private boolean shouldUseMtlsTransportFactory() { || this.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY || this.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory || this.transportFactory.getClass() == MtlsHttpTransportFactory.class + || (this.transportFactory instanceof MtlsHttpTransportFactory + && !((MtlsHttpTransportFactory) this.transportFactory).hasKeyStore()) || (this.defaultMtlsTransportFactory != null && this.transportFactory == this.defaultMtlsTransportFactory); } @@ -252,16 +236,15 @@ public AccessToken refreshAccessToken() throws IOException { pinnedKeyStore = this.x509Provider.getKeyStore(); cycleTransportFactory = createMtlsTransportFactory(pinnedKeyStore); } - return refreshWithRetry(cycleTransportFactory, pinnedKeyStore, true); + return refreshWithRetry(cycleTransportFactory, pinnedKeyStore, /* allowRetry= */ true); } - @InternalExtensionOnly @Override AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) throws IOException { // Retry is intentionally disabled when an explicit cycleTransportFactory is supplied to // ensure transport synchronization across multi-step token exchanges (e.g. STS and IAM) // and prevent nested retry amplification. Outer callers manage retry coordination. - return refreshWithRetry(cycleTransportFactory, null, false); + return refreshWithRetry(cycleTransportFactory, null, /* allowRetry= */ false); } private AccessToken refreshWithRetry( @@ -272,7 +255,8 @@ private AccessToken refreshWithRetry( try { ImpersonatedCredentials impersonated = getImpersonatedCredentials(); if (impersonated != null) { - return impersonated.refreshAccessToken(cycleTransportFactory); + return impersonated.refreshAccessToken( + pinnedKeyStore != null ? cycleTransportFactory : null); } // Read subject and actor tokens, atomically if from the same file supplier. @@ -324,9 +308,7 @@ && shouldUseMtlsTransportFactory()) { } catch (Exception reloadException) { IOException ioException = new IOException("Failed to reload certificate on retry", reloadException); - if (reloadException != e) { - ioException.addSuppressed(e); - } + ioException.addSuppressed(e); throw ioException; } @@ -336,7 +318,7 @@ && shouldUseMtlsTransportFactory()) { try { HttpTransportFactory retryTransportFactory = createMtlsTransportFactory(freshKeyStore); - return refreshWithRetry(retryTransportFactory, freshKeyStore, false); + return refreshWithRetry(retryTransportFactory, freshKeyStore, /* allowRetry= */ false); } catch (IOException | RuntimeException retryException) { if (retryException != e) { retryException.addSuppressed(e); @@ -407,18 +389,12 @@ public Builder toBuilder() { return new Builder(this); } - private IdentityPoolSubjectTokenSupplier createCertificateSubjectTokenSupplier( + private void initializeMtlsTransport( Builder builder, IdentityPoolCredentialSource credentialSource) throws IOException { - // Configure the mTLS transport with the x509 keystore if custom transport was not provided. X509Provider x509Provider = getX509Provider(builder, credentialSource); this.x509Provider = x509Provider; KeyStore mtlsKeyStore = x509Provider.getKeyStore(); - if (builder.transportFactory == null - || builder.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY - || builder.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory - || builder.transportFactory.getClass() == MtlsHttpTransportFactory.class - || (builder.defaultMtlsTransportFactory != null - && builder.transportFactory == builder.defaultMtlsTransportFactory)) { + if (builder.transportFactory == null || shouldUseMtlsTransportFactory()) { this.transportFactory = createMtlsTransportFactory(mtlsKeyStore); this.defaultMtlsTransportFactory = this.transportFactory; } else if (!(builder.transportFactory instanceof MtlsHttpTransportFactory)) { @@ -429,6 +405,12 @@ private IdentityPoolSubjectTokenSupplier createCertificateSubjectTokenSupplier( + " automatic MtlsHttpTransportFactory upgrade. Ensure the custom transport" + " factory is configured for mTLS if required by the token endpoint."); } + } + + private IdentityPoolSubjectTokenSupplier createCertificateSubjectTokenSupplier( + Builder builder, IdentityPoolCredentialSource credentialSource) throws IOException { + // Configure the mTLS transport with the x509 keystore if custom transport was not provided. + initializeMtlsTransport(builder, credentialSource); // Initialize the subject token supplier with the certificate path. String explicitCertConfigPath = getExplicitCertConfigPath(credentialSource); @@ -462,9 +444,7 @@ private void readObject(ObjectInputStream input) throws IOException, ClassNotFou new X509Provider(getEnvironmentProvider(), getPropertyProvider(), explicitCertConfigPath); try { KeyStore mtlsKeyStore = this.x509Provider.getKeyStore(); - if (shouldUseMtlsTransportFactory() - || (this.transportFactory instanceof MtlsHttpTransportFactory - && !((MtlsHttpTransportFactory) this.transportFactory).hasKeyStore())) { + if (shouldUseMtlsTransportFactory()) { this.transportFactory = createMtlsTransportFactory(mtlsKeyStore); this.defaultMtlsTransportFactory = this.transportFactory; } @@ -518,10 +498,9 @@ public static class Builder extends ExternalAccountCredentials.Builder { this.actorTokenSupplier = credentials.actorTokenSupplier; } // Note: when credentialSource is present, subjectTokenSupplier and file-based - // actorTokenSupplier - // are intentionally NOT copied here. They will be reconstructed from credentialSource - // during build(), which ensures they share the same FileIdentityPoolSubjectTokenSupplier - // instance for atomic token reads. + // actorTokenSupplier are intentionally NOT copied here. They will be reconstructed from + // credentialSource during build(), which ensures they share the same + // FileIdentityPoolSubjectTokenSupplier instance for atomic token reads. this.actorTokenType = credentials.actorTokenType; this.x509Provider = credentials.x509Provider; this.defaultMtlsTransportFactory = credentials.defaultMtlsTransportFactory; diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java index e079a1641cb1..ea557c62f7b0 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java @@ -46,7 +46,6 @@ import com.google.api.client.util.GenericData; import com.google.api.core.ObsoleteApi; import com.google.auth.CredentialTypeForMetrics; -import com.google.auth.Credentials; import com.google.auth.ServiceAccountSigner; import com.google.auth.http.HttpCredentialsAdapter; import com.google.auth.http.HttpTransportFactory; @@ -601,41 +600,40 @@ public AccessToken refreshAccessToken() throws IOException { AccessToken refreshAccessToken(@Nullable HttpTransportFactory cycleTransportFactory) throws IOException { HttpTransportFactory effectiveTransportFactory = - cycleTransportFactory != null - ? cycleTransportFactory - : (this.transportFactory != null - ? this.transportFactory - : OAuth2Utils.HTTP_TRANSPORT_FACTORY); + firstNonNull(cycleTransportFactory, this.transportFactory); HttpCredentialsAdapter adapter; if (this.sourceCredentials instanceof ExternalAccountCredentials) { - Collection currentScopes = - ((ExternalAccountCredentials) this.sourceCredentials).getScopes(); - if (currentScopes == null || !currentScopes.contains(OAuth2Utils.CLOUD_PLATFORM_SCOPE)) { - this.sourceCredentials = - this.sourceCredentials.createScoped( - Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE)); + ExternalAccountCredentials externalSource; + synchronized (this) { + Collection currentScopes = + ((ExternalAccountCredentials) this.sourceCredentials).getScopes(); + if (currentScopes == null || !currentScopes.contains(OAuth2Utils.CLOUD_PLATFORM_SCOPE)) { + List updatedScopes = + currentScopes != null ? new ArrayList<>(currentScopes) : new ArrayList<>(); + updatedScopes.add(OAuth2Utils.CLOUD_PLATFORM_SCOPE); + this.sourceCredentials = this.sourceCredentials.createScoped(updatedScopes); + } + externalSource = (ExternalAccountCredentials) this.sourceCredentials; } if (cycleTransportFactory == null) { try { - this.sourceCredentials.refreshIfExpired(); + externalSource.refreshIfExpired(); } catch (IOException e) { throw new IOException("Unable to refresh sourceCredentials", e); } - adapter = new HttpCredentialsAdapter(this.sourceCredentials); + adapter = new HttpCredentialsAdapter(externalSource); } else { AccessToken intermediateAccessToken; try { - intermediateAccessToken = - ((ExternalAccountCredentials) this.sourceCredentials) - .refreshAccessToken(effectiveTransportFactory); + intermediateAccessToken = externalSource.refreshAccessToken(effectiveTransportFactory); } catch (IOException e) { throw new IOException("Unable to refresh sourceCredentials", e); } - Credentials authCredentials = + GoogleCredentials authCredentials = new GoogleCredentials( GoogleCredentials.newBuilder() - .setQuotaProjectId(this.sourceCredentials.getQuotaProjectId()) - .setUniverseDomain(this.sourceCredentials.getUniverseDomain())) { + .setQuotaProjectId(externalSource.getQuotaProjectId()) + .setUniverseDomain(externalSource.getUniverseDomain())) { @Override public AccessToken refreshAccessToken() { return intermediateAccessToken; @@ -709,14 +707,14 @@ public AccessToken refreshAccessToken() { try { LoggingUtils.logRequest(request, LOGGER_PROVIDER, "Sending request to refresh access token"); response = request.execute(); - LoggingUtils.logResponse( - response, LOGGER_PROVIDER, "Received response for refresh access token"); } catch (IOException e) { throw new IOException("Error requesting access token", e); } GenericData responseData; try { + LoggingUtils.logResponse( + response, LOGGER_PROVIDER, "Received response for refresh access token"); responseData = response.parseAs(GenericData.class); LoggingUtils.logResponsePayload( responseData, LOGGER_PROVIDER, "Response payload for access token"); diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java index 8549ff90fb8f..b7386ca0c3ea 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java @@ -58,9 +58,10 @@ import java.math.BigDecimal; import java.net.URI; import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.Key; import java.security.KeyFactory; import java.security.KeyStore; -import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; import java.security.cert.Certificate; @@ -335,6 +336,9 @@ static String generateBasicAuthHeader(String username, String password) { * Returns whether the given throwable or any exception in its causal chain represents a 401 * Unauthorized error (either an {@link OAuthException} or {@link HttpResponseException} with * status code 401). + * + * @param t the throwable to inspect + * @return {@code true} if {@code t} or any cause in its chain is a 401 error */ static boolean isUnauthorizedException(@Nullable Throwable t) { while (t != null) { @@ -355,8 +359,13 @@ static boolean isUnauthorizedException(@Nullable Throwable t) { } /** - * Returns whether the certificate chain in {@code newKeyStore} differs from {@code oldKeyStore}. - * Used on 401 retry recovery to avoid retrying when the reloaded certificate is unchanged. + * Returns whether the certificate chain or private key in {@code newKeyStore} differs from {@code + * oldKeyStore}. Used on 401 retry recovery to avoid retrying when the reloaded certificate and + * key are unchanged. + * + * @param oldKeyStore the previously loaded keystore + * @param newKeyStore the newly reloaded keystore + * @return {@code true} if the certificates or keys differ, or if either keystore cannot be read */ static boolean hasCertificateChanged( @Nullable KeyStore oldKeyStore, @Nullable KeyStore newKeyStore) { @@ -366,13 +375,16 @@ static boolean hasCertificateChanged( if (oldKeyStore == null || newKeyStore == null) { return true; } - List oldCerts = getCertificates(oldKeyStore); - List newCerts = getCertificates(newKeyStore); - return !oldCerts.equals(newCerts); + List oldEntries = getKeyStoreEntries(oldKeyStore); + List newEntries = getKeyStoreEntries(newKeyStore); + if (oldEntries == null || newEntries == null) { + return true; + } + return !oldEntries.equals(newEntries); } - private static List getCertificates(KeyStore keyStore) { - List certs = new ArrayList<>(); + private static @Nullable List getKeyStoreEntries(KeyStore keyStore) { + List entries = new ArrayList<>(); try { Enumeration aliases = keyStore.aliases(); if (aliases != null) { @@ -381,19 +393,25 @@ private static List getCertificates(KeyStore keyStore) { for (String alias : aliasList) { Certificate[] chain = keyStore.getCertificateChain(alias); if (chain != null && chain.length > 0) { - Collections.addAll(certs, chain); + Collections.addAll(entries, chain); } else { Certificate cert = keyStore.getCertificate(alias); if (cert != null) { - certs.add(cert); + entries.add(cert); + } + } + if (keyStore.isKeyEntry(alias)) { + Key key = keyStore.getKey(alias, "".toCharArray()); + if (key != null) { + entries.add(key); } } } } - } catch (KeyStoreException e) { - // If a KeyStore cannot be inspected, treat its certificates as empty + } catch (GeneralSecurityException e) { + return null; } - return certs; + return entries; } private OAuth2Utils() {} diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java index 4c928ffb40e0..f9bea654470d 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java @@ -31,7 +31,6 @@ package com.google.auth.oauth2; -import com.google.api.core.InternalExtensionOnly; import com.google.auth.http.HttpTransportFactory; import com.google.auth.oauth2.ExecutableHandler.ExecutableOptions; import com.google.common.annotations.VisibleForTesting; @@ -125,12 +124,11 @@ public AccessToken refreshAccessToken() throws IOException { return refreshAccessToken(this.transportFactory); } - @InternalExtensionOnly @Override AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) throws IOException { ImpersonatedCredentials impersonated = getImpersonatedCredentials(); if (impersonated != null) { - return impersonated.refreshAccessToken(cycleTransportFactory); + return impersonated.refreshAccessToken(null); } String credential = retrieveSubjectToken(); diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java index 3064d993a4e9..4ea4d133f959 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java @@ -165,10 +165,19 @@ void refreshAccessToken_withServiceAccountImpersonation() throws IOException { assertEquals( transportFactory.transport.getServiceAccountAccessToken(), accessToken.getTokenValue()); - // Validate metrics header is set correctly on the sts request. + // Requests 0..2 are the AWS metadata calls from sourceCredentials (no duplicate outer fetch), + // request 3 is the STS token exchange, and request 4 is the IAM generateAccessToken call. + assertEquals(5, transportFactory.transport.getRequests().size()); + assertEquals( + transportFactory.transport.getStsUrl(), + transportFactory.transport.getRequests().get(3).getUrl()); Map> headers = transportFactory.transport.getRequests().get(3).getHeaders(); ExternalAccountCredentialsTest.validateMetricsHeader(headers, "aws", true, false); + + // A second refresh while the intermediate STS token is still valid should only call IAM. + awsCredential.refreshAccessToken(); + assertEquals(6, transportFactory.transport.getRequests().size()); } @Test @@ -1246,6 +1255,9 @@ void serialize() throws IOException, ClassNotFoundException { assertEquals(testCredentials.hashCode(), deserializedCredentials.hashCode()); assertEquals(testCredentials.toString(), deserializedCredentials.toString()); assertSame(Clock.SYSTEM, deserializedCredentials.clock); + assertNotNull(deserializedCredentials.getServiceAccountEmail()); + assertEquals( + testCredentials.getServiceAccountEmail(), deserializedCredentials.getServiceAccountEmail()); } /** diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java index 26b92cafe45c..70963d600065 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java @@ -107,65 +107,6 @@ class IdentityPoolCredentialsTest extends BaseSerializationTest { private static final IdentityPoolActorTokenSupplier testActorSupplier = (ExternalAccountSupplierContext context) -> "testActorToken"; - private static final String ROTATED_CERT_AND_KEY_PEM = - "-----BEGIN CERTIFICATE-----\n" - + "MIIDDzCCAfegAwIBAgIUcbzNP4BjFtH2pLfSr1KMZClf5eQwDQYJKoZIhvcNAQEL\n" - + "BQAwFzEVMBMGA1UEAwwMcm90YXRlZC1jZXJ0MB4XDTI2MDkxNzE2NDk1NFoXDTM2\n" - + "MDkxNDE2NDk1NFowFzEVMBMGA1UEAwwMcm90YXRlZC1jZXJ0MIIBIjANBgkqhkiG\n" - + "9w0BAQEFAAOCAQ8AMIIBCgKCAQEAi3vzaruGAex4T/FSHqzh+80RT//gWhGpm/JG\n" - + "tyK2hr54ExO5kzSeZDo+VzIJBhTdg9lf8USPTgsXcC3SNatMtWRBOMu9hg/NKLrg\n" - + "S+bCYw0iw6Wzy59XuWn+XcphD/SNUsO3Oas9vg1uj6H3BNWUuLsrPgfDYyIBtBrN\n" - + "6HEWHH7fl7/Nz8lUyj0Pv/uiAKF7bZyMeDv8Jwlv8yRVaEFpjlImWhKb+bCqPUYh\n" - + "adLI33aHF1npy1Jg1LWxecTP+VhvoFY6HJscIDJm47ENUtBSmrNKN2WJUVU7nhHw\n" - + "MYOKwXivm5J6HwxhK9rw2ifAJPStwGW0SNn0wSajvp66i5TINQIDAQABo1MwUTAd\n" - + "BgNVHQ4EFgQUI+rMQW4pBZOnwo51UrCXVFWlJ3swHwYDVR0jBBgwFoAUI+rMQW4p\n" - + "BZOnwo51UrCXVFWlJ3swDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOC\n" - + "AQEAL6LIJbZec8PNCaA176J6C7QW03ZWCgp2GSxb5V42kjgVMyqn5mrez7DQy1UY\n" - + "aDi4/n+OMOAWiJ1qWyYPe8xEKcYtG2sPkAs53wRoY8cbKYOxHr1JQkWh2v7gAwr0\n" - + "WpYsW60mGqAFjiqZz6S2xBdVRwTZ2dvONFMuJBw4JlJFFdxGU5XT3/XvGcvx5UK5\n" - + "2MzYuXkGDr3zTaLMwyBgi3paRs+46POtPZX/i4zUtpaGSG7HDAkCVWK4JMcbKiPk\n" - + "I/vV55YKOblwu8hk6qOyxbX4sSsaCXllH7YWryiyTwBOQjlUqNqdwfxe/jezdeGG\n" - + "OLTM9LO1/oNvD/2RpCH/5D2+fw==\n" - + "-----END CERTIFICATE-----\n" - + "-----BEGIN PRIVATE KEY-----\n" - + "MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCLe/Nqu4YB7HhP\n" - + "8VIerOH7zRFP/+BaEamb8ka3IraGvngTE7mTNJ5kOj5XMgkGFN2D2V/xRI9OCxdw\n" - + "LdI1q0y1ZEE4y72GD80ouuBL5sJjDSLDpbPLn1e5af5dymEP9I1Sw7c5qz2+DW6P\n" - + "ofcE1ZS4uys+B8NjIgG0Gs3ocRYcft+Xv83PyVTKPQ+/+6IAoXttnIx4O/wnCW/z\n" - + "JFVoQWmOUiZaEpv5sKo9RiFp0sjfdocXWenLUmDUtbF5xM/5WG+gVjocmxwgMmbj\n" - + "sQ1S0FKas0o3ZYlRVTueEfAxg4rBeK+bknofDGEr2vDaJ8Ak9K3AZbRI2fTBJqO+\n" - + "nrqLlMg1AgMBAAECggEAOetS5Q+QMk1MmjmFVYqJXiNFnJgOQ6hQ6xocBiDKdUIz\n" - + "HwzSQs+XM9xBlbiHqbhRUVYaslc7QHd3mJPWVYXXmPzT3m8vuDLoiJCs4aelMTc7\n" - + "p80vTw7QAQSD5NNMIbF1W5g8hZxXS4tNTSQ+rAm6M0k5SA02M3xkA7MbrHkE6vig\n" - + "/NgJ/9qZTMLIbSgQnflPKsGkv8kaXAdh/6APXnIM0pfBf5Fu7SXDUucsLPLRPkiS\n" - + "CmI062OW5/MEKehof1nuzzgXbR80yjuttIDRN1g4XSRJav2WePDxet2hTjnMaOxL\n" - + "hB8BDUMoUw5wi23nAzZgjHaxCpVDD+crBiflR5crkwKBgQDDvpY1mALyHr2Z2a0u\n" - + "bapwN+xhIv5MAq5zAt/mxQ4u8lxlJfemX1ulN4ZVxgqMsyGHHdFXS3dnhgs299Y0\n" - + "cAT6Fd0rxorRo/S/0F6G+iGbZQbFCO7HB3tpQZ06VWEF16xow10jgSoOIU05iitl\n" - + "sJbB2BuNjrHYuV6RpkcXme4UTwKBgQC2a9ZxKlJmMbZ9g+dS13t3VZISOxX/hvol\n" - + "fN1+vg2tkTnKaPgYxA0A/W28k++cgW4syS7ysNe9X9NApmERvSVJoI1g8BlQLVuh\n" - + "AZXHXK5cZknSB7iBZxuT/Ag55QE3gA0FipJHYSLDHYeoLXskiWXBUq1MHPOsSC4q\n" - + "pQHkNK/GOwKBgQCfy62aUN9OwvOrbj1nopU6CR1KayPH74R0VYttO78JakctN6KF\n" - + "SmFpbfuXeBXSqMWdJSVpqyzt8UqkdAyFQFF/y2uDuhBHdh5unG8ep4HZ9s5g+Zrc\n" - + "FeqUkcEGBv8uotOXrq0RN/eaE2uUpowo9tELrB1KIYxkTWe7ZU+yH7JxFwKBgQCH\n" - + "PQcrul6AGNbb0pAKIGoOHEhAb8FtQNnuNNXYgnmNdZ7MammTorSpSTizl1EKTAIr\n" - + "/bJqhaRLZuEsiqxoBDvCi96EQTvi7t2BTbWGqTUylzqfFM46UQBnA2/ty9LNHIeK\n" - + "1iJ//IlS8W+CxMUIXzwqyGplhQk5bgGb59yxHEY7xQKBgQCVaR/0DYDsmryP5Ntt\n" - + "uQjSYMKRCv/7ABegPcocQdLNbr+KvzB8dQUm+QRdBXUMWS69eTgwb4f7F5ilMCi6\n" - + "oPJwwyKpnYzxSxWaQQOFRB3L6b1w7MFMO7TV+5ZcFvIpTRkGqi1NwMFUMdlwQcjG\n" - + "7dyMDd4JN8ac/jwHngxJidcNGg==\n" - + "-----END PRIVATE KEY-----\n"; - - private static final String ROTATED_CERT_PEM = - ROTATED_CERT_AND_KEY_PEM.substring( - 0, - ROTATED_CERT_AND_KEY_PEM.indexOf("-----END CERTIFICATE-----") - + "-----END CERTIFICATE-----\n".length()); - - private static final String ROTATED_KEY_PEM = - ROTATED_CERT_AND_KEY_PEM.substring( - ROTATED_CERT_AND_KEY_PEM.indexOf("-----BEGIN PRIVATE KEY-----")); - static KeyStore createPopulatedKeyStore() { try (InputStream certStream = new FileInputStream(new File("testresources/mtls/test_cert.pem")); @@ -178,9 +119,11 @@ static KeyStore createPopulatedKeyStore() { } static KeyStore createRotatedPopulatedKeyStore() { - try (InputStream stream = - new ByteArrayInputStream(ROTATED_CERT_AND_KEY_PEM.getBytes(StandardCharsets.UTF_8))) { - return SecurityUtils.createMtlsKeyStore(stream); + try (InputStream certStream = + new FileInputStream(new File("testresources/mtls/test_cert_2.pem")); + InputStream keyStream = new FileInputStream(new File("testresources/mtls/test_key_2.pem")); + InputStream combined = new SequenceInputStream(certStream, keyStream)) { + return SecurityUtils.createMtlsKeyStore(combined); } catch (Exception e) { throw new RuntimeException("Failed to create rotated test KeyStore", e); } @@ -3176,8 +3119,13 @@ protected AccessToken exchangeExternalCredentialForAccessToken( HttpTransportFactory cycleTransportFactory) throws IOException { if (exchangeCount.incrementAndGet() == 1) { - Files.write(certFile, ROTATED_CERT_PEM.getBytes(StandardCharsets.UTF_8)); - Files.write(keyFile, ROTATED_KEY_PEM.getBytes(StandardCharsets.UTF_8)); + Files.write( + certFile, + Files.readAllBytes( + java.nio.file.Paths.get("testresources/mtls/test_cert_2.pem"))); + Files.write( + keyFile, + Files.readAllBytes(java.nio.file.Paths.get("testresources/mtls/test_key_2.pem"))); throw new OAuthException("invalid_client", "Unauthorized", null, 401); } return new AccessToken("rotatedRetryToken", null); @@ -4050,11 +3998,16 @@ public static class CustomMtlsHttpTransportFactory extends MtlsHttpTransportFact public CustomMtlsHttpTransportFactory() { super(); } + + public CustomMtlsHttpTransportFactory(KeyStore keyStore) { + super(keyStore); + } } @Test - void customMtlsHttpTransportFactorySubclass_preservedInConstructorAndRefreshAndDeserialization() - throws Exception { + void + customMtlsHttpTransportFactorySubclass_preservedInConstructorAndRefresh_rebuiltOnDeserialization() + throws Exception { Map certificateMap = new HashMap<>(); certificateMap.put("use_default_certificate_config", false); certificateMap.put("certificate_config_location", "testresources/mtls/certificate_config.json"); @@ -4064,8 +4017,8 @@ void customMtlsHttpTransportFactorySubclass_preservedInConstructorAndRefreshAndD IdentityPoolCredentialSource credentialSource = new IdentityPoolCredentialSource(credentialSourceMap); - CustomMtlsHttpTransportFactory customFactory = new CustomMtlsHttpTransportFactory(); KeyStore ks = createPopulatedKeyStore(); + CustomMtlsHttpTransportFactory customFactory = new CustomMtlsHttpTransportFactory(ks); X509Provider x509Provider = new TestX509Provider(ks, "certificate_config_location"); List capturedCycleFactories = new ArrayList<>(); @@ -4108,19 +4061,19 @@ protected AccessToken exchangeExternalCredentialForAccessToken( .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") .build(); IdentityPoolCredentials deserialized = serializeAndDeserialize(regularCredentials); - assertTrue( - deserialized.getTransportFactory() instanceof MtlsHttpTransportFactory, - "readObject must restore an MtlsHttpTransportFactory"); + assertEquals( + MtlsHttpTransportFactory.class, + deserialized.getTransportFactory().getClass(), + "readObject must rebuild a base MtlsHttpTransportFactory when transient KeyStore is lost"); assertTrue( ((MtlsHttpTransportFactory) deserialized.getTransportFactory()).hasKeyStore(), "readObject must restore a KeyStore-backed MtlsHttpTransportFactory"); } @Test - void fileCredentialSourceWithCertConfig_overriddenCreateMtlsTransportFactory_rotatesPerCycle() - throws Exception { - File tokenFile = File.createTempFile("subject_token", ".txt"); - tokenFile.deleteOnExit(); + void fileCredentialSourceWithCertConfig_overriddenCreateMtlsTransportFactory_rotatesPerCycle( + @TempDir Path tempDir) throws Exception { + File tokenFile = tempDir.resolve("subject_token.txt").toFile(); Files.write(tokenFile.toPath(), "test-subject-token".getBytes(StandardCharsets.UTF_8)); Map certificateMap = new HashMap<>(); @@ -4193,4 +4146,128 @@ public LowLevelHttpResponse execute() { assertEquals("rotated-sts-token", token.getTokenValue()); assertEquals(Arrays.asList(ksA, ksB), requestKeyStores); } + + @Test + void + refreshAccessToken_whenKeyStoreReloadThrowsRuntimeException_wrapsInIOExceptionAndSuppresses401() + throws Exception { + KeyStore ks1 = createPopulatedKeyStore(); + AtomicInteger getKeyStoreCount = new AtomicInteger(0); + X509Provider x509Provider = + new X509Provider(null) { + @Override + public KeyStore getKeyStore() { + if (getKeyStoreCount.incrementAndGet() == 1) { + return ks1; + } + throw new IllegalStateException("Unexpected keystore provider failure"); + } + }; + + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(x509Provider) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token")) { + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) + throws IOException { + throw new OAuthException("invalid_client", "Unauthorized", null, 401); + } + }; + + IOException thrown = assertThrows(IOException.class, credential::refreshAccessToken); + assertEquals("Failed to reload certificate on retry", thrown.getMessage()); + assertTrue(thrown.getCause() instanceof IllegalStateException); + assertEquals("Unexpected keystore provider failure", thrown.getCause().getMessage()); + assertEquals(1, thrown.getSuppressed().length); + assertTrue(thrown.getSuppressed()[0] instanceof OAuthException); + } + + @Test + void refreshAccessToken_impersonation_stsReturns401_retriesOnceViaOuterCycle() throws Exception { + KeyStore ks1 = createPopulatedKeyStore(); + KeyStore ks2 = createRotatedPopulatedKeyStore(); + AtomicInteger getKeyStoreCallCount = new AtomicInteger(0); + X509Provider x509Provider = + new X509Provider(null) { + @Override + public KeyStore getKeyStore() { + return getKeyStoreCallCount.incrementAndGet() == 1 ? ks1 : ks2; + } + }; + + AtomicInteger stsCallCount = new AtomicInteger(0); + AtomicInteger iamCallCount = new AtomicInteger(0); + HttpTransport mockTransport = + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() throws IOException { + if (url.contains("/v1/token")) { + int count = stsCallCount.incrementAndGet(); + if (count == 1) { + return new MockLowLevelHttpResponse() + .setStatusCode(401) + .setContentType(Json.MEDIA_TYPE) + .setContent( + "{\"error\":\"invalid_client\",\"error_description\":\"Cert mismatch\"}"); + } + 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", "urn:ietf:params:oauth:token-type:access_token"); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } else if (url.contains(":generateAccessToken")) { + int count = iamCallCount.incrementAndGet(); + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("accessToken", "final-iam-token-" + count); + response.put("expireTime", "2030-01-01T00:00:00Z"); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } + return new MockLowLevelHttpResponse().setStatusCode(404); + } + }; + } + }; + + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(x509Provider) + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .setServiceAccountImpersonationUrl( + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken")) { + @Override + HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { + return () -> mockTransport; + } + }; + + AccessToken token = credential.refreshAccessToken(); + assertEquals("final-iam-token-1", token.getTokenValue()); + // STS must be called only twice (initial attempt + 1 outer retry), not 4 times. + assertEquals(2, stsCallCount.get()); + assertEquals(1, iamCallCount.get()); + assertEquals(2, getKeyStoreCallCount.get()); + } } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java index 883b9e8a5460..cd33872d146c 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java @@ -1528,10 +1528,12 @@ public AccessToken refreshAccessToken() { AccessToken token = impersonated.refreshAccessToken(); assertEquals("final-iam-token", token.getTokenValue()); - // Verify STS request received cloud-platform scope + // Verify STS request preserved existing source scope and added cloud-platform scope String stsContent = stsTransport.getRequests().get(0).getContentAsString(); Map stsParams = TestUtils.parseQuery(stsContent); - assertEquals(OAuth2Utils.CLOUD_PLATFORM_SCOPE, stsParams.get("scope")); + assertEquals( + "https://www.googleapis.com/auth/devstorage.read_only " + OAuth2Utils.CLOUD_PLATFORM_SCOPE, + stsParams.get("scope")); // Verify IAM request received the target bigquery scope assertTrue( diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuth2UtilsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuth2UtilsTest.java index b5f2c8f9917a..ded8b0af220f 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuth2UtilsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuth2UtilsTest.java @@ -172,4 +172,36 @@ void hasCertificateChanged_distinctKeyStoreInstances_comparesCertificates() { assertFalse(OAuth2Utils.hasCertificateChanged(ks1, ks2)); assertTrue(OAuth2Utils.hasCertificateChanged(ks1, ksRotated)); } + + @Test + void hasCertificateChanged_sameCertificateDifferentPrivateKey_returnsTrue() throws Exception { + byte[] certBytes = + java.nio.file.Files.readAllBytes( + java.nio.file.Paths.get("testresources/mtls/test_cert.pem")); + byte[] key1Bytes = + java.nio.file.Files.readAllBytes( + java.nio.file.Paths.get("testresources/mtls/test_key.pem")); + byte[] key2Bytes = + java.nio.file.Files.readAllBytes( + java.nio.file.Paths.get("testresources/mtls/test_key_2.pem")); + + KeyStore ks1 = + com.google.api.client.util.SecurityUtils.createMtlsKeyStore( + new java.io.ByteArrayInputStream( + com.google.common.primitives.Bytes.concat(certBytes, "\n".getBytes(), key1Bytes))); + KeyStore ks2 = + com.google.api.client.util.SecurityUtils.createMtlsKeyStore( + new java.io.ByteArrayInputStream( + com.google.common.primitives.Bytes.concat(certBytes, "\n".getBytes(), key2Bytes))); + + assertTrue(OAuth2Utils.hasCertificateChanged(ks1, ks2)); + } + + @Test + void hasCertificateChanged_uninitializedKeyStore_returnsTrue() throws Exception { + KeyStore uninitialized1 = KeyStore.getInstance(KeyStore.getDefaultType()); + KeyStore uninitialized2 = KeyStore.getInstance(KeyStore.getDefaultType()); + + assertTrue(OAuth2Utils.hasCertificateChanged(uninitialized1, uninitialized2)); + } } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java index 0675d4430f6d..028f155b787a 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java @@ -226,16 +226,24 @@ void refreshAccessToken_withServiceAccountImpersonation() throws IOException { .build(); final ExecutableOptions[] providedOptions = {null}; + final int[] executableCallCount = {0}; credential = PluggableAuthCredentials.newBuilder(credential) .setExecutableHandler( options -> { + executableCallCount[0]++; providedOptions[0] = options; return "pluggableAuthToken"; }) .build(); AccessToken accessToken = credential.refreshAccessToken(); + assertEquals(1, executableCallCount[0]); + + // A second refresh while the intermediate STS token is still valid should reuse the cached + // sourceCredentials token without re-running the executable. + credential.refreshAccessToken(); + assertEquals(1, executableCallCount[0]); assertEquals( credential.getServiceAccountEmail(), diff --git a/google-auth-library-java/oauth2_http/testresources/mtls/test_cert_2.pem b/google-auth-library-java/oauth2_http/testresources/mtls/test_cert_2.pem new file mode 100644 index 000000000000..3198bfa7cc82 --- /dev/null +++ b/google-auth-library-java/oauth2_http/testresources/mtls/test_cert_2.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDSzCCAjOgAwIBAgIUGdqGf10PtKM9CrhxJjJWo1bwBrowDQYJKoZIhvcNAQEL +BQAwNDEyMDAGA1UEAwwpcm90YXRlZC1jbGllbnQuYXBwcy5nb29nbGV1c2VyY29u +dGVudC5jb20wIBcNMjYwOTE4MTk1NjM4WhgPMjEyNjA4MjUxOTU2MzhaMDQxMjAw +BgNVBAMMKXJvdGF0ZWQtY2xpZW50LmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29t +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyKMqPCwcqeoVtrrdfhsf +wDldPteZyTi2e9dRWQB+H0GGoTfwI5edvXBKc+ACIpoyl/fEsBLsGYhZqIyGYl90 +pVnHnLYoP4gppoeR9L1wIfY/t8AFCQb+ko9c31gVJLlHCmtPuBf9B/yRrnmwJDXe +BNEuPALdcq9y8ZklDoF+QWXiw0SoZ7HbhhgumK3juNfeOqqxaGt7JGWoZ5ub+Nv/ +fEikINB9gHyUeW33FPoUDIhwDQYRLuyNytKX6tnTHf1o5qIF9ELBBpu6T4U9/1F+ +jD9Wx0XPW59wvcu6f7k/mOswxBdJKNsTfqWeJ0iOn0mZgePmuZJWCsC3P0QPDrwe +KwIDAQABo1MwUTAdBgNVHQ4EFgQUDoOQ6j/nPz2bPi9DzMKijXnQYg0wHwYDVR0j +BBgwFoAUDoOQ6j/nPz2bPi9DzMKijXnQYg0wDwYDVR0TAQH/BAUwAwEB/zANBgkq +hkiG9w0BAQsFAAOCAQEAXf3anEUWyQfoet0e/diBeSvBAbMtG58nxB8q8JVzp4bD +4A+P9AkdS2fNTcOyuVQSQep+cviDXGTFQaAdP+6IHWUFus7bTtGV1VYWOuyTrlsh +8+PKOoAHASloJUUR8aXT0jRE+CEToZH0YnRrXr3d0UePd7BSD6lDlcpvmWpRuAbM +kJZTfOmmDavAt8SWq6fHqCt15PGrCQvyxbOnmMfDHARxH/ysJrs6dCzzNzcBqMAl +XqL8Mzcc+SwqfY4yX5RGDzFfPcwQ8yQKsM4nRYVTL1oGQDbaJXbd34OeIh5VdCmz +pgiAwbu3ubifmfGPBgtuL5FOBGYiXx++EX/+i3QskQ== +-----END CERTIFICATE----- diff --git a/google-auth-library-java/oauth2_http/testresources/mtls/test_key_2.pem b/google-auth-library-java/oauth2_http/testresources/mtls/test_key_2.pem new file mode 100644 index 000000000000..a7e7f18f50b1 --- /dev/null +++ b/google-auth-library-java/oauth2_http/testresources/mtls/test_key_2.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDIoyo8LByp6hW2 +ut1+Gx/AOV0+15nJOLZ711FZAH4fQYahN/Ajl529cEpz4AIimjKX98SwEuwZiFmo +jIZiX3SlWcectig/iCmmh5H0vXAh9j+3wAUJBv6Sj1zfWBUkuUcKa0+4F/0H/JGu +ebAkNd4E0S48At1yr3LxmSUOgX5BZeLDRKhnsduGGC6YreO41946qrFoa3skZahn +m5v42/98SKQg0H2AfJR5bfcU+hQMiHANBhEu7I3K0pfq2dMd/WjmogX0QsEGm7pP +hT3/UX6MP1bHRc9bn3C9y7p/uT+Y6zDEF0ko2xN+pZ4nSI6fSZmB4+a5klYKwLc/ +RA8OvB4rAgMBAAECggEATUph5k09+4zUMZD5628Kg7P1glRvB0/EKJ/xU/53F5ZY +BTo11Zg+KXCdFddpKyhIrjdB+2xGrK1UkLQVvsaz+lDEL1AS4CqVlPPr26KwlDy3 +HhOoPOqHXjFBHz7g4kVHozFkw+/lx1JaUqLJIqHnteZZT7/wzBHfj0emOVjUKN+m +RdtZrl23pP/QI3xVqFlfbzkA+S3RFQ0sxJx2x/P4P8XCTlwE1vqhQ5kB4UzeT68U +iF3hLvGLQ3mWnByPbHOGMOhizdLc2YNw/hyuM37K885BGDYMsNduSUGCe/4E5IHi +QgaRNHr2bZ10TgYIOYb0gEHNLxqHHyAV6VZEPR8JYQKBgQD2tp9Mq4u4vK9i5m79 +FWVcDRSyHuNYm+UR/hlaHKsb+Ps3suEaDcHBTd70Q3xUc0+ELyktw28pfLwNt1bm +do/AcdF6DZ3v7CceqmT9qKkCYb9hd0Fh9qBfysphVdCA9ADLhqTt1VhBlMi5gjop +qoq5xSppm3i2B5oKRVLd/MRdCwKBgQDQMIugrgj7tHXj30M5ZPWI81zQzwB9PK19 +SEMtY2U8CbK7UADojRgZt48jUvi+6JGwoRMGgZKNS738d5HLXomP2ixptOkP/Ojb +saRfYq3f/laEWmTmhKWuPpDRJomeBgN9RIlObCzHG8nXQpkpv3Pxquz+3XsNmqVc +EeVpzLa3YQKBgQDvFxxaGQVXCsSNe/OjoNCR3LtpwzVf8fSU80NuaMoKWchbuicc +MKjMxMKExcH/taAh4fJuPy/DbPZx9HNq9Qtq3OK8/eVJVfqzZT5p9MGVZVUrB399 +iJzzOHvYyGlADzeWBe5f7pmRNLcx0Fq9dmDql4D6x4i8H7HQsk4Ug5lo8QKBgQCQ +ieOmvq1F81gVyJ+nbUx6tFswLUctq77XSCA7ltJmCGWqXjUDP1IIKMSbxbMk99qO +QoYu8i6JIpjYezshcpUW5mYHTa6xhukz/fUctCn7ZV0zrn52/Ij6nD12t8a7i3lI +sxiQ9dFwuv47EOW9ckpQ8lZv69zO6Rr8/P/FoHcKgQKBgBwkBRCqIN+E9BP+NcKi +fIbmEJ3pyrO4FpzMIUF59cGgUZT0GqyU/GHUShk/I36LXL2wMZYPrRubUbz6ibXB ++vHChi14J+JSGHNK5nzapQGJgKvrRlHMOfy0kljnRphTGCuU33ZeRBsFts6tHYEL +dc8QzzRoO2+cylKxetiq45RE +-----END PRIVATE KEY----- From 08e026b5ca3574d8ae64f702193acc40368c872b Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Tue, 22 Sep 2026 03:44:01 +0000 Subject: [PATCH 14/20] fix: address PR #14220 review comments on OAuthException, MtlsPipelineLocalTest, and ITWorkloadIdentityFederationTest --- .../google/auth/oauth2/OAuthException.java | 37 ++++--- .../ITWorkloadIdentityFederationTest.java | 99 ++++++++----------- .../auth/oauth2/MtlsPipelineLocalTest.java | 60 +++++++++-- .../auth/oauth2/OAuthExceptionTest.java | 55 ++++++++++- .../testresources/mtls/test_cert.pem | 30 +++--- .../testresources/mtls/test_key.pem | 40 +++++--- 6 files changed, 215 insertions(+), 106 deletions(-) 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 13853eb25f51..7a0b9b417151 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 @@ -11,6 +11,7 @@ * 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. @@ -35,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; @@ -46,7 +47,7 @@ @NullMarked class OAuthException extends GoogleAuthException { - private static final long serialVersionUID = 1L; + private static final long serialVersionUID = -7883352585835000817L; private final String errorCode; @Nullable private final String errorDescription; @@ -97,24 +98,32 @@ int getHttpStatusCode() { return httpStatusCode; } - static OAuthException createFromHttpResponseException(HttpResponseException e) - throws IOException { + static OAuthException createFromHttpResponseException(HttpResponseException e) { String content = e.getContent(); if (content == null || content.trim().isEmpty()) { - return new OAuthException( - "http_error_" + e.getStatusCode(), e.getStatusMessage(), null, e.getStatusCode()); + OAuthException oauthException = + new OAuthException( + "http_error_" + e.getStatusCode(), e.getStatusMessage(), null, e.getStatusCode()); + oauthException.initCause(e); + return oauthException; } 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(), null, 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 java.util.Map) { - java.util.Map errorMap = (java.util.Map) rawError; + } else if (rawError instanceof Map) { + Map errorMap = (Map) rawError; if (errorMap.get("status") instanceof String) { errorCode = (String) errorMap.get("status"); } @@ -132,11 +141,15 @@ static OAuthException createFromHttpResponseException(HttpResponseException e) if (errorResponse.get("error_uri") instanceof String) { errorUri = (String) errorResponse.get("error_uri"); } - return new OAuthException(errorCode, errorDescription, errorUri, e.getStatusCode()); + OAuthException oauthException = + new OAuthException(errorCode, errorDescription, errorUri, e.getStatusCode()); + oauthException.initCause(e); + return oauthException; } catch (Exception parseException) { - String fallbackDescription = e.getStatusMessage() != null ? e.getStatusMessage() : content; - return new OAuthException( - "http_error_" + e.getStatusCode(), fallbackDescription, null, e.getStatusCode()); + OAuthException oauthException = + new OAuthException("http_error_" + e.getStatusCode(), content, null, e.getStatusCode()); + oauthException.initCause(e); + return oauthException; } } } 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 ee097ab58a2e..79633e8e8af1 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 @@ -39,6 +39,7 @@ 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,17 +47,15 @@ import com.google.api.client.json.JsonObjectParser; import com.google.api.client.json.gson.GsonFactory; import com.google.api.client.util.GenericData; -import com.google.api.client.util.SecurityUtils; 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.io.InputStream; -import java.io.SequenceInputStream; import java.nio.charset.StandardCharsets; import java.security.KeyStore; import java.time.Instant; @@ -296,9 +295,10 @@ void identityPoolCredentials_withProgrammaticAuth() throws IOException { * mTLS STS endpoint (https://sts.mtls.googleapis.com/v1/token) and calls GCS. */ @Test - void identityPoolCredentials_withCertificateBoundWorkloadAndActorToken() throws IOException { + void identityPoolCredentials_withCertificateBoundWorkloadAndActorToken() throws Exception { String subjectToken = generateGoogleIdToken(OIDC_AUDIENCE); String actorToken = generateGoogleIdToken(OIDC_AUDIENCE); + String certConfigPath = getMtlsCertificateConfigPath(); File tokenFile = File.createTempFile( @@ -336,7 +336,7 @@ void identityPoolCredentials_withCertificateBoundWorkloadAndActorToken() throws credentialSource.put("format", format); GenericJson certificate = new GenericJson(); - certificate.put("certificate_config_location", "testresources/mtls/certificate_config.json"); + certificate.put("certificate_config_location", certConfigPath); credentialSource.put("certificate", certificate); config.put("credential_source", credentialSource); @@ -345,7 +345,12 @@ void identityPoolCredentials_withCertificateBoundWorkloadAndActorToken() throws (IdentityPoolCredentials) ExternalAccountCredentials.fromJson(config, OAuth2Utils.HTTP_TRANSPORT_FACTORY); - callGcs(identityPoolCredentials); + 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(); } @@ -354,36 +359,16 @@ void identityPoolCredentials_withCertificateBoundWorkloadAndActorToken() throws /** * 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 test - * certificate resources. Exchanges the tokens over mTLS STS endpoint and calls GCS. + * 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 = - (ExternalAccountSupplierContext context) -> { - try { - return generateGoogleIdToken(OIDC_AUDIENCE); - } catch (IOException e) { - throw new RuntimeException(e); - } - }; - - IdentityPoolActorTokenSupplier actorSupplier = - (ExternalAccountSupplierContext context) -> { - try { - return generateGoogleIdToken(OIDC_AUDIENCE); - } catch (IOException e) { - throw new RuntimeException(e); - } - }; + context -> generateGoogleIdToken(OIDC_AUDIENCE); + IdentityPoolActorTokenSupplier actorSupplier = context -> generateGoogleIdToken(OIDC_AUDIENCE); - KeyStore keyStore; - try (InputStream certStream = - new FileInputStream(new File("testresources/mtls/test_cert.pem")); - InputStream keyStream = new FileInputStream(new File("testresources/mtls/test_key.pem")); - InputStream combined = new SequenceInputStream(certStream, keyStream)) { - keyStore = SecurityUtils.createMtlsKeyStore(combined); - } + KeyStore keyStore = new X509Provider(getMtlsCertificateConfigPath()).getKeyStore(); HttpTransportFactory transportFactory = new MtlsHttpTransportFactory(keyStore); IdentityPoolCredentials credentials = @@ -401,7 +386,10 @@ void identityPoolCredentials_withProgrammaticMtlsAndActorToken() throws Exceptio .setHttpTransportFactory(transportFactory) .build(); - callGcs(credentials); + callGcs( + credentials, + transportFactory.create(), + "https://storage.mtls.googleapis.com/storage/v1/b/"); } /** @@ -448,7 +436,7 @@ void identityPoolCredentials_directSts_withCertificateBoundWorkloadAndActorToken credentialSource.put("format", format); GenericJson certificate = new GenericJson(); - certificate.put("certificate_config_location", "testresources/mtls/certificate_config.json"); + certificate.put("certificate_config_location", getMtlsCertificateConfigPath()); credentialSource.put("certificate", certificate); config.put("credential_source", credentialSource); @@ -473,30 +461,10 @@ void identityPoolCredentials_directSts_withCertificateBoundWorkloadAndActorToken @Test void identityPoolCredentials_directSts_withProgrammaticMtlsAndActorToken() throws Exception { IdentityPoolSubjectTokenSupplier tokenSupplier = - (ExternalAccountSupplierContext context) -> { - try { - return generateGoogleIdToken(OIDC_AUDIENCE); - } catch (IOException e) { - throw new RuntimeException(e); - } - }; - - IdentityPoolActorTokenSupplier actorSupplier = - (ExternalAccountSupplierContext context) -> { - try { - return generateGoogleIdToken(OIDC_AUDIENCE); - } catch (IOException e) { - throw new RuntimeException(e); - } - }; + context -> generateGoogleIdToken(OIDC_AUDIENCE); + IdentityPoolActorTokenSupplier actorSupplier = context -> generateGoogleIdToken(OIDC_AUDIENCE); - KeyStore keyStore; - try (InputStream certStream = - new FileInputStream(new File("testresources/mtls/test_cert.pem")); - InputStream keyStream = new FileInputStream(new File("testresources/mtls/test_key.pem")); - InputStream combined = new SequenceInputStream(certStream, keyStream)) { - keyStore = SecurityUtils.createMtlsKeyStore(combined); - } + KeyStore keyStore = new X509Provider(getMtlsCertificateConfigPath()).getKeyStore(); HttpTransportFactory transportFactory = new MtlsHttpTransportFactory(keyStore); IdentityPoolCredentials credentials = @@ -616,17 +584,30 @@ private GenericJson buildAwsCredentialConfig() { return config; } + private String getMtlsCertificateConfigPath() { + String certConfigPath = System.getenv("GOOGLE_API_CERTIFICATE_CONFIG"); + if (certConfigPath != null && !certConfigPath.isEmpty()) { + return certConfigPath; + } + return "testresources/mtls/certificate_config.json"; + } + 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()); 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 index 86f0f2e8323e..a2993786f7d8 100644 --- 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 @@ -11,6 +11,7 @@ * 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. @@ -61,6 +62,7 @@ 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; @@ -93,6 +95,7 @@ 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. @@ -102,6 +105,7 @@ * 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"; @@ -124,6 +128,7 @@ class MtlsPipelineLocalTest { @BeforeAll static void beforeAll() throws Exception { + SSLContext.getDefault(); originalHostnameVerifier = HttpsURLConnection.getDefaultHostnameVerifier(); HttpsURLConnection.setDefaultHostnameVerifier((hostname, session) -> true); @@ -203,12 +208,13 @@ public void configure(HttpsParameters params) { } @AfterEach - void tearDown() { + void tearDown() throws InterruptedException { if (server != null) { server.stop(0); } if (serverExecutor != null) { serverExecutor.shutdownNow(); + serverExecutor.awaitTermination(5, TimeUnit.SECONDS); } } @@ -446,7 +452,7 @@ public void handle(HttpExchange exchange) throws IOException { certsPerRequest.add(session.getPeerCertificates()); // Always read and drain the request body - String body = readRequestBody(exchange); + readRequestBody(exchange); int count = requestCount.incrementAndGet(); if (count == 1) { @@ -754,6 +760,31 @@ public void handle(HttpExchange exchange) throws IOException { @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 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" + + "}"; + OAuth2Utils.writeInputStreamToFile( + new ByteArrayInputStream(certConfigContent.getBytes(StandardCharsets.UTF_8)), + certConfigFile.toString()); + AtomicInteger stsCallCount = new AtomicInteger(0); AtomicReference capturedStsCerts = new AtomicReference<>(); AtomicReference> capturedStsParams = new AtomicReference<>(); @@ -768,6 +799,16 @@ public void handle(HttpExchange exchange) throws IOException { 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)); @@ -855,8 +896,9 @@ public void handle(HttpExchange exchange) throws IOException { + " \"actor_token_field_name\": \"actor_token\"\n" + " },\n" + " \"certificate\": {\n" - + " \"certificate_config_location\":" - + " \"testresources/mtls/certificate_config.json\"\n" + + " \"certificate_config_location\": \"" + + certConfigFile.toString().replace("\\", "\\\\") + + "\"\n" + " }\n" + " }\n" + "}"; @@ -882,10 +924,12 @@ public void handle(HttpExchange exchange) throws IOException { assertTrue(iamCerts.length > 0); assertTrue(iamCerts[0] instanceof X509Certificate); - // Verify both handlers received the exact same client certificate principal - assertEquals( - ((X509Certificate) stsCerts[0]).getSubjectX500Principal(), - ((X509Certificate) iamCerts[0]).getSubjectX500Principal()); + // Verify both handlers received Cert A (proving IAM stayed on the pinned transport + // even though the cert on disk rotated to Cert B inside the STS handler) + String stsPrincipal = ((X509Certificate) stsCerts[0]).getSubjectX500Principal().getName(); + String iamPrincipal = ((X509Certificate) iamCerts[0]).getSubjectX500Principal().getName(); + assertEquals("CN=1009120726878.apps.googleusercontent.com", stsPrincipal); + assertEquals(stsPrincipal, iamPrincipal); // Asserts IAM handler receives Authorization: Bearer assertEquals("Bearer intermediate_sts_token_123", capturedIamAuthHeader.get()); 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 20b430a12a11..13f2a2570756 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,11 +33,17 @@ 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; /** Tests for {@link OAuthException}. */ @@ -146,6 +152,7 @@ void createFromHttpResponseException_nullContent() throws IOException { assertEquals("Unauthorized", e.getErrorDescription()); assertNull(e.getErrorUri()); assertEquals(401, e.getHttpStatusCode()); + assertSame(httpException, e.getCause()); } @Test @@ -162,6 +169,7 @@ void createFromHttpResponseException_emptyContent() throws IOException { assertEquals("Unauthorized", e.getErrorDescription()); assertNull(e.getErrorUri()); assertEquals(401, e.getHttpStatusCode()); + assertSame(httpException, e.getCause()); } @Test @@ -175,9 +183,10 @@ void createFromHttpResponseException_nonJsonContent() throws IOException { OAuthException e = OAuthException.createFromHttpResponseException(httpException); assertEquals("http_error_502", e.getErrorCode()); - assertEquals("Bad Gateway", e.getErrorDescription()); + assertEquals("Bad Gateway", e.getErrorDescription()); assertNull(e.getErrorUri()); assertEquals(502, e.getHttpStatusCode()); + assertSame(httpException, e.getCause()); } @Test @@ -194,6 +203,7 @@ void createFromHttpResponseException_missingErrorField() throws IOException { assertEquals("some description", e.getErrorDescription()); assertNull(e.getErrorUri()); assertEquals(400, e.getHttpStatusCode()); + assertSame(httpException, e.getCause()); } @Test @@ -212,5 +222,48 @@ void createFromHttpResponseException_googleApiJsonErrorObject() throws IOExcepti 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()); + assertNull(e.getErrorDescription()); + assertNull(e.getErrorUri()); + assertEquals(500, e.getHttpStatusCode()); + assertSame(httpException, e.getCause()); + } + + @Test + void serialVersionUID_matchesReleasedUidAndRoundTrips() throws Exception { + assertEquals( + -7883352585835000817L, + 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----- From 9313161ff0464c879ac600b6e316dadad4e74534 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Tue, 22 Sep 2026 19:39:48 +0000 Subject: [PATCH 15/20] fix(oauth2): pin leaf subject token from KeyStore, cache STS token with mTLS impersonation, and retry torn cert rotations --- .../auth/mtls/MtlsHttpTransportFactory.java | 5 + ...icateIdentityPoolSubjectTokenSupplier.java | 52 +- .../oauth2/ExternalAccountCredentials.java | 10 +- .../auth/oauth2/IdentityPoolCredentials.java | 139 +++-- .../auth/oauth2/ImpersonatedCredentials.java | 83 ++- .../auth/oauth2/AwsCredentialsTest.java | 6 + .../oauth2/IdentityPoolCredentialsTest.java | 490 ++++++++++++++++-- .../google/auth/oauth2/OAuth2UtilsTest.java | 29 +- 8 files changed, 701 insertions(+), 113 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsHttpTransportFactory.java b/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsHttpTransportFactory.java index 9b20c6c8f446..8f7fd6f198aa 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsHttpTransportFactory.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsHttpTransportFactory.java @@ -96,6 +96,11 @@ public boolean hasKeyStore() { return this.hasKeyStore; } + /** Returns the {@link KeyStore} used by this factory, or {@code null} if none was configured. */ + public @Nullable KeyStore getKeyStore() { + return this.mtlsKeyStore; + } + private static boolean checkHasKeyStore(@Nullable KeyStore keyStore) { if (keyStore == null) { return false; diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java index 5b2ad1169ed0..992d9bbed86f 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java @@ -42,6 +42,8 @@ import java.nio.file.Files; import java.nio.file.NoSuchFileException; import java.nio.file.Paths; +import java.security.KeyStore; +import java.security.KeyStoreException; import java.security.cert.Certificate; import java.security.cert.CertificateEncodingException; import java.security.cert.CertificateException; @@ -49,10 +51,12 @@ import java.security.cert.X509Certificate; import java.util.ArrayList; import java.util.Base64; +import java.util.Enumeration; import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; /** * Provider for retrieving the subject tokens for {@link IdentityPoolCredentials} by reading an @@ -96,6 +100,35 @@ private static String loadAndEncodeLeafCertificate(String path) throws IOExcepti } } + private static @Nullable String extractAndEncodeLeafCertificate(@Nullable KeyStore keyStore) + throws IOException { + if (keyStore == null) { + return null; + } + try { + Enumeration aliases = keyStore.aliases(); + if (aliases == null) { + return null; + } + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + if (keyStore.isKeyEntry(alias)) { + Certificate[] chain = keyStore.getCertificateChain(alias); + if (chain != null && chain.length > 0 && chain[0] instanceof X509Certificate) { + return encodeCert((X509Certificate) chain[0]); + } + Certificate cert = keyStore.getCertificate(alias); + if (cert instanceof X509Certificate) { + return encodeCert((X509Certificate) cert); + } + } + } + return null; + } catch (KeyStoreException | CertificateEncodingException e) { + throw new IOException("Failed to extract leaf certificate from pinned KeyStore", e); + } + } + @VisibleForTesting static X509Certificate parseCertificate(byte[] certData) throws CertificateException { if (certData == null || certData.length == 0) { @@ -135,14 +168,27 @@ private static String encodeCert(X509Certificate certificate) */ @Override public String getSubjectToken(ExternalAccountSupplierContext context) throws IOException { - String leafCertPath = credentialSource.getCredentialLocation(); + return getSubjectToken(context, null); + } + + /** + * Retrieves the X509 subject token, extracting the leaf certificate directly from {@code + * pinnedKeyStore} when provided so that the TLS client certificate and {@code subject_token} are + * pinned to the exact same certificate snapshot. + */ + String getSubjectToken(ExternalAccountSupplierContext context, @Nullable KeyStore pinnedKeyStore) + throws IOException { String trustChainPath = null; if (credentialSource.getCertificateConfig() != null) { trustChainPath = credentialSource.getCertificateConfig().getTrustChainPath(); } - // Load and encode the leaf certificate. - String encodedLeafCert = loadAndEncodeLeafCertificate(leafCertPath); + // Extract the leaf certificate from the pinned KeyStore if present; otherwise read from disk. + String encodedLeafCert = extractAndEncodeLeafCertificate(pinnedKeyStore); + if (encodedLeafCert == null) { + String leafCertPath = credentialSource.getCredentialLocation(); + encodedLeafCert = loadAndEncodeLeafCertificate(leafCertPath); + } // Initialize the certificate chain for the subject token. The Security Token Service (STS) // requires that the leaf certificate (the one used for authenticating this workload) must be diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java index 4c12124dc95e..8cd26293b13d 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java @@ -46,6 +46,7 @@ import java.io.ObjectInputStream; import java.math.BigDecimal; import java.net.URI; +import java.security.KeyStore; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -552,6 +553,12 @@ AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) throw return refreshAccessToken(); } + AccessToken refreshAccessToken( + HttpTransportFactory cycleTransportFactory, @Nullable KeyStore pinnedKeyStore) + throws IOException { + return refreshAccessToken(cycleTransportFactory); + } + /** * Exchanges the external credential for a Google Cloud access token. * @@ -580,7 +587,8 @@ protected AccessToken exchangeExternalCredentialForAccessToken( // Handle service account impersonation if necessary. ImpersonatedCredentials impersonated = getImpersonatedCredentials(); if (impersonated != null) { - return impersonated.refreshAccessToken(cycleTransportFactory); + return impersonated.refreshAccessToken( + cycleTransportFactory == this.transportFactory ? null : cycleTransportFactory); } StsRequestHandler.Builder requestHandler = diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java index 8b8286e985af..cc9a34ed5258 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java @@ -32,6 +32,7 @@ package com.google.auth.oauth2; import com.google.auth.http.HttpTransportFactory; +import com.google.auth.mtls.CertificateSourceUnavailableException; import com.google.auth.mtls.MtlsHttpTransportFactory; import com.google.auth.mtls.MtlsUtils; import com.google.auth.mtls.X509Provider; @@ -45,6 +46,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Map; +import javax.net.ssl.SSLException; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -73,7 +75,7 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { // Transient: not serialized directly. Reconstructed in readObject() from the credentialSource // certificate config so deserialized credentials remain usable for mTLS and refresh. private transient volatile @Nullable X509Provider x509Provider; - private transient @Nullable HttpTransportFactory defaultMtlsTransportFactory; + private final boolean useMtlsTransportFactory; private final ExternalAccountSupplierContext supplierContext; private final String metricsHeaderValue; @@ -99,9 +101,13 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { "A subjectTokenSupplier or a credentialSource must be provided."); } - // Store the x509Provider and defaultMtlsTransportFactory for per-cycle cert pinning. + // Store the x509Provider and useMtlsTransportFactory flag for per-cycle cert pinning and + // deserialization recovery. this.x509Provider = builder.x509Provider; - this.defaultMtlsTransportFactory = builder.defaultMtlsTransportFactory; + this.useMtlsTransportFactory = + builder.useMtlsTransportFactory != null + ? builder.useMtlsTransportFactory + : isDefaultOrMtlsTransportFactory(builder.transportFactory); // Initialize based on the source type if (builder.subjectTokenSupplier != null) { @@ -216,47 +222,86 @@ private boolean isMtlsConfigured() { && ((MtlsHttpTransportFactory) this.transportFactory).hasKeyStore()); } - private boolean shouldUseMtlsTransportFactory() { - return this.transportFactory == null - || this.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY - || this.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory - || this.transportFactory.getClass() == MtlsHttpTransportFactory.class + private static boolean isDefaultOrMtlsTransportFactory( + @Nullable HttpTransportFactory transportFactory) { + return transportFactory == null + || transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY + || transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory + || transportFactory.getClass() == MtlsHttpTransportFactory.class + || (transportFactory instanceof MtlsHttpTransportFactory + && !((MtlsHttpTransportFactory) transportFactory).hasKeyStore()); + } + + @VisibleForTesting + boolean shouldUseMtlsTransportFactory() { + return this.useMtlsTransportFactory || (this.transportFactory instanceof MtlsHttpTransportFactory - && !((MtlsHttpTransportFactory) this.transportFactory).hasKeyStore()) - || (this.defaultMtlsTransportFactory != null - && this.transportFactory == this.defaultMtlsTransportFactory); + && !((MtlsHttpTransportFactory) this.transportFactory).hasKeyStore()); + } + + private static boolean isSslException(@Nullable Throwable throwable) { + Throwable current = throwable; + while (current != null) { + if (current instanceof SSLException) { + return true; + } + current = current.getCause(); + } + return false; } @Override public AccessToken refreshAccessToken() throws IOException { - // Per-cycle cert pinning: snapshot the KeyStore at the start of each refresh cycle. - HttpTransportFactory cycleTransportFactory = this.transportFactory; - KeyStore pinnedKeyStore = null; - if (this.x509Provider != null && shouldUseMtlsTransportFactory()) { - pinnedKeyStore = this.x509Provider.getKeyStore(); - cycleTransportFactory = createMtlsTransportFactory(pinnedKeyStore); - } - return refreshWithRetry(cycleTransportFactory, pinnedKeyStore, /* allowRetry= */ true); + // Per-cycle cert pinning: snapshot the KeyStore at the start of each refresh cycle inside + // refreshWithRetry so transient mid-rotation KeyStore read errors and TLS handshake errors + // can be retried once. + return refreshWithRetry( + /* explicitTransportFactory= */ null, /* pinnedKeyStore= */ null, /* allowRetry= */ true); } @Override AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) throws IOException { + KeyStore pinnedKeyStore = + cycleTransportFactory instanceof MtlsHttpTransportFactory + ? ((MtlsHttpTransportFactory) cycleTransportFactory).getKeyStore() + : null; // Retry is intentionally disabled when an explicit cycleTransportFactory is supplied to // ensure transport synchronization across multi-step token exchanges (e.g. STS and IAM) // and prevent nested retry amplification. Outer callers manage retry coordination. - return refreshWithRetry(cycleTransportFactory, null, /* allowRetry= */ false); + return refreshWithRetry(cycleTransportFactory, pinnedKeyStore, /* allowRetry= */ false); + } + + @Override + AccessToken refreshAccessToken( + HttpTransportFactory cycleTransportFactory, @Nullable KeyStore pinnedKeyStore) + throws IOException { + if (pinnedKeyStore == null) { + return refreshAccessToken(cycleTransportFactory); + } + return refreshWithRetry(cycleTransportFactory, pinnedKeyStore, /* allowRetry= */ false); } private AccessToken refreshWithRetry( - HttpTransportFactory cycleTransportFactory, + @Nullable HttpTransportFactory explicitTransportFactory, @Nullable KeyStore pinnedKeyStore, boolean allowRetry) throws IOException { try { + HttpTransportFactory cycleTransportFactory = + explicitTransportFactory != null ? explicitTransportFactory : this.transportFactory; + if (explicitTransportFactory == null + && this.x509Provider != null + && shouldUseMtlsTransportFactory()) { + if (pinnedKeyStore == null) { + pinnedKeyStore = this.x509Provider.getKeyStore(); + } + cycleTransportFactory = createMtlsTransportFactory(pinnedKeyStore); + } + ImpersonatedCredentials impersonated = getImpersonatedCredentials(); if (impersonated != null) { return impersonated.refreshAccessToken( - pinnedKeyStore != null ? cycleTransportFactory : null); + pinnedKeyStore != null ? cycleTransportFactory : null, pinnedKeyStore); } // Read subject and actor tokens, atomically if from the same file supplier. @@ -270,7 +315,14 @@ private AccessToken refreshWithRetry( subjectToken = tokens.subject; actorToken = tokens.actor; } else { - subjectToken = retrieveSubjectToken(); + if (this.subjectTokenSupplier instanceof CertificateIdentityPoolSubjectTokenSupplier + && pinnedKeyStore != null) { + subjectToken = + ((CertificateIdentityPoolSubjectTokenSupplier) this.subjectTokenSupplier) + .getSubjectToken(supplierContext, pinnedKeyStore); + } else { + subjectToken = retrieveSubjectToken(); + } if (this.actorTokenSupplier != null) { actorToken = this.actorTokenSupplier.getActorToken(supplierContext); } @@ -292,13 +344,20 @@ private AccessToken refreshWithRetry( return exchangeExternalCredentialForAccessToken( stsTokenExchangeRequest.build(), cycleTransportFactory); } catch (IOException | RuntimeException e) { + boolean isInitialKeyStoreLoadFailure = + pinnedKeyStore == null + && e instanceof IOException + && !(e instanceof CertificateSourceUnavailableException); if (allowRetry - && OAuth2Utils.isUnauthorizedException(e) && this.x509Provider != null - && shouldUseMtlsTransportFactory()) { + && shouldUseMtlsTransportFactory() + && (OAuth2Utils.isUnauthorizedException(e) + || isSslException(e) + || isInitialKeyStoreLoadFailure)) { KeyStore freshKeyStore; try { - // On 401, re-read from X509Provider for fresh certs. + // On 401, TLS handshake failure, or transient initial KeyStore load failure, re-read + // from X509Provider for fresh certs. freshKeyStore = this.x509Provider.getKeyStore(); } catch (IOException reloadException) { if (reloadException != e) { @@ -312,7 +371,8 @@ && shouldUseMtlsTransportFactory()) { throw ioException; } - if (!OAuth2Utils.hasCertificateChanged(pinnedKeyStore, freshKeyStore)) { + if (!isInitialKeyStoreLoadFailure + && !OAuth2Utils.hasCertificateChanged(pinnedKeyStore, freshKeyStore)) { throw e; } @@ -332,6 +392,12 @@ && shouldUseMtlsTransportFactory()) { @Override public String retrieveSubjectToken() throws IOException { + if (this.subjectTokenSupplier instanceof CertificateIdentityPoolSubjectTokenSupplier + && this.x509Provider != null + && shouldUseMtlsTransportFactory()) { + return ((CertificateIdentityPoolSubjectTokenSupplier) this.subjectTokenSupplier) + .getSubjectToken(supplierContext, this.x509Provider.getKeyStore()); + } return this.subjectTokenSupplier.getSubjectToken(supplierContext); } @@ -394,9 +460,8 @@ private void initializeMtlsTransport( X509Provider x509Provider = getX509Provider(builder, credentialSource); this.x509Provider = x509Provider; KeyStore mtlsKeyStore = x509Provider.getKeyStore(); - if (builder.transportFactory == null || shouldUseMtlsTransportFactory()) { + if (shouldUseMtlsTransportFactory()) { this.transportFactory = createMtlsTransportFactory(mtlsKeyStore); - this.defaultMtlsTransportFactory = this.transportFactory; } else if (!(builder.transportFactory instanceof MtlsHttpTransportFactory)) { LOGGER_PROVIDER .getLogger() @@ -442,14 +507,13 @@ private void readObject(ObjectInputStream input) throws IOException, ClassNotFou String explicitCertConfigPath = getExplicitCertConfigPath(credentialSource); this.x509Provider = new X509Provider(getEnvironmentProvider(), getPropertyProvider(), explicitCertConfigPath); - try { - KeyStore mtlsKeyStore = this.x509Provider.getKeyStore(); - if (shouldUseMtlsTransportFactory()) { + if (shouldUseMtlsTransportFactory()) { + try { + KeyStore mtlsKeyStore = this.x509Provider.getKeyStore(); this.transportFactory = createMtlsTransportFactory(mtlsKeyStore); - this.defaultMtlsTransportFactory = this.transportFactory; + } catch (Exception e) { + // Cert loading failure will be handled on refreshAccessToken() } - } catch (Exception e) { - // Cert loading failure will be handled on refreshAccessToken() } } } @@ -485,7 +549,7 @@ public static class Builder extends ExternalAccountCredentials.Builder { private @Nullable IdentityPoolActorTokenSupplier actorTokenSupplier; private @Nullable String actorTokenType; private @Nullable X509Provider x509Provider; - private @Nullable HttpTransportFactory defaultMtlsTransportFactory; + private @Nullable Boolean useMtlsTransportFactory; Builder() {} @@ -503,7 +567,7 @@ public static class Builder extends ExternalAccountCredentials.Builder { // FileIdentityPoolSubjectTokenSupplier instance for atomic token reads. this.actorTokenType = credentials.actorTokenType; this.x509Provider = credentials.x509Provider; - this.defaultMtlsTransportFactory = credentials.defaultMtlsTransportFactory; + this.useMtlsTransportFactory = credentials.useMtlsTransportFactory; } /** @@ -572,6 +636,7 @@ Builder setActorTokenType(String actorTokenType) { @CanIgnoreReturnValue public Builder setHttpTransportFactory(HttpTransportFactory transportFactory) { super.setHttpTransportFactory(transportFactory); + this.useMtlsTransportFactory = isDefaultOrMtlsTransportFactory(transportFactory); return this; } diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java index ea557c62f7b0..912e71a0a9e0 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java @@ -49,6 +49,7 @@ import com.google.auth.ServiceAccountSigner; import com.google.auth.http.HttpCredentialsAdapter; import com.google.auth.http.HttpTransportFactory; +import com.google.auth.mtls.MtlsHttpTransportFactory; import com.google.auth.oauth2.MetricsUtils.RequestType; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.MoreObjects; @@ -59,6 +60,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.ObjectInputStream; +import java.security.KeyStore; import java.time.DateTimeException; import java.time.Instant; import java.time.format.DateTimeFormatter; @@ -112,6 +114,8 @@ public class ImpersonatedCredentials extends GoogleCredentials private static final int TWELVE_HOURS_IN_SECONDS = 43200; private static final int DEFAULT_LIFETIME_IN_SECONDS = 3600; private volatile GoogleCredentials sourceCredentials; + private transient volatile @Nullable AccessToken cachedStsAccessToken; + private transient volatile @Nullable KeyStore cachedStsKeyStore; private final String targetPrincipal; private List delegates; private final List scopes; @@ -588,6 +592,26 @@ public AccessToken refreshAccessToken() throws IOException { return refreshAccessToken(null); } + private boolean isCachedStsTokenReusable( + ExternalAccountCredentials externalSource, @Nullable KeyStore currentKeyStore) { + AccessToken token = this.cachedStsAccessToken; + if (token == null) { + return false; + } + Date expirationTime = token.getExpirationTime(); + if (expirationTime != null) { + long remainingMillis = expirationTime.getTime() - externalSource.clock.currentTimeMillis(); + if (remainingMillis <= externalSource.getExpirationMargin().toMillis()) { + return false; + } + } + if (currentKeyStore == null) { + return this.cachedStsKeyStore == null; + } + return this.cachedStsKeyStore != null + && !OAuth2Utils.hasCertificateChanged(this.cachedStsKeyStore, currentKeyStore); + } + /** * Refreshes the access token using the specified transport factory for per-cycle transport * pinning. @@ -599,9 +623,31 @@ public AccessToken refreshAccessToken() throws IOException { */ AccessToken refreshAccessToken(@Nullable HttpTransportFactory cycleTransportFactory) throws IOException { + KeyStore pinnedKeyStore = + cycleTransportFactory instanceof MtlsHttpTransportFactory + ? ((MtlsHttpTransportFactory) cycleTransportFactory).getKeyStore() + : null; + return refreshAccessToken(cycleTransportFactory, pinnedKeyStore); + } + + /** + * Refreshes the access token using the specified transport factory and pinned {@link KeyStore} + * for per-cycle transport pinning. + * + * @param cycleTransportFactory the HTTP transport factory to use, or {@code null} to use this + * instance's configured transport factory without overriding source credential transport + * @param pinnedKeyStore the {@link KeyStore} snapshot associated with {@code + * cycleTransportFactory}, or {@code null} if not using per-cycle mTLS pinning + * @return the refreshed access token + * @throws IOException if token refresh fails + */ + AccessToken refreshAccessToken( + @Nullable HttpTransportFactory cycleTransportFactory, @Nullable KeyStore pinnedKeyStore) + throws IOException { HttpTransportFactory effectiveTransportFactory = firstNonNull(cycleTransportFactory, this.transportFactory); HttpCredentialsAdapter adapter; + AccessToken intermediateAccessTokenForCache = null; if (this.sourceCredentials instanceof ExternalAccountCredentials) { ExternalAccountCredentials externalSource; synchronized (this) { @@ -623,12 +669,24 @@ AccessToken refreshAccessToken(@Nullable HttpTransportFactory cycleTransportFact } adapter = new HttpCredentialsAdapter(externalSource); } else { - AccessToken intermediateAccessToken; - try { - intermediateAccessToken = externalSource.refreshAccessToken(effectiveTransportFactory); - } catch (IOException e) { - throw new IOException("Unable to refresh sourceCredentials", e); + AccessToken intermediateAccessToken = null; + synchronized (this) { + if (isCachedStsTokenReusable(externalSource, pinnedKeyStore)) { + intermediateAccessToken = this.cachedStsAccessToken; + } + } + if (intermediateAccessToken == null) { + try { + intermediateAccessToken = + pinnedKeyStore != null + ? externalSource.refreshAccessToken(effectiveTransportFactory, pinnedKeyStore) + : externalSource.refreshAccessToken(effectiveTransportFactory); + } catch (IOException e) { + throw new IOException("Unable to refresh sourceCredentials", e); + } } + intermediateAccessTokenForCache = intermediateAccessToken; + final AccessToken tokenToUse = intermediateAccessToken; GoogleCredentials authCredentials = new GoogleCredentials( GoogleCredentials.newBuilder() @@ -636,7 +694,7 @@ AccessToken refreshAccessToken(@Nullable HttpTransportFactory cycleTransportFact .setUniverseDomain(externalSource.getUniverseDomain())) { @Override public AccessToken refreshAccessToken() { - return intermediateAccessToken; + return tokenToUse; } }; adapter = new HttpCredentialsAdapter(authCredentials); @@ -708,9 +766,22 @@ public AccessToken refreshAccessToken() { LoggingUtils.logRequest(request, LOGGER_PROVIDER, "Sending request to refresh access token"); response = request.execute(); } catch (IOException e) { + if (cycleTransportFactory != null) { + synchronized (this) { + this.cachedStsAccessToken = null; + this.cachedStsKeyStore = null; + } + } throw new IOException("Error requesting access token", e); } + if (cycleTransportFactory != null && intermediateAccessTokenForCache != null) { + synchronized (this) { + this.cachedStsAccessToken = intermediateAccessTokenForCache; + this.cachedStsKeyStore = pinnedKeyStore; + } + } + GenericData responseData; try { LoggingUtils.logResponse( diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java index 4ea4d133f959..2bb3e3e789a5 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java @@ -1258,6 +1258,12 @@ void serialize() throws IOException, ClassNotFoundException { assertNotNull(deserializedCredentials.getServiceAccountEmail()); assertEquals( testCredentials.getServiceAccountEmail(), deserializedCredentials.getServiceAccountEmail()); + AwsCredentials deserializedInnerSource = + (AwsCredentials) + deserializedCredentials.getImpersonatedCredentials().getSourceCredentials(); + assertNull(deserializedInnerSource.getServiceAccountImpersonationUrl()); + assertEquals( + testCredentials.getServiceAccountEmail(), deserializedInnerSource.getServiceAccountEmail()); } /** diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java index 70963d600065..a849e3d3a297 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java @@ -68,9 +68,11 @@ import java.io.ObjectInputStream; import java.io.ObjectStreamClass; import java.io.SequenceInputStream; +import java.io.Serializable; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.Paths; import java.security.KeyStore; import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; @@ -89,6 +91,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; +import javax.net.ssl.SSLHandshakeException; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -3069,10 +3072,10 @@ void fromStream_fileCredentialSource_certRotation_401Retry_succeeds(@TempDir Pat + " \"cert_configs\": {\n" + " \"workload\": {\n" + " \"cert_path\": \"" - + certFile.toString() + + certFile.toString().replace("\\", "\\\\") + "\",\n" + " \"key_path\": \"" - + keyFile.toString() + + keyFile.toString().replace("\\", "\\\\") + "\"\n" + " }\n" + " }\n" @@ -3088,7 +3091,7 @@ void fromStream_fileCredentialSource_certRotation_401Retry_succeeds(@TempDir Pat + " \"token_url\": \"https://sts.googleapis.com/v1/token\",\n" + " \"credential_source\": {\n" + " \"file\": \"" - + tokenFile.toString() + + tokenFile.toString().replace("\\", "\\\\") + "\",\n" + " \"format\": {\n" + " \"type\": \"json\",\n" @@ -3096,7 +3099,7 @@ void fromStream_fileCredentialSource_certRotation_401Retry_succeeds(@TempDir Pat + " },\n" + " \"certificate\": {\n" + " \"certificate_config_location\": \"" - + certConfigFile.toString() + + certConfigFile.toString().replace("\\", "\\\\") + "\"\n" + " }\n" + " }\n" @@ -3120,12 +3123,9 @@ protected AccessToken exchangeExternalCredentialForAccessToken( throws IOException { if (exchangeCount.incrementAndGet() == 1) { Files.write( - certFile, - Files.readAllBytes( - java.nio.file.Paths.get("testresources/mtls/test_cert_2.pem"))); + certFile, Files.readAllBytes(Paths.get("testresources/mtls/test_cert_2.pem"))); Files.write( - keyFile, - Files.readAllBytes(java.nio.file.Paths.get("testresources/mtls/test_key_2.pem"))); + keyFile, Files.readAllBytes(Paths.get("testresources/mtls/test_key_2.pem"))); throw new OAuthException("invalid_client", "Unauthorized", null, 401); } return new AccessToken("rotatedRetryToken", null); @@ -4204,48 +4204,214 @@ public KeyStore getKeyStore() { AtomicInteger stsCallCount = new AtomicInteger(0); AtomicInteger iamCallCount = new AtomicInteger(0); - HttpTransport mockTransport = - new MockHttpTransport() { + List capturedKeyStores = new ArrayList<>(); + List requestKeyStores = new ArrayList<>(); + + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(x509Provider) + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .setServiceAccountImpersonationUrl( + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken")) { @Override - public LowLevelHttpRequest buildRequest(String method, String url) { - return new MockLowLevelHttpRequest(url) { - @Override - public LowLevelHttpResponse execute() throws IOException { - if (url.contains("/v1/token")) { - int count = stsCallCount.incrementAndGet(); - if (count == 1) { - return new MockLowLevelHttpResponse() - .setStatusCode(401) - .setContentType(Json.MEDIA_TYPE) - .setContent( - "{\"error\":\"invalid_client\",\"error_description\":\"Cert mismatch\"}"); + HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { + capturedKeyStores.add(keyStore); + return () -> + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + requestKeyStores.add(keyStore); + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() { + if (url.contains("/v1/token")) { + int count = stsCallCount.incrementAndGet(); + if (keyStore == ks1) { + return new MockLowLevelHttpResponse() + .setStatusCode(401) + .setContentType(Json.MEDIA_TYPE) + .setContent( + "{\"error\":\"invalid_client\",\"error_description\":\"Cert" + + " mismatch\"}"); + } + 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", "urn:ietf:params:oauth:token-type:access_token"); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } else if (url.contains(":generateAccessToken")) { + int count = iamCallCount.incrementAndGet(); + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("accessToken", "final-iam-token-" + count); + response.put("expireTime", "2030-01-01T00:00:00Z"); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } + return new MockLowLevelHttpResponse().setStatusCode(404); + } + }; } - 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", "urn:ietf:params:oauth:token-type:access_token"); - return new MockLowLevelHttpResponse() - .setContentType(Json.MEDIA_TYPE) - .setContent(response.toString()); - } else if (url.contains(":generateAccessToken")) { - int count = iamCallCount.incrementAndGet(); - GenericJson response = new GenericJson(); - response.setFactory(OAuth2Utils.JSON_FACTORY); - response.put("accessToken", "final-iam-token-" + count); - response.put("expireTime", "2030-01-01T00:00:00Z"); - return new MockLowLevelHttpResponse() - .setContentType(Json.MEDIA_TYPE) - .setContent(response.toString()); - } - return new MockLowLevelHttpResponse().setStatusCode(404); + }; + } + }; + + AccessToken token = credential.refreshAccessToken(); + assertEquals("final-iam-token-1", token.getTokenValue()); + // STS must be called only twice (initial attempt with ks1 + 1 outer retry with ks2). + assertEquals(2, stsCallCount.get()); + assertEquals(1, iamCallCount.get()); + assertEquals(2, getKeyStoreCallCount.get()); + assertEquals(Arrays.asList(ks1, ks2), capturedKeyStores); + assertEquals(Arrays.asList(ks1, ks2, ks2), requestKeyStores); + } + + @Test + void + refreshAccessToken_certSubjectTokenSupplier_extractsLeafCertFromPinnedKeyStoreEvenWhenDiskRotates( + @TempDir Path tempDir) throws Exception { + Path certFile1 = tempDir.resolve("cert1.pem"); + Path keyFile1 = tempDir.resolve("key1.pem"); + Path certFile2 = tempDir.resolve("cert2.pem"); + Path keyFile2 = tempDir.resolve("key2.pem"); + Files.copy(Paths.get("testresources/mtls/test_cert.pem"), certFile1); + Files.copy(Paths.get("testresources/mtls/test_key.pem"), keyFile1); + Files.copy(Paths.get("testresources/mtls/test_cert_2.pem"), certFile2); + Files.copy(Paths.get("testresources/mtls/test_key_2.pem"), keyFile2); + + Path certConfigFile = tempDir.resolve("certificate_config.json"); + String certConfigJson1 = + "{\n" + + " \"cert_configs\": {\n" + + " \"workload\": {\n" + + " \"cert_path\": \"" + + certFile1.toString().replace("\\", "\\\\") + + "\",\n" + + " \"key_path\": \"" + + keyFile1.toString().replace("\\", "\\\\") + + "\"\n" + + " }\n" + + " }\n" + + "}"; + Files.write(certConfigFile, certConfigJson1.getBytes(StandardCharsets.UTF_8)); + + Map certificateMap = new HashMap<>(); + certificateMap.put("certificate_config_location", certConfigFile.toString()); + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("certificate", certificateMap); + IdentityPoolCredentialSource credentialSource = + new IdentityPoolCredentialSource(credentialSourceMap); + + KeyStore ks1 = createPopulatedKeyStore(); + KeyStore ks2 = createRotatedPopulatedKeyStore(); + AtomicInteger getKeyStoreCount = new AtomicInteger(0); + X509Provider x509Provider = + new X509Provider(null) { + @Override + public KeyStore getKeyStore() throws IOException { + int call = getKeyStoreCount.incrementAndGet(); + if (call <= 2) { + // On call 2 (the start of refreshAccessToken), overwrite cert1.pem on disk with + // cert2.pem AND update certificate_config.json to point to cert2.pem/key2.pem AFTER + // ks1 is loaded. This simulates a mid-cycle cert rotation between getKeyStore() and + // getSubjectToken(). + if (call == 2) { + Files.copy(certFile2, certFile1, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + String certConfigJson2 = + "{\n" + + " \"cert_configs\": {\n" + + " \"workload\": {\n" + + " \"cert_path\": \"" + + certFile2.toString().replace("\\", "\\\\") + + "\",\n" + + " \"key_path\": \"" + + keyFile2.toString().replace("\\", "\\\\") + + "\"\n" + + " }\n" + + " }\n" + + "}"; + Files.write(certConfigFile, certConfigJson2.getBytes(StandardCharsets.UTF_8)); } - }; + return ks1; + } + return ks2; } }; + List capturedSubjectTokens = new ArrayList<>(); + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setCredentialSource(credentialSource) + .setX509Provider(x509Provider) + .setAudience("audience") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:mtls") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token")) { + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) { + capturedSubjectTokens.add(stsTokenExchangeRequest.getSubjectToken()); + return new AccessToken("mtls-bound-token", null); + } + }; + + // Cycle 1: Even though cert1.pem on disk was overwritten with cert2.pem right after ks1 was + // snapshotted, subject_token MUST match ks1 (not cert2.pem on disk). + credential.refreshAccessToken(); + // Cycle 2: Now getKeyStore() returns ks2, so subject_token MUST match ks2. + credential.refreshAccessToken(); + + String expectedCert1Base64 = + Base64.getEncoder() + .encodeToString( + CertificateIdentityPoolSubjectTokenSupplier.parseCertificate( + Files.readAllBytes(Paths.get("testresources/mtls/test_cert.pem"))) + .getEncoded()); + String expectedCert2Base64 = + Base64.getEncoder() + .encodeToString( + CertificateIdentityPoolSubjectTokenSupplier.parseCertificate( + Files.readAllBytes(Paths.get("testresources/mtls/test_cert_2.pem"))) + .getEncoded()); + + assertEquals(2, capturedSubjectTokens.size()); + assertTrue(capturedSubjectTokens.get(0).contains(expectedCert1Base64)); + assertFalse(capturedSubjectTokens.get(0).contains(expectedCert2Base64)); + assertTrue(capturedSubjectTokens.get(1).contains(expectedCert2Base64)); + } + + @Test + void + refreshAccessToken_impersonation_cachesOneHourStsTokenWhileKeyStoreUnchangedAndInvalidatesOnRotation() + throws Exception { + KeyStore ks1 = createPopulatedKeyStore(); + KeyStore ks2 = createRotatedPopulatedKeyStore(); + AtomicInteger getKeyStoreCallCount = new AtomicInteger(0); + X509Provider x509Provider = + new X509Provider(null) { + @Override + public KeyStore getKeyStore() { + // Calls 1 & 2 return ks1 (unchanged cert); Call 3 returns ks2 (rotated cert). + return getKeyStoreCallCount.incrementAndGet() <= 2 ? ks1 : ks2; + } + }; + + AtomicInteger stsCallCount = new AtomicInteger(0); + AtomicInteger iamCallCount = new AtomicInteger(0); + List iamBearerHeaders = new ArrayList<>(); + IdentityPoolCredentials credential = new IdentityPoolCredentials( IdentityPoolCredentials.newBuilder() @@ -4259,15 +4425,237 @@ public LowLevelHttpResponse execute() throws IOException { "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken")) { @Override HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { - return () -> mockTransport; + return () -> + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() { + if (url.contains("/v1/token")) { + int count = stsCallCount.incrementAndGet(); + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("access_token", "cached-sts-token-" + count); + response.put("token_type", "Bearer"); + response.put("expires_in", 3600); + response.put( + "issued_token_type", "urn:ietf:params:oauth:token-type:access_token"); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } else if (url.contains(":generateAccessToken")) { + int count = iamCallCount.incrementAndGet(); + iamBearerHeaders.add(getFirstHeaderValue("Authorization")); + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("accessToken", "final-iam-token-" + count); + response.put("expireTime", "2030-01-01T00:00:00Z"); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } + return new MockLowLevelHttpResponse().setStatusCode(404); + } + }; + } + }; } }; - AccessToken token = credential.refreshAccessToken(); - assertEquals("final-iam-token-1", token.getTokenValue()); - // STS must be called only twice (initial attempt + 1 outer retry), not 4 times. - assertEquals(2, stsCallCount.get()); + // Refresh 1 (ks1): mints STS token 1 and IAM token 1. + AccessToken token1 = credential.refreshAccessToken(); + assertEquals("final-iam-token-1", token1.getTokenValue()); + assertEquals(1, stsCallCount.get()); assertEquals(1, iamCallCount.get()); - assertEquals(2, getKeyStoreCallCount.get()); + + // Refresh 2 (still ks1, STS token 1 still valid): MUST reuse cached STS token 1 without calling + // STS again. + AccessToken token2 = credential.refreshAccessToken(); + assertEquals("final-iam-token-2", token2.getTokenValue()); + assertEquals(1, stsCallCount.get()); + assertEquals(2, iamCallCount.get()); + assertEquals("Bearer cached-sts-token-1", iamBearerHeaders.get(0)); + assertEquals("Bearer cached-sts-token-1", iamBearerHeaders.get(1)); + + // Refresh 3 (ks2 rotated): MUST invalidate cached STS token 1 and mint STS token 2. + AccessToken token3 = credential.refreshAccessToken(); + assertEquals("final-iam-token-3", token3.getTokenValue()); + assertEquals(2, stsCallCount.get()); + assertEquals(3, iamCallCount.get()); + assertEquals("Bearer cached-sts-token-2", iamBearerHeaders.get(2)); + } + + @Test + void refreshAccessToken_sslHandshakeExceptionFromTornRotation_retriesWhenKeyStoreChanges() + throws Exception { + // Torn KeyStore: cert2 + key1; Completed rotation KeyStore: cert2 + key2. + byte[] cert2Bytes = Files.readAllBytes(Paths.get("testresources/mtls/test_cert_2.pem")); + byte[] key1Bytes = Files.readAllBytes(Paths.get("testresources/mtls/test_key.pem")); + byte[] key2Bytes = Files.readAllBytes(Paths.get("testresources/mtls/test_key_2.pem")); + byte[] newline = "\n".getBytes(StandardCharsets.UTF_8); + + KeyStore tornKeyStore = + SecurityUtils.createMtlsKeyStore( + new ByteArrayInputStream( + com.google.common.primitives.Bytes.concat(cert2Bytes, newline, key1Bytes))); + KeyStore validRotatedKeyStore = + SecurityUtils.createMtlsKeyStore( + new ByteArrayInputStream( + com.google.common.primitives.Bytes.concat(cert2Bytes, newline, key2Bytes))); + + AtomicInteger getKeyStoreCount = new AtomicInteger(0); + X509Provider x509Provider = + new X509Provider(null) { + @Override + public KeyStore getKeyStore() { + return getKeyStoreCount.incrementAndGet() == 1 ? tornKeyStore : validRotatedKeyStore; + } + }; + + AtomicInteger exchangeCount = new AtomicInteger(0); + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(x509Provider) + .setAudience("audience") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token")) { + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) + throws IOException { + if (exchangeCount.incrementAndGet() == 1) { + throw new IOException( + "Error writing request body to server", + new SSLHandshakeException("Received fatal alert: decrypt_error")); + } + return new AccessToken("recovered-after-ssl-handshake-retry", null); + } + }; + + AccessToken token = credential.refreshAccessToken(); + assertEquals("recovered-after-ssl-handshake-retry", token.getTokenValue()); + assertEquals(2, getKeyStoreCount.get()); + assertEquals(2, exchangeCount.get()); + } + + @Test + void refreshAccessToken_initialKeyStoreLoadIOException_retriesOnceAndSucceeds() throws Exception { + KeyStore validKeyStore = createPopulatedKeyStore(); + AtomicInteger getKeyStoreCount = new AtomicInteger(0); + X509Provider x509Provider = + new X509Provider(null) { + @Override + public KeyStore getKeyStore() throws IOException { + if (getKeyStoreCount.incrementAndGet() == 1) { + throw new IOException("X509Provider: Unexpected IOException: mid-write PEM"); + } + return validKeyStore; + } + }; + + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(x509Provider) + .setAudience("audience") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token")) { + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) { + return new AccessToken("recovered-after-initial-keystore-ioe", null); + } + }; + + AccessToken token = credential.refreshAccessToken(); + assertEquals("recovered-after-initial-keystore-ioe", token.getTokenValue()); + assertEquals(2, getKeyStoreCount.get()); + } + + public static class SerializableCustomTransportFactory + implements HttpTransportFactory, Serializable { + private static final long serialVersionUID = 1L; + private static final AtomicInteger getKeyStoreCountDuringReadObject = new AtomicInteger(0); + + public SerializableCustomTransportFactory() {} + + @Override + public HttpTransport create() { + return new MockHttpTransport(); + } + } + + private static class CountingSerializableX509Provider extends X509Provider { + private static final long serialVersionUID = 1L; + + CountingSerializableX509Provider() { + super(null); + } + + @Override + public KeyStore getKeyStore() throws IOException { + SerializableCustomTransportFactory.getKeyStoreCountDuringReadObject.incrementAndGet(); + try { + return createPopulatedKeyStore(); + } catch (Exception e) { + throw new IOException(e); + } + } + } + + @Test + void deserialization_respectsUseMtlsTransportFactoryFlag() throws Exception { + Map certMap = new HashMap<>(); + certMap.put("certificate_config_location", "testresources/mtls/certificate_config.json"); + Map sourceMap = new HashMap<>(); + sourceMap.put("file", "credential.json"); + sourceMap.put("certificate", certMap); + IdentityPoolCredentialSource credentialSource = new IdentityPoolCredentialSource(sourceMap); + + // Case 1: No custom HttpTransportFactory set -> useMtlsTransportFactory is true. + // Build initializes MtlsHttpTransportFactory, and deserialization restores + // MtlsHttpTransportFactory in readObject(). + SerializableCustomTransportFactory.getKeyStoreCountDuringReadObject.set(0); + IdentityPoolCredentials defaultMtlsCreds = + IdentityPoolCredentials.newBuilder() + .setCredentialSource(credentialSource) + .setX509Provider(new CountingSerializableX509Provider()) + .setAudience("audience") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build(); + assertEquals(1, SerializableCustomTransportFactory.getKeyStoreCountDuringReadObject.get()); + assertTrue(defaultMtlsCreds.shouldUseMtlsTransportFactory()); + assertTrue(defaultMtlsCreds.toBuilder().build().shouldUseMtlsTransportFactory()); + + IdentityPoolCredentials deserializedDefault = serializeAndDeserialize(defaultMtlsCreds); + assertTrue(deserializedDefault.shouldUseMtlsTransportFactory()); + assertTrue(deserializedDefault.getTransportFactory() instanceof MtlsHttpTransportFactory); + + // Case 2: Custom HttpTransportFactory explicitly set -> useMtlsTransportFactory is false. + // Deserialization must preserve SerializableCustomTransportFactory and NOT overwrite it with + // MtlsHttpTransportFactory. + IdentityPoolCredentials customTransportCreds = + IdentityPoolCredentials.newBuilder() + .setCredentialSource(credentialSource) + .setX509Provider(new CountingSerializableX509Provider()) + .setHttpTransportFactory(new SerializableCustomTransportFactory()) + .setAudience("audience") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build(); + assertFalse(customTransportCreds.shouldUseMtlsTransportFactory()); + assertFalse(customTransportCreds.toBuilder().build().shouldUseMtlsTransportFactory()); + + IdentityPoolCredentials deserializedCustom = serializeAndDeserialize(customTransportCreds); + assertFalse(deserializedCustom.shouldUseMtlsTransportFactory()); + assertTrue( + deserializedCustom.getTransportFactory() instanceof SerializableCustomTransportFactory); } } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuth2UtilsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuth2UtilsTest.java index ded8b0af220f..36d3261c8c31 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuth2UtilsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuth2UtilsTest.java @@ -39,7 +39,13 @@ import com.google.api.client.http.HttpHeaders; import com.google.api.client.http.HttpResponseException; +import com.google.api.client.util.SecurityUtils; +import com.google.common.primitives.Bytes; +import java.io.ByteArrayInputStream; import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; import java.security.KeyStore; import org.junit.jupiter.api.Test; @@ -175,24 +181,17 @@ void hasCertificateChanged_distinctKeyStoreInstances_comparesCertificates() { @Test void hasCertificateChanged_sameCertificateDifferentPrivateKey_returnsTrue() throws Exception { - byte[] certBytes = - java.nio.file.Files.readAllBytes( - java.nio.file.Paths.get("testresources/mtls/test_cert.pem")); - byte[] key1Bytes = - java.nio.file.Files.readAllBytes( - java.nio.file.Paths.get("testresources/mtls/test_key.pem")); - byte[] key2Bytes = - java.nio.file.Files.readAllBytes( - java.nio.file.Paths.get("testresources/mtls/test_key_2.pem")); + byte[] certBytes = Files.readAllBytes(Paths.get("testresources/mtls/test_cert.pem")); + byte[] key1Bytes = Files.readAllBytes(Paths.get("testresources/mtls/test_key.pem")); + byte[] key2Bytes = Files.readAllBytes(Paths.get("testresources/mtls/test_key_2.pem")); + byte[] newlineBytes = "\n".getBytes(StandardCharsets.UTF_8); KeyStore ks1 = - com.google.api.client.util.SecurityUtils.createMtlsKeyStore( - new java.io.ByteArrayInputStream( - com.google.common.primitives.Bytes.concat(certBytes, "\n".getBytes(), key1Bytes))); + SecurityUtils.createMtlsKeyStore( + new ByteArrayInputStream(Bytes.concat(certBytes, newlineBytes, key1Bytes))); KeyStore ks2 = - com.google.api.client.util.SecurityUtils.createMtlsKeyStore( - new java.io.ByteArrayInputStream( - com.google.common.primitives.Bytes.concat(certBytes, "\n".getBytes(), key2Bytes))); + SecurityUtils.createMtlsKeyStore( + new ByteArrayInputStream(Bytes.concat(certBytes, newlineBytes, key2Bytes))); assertTrue(OAuth2Utils.hasCertificateChanged(ks1, ks2)); } From 6e5f7fe7e66b5af803d1879ca54e666e50634177 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Tue, 22 Sep 2026 20:57:19 +0000 Subject: [PATCH 16/20] fix(oauth2): support mTLS pinning for standalone ImpersonatedCredentials and retry bare IOExceptions on split cert writes --- .../auth/oauth2/IdentityPoolCredentials.java | 64 ++-- .../auth/oauth2/ImpersonatedCredentials.java | 56 +++- .../oauth2/IdentityPoolCredentialsTest.java | 285 ++++++++++++++++++ 3 files changed, 374 insertions(+), 31 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java index cc9a34ed5258..8bd293ca5d9c 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java @@ -31,6 +31,7 @@ package com.google.auth.oauth2; +import com.google.api.client.http.HttpResponseException; import com.google.auth.http.HttpTransportFactory; import com.google.auth.mtls.CertificateSourceUnavailableException; import com.google.auth.mtls.MtlsHttpTransportFactory; @@ -46,7 +47,6 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Map; -import javax.net.ssl.SSLException; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -216,14 +216,13 @@ private static void validateMtlsEndpoint(@Nullable String url, String fieldName) * positives from a no-arg-constructed MtlsHttpTransportFactory (e.g. after deserialization) that * has no actual certificates. */ - private boolean isMtlsConfigured() { + boolean isMtlsConfigured() { return this.x509Provider != null || (this.transportFactory instanceof MtlsHttpTransportFactory && ((MtlsHttpTransportFactory) this.transportFactory).hasKeyStore()); } - private static boolean isDefaultOrMtlsTransportFactory( - @Nullable HttpTransportFactory transportFactory) { + static boolean isDefaultOrMtlsTransportFactory(@Nullable HttpTransportFactory transportFactory) { return transportFactory == null || transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY || transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory @@ -239,15 +238,32 @@ boolean shouldUseMtlsTransportFactory() { && !((MtlsHttpTransportFactory) this.transportFactory).hasKeyStore()); } - private static boolean isSslException(@Nullable Throwable throwable) { + boolean hasMtlsProviderForImpersonation() { + return this.x509Provider != null && shouldUseMtlsTransportFactory(); + } + + AccessToken refreshImpersonatedAccessTokenWithRetry(ImpersonatedCredentials impersonated) + throws IOException { + return refreshWithRetry( + /* explicitTransportFactory= */ null, + /* pinnedKeyStore= */ null, + /* targetImpersonated= */ impersonated, + /* allowRetry= */ true); + } + + private static boolean isRetryableTransportException(@Nullable Throwable throwable) { + if (!(throwable instanceof IOException) + || throwable instanceof CertificateSourceUnavailableException) { + return false; + } Throwable current = throwable; while (current != null) { - if (current instanceof SSLException) { - return true; + if (current instanceof OAuthException || current instanceof HttpResponseException) { + return false; } current = current.getCause(); } - return false; + return true; } @Override @@ -256,7 +272,10 @@ public AccessToken refreshAccessToken() throws IOException { // refreshWithRetry so transient mid-rotation KeyStore read errors and TLS handshake errors // can be retried once. return refreshWithRetry( - /* explicitTransportFactory= */ null, /* pinnedKeyStore= */ null, /* allowRetry= */ true); + /* explicitTransportFactory= */ null, + /* pinnedKeyStore= */ null, + getImpersonatedCredentials(), + /* allowRetry= */ true); } @Override @@ -268,7 +287,11 @@ AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) throw // Retry is intentionally disabled when an explicit cycleTransportFactory is supplied to // ensure transport synchronization across multi-step token exchanges (e.g. STS and IAM) // and prevent nested retry amplification. Outer callers manage retry coordination. - return refreshWithRetry(cycleTransportFactory, pinnedKeyStore, /* allowRetry= */ false); + return refreshWithRetry( + cycleTransportFactory, + pinnedKeyStore, + getImpersonatedCredentials(), + /* allowRetry= */ false); } @Override @@ -278,12 +301,17 @@ AccessToken refreshAccessToken( if (pinnedKeyStore == null) { return refreshAccessToken(cycleTransportFactory); } - return refreshWithRetry(cycleTransportFactory, pinnedKeyStore, /* allowRetry= */ false); + return refreshWithRetry( + cycleTransportFactory, + pinnedKeyStore, + getImpersonatedCredentials(), + /* allowRetry= */ false); } private AccessToken refreshWithRetry( @Nullable HttpTransportFactory explicitTransportFactory, @Nullable KeyStore pinnedKeyStore, + @Nullable ImpersonatedCredentials targetImpersonated, boolean allowRetry) throws IOException { try { @@ -298,9 +326,8 @@ && shouldUseMtlsTransportFactory()) { cycleTransportFactory = createMtlsTransportFactory(pinnedKeyStore); } - ImpersonatedCredentials impersonated = getImpersonatedCredentials(); - if (impersonated != null) { - return impersonated.refreshAccessToken( + if (targetImpersonated != null) { + return targetImpersonated.refreshAccessToken( pinnedKeyStore != null ? cycleTransportFactory : null, pinnedKeyStore); } @@ -352,12 +379,12 @@ && shouldUseMtlsTransportFactory()) { && this.x509Provider != null && shouldUseMtlsTransportFactory() && (OAuth2Utils.isUnauthorizedException(e) - || isSslException(e) + || isRetryableTransportException(e) || isInitialKeyStoreLoadFailure)) { KeyStore freshKeyStore; try { - // On 401, TLS handshake failure, or transient initial KeyStore load failure, re-read - // from X509Provider for fresh certs. + // On 401, TLS handshake/transport failure, or transient initial KeyStore load failure, + // re-read from X509Provider for fresh certs. freshKeyStore = this.x509Provider.getKeyStore(); } catch (IOException reloadException) { if (reloadException != e) { @@ -378,7 +405,8 @@ && shouldUseMtlsTransportFactory() try { HttpTransportFactory retryTransportFactory = createMtlsTransportFactory(freshKeyStore); - return refreshWithRetry(retryTransportFactory, freshKeyStore, /* allowRetry= */ false); + return refreshWithRetry( + retryTransportFactory, freshKeyStore, targetImpersonated, /* allowRetry= */ false); } catch (IOException | RuntimeException retryException) { if (retryException != e) { retryException.addSuppressed(e); diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java index 912e71a0a9e0..10391676bb6f 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java @@ -547,9 +547,16 @@ private ImpersonatedCredentials(Builder builder) throws IOException { this.delegates = builder.getDelegates(); this.scopes = ImmutableList.copyOf(builder.getScopes()); this.lifetime = builder.getLifetime(); + HttpTransportFactory builderTransportFactory = builder.getHttpTransportFactory(); + if (builderTransportFactory == null + && this.sourceCredentials instanceof IdentityPoolCredentials + && ((IdentityPoolCredentials) this.sourceCredentials).isMtlsConfigured()) { + builderTransportFactory = + ((IdentityPoolCredentials) this.sourceCredentials).getTransportFactory(); + } this.transportFactory = firstNonNull( - builder.getHttpTransportFactory(), + builderTransportFactory, getFromServiceLoader(HttpTransportFactory.class, OAuth2Utils.HTTP_TRANSPORT_FACTORY)); this.iamEndpointOverride = builder.iamEndpointOverride; this.transportFactoryClassName = this.transportFactory.getClass().getName(); @@ -587,8 +594,32 @@ public String getUniverseDomain() throws IOException { return this.sourceCredentials.getUniverseDomain(); } + private ExternalAccountCredentials ensureExternalSourceScoped() { + synchronized (this) { + Collection currentScopes = + ((ExternalAccountCredentials) this.sourceCredentials).getScopes(); + if (currentScopes == null || !currentScopes.contains(OAuth2Utils.CLOUD_PLATFORM_SCOPE)) { + List updatedScopes = + currentScopes != null ? new ArrayList<>(currentScopes) : new ArrayList<>(); + updatedScopes.add(OAuth2Utils.CLOUD_PLATFORM_SCOPE); + this.sourceCredentials = this.sourceCredentials.createScoped(updatedScopes); + } + return (ExternalAccountCredentials) this.sourceCredentials; + } + } + @Override public AccessToken refreshAccessToken() throws IOException { + if (this.sourceCredentials instanceof ExternalAccountCredentials) { + ExternalAccountCredentials externalSource = ensureExternalSourceScoped(); + if (externalSource instanceof IdentityPoolCredentials) { + IdentityPoolCredentials identityPoolSource = (IdentityPoolCredentials) externalSource; + if (identityPoolSource.hasMtlsProviderForImpersonation() + && IdentityPoolCredentials.isDefaultOrMtlsTransportFactory(this.transportFactory)) { + return identityPoolSource.refreshImpersonatedAccessTokenWithRetry(this); + } + } + } return refreshAccessToken(null); } @@ -649,18 +680,7 @@ AccessToken refreshAccessToken( HttpCredentialsAdapter adapter; AccessToken intermediateAccessTokenForCache = null; if (this.sourceCredentials instanceof ExternalAccountCredentials) { - ExternalAccountCredentials externalSource; - synchronized (this) { - Collection currentScopes = - ((ExternalAccountCredentials) this.sourceCredentials).getScopes(); - if (currentScopes == null || !currentScopes.contains(OAuth2Utils.CLOUD_PLATFORM_SCOPE)) { - List updatedScopes = - currentScopes != null ? new ArrayList<>(currentScopes) : new ArrayList<>(); - updatedScopes.add(OAuth2Utils.CLOUD_PLATFORM_SCOPE); - this.sourceCredentials = this.sourceCredentials.createScoped(updatedScopes); - } - externalSource = (ExternalAccountCredentials) this.sourceCredentials; - } + ExternalAccountCredentials externalSource = ensureExternalSourceScoped(); if (cycleTransportFactory == null) { try { externalSource.refreshIfExpired(); @@ -1073,5 +1093,15 @@ public ImpersonatedCredentials build() { private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException { input.defaultReadObject(); transportFactory = newInstance(transportFactoryClassName); + if (this.sourceCredentials instanceof IdentityPoolCredentials + && this.transportFactory instanceof MtlsHttpTransportFactory + && !((MtlsHttpTransportFactory) this.transportFactory).hasKeyStore()) { + HttpTransportFactory sourceTransportFactory = + ((IdentityPoolCredentials) this.sourceCredentials).getTransportFactory(); + if (sourceTransportFactory instanceof MtlsHttpTransportFactory + && ((MtlsHttpTransportFactory) sourceTransportFactory).hasKeyStore()) { + this.transportFactory = sourceTransportFactory; + } + } } } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java index a849e3d3a297..cb77144f792b 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java @@ -4658,4 +4658,289 @@ void deserialization_respectsUseMtlsTransportFactoryFlag() throws Exception { assertTrue( deserializedCustom.getTransportFactory() instanceof SerializableCustomTransportFactory); } + + @Test + void refreshAccessToken_bareIoExceptionFromSplitWrite_retriesWhenKeyStoreChanges() + throws Exception { + // Against live sts.mtls.googleapis.com, a split write (cert2 + key1) fails during the TLS + // handshake with a bare `new IOException("Error writing request body to server")` and an + // empty cause chain (getCause() == null). + byte[] cert2Bytes = Files.readAllBytes(Paths.get("testresources/mtls/test_cert_2.pem")); + byte[] key1Bytes = Files.readAllBytes(Paths.get("testresources/mtls/test_key.pem")); + byte[] key2Bytes = Files.readAllBytes(Paths.get("testresources/mtls/test_key_2.pem")); + byte[] newline = "\n".getBytes(StandardCharsets.UTF_8); + + KeyStore splitWriteKeyStore = + SecurityUtils.createMtlsKeyStore( + new ByteArrayInputStream( + com.google.common.primitives.Bytes.concat(cert2Bytes, newline, key1Bytes))); + KeyStore completedRotationKeyStore = + SecurityUtils.createMtlsKeyStore( + new ByteArrayInputStream( + com.google.common.primitives.Bytes.concat(cert2Bytes, newline, key2Bytes))); + + AtomicInteger getKeyStoreCount = new AtomicInteger(0); + X509Provider x509Provider = + new X509Provider(null) { + @Override + public KeyStore getKeyStore() { + return getKeyStoreCount.incrementAndGet() == 1 + ? splitWriteKeyStore + : completedRotationKeyStore; + } + }; + + AtomicInteger exchangeCount = new AtomicInteger(0); + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(x509Provider) + .setAudience("audience") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token")) { + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) + throws IOException { + if (exchangeCount.incrementAndGet() == 1) { + // Bare IOException with getCause() == null, matching HttpURLConnection behavior + throw new IOException("Error writing request body to server"); + } + return new AccessToken("recovered-after-bare-io-exception", null); + } + }; + + AccessToken token = credential.refreshAccessToken(); + assertEquals("recovered-after-bare-io-exception", token.getTokenValue()); + assertEquals(2, getKeyStoreCount.get()); + assertEquals(2, exchangeCount.get()); + } + + @Test + void + standaloneImpersonatedCredentials_wrappingMtlsIdentityPoolCredentials_usesMtlsAndRetriesOnRotation() + throws Exception { + KeyStore ks1 = createPopulatedKeyStore(); + KeyStore ks2 = createRotatedPopulatedKeyStore(); + + AtomicInteger getKeyStoreCount = new AtomicInteger(0); + X509Provider rotatingProvider = + new X509Provider(null) { + @Override + public KeyStore getKeyStore() { + int count = getKeyStoreCount.incrementAndGet(); + return count == 1 ? ks1 : ks2; + } + }; + + AtomicInteger stsCallCount = new AtomicInteger(0); + AtomicInteger iamCallCount = new AtomicInteger(0); + List capturedKeyStores = new ArrayList<>(); + + IdentityPoolCredentials sourceCredentials = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(rotatingProvider) + .setScopes(Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE)) + .setAudience("audience") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token")) { + @Override + HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { + capturedKeyStores.add(keyStore); + return () -> + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() throws IOException { + if (url.contains("sts.mtls.googleapis.com")) { + int count = stsCallCount.incrementAndGet(); + if (keyStore == ks1) { + // Simulate split write bare IOException on ks1 + throw new IOException("Error writing request body to server"); + } + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("access_token", "standalone-sts-token-" + count); + response.put( + "issued_token_type", "urn:ietf:params:oauth:token-type:access_token"); + response.put("token_type", "Bearer"); + response.put("expires_in", 3600); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } else if (url.contains("iamcredentials")) { + int count = iamCallCount.incrementAndGet(); + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("accessToken", "standalone-iam-token-" + count); + response.put("expireTime", "2030-01-01T00:00:00Z"); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } + return new MockLowLevelHttpResponse().setStatusCode(404); + } + }; + } + }; + } + }; + + // Build standalone ImpersonatedCredentials directly without setting HttpTransportFactory. + ImpersonatedCredentials standaloneImpersonated = + ImpersonatedCredentials.newBuilder() + .setSourceCredentials(sourceCredentials) + .setTargetPrincipal("sa@project.iam.gserviceaccount.com") + .setScopes(Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE)) + .build(); + + // Refresh 1: ks1 throws bare IOException -> retries once with ks2 -> both STS and IAM succeed + // over the pinned mTLS transport. + AccessToken token1 = standaloneImpersonated.refreshAccessToken(); + assertEquals("standalone-iam-token-1", token1.getTokenValue()); + assertEquals(2, stsCallCount.get()); + assertEquals(1, iamCallCount.get()); + assertEquals(Arrays.asList(ks1, ks2), capturedKeyStores); + + // Refresh 2 (still ks2): reuses the cached 1-hour STS token without calling STS again! + AccessToken token2 = standaloneImpersonated.refreshAccessToken(); + assertEquals("standalone-iam-token-2", token2.getTokenValue()); + assertEquals(2, stsCallCount.get()); + assertEquals(2, iamCallCount.get()); + } + + @Test + void refreshAccessToken_bareIoException_doesNotRetryWhenKeyStoreUnchanged() throws Exception { + KeyStore ks1 = createPopulatedKeyStore(); + KeyStore ks1Same = createPopulatedKeyStore(); + AtomicInteger getKeyStoreCount = new AtomicInteger(0); + X509Provider provider = + new X509Provider(null) { + @Override + public KeyStore getKeyStore() { + return getKeyStoreCount.incrementAndGet() == 1 ? ks1 : ks1Same; + } + }; + + AtomicInteger exchangeCount = new AtomicInteger(0); + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(provider) + .setAudience("audience") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token")) { + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) + throws IOException { + exchangeCount.incrementAndGet(); + throw new IOException("Error writing request body to server"); + } + }; + + IOException thrown = assertThrows(IOException.class, credential::refreshAccessToken); + assertEquals("Error writing request body to server", thrown.getMessage()); + assertEquals(2, getKeyStoreCount.get()); + assertEquals(1, exchangeCount.get()); + } + + @Test + void refreshAccessToken_non401OAuthException_doesNotRetryEvenWhenKeyStoreChanges() + throws Exception { + KeyStore ks1 = createPopulatedKeyStore(); + KeyStore ks2 = createRotatedPopulatedKeyStore(); + AtomicInteger getKeyStoreCount = new AtomicInteger(0); + X509Provider rotatingProvider = + new X509Provider(null) { + @Override + public KeyStore getKeyStore() { + return getKeyStoreCount.incrementAndGet() == 1 ? ks1 : ks2; + } + }; + + AtomicInteger exchangeCount = new AtomicInteger(0); + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(rotatingProvider) + .setAudience("audience") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token")) { + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) + throws IOException { + exchangeCount.incrementAndGet(); + throw new OAuthException("invalid_request", "Bad Request", null, 400); + } + }; + + OAuthException thrown = assertThrows(OAuthException.class, credential::refreshAccessToken); + assertEquals(400, thrown.getHttpStatusCode()); + assertEquals(1, getKeyStoreCount.get()); + assertEquals(1, exchangeCount.get()); + } + + @Test + void + standaloneImpersonatedCredentials_withFileCertConfig_survivesDeserializationAndAddsCloudPlatformScope() + throws Exception { + Map certMap = new HashMap<>(); + certMap.put("certificate_config_location", "testresources/mtls/certificate_config.json"); + Map sourceMap = new HashMap<>(); + sourceMap.put("file", "credential.json"); + sourceMap.put("certificate", certMap); + IdentityPoolCredentialSource credentialSource = new IdentityPoolCredentialSource(sourceMap); + + // Build IdentityPoolCredentials with a custom scope (missing cloud-platform) and file cert + // config. + IdentityPoolCredentials sourceCredentials = + IdentityPoolCredentials.newBuilder() + .setCredentialSource(credentialSource) + .setScopes(Collections.singletonList("https://www.googleapis.com/auth/CustomScope")) + .setAudience("audience") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build(); + assertTrue(sourceCredentials.getTransportFactory() instanceof MtlsHttpTransportFactory); + assertTrue(((MtlsHttpTransportFactory) sourceCredentials.getTransportFactory()).hasKeyStore()); + + // Build standalone ImpersonatedCredentials without setting HttpTransportFactory. + ImpersonatedCredentials standaloneImpersonated = + ImpersonatedCredentials.newBuilder() + .setSourceCredentials(sourceCredentials) + .setTargetPrincipal("sa@project.iam.gserviceaccount.com") + .setScopes(Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE)) + .build(); + + // Verify standaloneImpersonated inherited the populated MtlsHttpTransportFactory at build time + assertTrue( + standaloneImpersonated.toBuilder().getHttpTransportFactory() + instanceof MtlsHttpTransportFactory); + assertTrue( + ((MtlsHttpTransportFactory) standaloneImpersonated.toBuilder().getHttpTransportFactory()) + .hasKeyStore()); + + // Serialize and deserialize standaloneImpersonated and verify readObject() restores the + // populated MtlsHttpTransportFactory from sourceCredentials. + ImpersonatedCredentials deserializedImpersonated = + serializeAndDeserialize(standaloneImpersonated); + assertTrue( + deserializedImpersonated.toBuilder().getHttpTransportFactory() + instanceof MtlsHttpTransportFactory); + assertTrue( + ((MtlsHttpTransportFactory) deserializedImpersonated.toBuilder().getHttpTransportFactory()) + .hasKeyStore()); + } } From ddd5ea8176c64488a5ffd9ae63477dfc2e405887 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Wed, 23 Sep 2026 03:01:59 +0000 Subject: [PATCH 17/20] fix(oauth2): handle mid-rotation invalid_grant, cached STS 401 retry, and single cert read on impersonated refresh --- .../auth/oauth2/IdentityPoolCredentials.java | 83 ++++-- .../auth/oauth2/ImpersonatedCredentials.java | 32 +- .../com/google/auth/oauth2/OAuth2Utils.java | 24 ++ .../oauth2/IdentityPoolCredentialsTest.java | 280 +++++++++++++++++- 4 files changed, 388 insertions(+), 31 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java index 8bd293ca5d9c..92c0bda203a4 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java @@ -43,6 +43,7 @@ import java.io.IOException; import java.io.ObjectInputStream; import java.net.URI; +import java.security.GeneralSecurityException; import java.security.KeyStore; import java.util.ArrayList; import java.util.Collection; @@ -242,6 +243,13 @@ boolean hasMtlsProviderForImpersonation() { return this.x509Provider != null && shouldUseMtlsTransportFactory(); } + boolean hasInitializedMtlsTransport() { + return this.x509Provider != null + && (!shouldUseMtlsTransportFactory() + || (this.transportFactory instanceof MtlsHttpTransportFactory + && ((MtlsHttpTransportFactory) this.transportFactory).hasKeyStore())); + } + AccessToken refreshImpersonatedAccessTokenWithRetry(ImpersonatedCredentials impersonated) throws IOException { return refreshWithRetry( @@ -252,18 +260,27 @@ AccessToken refreshImpersonatedAccessTokenWithRetry(ImpersonatedCredentials impe } private static boolean isRetryableTransportException(@Nullable Throwable throwable) { - if (!(throwable instanceof IOException) - || throwable instanceof CertificateSourceUnavailableException) { + if (throwable == null || throwable instanceof CertificateSourceUnavailableException) { return false; } + boolean hasIoOrSecurityException = throwable instanceof IOException; Throwable current = throwable; while (current != null) { - if (current instanceof OAuthException || current instanceof HttpResponseException) { + if (current instanceof CertificateSourceUnavailableException + || current instanceof OAuthException + || current instanceof HttpResponseException) { return false; } - current = current.getCause(); + if (current instanceof IOException || current instanceof GeneralSecurityException) { + hasIoOrSecurityException = true; + } + Throwable cause = current.getCause(); + if (cause == current) { + break; + } + current = cause; } - return true; + return hasIoOrSecurityException; } @Override @@ -274,7 +291,7 @@ public AccessToken refreshAccessToken() throws IOException { return refreshWithRetry( /* explicitTransportFactory= */ null, /* pinnedKeyStore= */ null, - getImpersonatedCredentials(), + /* targetImpersonated= */ null, /* allowRetry= */ true); } @@ -290,7 +307,7 @@ AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) throw return refreshWithRetry( cycleTransportFactory, pinnedKeyStore, - getImpersonatedCredentials(), + /* targetImpersonated= */ null, /* allowRetry= */ false); } @@ -304,7 +321,7 @@ AccessToken refreshAccessToken( return refreshWithRetry( cycleTransportFactory, pinnedKeyStore, - getImpersonatedCredentials(), + /* targetImpersonated= */ null, /* allowRetry= */ false); } @@ -314,6 +331,7 @@ private AccessToken refreshWithRetry( @Nullable ImpersonatedCredentials targetImpersonated, boolean allowRetry) throws IOException { + ImpersonatedCredentials effectiveImpersonated = targetImpersonated; try { HttpTransportFactory cycleTransportFactory = explicitTransportFactory != null ? explicitTransportFactory : this.transportFactory; @@ -324,10 +342,16 @@ && shouldUseMtlsTransportFactory()) { pinnedKeyStore = this.x509Provider.getKeyStore(); } cycleTransportFactory = createMtlsTransportFactory(pinnedKeyStore); + if (!hasInitializedMtlsTransport()) { + this.transportFactory = cycleTransportFactory; + } } - if (targetImpersonated != null) { - return targetImpersonated.refreshAccessToken( + if (effectiveImpersonated == null) { + effectiveImpersonated = getImpersonatedCredentials(); + } + if (effectiveImpersonated != null) { + return effectiveImpersonated.refreshAccessToken( pinnedKeyStore != null ? cycleTransportFactory : null, pinnedKeyStore); } @@ -373,18 +397,23 @@ && shouldUseMtlsTransportFactory()) { } catch (IOException | RuntimeException e) { boolean isInitialKeyStoreLoadFailure = pinnedKeyStore == null - && e instanceof IOException - && !(e instanceof CertificateSourceUnavailableException); + && !(e instanceof CertificateSourceUnavailableException) + && isRetryableTransportException(e); + boolean reusedCachedStsTokenOn401 = + effectiveImpersonated != null + && effectiveImpersonated.consumeInvalidatedCachedStsTokenOn401() + && OAuth2Utils.isUnauthorizedException(e); if (allowRetry && this.x509Provider != null && shouldUseMtlsTransportFactory() && (OAuth2Utils.isUnauthorizedException(e) + || OAuth2Utils.isInvalidGrantException(e) || isRetryableTransportException(e) || isInitialKeyStoreLoadFailure)) { KeyStore freshKeyStore; try { - // On 401, TLS handshake/transport failure, or transient initial KeyStore load failure, - // re-read from X509Provider for fresh certs. + // On 401, STS invalid_grant, TLS handshake/transport failure, or transient initial + // KeyStore load failure, re-read from X509Provider for fresh certs. freshKeyStore = this.x509Provider.getKeyStore(); } catch (IOException reloadException) { if (reloadException != e) { @@ -399,14 +428,18 @@ && shouldUseMtlsTransportFactory() } if (!isInitialKeyStoreLoadFailure + && !reusedCachedStsTokenOn401 && !OAuth2Utils.hasCertificateChanged(pinnedKeyStore, freshKeyStore)) { throw e; } try { HttpTransportFactory retryTransportFactory = createMtlsTransportFactory(freshKeyStore); + if (!hasInitializedMtlsTransport()) { + this.transportFactory = retryTransportFactory; + } return refreshWithRetry( - retryTransportFactory, freshKeyStore, targetImpersonated, /* allowRetry= */ false); + retryTransportFactory, freshKeyStore, effectiveImpersonated, /* allowRetry= */ false); } catch (IOException | RuntimeException retryException) { if (retryException != e) { retryException.addSuppressed(e); @@ -487,6 +520,9 @@ private void initializeMtlsTransport( Builder builder, IdentityPoolCredentialSource credentialSource) throws IOException { X509Provider x509Provider = getX509Provider(builder, credentialSource); this.x509Provider = x509Provider; + if (builder.isClonedTransportInitialized) { + return; + } KeyStore mtlsKeyStore = x509Provider.getKeyStore(); if (shouldUseMtlsTransportFactory()) { this.transportFactory = createMtlsTransportFactory(mtlsKeyStore); @@ -505,11 +541,13 @@ private IdentityPoolSubjectTokenSupplier createCertificateSubjectTokenSupplier( // Configure the mTLS transport with the x509 keystore if custom transport was not provided. initializeMtlsTransport(builder, credentialSource); - // Initialize the subject token supplier with the certificate path. - String explicitCertConfigPath = getExplicitCertConfigPath(credentialSource); - credentialSource.setCredentialLocation( - MtlsUtils.getCertificatePath( - getEnvironmentProvider(), getPropertyProvider(), explicitCertConfigPath)); + // Initialize the subject token supplier with the certificate path if not already set. + if (credentialSource.getCredentialLocation() == null) { + String explicitCertConfigPath = getExplicitCertConfigPath(credentialSource); + credentialSource.setCredentialLocation( + MtlsUtils.getCertificatePath( + getEnvironmentProvider(), getPropertyProvider(), explicitCertConfigPath)); + } return new CertificateIdentityPoolSubjectTokenSupplier(credentialSource); } @@ -578,6 +616,7 @@ public static class Builder extends ExternalAccountCredentials.Builder { private @Nullable String actorTokenType; private @Nullable X509Provider x509Provider; private @Nullable Boolean useMtlsTransportFactory; + private boolean isClonedTransportInitialized; Builder() {} @@ -596,6 +635,7 @@ public static class Builder extends ExternalAccountCredentials.Builder { this.actorTokenType = credentials.actorTokenType; this.x509Provider = credentials.x509Provider; this.useMtlsTransportFactory = credentials.useMtlsTransportFactory; + this.isClonedTransportInitialized = credentials.hasInitializedMtlsTransport(); } /** @@ -611,6 +651,7 @@ public static class Builder extends ExternalAccountCredentials.Builder { @VisibleForTesting Builder setX509Provider(X509Provider x509Provider) { this.x509Provider = x509Provider; + this.isClonedTransportInitialized = false; return this; } @@ -665,6 +706,7 @@ Builder setActorTokenType(String actorTokenType) { public Builder setHttpTransportFactory(HttpTransportFactory transportFactory) { super.setHttpTransportFactory(transportFactory); this.useMtlsTransportFactory = isDefaultOrMtlsTransportFactory(transportFactory); + this.isClonedTransportInitialized = false; return this; } @@ -699,6 +741,7 @@ public Builder setTokenUrl(String tokenUrl) { @CanIgnoreReturnValue public Builder setCredentialSource(IdentityPoolCredentialSource credentialSource) { super.setCredentialSource(credentialSource); + this.isClonedTransportInitialized = false; return this; } diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java index 10391676bb6f..f4831a5c9be8 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java @@ -116,6 +116,7 @@ public class ImpersonatedCredentials extends GoogleCredentials private volatile GoogleCredentials sourceCredentials; private transient volatile @Nullable AccessToken cachedStsAccessToken; private transient volatile @Nullable KeyStore cachedStsKeyStore; + private transient volatile boolean invalidatedCachedStsTokenOn401; private final String targetPrincipal; private List delegates; private final List scopes; @@ -608,18 +609,24 @@ private ExternalAccountCredentials ensureExternalSourceScoped() { } } + synchronized boolean consumeInvalidatedCachedStsTokenOn401() { + boolean value = this.invalidatedCachedStsTokenOn401; + this.invalidatedCachedStsTokenOn401 = false; + return value; + } + @Override public AccessToken refreshAccessToken() throws IOException { - if (this.sourceCredentials instanceof ExternalAccountCredentials) { - ExternalAccountCredentials externalSource = ensureExternalSourceScoped(); - if (externalSource instanceof IdentityPoolCredentials) { - IdentityPoolCredentials identityPoolSource = (IdentityPoolCredentials) externalSource; - if (identityPoolSource.hasMtlsProviderForImpersonation() - && IdentityPoolCredentials.isDefaultOrMtlsTransportFactory(this.transportFactory)) { - return identityPoolSource.refreshImpersonatedAccessTokenWithRetry(this); - } + if (this.sourceCredentials instanceof IdentityPoolCredentials) { + IdentityPoolCredentials identityPoolSource = (IdentityPoolCredentials) this.sourceCredentials; + if (identityPoolSource.hasMtlsProviderForImpersonation() + && IdentityPoolCredentials.isDefaultOrMtlsTransportFactory(this.transportFactory)) { + return identityPoolSource.refreshImpersonatedAccessTokenWithRetry(this); } } + if (this.sourceCredentials instanceof ExternalAccountCredentials) { + ensureExternalSourceScoped(); + } return refreshAccessToken(null); } @@ -679,6 +686,12 @@ AccessToken refreshAccessToken( firstNonNull(cycleTransportFactory, this.transportFactory); HttpCredentialsAdapter adapter; AccessToken intermediateAccessTokenForCache = null; + boolean usedCachedStsToken = false; + if (cycleTransportFactory != null) { + synchronized (this) { + this.invalidatedCachedStsTokenOn401 = false; + } + } if (this.sourceCredentials instanceof ExternalAccountCredentials) { ExternalAccountCredentials externalSource = ensureExternalSourceScoped(); if (cycleTransportFactory == null) { @@ -693,6 +706,7 @@ AccessToken refreshAccessToken( synchronized (this) { if (isCachedStsTokenReusable(externalSource, pinnedKeyStore)) { intermediateAccessToken = this.cachedStsAccessToken; + usedCachedStsToken = true; } } if (intermediateAccessToken == null) { @@ -790,6 +804,8 @@ public AccessToken refreshAccessToken() { synchronized (this) { this.cachedStsAccessToken = null; this.cachedStsKeyStore = null; + this.invalidatedCachedStsTokenOn401 = + usedCachedStsToken && OAuth2Utils.isUnauthorizedException(e); } } throw new IOException("Error requesting access token", e); diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java index b7386ca0c3ea..e5bed067db00 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java @@ -358,6 +358,30 @@ static boolean isUnauthorizedException(@Nullable Throwable t) { return false; } + /** + * Returns whether the given throwable or any exception in its causal chain represents an OAuth + * {@code invalid_grant} error (for example, HTTP 400 {@code invalid_grant} returned by STS when a + * client certificate and {@code subject_token} mismatch during certificate rotation). + * + * @param t the throwable to inspect + * @return {@code true} if {@code t} or any cause in its chain is an {@code invalid_grant} {@link + * OAuthException} + */ + static boolean isInvalidGrantException(@Nullable Throwable t) { + while (t != null) { + if (t instanceof OAuthException + && "invalid_grant".equals(((OAuthException) t).getErrorCode())) { + return true; + } + Throwable cause = t.getCause(); + if (cause == t) { + break; + } + t = cause; + } + return false; + } + /** * Returns whether the certificate chain or private key in {@code newKeyStore} differs from {@code * oldKeyStore}. Used on 401 retry recovery to avoid retrying when the reloaded certificate and diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java index cb77144f792b..c22bff87b135 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java @@ -3239,9 +3239,14 @@ public KeyStore getKeyStore() { credential.createScoped( Collections.singletonList("https://www.googleapis.com/auth/cloud-platform")); - assertEquals(2, getKeyStoreCount.get()); + assertEquals(1, getKeyStoreCount.get()); assertTrue(scoped.getTransportFactory() instanceof MtlsHttpTransportFactory); - assertNotSame(originalTransportFactory, scoped.getTransportFactory()); + assertSame(originalTransportFactory, scoped.getTransportFactory()); + + IdentityPoolCredentials rebuiltWithProvider = + credential.toBuilder().setX509Provider(trackingProvider).build(); + assertEquals(2, getKeyStoreCount.get()); + assertNotSame(originalTransportFactory, rebuiltWithProvider.getTransportFactory()); } // ================================================================================== @@ -4123,7 +4128,8 @@ public LowLevelHttpResponse execute() { .setStatusCode(401) .setContentType(Json.MEDIA_TYPE) .setContent( - "{\"error\": \"invalid_client\", \"error_description\": \"Unauthorized\"}"); + "{\"error\": \"invalid_client\", \"error_description\":" + + " \"Unauthorized\"}"); } GenericJson response = new GenericJson(); response.setFactory(OAuth2Utils.JSON_FACTORY); @@ -4943,4 +4949,272 @@ protected AccessToken exchangeExternalCredentialForAccessToken( ((MtlsHttpTransportFactory) deserializedImpersonated.toBuilder().getHttpTransportFactory()) .hasKeyStore()); } + + @Test + void refreshAccessToken_invalidGrantFromMidRotationCertKeyMismatch_retriesWhenKeyStoreChanges() + throws Exception { + byte[] cert2Bytes = Files.readAllBytes(Paths.get("testresources/mtls/test_cert_2.pem")); + byte[] key1Bytes = Files.readAllBytes(Paths.get("testresources/mtls/test_key.pem")); + byte[] key2Bytes = Files.readAllBytes(Paths.get("testresources/mtls/test_key_2.pem")); + byte[] newline = "\n".getBytes(StandardCharsets.UTF_8); + + // Mid-rotation state: cert_path updated to cert2, key_path still has key1. + KeyStore midRotationKeyStore = + SecurityUtils.createMtlsKeyStore( + new ByteArrayInputStream( + com.google.common.primitives.Bytes.concat(cert2Bytes, newline, key1Bytes))); + // Completed rotation state: both cert_path and key_path updated to cert2 + key2. + KeyStore completedRotationKeyStore = + SecurityUtils.createMtlsKeyStore( + new ByteArrayInputStream( + com.google.common.primitives.Bytes.concat(cert2Bytes, newline, key2Bytes))); + + AtomicInteger getKeyStoreCount = new AtomicInteger(0); + X509Provider rotatingProvider = + new X509Provider(null) { + @Override + public KeyStore getKeyStore() { + return getKeyStoreCount.incrementAndGet() == 1 + ? midRotationKeyStore + : completedRotationKeyStore; + } + }; + + AtomicInteger exchangeCount = new AtomicInteger(0); + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(rotatingProvider) + .setAudience("audience") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:mtls") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token")) { + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) + throws IOException { + if (exchangeCount.incrementAndGet() == 1) { + throw new OAuthException( + "invalid_grant", + "The subject_token does not match the client certificate.", + null, + 400); + } + return new AccessToken("recovered-after-invalid-grant-retry", null); + } + }; + + AccessToken token = credential.refreshAccessToken(); + assertEquals("recovered-after-invalid-grant-retry", token.getTokenValue()); + assertEquals(2, getKeyStoreCount.get()); + assertEquals(2, exchangeCount.get()); + } + + @Test + void refreshAccessToken_invalidGrant_doesNotRetryWhenKeyStoreUnchanged() throws Exception { + KeyStore ks1 = createPopulatedKeyStore(); + KeyStore ks1Same = createPopulatedKeyStore(); + AtomicInteger getKeyStoreCount = new AtomicInteger(0); + X509Provider unchangedProvider = + new X509Provider(null) { + @Override + public KeyStore getKeyStore() { + return getKeyStoreCount.incrementAndGet() == 1 ? ks1 : ks1Same; + } + }; + + AtomicInteger exchangeCount = new AtomicInteger(0); + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(unchangedProvider) + .setAudience("audience") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:mtls") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token")) { + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) + throws IOException { + exchangeCount.incrementAndGet(); + throw new OAuthException("invalid_grant", "Invalid subject token", null, 400); + } + }; + + OAuthException thrown = assertThrows(OAuthException.class, credential::refreshAccessToken); + assertEquals("invalid_grant", thrown.getErrorCode()); + assertEquals(2, getKeyStoreCount.get()); + assertEquals(1, exchangeCount.get()); + } + + @Test + void + refreshAccessToken_impersonation_401OnIamWithCachedStsToken_retriesAndMintsFreshStsTokenEvenWhenCertUnchanged() + throws Exception { + KeyStore ks1 = createPopulatedKeyStore(); + AtomicInteger getKeyStoreCallCount = new AtomicInteger(0); + X509Provider unchangedProvider = + new X509Provider(null) { + @Override + public KeyStore getKeyStore() { + getKeyStoreCallCount.incrementAndGet(); + return ks1; + } + }; + + AtomicInteger stsCallCount = new AtomicInteger(0); + AtomicInteger iamCallCount = new AtomicInteger(0); + List iamBearerHeaders = new ArrayList<>(); + + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(unchangedProvider) + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .setServiceAccountImpersonationUrl( + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken")) { + @Override + HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { + return () -> + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() { + if (url.contains("/v1/token")) { + int count = stsCallCount.incrementAndGet(); + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("access_token", "cached-sts-token-" + count); + response.put("token_type", "Bearer"); + response.put("expires_in", 3600); + response.put( + "issued_token_type", "urn:ietf:params:oauth:token-type:access_token"); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } else if (url.contains(":generateAccessToken")) { + int count = iamCallCount.incrementAndGet(); + iamBearerHeaders.add(getFirstHeaderValue("Authorization")); + if (count == 2) { + // On the 2nd IAM call (which reuses cached-sts-token-1), IAM returns + // 401 Unauthorized even though the cert on disk has NOT changed. + return new MockLowLevelHttpResponse() + .setStatusCode(401) + .setContentType(Json.MEDIA_TYPE) + .setContent( + "{\"error\":{\"code\":401,\"status\":\"UNAUTHENTICATED\"}}"); + } + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("accessToken", "final-iam-token-" + count); + response.put("expireTime", "2030-01-01T00:00:00Z"); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } + return new MockLowLevelHttpResponse().setStatusCode(404); + } + }; + } + }; + } + }; + + // Refresh 1: mints cached-sts-token-1 and final-iam-token-1. + AccessToken token1 = credential.refreshAccessToken(); + assertEquals("final-iam-token-1", token1.getTokenValue()); + assertEquals(1, stsCallCount.get()); + assertEquals(1, iamCallCount.get()); + + // Refresh 2: reuses cached-sts-token-1 -> IAM returns 401 -> clears cachedStsAccessToken and + // retries once even though ks1 is unchanged -> mints cached-sts-token-2 -> IAM succeeds! + AccessToken token2 = credential.refreshAccessToken(); + assertEquals("final-iam-token-3", token2.getTokenValue()); + assertEquals(2, stsCallCount.get()); + assertEquals(3, iamCallCount.get()); + assertEquals( + Arrays.asList( + "Bearer cached-sts-token-1", "Bearer cached-sts-token-1", "Bearer cached-sts-token-2"), + iamBearerHeaders); + } + + @Test + void refreshAccessToken_firstImpersonatedRefresh_readsKeyStoreOnlyOncePerRefreshCycle() + throws Exception { + KeyStore ks1 = createPopulatedKeyStore(); + AtomicInteger getKeyStoreCallCount = new AtomicInteger(0); + X509Provider singleReadPerRefreshProvider = + new X509Provider(null) { + @Override + public KeyStore getKeyStore() throws IOException { + int call = getKeyStoreCallCount.incrementAndGet(); + if (call > 1) { + throw new IOException( + "Unexpected extra getKeyStore() call #" + call + " during first refresh"); + } + return ks1; + } + }; + + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(singleReadPerRefreshProvider) + .setAudience("audience") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .setServiceAccountImpersonationUrl( + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken")) { + @Override + HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { + return () -> + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() { + if (url.contains("/v1/token")) { + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("access_token", "sts-token-1"); + response.put("token_type", "Bearer"); + response.put("expires_in", 3600); + response.put( + "issued_token_type", "urn:ietf:params:oauth:token-type:access_token"); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } else if (url.contains(":generateAccessToken")) { + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("accessToken", "iam-token-1"); + response.put("expireTime", "2030-01-01T00:00:00Z"); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } + return new MockLowLevelHttpResponse().setStatusCode(404); + } + }; + } + }; + } + }; + + // First impersonated refresh calls getImpersonatedCredentials() -> createScoped() -> + // toBuilder().build(). This MUST NOT call getKeyStore() a second time! + AccessToken token = credential.refreshAccessToken(); + assertEquals("iam-token-1", token.getTokenValue()); + assertEquals(1, getKeyStoreCallCount.get()); + } } From b47ab2a911e043a4080da02146e24213f1af867b Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Wed, 23 Sep 2026 03:31:00 +0000 Subject: [PATCH 18/20] fix(auth): harden concurrency and nullability in ImpersonatedCredentials and CertificateIdentityPoolSubjectTokenSupplier --- ...icateIdentityPoolSubjectTokenSupplier.java | 5 +-- .../auth/oauth2/ImpersonatedCredentials.java | 36 +++++++++++-------- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java index 992d9bbed86f..5b07fc3d2791 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java @@ -268,7 +268,8 @@ private void populateCertChainFromTrustChain( // elsewhere in the chain. if (encodedCurrentCert.equals(encodedLeafCert)) { throw new IllegalArgumentException( - "The leaf certificate should only appear at the beginning of the trust chain file, or be omitted entirely."); + "The leaf certificate should only appear at the beginning of the trust chain file, or" + + " be omitted entirely."); } // Add the current certificate to the chain. @@ -287,7 +288,7 @@ private void populateCertChainFromTrustChain( * @throws CertificateException If an error occurs while parsing a certificate. */ @VisibleForTesting - static List readTrustChain(String trustChainPath) + static List readTrustChain(@Nullable String trustChainPath) throws IOException, CertificateException { List certificateTrustChain = new ArrayList<>(); diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java index f4831a5c9be8..8534866f30c0 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java @@ -116,7 +116,8 @@ public class ImpersonatedCredentials extends GoogleCredentials private volatile GoogleCredentials sourceCredentials; private transient volatile @Nullable AccessToken cachedStsAccessToken; private transient volatile @Nullable KeyStore cachedStsKeyStore; - private transient volatile boolean invalidatedCachedStsTokenOn401; + private static final ThreadLocal INVALIDATED_CACHED_STS_TOKEN_ON_401 = + new ThreadLocal<>(); private final String targetPrincipal; private List delegates; private final List scopes; @@ -609,9 +610,9 @@ private ExternalAccountCredentials ensureExternalSourceScoped() { } } - synchronized boolean consumeInvalidatedCachedStsTokenOn401() { - boolean value = this.invalidatedCachedStsTokenOn401; - this.invalidatedCachedStsTokenOn401 = false; + boolean consumeInvalidatedCachedStsTokenOn401() { + boolean value = Boolean.TRUE.equals(INVALIDATED_CACHED_STS_TOKEN_ON_401.get()); + INVALIDATED_CACHED_STS_TOKEN_ON_401.remove(); return value; } @@ -682,16 +683,18 @@ AccessToken refreshAccessToken(@Nullable HttpTransportFactory cycleTransportFact AccessToken refreshAccessToken( @Nullable HttpTransportFactory cycleTransportFactory, @Nullable KeyStore pinnedKeyStore) throws IOException { + if (cycleTransportFactory != null) { + INVALIDATED_CACHED_STS_TOKEN_ON_401.remove(); + if (this.transportFactory instanceof MtlsHttpTransportFactory + && !((MtlsHttpTransportFactory) this.transportFactory).hasKeyStore()) { + this.transportFactory = cycleTransportFactory; + } + } HttpTransportFactory effectiveTransportFactory = firstNonNull(cycleTransportFactory, this.transportFactory); HttpCredentialsAdapter adapter; AccessToken intermediateAccessTokenForCache = null; boolean usedCachedStsToken = false; - if (cycleTransportFactory != null) { - synchronized (this) { - this.invalidatedCachedStsTokenOn401 = false; - } - } if (this.sourceCredentials instanceof ExternalAccountCredentials) { ExternalAccountCredentials externalSource = ensureExternalSourceScoped(); if (cycleTransportFactory == null) { @@ -802,16 +805,21 @@ public AccessToken refreshAccessToken() { } catch (IOException e) { if (cycleTransportFactory != null) { synchronized (this) { - this.cachedStsAccessToken = null; - this.cachedStsKeyStore = null; - this.invalidatedCachedStsTokenOn401 = - usedCachedStsToken && OAuth2Utils.isUnauthorizedException(e); + if (!usedCachedStsToken || this.cachedStsAccessToken == intermediateAccessTokenForCache) { + this.cachedStsAccessToken = null; + this.cachedStsKeyStore = null; + } + } + if (usedCachedStsToken && OAuth2Utils.isUnauthorizedException(e)) { + INVALIDATED_CACHED_STS_TOKEN_ON_401.set(Boolean.TRUE); } } throw new IOException("Error requesting access token", e); } - if (cycleTransportFactory != null && intermediateAccessTokenForCache != null) { + if (cycleTransportFactory != null + && !usedCachedStsToken + && intermediateAccessTokenForCache != null) { synchronized (this) { this.cachedStsAccessToken = intermediateAccessTokenForCache; this.cachedStsKeyStore = pinnedKeyStore; From 22556ff16c2e74694f7a595b921d3b6add66d7f1 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Wed, 23 Sep 2026 03:57:20 +0000 Subject: [PATCH 19/20] fix(oauth2): address PR #14220 round 2 review feedback --- .../google/auth/oauth2/OAuthException.java | 13 +- .../ITWorkloadIdentityFederationTest.java | 20 ++- .../auth/oauth2/MtlsPipelineLocalTest.java | 137 +++++++++++++++++- .../auth/oauth2/OAuthExceptionTest.java | 48 +++++- 4 files changed, 198 insertions(+), 20 deletions(-) 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 7a0b9b417151..cfa684bcc113 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 @@ -47,7 +47,7 @@ @NullMarked class OAuthException extends GoogleAuthException { - private static final long serialVersionUID = -7883352585835000817L; + private static final long serialVersionUID = -5276727039237496975L; private final String errorCode; @Nullable private final String errorDescription; @@ -112,7 +112,8 @@ static OAuthException createFromHttpResponseException(HttpResponseException e) { GenericJson errorResponse = parser.parseAndClose(GenericJson.class); if (errorResponse == null) { OAuthException oauthException = - new OAuthException("http_error_" + e.getStatusCode(), null, null, e.getStatusCode()); + new OAuthException( + "http_error_" + e.getStatusCode(), e.getStatusMessage(), null, e.getStatusCode()); oauthException.initCause(e); return oauthException; } @@ -137,6 +138,14 @@ static OAuthException createFromHttpResponseException(HttpResponseException e) { 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 == null) { + errorDescription = + errorResponse.isEmpty() && e.getStatusMessage() != null + ? e.getStatusMessage() + : content; } if (errorResponse.get("error_uri") instanceof String) { errorUri = (String) errorResponse.get("error_uri"); 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 79633e8e8af1..2a381931d4c8 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 @@ -368,8 +368,9 @@ void identityPoolCredentials_withProgrammaticMtlsAndActorToken() throws Exceptio context -> generateGoogleIdToken(OIDC_AUDIENCE); IdentityPoolActorTokenSupplier actorSupplier = context -> generateGoogleIdToken(OIDC_AUDIENCE); - KeyStore keyStore = new X509Provider(getMtlsCertificateConfigPath()).getKeyStore(); - HttpTransportFactory transportFactory = new MtlsHttpTransportFactory(keyStore); + X509Provider x509Provider = new X509Provider(getMtlsCertificateConfigPath()); + HttpTransportFactory transportFactory = + new MtlsHttpTransportFactory(x509Provider.getKeyStore()); IdentityPoolCredentials credentials = IdentityPoolCredentials.newBuilder() @@ -383,6 +384,7 @@ void identityPoolCredentials_withProgrammaticMtlsAndActorToken() throws Exceptio String.format( "https://iamcredentials.mtls.googleapis.com/v1/projects/-/serviceAccounts/%s:generateAccessToken", clientEmail)) + .setX509Provider(x509Provider) .setHttpTransportFactory(transportFactory) .build(); @@ -464,8 +466,9 @@ void identityPoolCredentials_directSts_withProgrammaticMtlsAndActorToken() throw context -> generateGoogleIdToken(OIDC_AUDIENCE); IdentityPoolActorTokenSupplier actorSupplier = context -> generateGoogleIdToken(OIDC_AUDIENCE); - KeyStore keyStore = new X509Provider(getMtlsCertificateConfigPath()).getKeyStore(); - HttpTransportFactory transportFactory = new MtlsHttpTransportFactory(keyStore); + X509Provider x509Provider = new X509Provider(getMtlsCertificateConfigPath()); + HttpTransportFactory transportFactory = + new MtlsHttpTransportFactory(x509Provider.getKeyStore()); IdentityPoolCredentials credentials = IdentityPoolCredentials.newBuilder() @@ -475,6 +478,7 @@ void identityPoolCredentials_directSts_withProgrammaticMtlsAndActorToken() throw .setAudience(OIDC_AUDIENCE) .setSubjectTokenType(SubjectTokenTypes.JWT) .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .setX509Provider(x509Provider) .setHttpTransportFactory(transportFactory) .build(); @@ -587,7 +591,13 @@ private GenericJson buildAwsCredentialConfig() { private String getMtlsCertificateConfigPath() { String certConfigPath = System.getenv("GOOGLE_API_CERTIFICATE_CONFIG"); if (certConfigPath != null && !certConfigPath.isEmpty()) { - return certConfigPath; + try { + if (new X509Provider(certConfigPath).isAvailable()) { + return certConfigPath; + } + } catch (Exception ignored) { + // Fall back to testresources/mtls/certificate_config.json if env config is unavailable + } } return "testresources/mtls/certificate_config.json"; } 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 index a2993786f7d8..e7f08f3da9ce 100644 --- 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 @@ -205,6 +205,7 @@ public void configure(HttpsParameters params) { serverExecutor = Executors.newCachedThreadPool(); server.setExecutor(serverExecutor); + serverPort = server.getAddress().getPort(); } @AfterEach @@ -335,7 +336,6 @@ public void handle(HttpExchange exchange) throws IOException { } }); server.start(); - serverPort = server.getAddress().getPort(); Path tokenFile = tempDir.resolve("credential.json"); GenericJson tokenJson = new GenericJson(); @@ -482,7 +482,6 @@ public void handle(HttpExchange exchange) throws IOException { } }); server.start(); - serverPort = server.getAddress().getPort(); Path tokenFile = tempDir.resolve("credential.json"); GenericJson tokenJson = new GenericJson(); @@ -544,6 +543,136 @@ public void handle(HttpExchange exchange) throws IOException { ((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 * @@ -585,7 +714,6 @@ public void handle(HttpExchange exchange) throws IOException { } }); server.start(); - serverPort = server.getAddress().getPort(); Path tokenFile = tempDir.resolve("credential.json"); GenericJson tokenJson = new GenericJson(); @@ -692,7 +820,6 @@ public void handle(HttpExchange exchange) throws IOException { } }); server.start(); - serverPort = server.getAddress().getPort(); Path tokenFile = tempDir.resolve("credential.json"); GenericJson tokenJson = new GenericJson(); @@ -856,7 +983,6 @@ public void handle(HttpExchange exchange) throws IOException { }); server.start(); - serverPort = server.getAddress().getPort(); Path tokenFile = tempDir.resolve("credential.json"); GenericJson tokenJson = new GenericJson(); @@ -1047,7 +1173,6 @@ public void handle(HttpExchange exchange) throws IOException { }); server.start(); - serverPort = server.getAddress().getPort(); Path tokenFile = tempDir.resolve("credential.json"); GenericJson tokenJson = new GenericJson(); 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 13f2a2570756..a12ac96e099c 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 @@ -139,7 +139,7 @@ void createFromHttpResponseException_baseFormat() throws IOException { } @Test - void createFromHttpResponseException_nullContent() throws IOException { + void createFromHttpResponseException_nullContent() { HttpResponseException httpException = new HttpResponseException.Builder( /* statusCode= */ 401, /* statusMessage= */ "Unauthorized", new HttpHeaders()) @@ -156,7 +156,7 @@ void createFromHttpResponseException_nullContent() throws IOException { } @Test - void createFromHttpResponseException_emptyContent() throws IOException { + void createFromHttpResponseException_emptyContent() { HttpResponseException httpException = new HttpResponseException.Builder( /* statusCode= */ 401, /* statusMessage= */ "Unauthorized", new HttpHeaders()) @@ -173,7 +173,7 @@ void createFromHttpResponseException_emptyContent() throws IOException { } @Test - void createFromHttpResponseException_nonJsonContent() throws IOException { + void createFromHttpResponseException_nonJsonContent() { HttpResponseException httpException = new HttpResponseException.Builder( /* statusCode= */ 502, /* statusMessage= */ "Bad Gateway", new HttpHeaders()) @@ -190,7 +190,7 @@ void createFromHttpResponseException_nonJsonContent() throws IOException { } @Test - void createFromHttpResponseException_missingErrorField() throws IOException { + void createFromHttpResponseException_missingErrorField() { HttpResponseException httpException = new HttpResponseException.Builder( /* statusCode= */ 400, /* statusMessage= */ "Bad Request", new HttpHeaders()) @@ -207,7 +207,7 @@ void createFromHttpResponseException_missingErrorField() throws IOException { } @Test - void createFromHttpResponseException_googleApiJsonErrorObject() throws IOException { + void createFromHttpResponseException_googleApiJsonErrorObject() { HttpResponseException httpException = new HttpResponseException.Builder( /* statusCode= */ 401, /* statusMessage= */ null, new HttpHeaders()) @@ -238,16 +238,50 @@ void createFromHttpResponseException_jsonLiteralNull() { OAuthException e = OAuthException.createFromHttpResponseException(httpException); assertEquals("http_error_500", e.getErrorCode()); - assertNull(e.getErrorDescription()); + 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 serialVersionUID_matchesReleasedUidAndRoundTrips() throws Exception { assertEquals( - -7883352585835000817L, + -5276727039237496975L, ObjectStreamClass.lookup(OAuthException.class).getSerialVersionUID()); OAuthException original = From 849c4e51a6e03831d2549176a775a6c29c80a5be Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Wed, 23 Sep 2026 04:01:20 +0000 Subject: [PATCH 20/20] style(oauth2): remove unused body variable in MtlsPipelineLocalTest --- .../javatests/com/google/auth/oauth2/MtlsPipelineLocalTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index e7f08f3da9ce..ebaebd776b26 100644 --- 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 @@ -698,7 +698,7 @@ public void handle(HttpExchange exchange) throws IOException { } // Always read and drain the request body - String body = readRequestBody(exchange); + readRequestBody(exchange); int count = requestCounter.incrementAndGet(); GenericJson response = new GenericJson();