From dadeda68ee819c9efcb041c861062c45ab18005b Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Thu, 17 Sep 2026 03:17:32 +0000 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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); }