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

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

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

Usage: * *

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

This package-private method is intended for internal transport pinning by {@link - * ExternalAccountCredentials} during service account impersonation. For mTLS Workload Identity - * Federation with impersonation, applications should configure {@code - * setServiceAccountImpersonationUrl} directly on {@code IdentityPoolCredentials}, which manages - * the certificate lifecycle and 401 recovery. + * Refreshes the access token using the specified transport factory for per-cycle transport + * pinning. * - * @param transportFactory the HTTP transport factory to use, or {@code null} to use this + * @param cycleTransportFactory the HTTP transport factory to use, or {@code null} to use this * instance's configured transport factory without overriding source credential transport * @return the refreshed access token * @throws IOException if token refresh fails */ - AccessToken refreshAccessToken(@Nullable HttpTransportFactory transportFactory) + AccessToken refreshAccessToken(@Nullable HttpTransportFactory cycleTransportFactory) throws IOException { HttpTransportFactory effectiveTransportFactory = - transportFactory != null - ? transportFactory + cycleTransportFactory != null + ? cycleTransportFactory : (this.transportFactory != null ? this.transportFactory : OAuth2Utils.HTTP_TRANSPORT_FACTORY); @@ -615,15 +615,29 @@ AccessToken refreshAccessToken(@Nullable HttpTransportFactory transportFactory) this.sourceCredentials.createScoped( Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE)); } - AccessToken intermediateAccessToken = - (transportFactory == null) - ? ((ExternalAccountCredentials) this.sourceCredentials).refreshAccessToken() - : ((ExternalAccountCredentials) this.sourceCredentials) + AccessToken intermediateAccessToken; + try { + if (cycleTransportFactory == null) { + this.sourceCredentials.refreshIfExpired(); + intermediateAccessToken = this.sourceCredentials.getAccessToken(); + } else { + intermediateAccessToken = + ((ExternalAccountCredentials) this.sourceCredentials) .refreshAccessToken(effectiveTransportFactory); + } + } catch (IOException e) { + throw new IOException("Unable to refresh sourceCredentials", e); + } Credentials authCredentials = - intermediateAccessToken != null - ? OAuth2Credentials.create(intermediateAccessToken) - : this.sourceCredentials; + new GoogleCredentials( + GoogleCredentials.newBuilder() + .setQuotaProjectId(this.sourceCredentials.getQuotaProjectId()) + .setUniverseDomain(this.sourceCredentials.getUniverseDomain())) { + @Override + public AccessToken refreshAccessToken() { + return intermediateAccessToken; + } + }; adapter = new HttpCredentialsAdapter(authCredentials); } else { if (this.sourceCredentials.getAccessToken() == null) { diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java index 61d58bc55316..efab5f66e092 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java @@ -59,12 +59,18 @@ import java.net.URI; import java.nio.charset.StandardCharsets; import java.security.KeyFactory; +import java.security.KeyStore; +import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; +import java.security.cert.Certificate; import java.security.spec.InvalidKeySpecException; import java.security.spec.PKCS8EncodedKeySpec; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; +import java.util.Collections; +import java.util.Enumeration; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -348,5 +354,46 @@ static boolean isUnauthorizedException(@Nullable Throwable t) { return false; } + /** + * Returns whether the certificate chain in {@code newKeyStore} differs from {@code oldKeyStore}. + * Used on 401 retry recovery to avoid retrying when the reloaded certificate is unchanged. + */ + static boolean hasCertificateChanged( + @Nullable KeyStore oldKeyStore, @Nullable KeyStore newKeyStore) { + if (oldKeyStore == newKeyStore) { + return false; + } + if (oldKeyStore == null || newKeyStore == null) { + return true; + } + List oldCerts = getCertificates(oldKeyStore); + List newCerts = getCertificates(newKeyStore); + return !oldCerts.equals(newCerts); + } + + private static List getCertificates(KeyStore keyStore) { + List certs = new ArrayList<>(); + try { + Enumeration aliases = keyStore.aliases(); + if (aliases != null) { + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + Certificate[] chain = keyStore.getCertificateChain(alias); + if (chain != null && chain.length > 0) { + Collections.addAll(certs, chain); + } else { + Certificate cert = keyStore.getCertificate(alias); + if (cert != null) { + certs.add(cert); + } + } + } + } + } catch (KeyStoreException e) { + // If a KeyStore cannot be inspected, treat its certificates as empty + } + return certs; + } + private OAuth2Utils() {} } diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java index ae76abe63093..7eaffdd1c253 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java @@ -125,7 +125,12 @@ public AccessToken refreshAccessToken() throws IOException { } @Override - public AccessToken refreshAccessToken(HttpTransportFactory transportFactory) throws IOException { + AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) throws IOException { + ImpersonatedCredentials impersonated = getImpersonatedCredentials(); + if (impersonated != null) { + return impersonated.refreshAccessToken(cycleTransportFactory); + } + String credential = retrieveSubjectToken(); StsTokenExchangeRequest.Builder stsTokenExchangeRequest = StsTokenExchangeRequest.newBuilder(credential, getSubjectTokenType()) @@ -136,7 +141,7 @@ public AccessToken refreshAccessToken(HttpTransportFactory transportFactory) thr stsTokenExchangeRequest.setScopes(new ArrayList<>(scopes)); } return exchangeExternalCredentialForAccessToken( - stsTokenExchangeRequest.build(), transportFactory); + stsTokenExchangeRequest.build(), cycleTransportFactory); } /** diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java index c7556c0ac3c6..3064d993a4e9 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java @@ -167,7 +167,7 @@ void refreshAccessToken_withServiceAccountImpersonation() throws IOException { // Validate metrics header is set correctly on the sts request. Map> headers = - transportFactory.transport.getRequests().get(6).getHeaders(); + transportFactory.transport.getRequests().get(3).getHeaders(); ExternalAccountCredentialsTest.validateMetricsHeader(headers, "aws", true, false); } @@ -206,7 +206,7 @@ void refreshAccessToken_withServiceAccountImpersonationOptions() throws IOExcept // Validate metrics header is set correctly on the sts request. Map> headers = - transportFactory.transport.getRequests().get(6).getHeaders(); + transportFactory.transport.getRequests().get(3).getHeaders(); ExternalAccountCredentialsTest.validateMetricsHeader(headers, "aws", true, true); } } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java index cbf22907d9ec..494871761c33 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java @@ -106,6 +106,65 @@ class IdentityPoolCredentialsTest extends BaseSerializationTest { private static final IdentityPoolActorTokenSupplier testActorSupplier = (ExternalAccountSupplierContext context) -> "testActorToken"; + private static final String ROTATED_CERT_AND_KEY_PEM = + "-----BEGIN CERTIFICATE-----\n" + + "MIIDDzCCAfegAwIBAgIUcbzNP4BjFtH2pLfSr1KMZClf5eQwDQYJKoZIhvcNAQEL\n" + + "BQAwFzEVMBMGA1UEAwwMcm90YXRlZC1jZXJ0MB4XDTI2MDkxNzE2NDk1NFoXDTM2\n" + + "MDkxNDE2NDk1NFowFzEVMBMGA1UEAwwMcm90YXRlZC1jZXJ0MIIBIjANBgkqhkiG\n" + + "9w0BAQEFAAOCAQ8AMIIBCgKCAQEAi3vzaruGAex4T/FSHqzh+80RT//gWhGpm/JG\n" + + "tyK2hr54ExO5kzSeZDo+VzIJBhTdg9lf8USPTgsXcC3SNatMtWRBOMu9hg/NKLrg\n" + + "S+bCYw0iw6Wzy59XuWn+XcphD/SNUsO3Oas9vg1uj6H3BNWUuLsrPgfDYyIBtBrN\n" + + "6HEWHH7fl7/Nz8lUyj0Pv/uiAKF7bZyMeDv8Jwlv8yRVaEFpjlImWhKb+bCqPUYh\n" + + "adLI33aHF1npy1Jg1LWxecTP+VhvoFY6HJscIDJm47ENUtBSmrNKN2WJUVU7nhHw\n" + + "MYOKwXivm5J6HwxhK9rw2ifAJPStwGW0SNn0wSajvp66i5TINQIDAQABo1MwUTAd\n" + + "BgNVHQ4EFgQUI+rMQW4pBZOnwo51UrCXVFWlJ3swHwYDVR0jBBgwFoAUI+rMQW4p\n" + + "BZOnwo51UrCXVFWlJ3swDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOC\n" + + "AQEAL6LIJbZec8PNCaA176J6C7QW03ZWCgp2GSxb5V42kjgVMyqn5mrez7DQy1UY\n" + + "aDi4/n+OMOAWiJ1qWyYPe8xEKcYtG2sPkAs53wRoY8cbKYOxHr1JQkWh2v7gAwr0\n" + + "WpYsW60mGqAFjiqZz6S2xBdVRwTZ2dvONFMuJBw4JlJFFdxGU5XT3/XvGcvx5UK5\n" + + "2MzYuXkGDr3zTaLMwyBgi3paRs+46POtPZX/i4zUtpaGSG7HDAkCVWK4JMcbKiPk\n" + + "I/vV55YKOblwu8hk6qOyxbX4sSsaCXllH7YWryiyTwBOQjlUqNqdwfxe/jezdeGG\n" + + "OLTM9LO1/oNvD/2RpCH/5D2+fw==\n" + + "-----END CERTIFICATE-----\n" + + "-----BEGIN PRIVATE KEY-----\n" + + "MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCLe/Nqu4YB7HhP\n" + + "8VIerOH7zRFP/+BaEamb8ka3IraGvngTE7mTNJ5kOj5XMgkGFN2D2V/xRI9OCxdw\n" + + "LdI1q0y1ZEE4y72GD80ouuBL5sJjDSLDpbPLn1e5af5dymEP9I1Sw7c5qz2+DW6P\n" + + "ofcE1ZS4uys+B8NjIgG0Gs3ocRYcft+Xv83PyVTKPQ+/+6IAoXttnIx4O/wnCW/z\n" + + "JFVoQWmOUiZaEpv5sKo9RiFp0sjfdocXWenLUmDUtbF5xM/5WG+gVjocmxwgMmbj\n" + + "sQ1S0FKas0o3ZYlRVTueEfAxg4rBeK+bknofDGEr2vDaJ8Ak9K3AZbRI2fTBJqO+\n" + + "nrqLlMg1AgMBAAECggEAOetS5Q+QMk1MmjmFVYqJXiNFnJgOQ6hQ6xocBiDKdUIz\n" + + "HwzSQs+XM9xBlbiHqbhRUVYaslc7QHd3mJPWVYXXmPzT3m8vuDLoiJCs4aelMTc7\n" + + "p80vTw7QAQSD5NNMIbF1W5g8hZxXS4tNTSQ+rAm6M0k5SA02M3xkA7MbrHkE6vig\n" + + "/NgJ/9qZTMLIbSgQnflPKsGkv8kaXAdh/6APXnIM0pfBf5Fu7SXDUucsLPLRPkiS\n" + + "CmI062OW5/MEKehof1nuzzgXbR80yjuttIDRN1g4XSRJav2WePDxet2hTjnMaOxL\n" + + "hB8BDUMoUw5wi23nAzZgjHaxCpVDD+crBiflR5crkwKBgQDDvpY1mALyHr2Z2a0u\n" + + "bapwN+xhIv5MAq5zAt/mxQ4u8lxlJfemX1ulN4ZVxgqMsyGHHdFXS3dnhgs299Y0\n" + + "cAT6Fd0rxorRo/S/0F6G+iGbZQbFCO7HB3tpQZ06VWEF16xow10jgSoOIU05iitl\n" + + "sJbB2BuNjrHYuV6RpkcXme4UTwKBgQC2a9ZxKlJmMbZ9g+dS13t3VZISOxX/hvol\n" + + "fN1+vg2tkTnKaPgYxA0A/W28k++cgW4syS7ysNe9X9NApmERvSVJoI1g8BlQLVuh\n" + + "AZXHXK5cZknSB7iBZxuT/Ag55QE3gA0FipJHYSLDHYeoLXskiWXBUq1MHPOsSC4q\n" + + "pQHkNK/GOwKBgQCfy62aUN9OwvOrbj1nopU6CR1KayPH74R0VYttO78JakctN6KF\n" + + "SmFpbfuXeBXSqMWdJSVpqyzt8UqkdAyFQFF/y2uDuhBHdh5unG8ep4HZ9s5g+Zrc\n" + + "FeqUkcEGBv8uotOXrq0RN/eaE2uUpowo9tELrB1KIYxkTWe7ZU+yH7JxFwKBgQCH\n" + + "PQcrul6AGNbb0pAKIGoOHEhAb8FtQNnuNNXYgnmNdZ7MammTorSpSTizl1EKTAIr\n" + + "/bJqhaRLZuEsiqxoBDvCi96EQTvi7t2BTbWGqTUylzqfFM46UQBnA2/ty9LNHIeK\n" + + "1iJ//IlS8W+CxMUIXzwqyGplhQk5bgGb59yxHEY7xQKBgQCVaR/0DYDsmryP5Ntt\n" + + "uQjSYMKRCv/7ABegPcocQdLNbr+KvzB8dQUm+QRdBXUMWS69eTgwb4f7F5ilMCi6\n" + + "oPJwwyKpnYzxSxWaQQOFRB3L6b1w7MFMO7TV+5ZcFvIpTRkGqi1NwMFUMdlwQcjG\n" + + "7dyMDd4JN8ac/jwHngxJidcNGg==\n" + + "-----END PRIVATE KEY-----\n"; + + private static final String ROTATED_CERT_PEM = + ROTATED_CERT_AND_KEY_PEM.substring( + 0, + ROTATED_CERT_AND_KEY_PEM.indexOf("-----END CERTIFICATE-----") + + "-----END CERTIFICATE-----\n".length()); + + private static final String ROTATED_KEY_PEM = + ROTATED_CERT_AND_KEY_PEM.substring( + ROTATED_CERT_AND_KEY_PEM.indexOf("-----BEGIN PRIVATE KEY-----")); + private static KeyStore createPopulatedKeyStore() { try (InputStream certStream = new FileInputStream(new File("testresources/mtls/test_cert.pem")); @@ -117,6 +176,15 @@ private static KeyStore createPopulatedKeyStore() { } } + private static KeyStore createRotatedPopulatedKeyStore() { + try (InputStream stream = + new ByteArrayInputStream(ROTATED_CERT_AND_KEY_PEM.getBytes(StandardCharsets.UTF_8))) { + return SecurityUtils.createMtlsKeyStore(stream); + } catch (Exception e) { + throw new RuntimeException("Failed to create rotated test KeyStore", e); + } + } + @Test void createdScoped_clonedCredentialWithAddedScopes() { IdentityPoolCredentials credentials = @@ -491,7 +559,7 @@ void refreshAccessToken_withServiceAccountImpersonation() throws IOException { // Validate metrics header is set correctly on the sts request. Map> headers = - transportFactory.transport.getRequests().get(2).getHeaders(); + transportFactory.transport.getRequests().get(1).getHeaders(); ExternalAccountCredentialsTest.validateMetricsHeader(headers, "url", true, false); } @@ -532,7 +600,7 @@ void refreshAccessToken_withServiceAccountImpersonationOptions() throws IOExcept // Validate metrics header is set correctly on the sts request. Map> headers = - transportFactory.transport.getRequests().get(2).getHeaders(); + transportFactory.transport.getRequests().get(1).getHeaders(); ExternalAccountCredentialsTest.validateMetricsHeader(headers, "url", true, true); } @@ -1817,116 +1885,6 @@ void mtlsHttpTransportFactory_hasKeyStore_noArg_returnsFalse() { assertFalse(factory.hasKeyStore()); } - @Test - void builder_actorToken_plainPublicTokenUrl_throws() throws Exception { - KeyStore ks = createPopulatedKeyStore(); - MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); - - IllegalArgumentException e = - assertThrows( - IllegalArgumentException.class, - () -> - IdentityPoolCredentials.newBuilder() - .setSubjectTokenSupplier(testProvider) - .setActorTokenSupplier(testActorSupplier) - .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") - .setHttpTransportFactory(mtlsTransport) - .setAudience("audience") - .setSubjectTokenType("subjectTokenType") - .setTokenUrl("https://sts.googleapis.com/v1/token") - .build()); - assertTrue( - e.getMessage() - .contains( - "cannot be used with actor tokens because it is a plain public Google API" - + " endpoint")); - } - - @Test - void builder_actorToken_plainPublicImpersonationUrl_throws() throws Exception { - KeyStore ks = createPopulatedKeyStore(); - MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); - - IllegalArgumentException e = - assertThrows( - IllegalArgumentException.class, - () -> - IdentityPoolCredentials.newBuilder() - .setSubjectTokenSupplier(testProvider) - .setActorTokenSupplier(testActorSupplier) - .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") - .setHttpTransportFactory(mtlsTransport) - .setAudience("audience") - .setSubjectTokenType("subjectTokenType") - .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") - .setServiceAccountImpersonationUrl( - "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@test.iam.gserviceaccount.com:generateAccessToken") - .build()); - assertTrue( - e.getMessage() - .contains( - "cannot be used with actor tokens because it is a plain public Google API" - + " endpoint")); - } - - @Test - void builder_actorToken_mtlsEndpoints_succeeds() throws Exception { - KeyStore ks = createPopulatedKeyStore(); - MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); - - IdentityPoolCredentials credentials = - IdentityPoolCredentials.newBuilder() - .setSubjectTokenSupplier(testProvider) - .setActorTokenSupplier(testActorSupplier) - .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") - .setHttpTransportFactory(mtlsTransport) - .setAudience("audience") - .setSubjectTokenType("subjectTokenType") - .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") - .setServiceAccountImpersonationUrl( - "https://iamcredentials.mtls.googleapis.com/v1/projects/-/serviceAccounts/test@test.iam.gserviceaccount.com:generateAccessToken") - .build(); - assertNotNull(credentials); - } - - @Test - void builder_actorToken_pscEndpoints_succeeds() throws Exception { - KeyStore ks = createPopulatedKeyStore(); - MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); - - IdentityPoolCredentials credentials = - IdentityPoolCredentials.newBuilder() - .setSubjectTokenSupplier(testProvider) - .setActorTokenSupplier(testActorSupplier) - .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") - .setHttpTransportFactory(mtlsTransport) - .setAudience("audience") - .setSubjectTokenType("subjectTokenType") - .setTokenUrl("https://sts.p.googleapis.com/v1/token") - .setServiceAccountImpersonationUrl( - "https://iamcredentials.p.googleapis.com/v1/projects/-/serviceAccounts/test@test.iam.gserviceaccount.com:generateAccessToken") - .build(); - assertNotNull(credentials); - } - - @Test - void builder_actorToken_customNonGoogleHost_succeeds() throws Exception { - KeyStore ks = createPopulatedKeyStore(); - MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); - - IdentityPoolCredentials credentials = - IdentityPoolCredentials.newBuilder() - .setSubjectTokenSupplier(testProvider) - .setActorTokenSupplier(testActorSupplier) - .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") - .setHttpTransportFactory(mtlsTransport) - .setAudience("audience") - .setSubjectTokenType("subjectTokenType") - .setTokenUrl("https://custom-auth-proxy.internal.corp/token") - .build(); - assertNotNull(credentials); - } - // ================================================================================== // Section A: Cert Pinning & Transport Factory Tests // ================================================================================== @@ -1981,7 +1939,7 @@ public KeyStore getKeyStore() { void refreshAccessToken_certRotationBetweenCycles_usesNewCert() throws Exception { // First refresh uses cert A, rotate the provider, second refresh uses cert B. KeyStore ksA = createPopulatedKeyStore(); - KeyStore ksB = createPopulatedKeyStore(); + KeyStore ksB = createRotatedPopulatedKeyStore(); AtomicInteger callCount = new AtomicInteger(0); X509Provider rotatingProvider = @@ -2030,7 +1988,7 @@ public KeyStore getKeyStore() { void refreshAccessToken_401Retry_reReadsFromDisk() throws Exception { // On 401, the code should re-read from X509Provider to get fresh certs and retry. KeyStore ksA = createPopulatedKeyStore(); - KeyStore ksB = createPopulatedKeyStore(); + KeyStore ksB = createRotatedPopulatedKeyStore(); AtomicInteger callCount = new AtomicInteger(0); X509Provider rotatingProvider = @@ -2071,7 +2029,7 @@ public KeyStore getKeyStore() { @Test void refreshAccessToken_401Retry_viaHttpTransport_retriesAndSucceeds() throws Exception { KeyStore ksA = createPopulatedKeyStore(); - KeyStore ksB = createPopulatedKeyStore(); + KeyStore ksB = createRotatedPopulatedKeyStore(); AtomicInteger callCount = new AtomicInteger(0); X509Provider rotatingProvider = @@ -2115,7 +2073,7 @@ HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { assertEquals(2, transport.getRequests().size()); // Verify initial cycle used ksA, and retry used ksB - assertEquals(java.util.Arrays.asList(ksA, ksB), usedKeyStores); + assertEquals(Arrays.asList(ksA, ksB), usedKeyStores); } @Test @@ -2143,21 +2101,23 @@ void refreshAccessToken_401Retry_nonMtls_bubblesUp() throws Exception { @Test void refreshAccessToken_401Retry_secondAttemptFails_throws() throws Exception { - // 401 → retry → retry also fails → exception propagates. - KeyStore ks = createPopulatedKeyStore(); + // 401 → retry with rotated cert → retry also fails → exception propagates. + KeyStore ksA = createPopulatedKeyStore(); + KeyStore ksB = createRotatedPopulatedKeyStore(); + AtomicInteger callCount = new AtomicInteger(0); X509Provider provider = new X509Provider() { @Override public KeyStore getKeyStore() { - return ks; + return callCount.getAndIncrement() == 0 ? ksA : ksB; } }; MockExternalAccountCredentialsTransportFactory transportFactory = new MockExternalAccountCredentialsTransportFactory(); - MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ksA); // Testable credential that always throws 401 (both first and retry). TestableIdentityPoolCredentials credential = @@ -2179,6 +2139,45 @@ public KeyStore getKeyStore() { assertEquals(2, credential.getExchangeCallCount()); } + @Test + void refreshAccessToken_401Retry_unchangedCert_doesNotRetry() throws Exception { + // When X509Provider returns a KeyStore containing the exact same certificate on 401, + // refreshWithRetry should NOT retry. + KeyStore ks1 = createPopulatedKeyStore(); + KeyStore ks2SameCert = createPopulatedKeyStore(); + AtomicInteger callCount = new AtomicInteger(0); + + X509Provider provider = + new X509Provider() { + @Override + public KeyStore getKeyStore() { + return callCount.getAndIncrement() == 0 ? ks1 : ks2SameCert; + } + }; + + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks1); + + TestableIdentityPoolCredentials credential = + new TestableIdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(provider) + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl(transportFactory.transport.getStsUrl()) + .setHttpTransportFactory(mtlsTransport), + /* failOnFirstExchange= */ true); + + OAuthException e = assertThrows(OAuthException.class, credential::refreshAccessToken); + assertEquals(401, e.getHttpStatusCode()); + assertEquals(2, callCount.get()); + // Because the certificate in ks2SameCert did not change, no retry exchange was performed! + assertEquals(1, credential.getExchangeCallCount()); + } + @Test void refreshAccessToken_401Retry_certLoadFailure_preservesOriginalError() throws Exception { // When a 401 triggers retry but X509Provider.getKeyStore() throws on the retry, @@ -2304,7 +2303,7 @@ void refreshAccessToken_concurrent_eachGetOwnSnapshot() throws Exception { // Two threads refresh simultaneously. Each should get their own KeyStore snapshot. AtomicInteger getKeyStoreCount = new AtomicInteger(0); KeyStore ks1 = createPopulatedKeyStore(); - KeyStore ks2 = createPopulatedKeyStore(); + KeyStore ks2 = createRotatedPopulatedKeyStore(); X509Provider countingProvider = new X509Provider() { @@ -2370,7 +2369,7 @@ void refreshAccessToken_concurrent_401OnOneThread_doesNotAffectOther() throws Ex // Verify that Thread B's retry (re-read from X509Provider) does not affect Thread A's // transport — each thread has its own local cycleTransportFactory. KeyStore ksInitial = createPopulatedKeyStore(); - KeyStore ksRetry = createPopulatedKeyStore(); + KeyStore ksRetry = createRotatedPopulatedKeyStore(); AtomicInteger getKeyStoreCount = new AtomicInteger(0); X509Provider provider = @@ -2460,7 +2459,7 @@ void refreshAccessToken_certRotationDuringRefresh_pinnedCertUsed() throws Except // Verify the transport factory used in exchange is the one pinned at snapshot time, // not the rotated cert. KeyStore ksOriginal = createPopulatedKeyStore(); - KeyStore ksRotated = createPopulatedKeyStore(); + KeyStore ksRotated = createRotatedPopulatedKeyStore(); AtomicReference currentKeyStore = new AtomicReference<>(ksOriginal); AtomicInteger snapshotCount = new AtomicInteger(0); @@ -2580,10 +2579,19 @@ void serialize_deserialize_withActorTokenConfig_roundTrips() throws Exception { @Test void serialize_deserialize_withCustomTransportFactory_preservesCustomTransport() throws Exception { + Map certificateMap = new HashMap<>(); + certificateMap.put("use_default_certificate_config", false); + certificateMap.put("certificate_config_location", "testresources/mtls/certificate_config.json"); + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", "testresources/mtls/certificate_config.json"); + credentialSourceMap.put("certificate", certificateMap); + IdentityPoolCredentialSource credentialSource = + new IdentityPoolCredentialSource(credentialSourceMap); + IdentityPoolCredentials credentials = IdentityPoolCredentials.newBuilder() .setHttpTransportFactory(new MockHttpTransportFactory()) - .setSubjectTokenSupplier(testProvider) + .setCredentialSource(credentialSource) .setAudience("audience") .setSubjectTokenType("subjectTokenType") .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") @@ -3107,6 +3115,27 @@ void fromStream_fileCredentialSource_certRotation_401Retry_succeeds(@TempDir Pat new ByteArrayInputStream(tokenJson.toPrettyString().getBytes(StandardCharsets.UTF_8)), tokenFile.toString()); + Path certFile = tempDir.resolve("cert.pem"); + Path keyFile = tempDir.resolve("key.pem"); + Files.copy(new File("testresources/mtls/test_cert.pem").toPath(), certFile); + Files.copy(new File("testresources/mtls/test_key.pem").toPath(), keyFile); + + Path certConfigFile = tempDir.resolve("certificate_config.json"); + String certConfigJson = + "{\n" + + " \"cert_configs\": {\n" + + " \"workload\": {\n" + + " \"cert_path\": \"" + + certFile.toString() + + "\",\n" + + " \"key_path\": \"" + + keyFile.toString() + + "\"\n" + + " }\n" + + " }\n" + + "}"; + Files.write(certConfigFile, certConfigJson.getBytes(StandardCharsets.UTF_8)); + String configJson = "{\n" + " \"type\": \"external_account\",\n" @@ -3123,8 +3152,9 @@ void fromStream_fileCredentialSource_certRotation_401Retry_succeeds(@TempDir Pat + " \"subject_token_field_name\": \"subject_token\"\n" + " },\n" + " \"certificate\": {\n" - + " \"certificate_config_location\":" - + " \"testresources/mtls/certificate_config.json\"\n" + + " \"certificate_config_location\": \"" + + certConfigFile.toString() + + "\"\n" + " }\n" + " }\n" + "}"; @@ -3146,6 +3176,8 @@ protected AccessToken exchangeExternalCredentialForAccessToken( HttpTransportFactory cycleTransportFactory) throws IOException { if (exchangeCount.incrementAndGet() == 1) { + Files.write(certFile, ROTATED_CERT_PEM.getBytes(StandardCharsets.UTF_8)); + Files.write(keyFile, ROTATED_KEY_PEM.getBytes(StandardCharsets.UTF_8)); throw new OAuthException("invalid_client", "Unauthorized", null, 401); } return new AccessToken("rotatedRetryToken", null); @@ -3399,6 +3431,7 @@ public LowLevelHttpResponse execute() { }; List usedKeyStores = new ArrayList<>(); + List requestKeyStores = Collections.synchronizedList(new ArrayList<>()); IdentityPoolCredentials credential = new IdentityPoolCredentials( IdentityPoolCredentials.newBuilder() @@ -3413,7 +3446,15 @@ public LowLevelHttpResponse execute() { @Override HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { usedKeyStores.add(keyStore); - return () -> mockTransport; + return () -> + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) + throws IOException { + requestKeyStores.add(keyStore); + return mockTransport.buildRequest(method, url); + } + }; } }; @@ -3423,6 +3464,7 @@ HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { // Verify MtlsHttpTransportFactory was constructed with the pinned KeyStore. assertEquals(Collections.singletonList(ks), usedKeyStores); + assertEquals(Arrays.asList(ks, ks), requestKeyStores); // getKeyStore() should be called exactly once per refresh cycle. assertEquals(1, getKeyStoreCallCount.get()); @@ -3440,7 +3482,7 @@ HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { void refreshAccessToken_impersonation_401OnIam_retriesBothStsAndIamWithFreshCert() throws Exception { KeyStore ks1 = createPopulatedKeyStore(); - KeyStore ks2 = createPopulatedKeyStore(); + KeyStore ks2 = createRotatedPopulatedKeyStore(); AtomicInteger getKeyStoreCallCount = new AtomicInteger(0); X509Provider x509Provider = new X509Provider() { @@ -3498,6 +3540,7 @@ public LowLevelHttpResponse execute() { }; List usedKeyStores = new ArrayList<>(); + List requestKeyStores = Collections.synchronizedList(new ArrayList<>()); IdentityPoolCredentials credential = new IdentityPoolCredentials( IdentityPoolCredentials.newBuilder() @@ -3512,7 +3555,15 @@ public LowLevelHttpResponse execute() { @Override HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { usedKeyStores.add(keyStore); - return () -> mockTransport; + return () -> + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) + throws IOException { + requestKeyStores.add(keyStore); + return mockTransport.buildRequest(method, url); + } + }; } }; @@ -3521,7 +3572,8 @@ HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { assertEquals("final-iam-token-2", token.getTokenValue()); // Verify initial cycle used ks1, and 401 retry used ks2 (fresh cert). - assertEquals(java.util.Arrays.asList(ks1, ks2), usedKeyStores); + assertEquals(Arrays.asList(ks1, ks2), usedKeyStores); + assertEquals(Arrays.asList(ks1, ks1, ks2, ks2), requestKeyStores); // 1st call for initial cycle + 2nd call on 401 retry. assertEquals(2, getKeyStoreCallCount.get()); @@ -3614,7 +3666,7 @@ HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { @Test void refreshAccessToken_impersonation_certRotationBetweenCycles_usesNewCert() throws Exception { KeyStore ksA = createPopulatedKeyStore(); - KeyStore ksB = createPopulatedKeyStore(); + KeyStore ksB = createRotatedPopulatedKeyStore(); AtomicInteger getKeyStoreCallCount = new AtomicInteger(0); X509Provider x509Provider = new X509Provider() { @@ -3666,6 +3718,7 @@ public LowLevelHttpResponse execute() { }; List usedKeyStores = new ArrayList<>(); + List requestKeyStores = Collections.synchronizedList(new ArrayList<>()); IdentityPoolCredentials credential = new IdentityPoolCredentials( IdentityPoolCredentials.newBuilder() @@ -3680,7 +3733,15 @@ public LowLevelHttpResponse execute() { @Override HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { usedKeyStores.add(keyStore); - return () -> mockTransport; + return () -> + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) + throws IOException { + requestKeyStores.add(keyStore); + return mockTransport.buildRequest(method, url); + } + }; } }; @@ -3701,7 +3762,177 @@ HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { assertEquals(2, stsCallCount.get()); assertEquals(2, iamCallCount.get()); assertEquals("Bearer intermediate-sts-token-2", iamAuthHeaders.get(1)); - assertEquals(java.util.Arrays.asList(ksA, ksB), usedKeyStores); + assertEquals(Arrays.asList(ksA, ksB), usedKeyStores); + assertEquals(Arrays.asList(ksA, ksA, ksB, ksB), requestKeyStores); + } + + @Test + void refreshAccessToken_impersonation_persistent401OnIam_throwsWithSuppressed() throws Exception { + KeyStore ks1 = createPopulatedKeyStore(); + KeyStore ks2 = createRotatedPopulatedKeyStore(); + AtomicInteger getKeyStoreCallCount = new AtomicInteger(0); + X509Provider x509Provider = + new X509Provider() { + @Override + public KeyStore getKeyStore() { + int count = getKeyStoreCallCount.incrementAndGet(); + return count == 1 ? ks1 : ks2; + } + }; + + AtomicInteger stsCallCount = new AtomicInteger(0); + AtomicInteger iamCallCount = new AtomicInteger(0); + + MockHttpTransport mockTransport = + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() { + if (url.contains("/v1/token")) { + int count = stsCallCount.incrementAndGet(); + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("access_token", "intermediate-sts-token-" + count); + response.put("token_type", "Bearer"); + response.put("expires_in", 3600); + response.put( + "issued_token_type", "urn:ietf:params:oauth:token-type:access_token"); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } else if (url.contains(":generateAccessToken")) { + iamCallCount.incrementAndGet(); + return new MockLowLevelHttpResponse() + .setStatusCode(401) + .setContentType(Json.MEDIA_TYPE) + .setContent("{\"error\": {\"code\": 401, \"message\": \"Unauthorized\"}}"); + } + return new MockLowLevelHttpResponse().setStatusCode(404); + } + }; + } + }; + + List requestKeyStores = Collections.synchronizedList(new ArrayList<>()); + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(x509Provider) + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .setServiceAccountImpersonationUrl( + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken")) { + @Override + HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { + return () -> + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) + throws IOException { + requestKeyStores.add(keyStore); + return mockTransport.buildRequest(method, url); + } + }; + } + }; + + IOException thrown = assertThrows(IOException.class, credential::refreshAccessToken); + assertTrue(OAuth2Utils.isUnauthorizedException(thrown)); + assertEquals(1, thrown.getSuppressed().length); + assertTrue(OAuth2Utils.isUnauthorizedException(thrown.getSuppressed()[0])); + assertEquals(2, getKeyStoreCallCount.get()); + assertEquals(2, stsCallCount.get()); + assertEquals(2, iamCallCount.get()); + assertEquals(Arrays.asList(ks1, ks1, ks2, ks2), requestKeyStores); + } + + @Test + void refreshAccessToken_impersonation_non401OnIam_doesNotRetry() throws Exception { + KeyStore ks1 = createPopulatedKeyStore(); + AtomicInteger getKeyStoreCallCount = new AtomicInteger(0); + X509Provider x509Provider = + new X509Provider() { + @Override + public KeyStore getKeyStore() { + getKeyStoreCallCount.incrementAndGet(); + return ks1; + } + }; + + AtomicInteger stsCallCount = new AtomicInteger(0); + AtomicInteger iamCallCount = new AtomicInteger(0); + + MockHttpTransport mockTransport = + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() { + if (url.contains("/v1/token")) { + int count = stsCallCount.incrementAndGet(); + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("access_token", "intermediate-sts-token-" + count); + response.put("token_type", "Bearer"); + response.put("expires_in", 3600); + response.put( + "issued_token_type", "urn:ietf:params:oauth:token-type:access_token"); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } else if (url.contains(":generateAccessToken")) { + iamCallCount.incrementAndGet(); + return new MockLowLevelHttpResponse() + .setStatusCode(500) + .setContentType(Json.MEDIA_TYPE) + .setContent( + "{\"error\": {\"code\": 500, \"message\": \"Internal Server Error\"}}"); + } + return new MockLowLevelHttpResponse().setStatusCode(404); + } + }; + } + }; + + List requestKeyStores = Collections.synchronizedList(new ArrayList<>()); + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(x509Provider) + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .setServiceAccountImpersonationUrl( + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken")) { + @Override + HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { + return () -> + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) + throws IOException { + requestKeyStores.add(keyStore); + return mockTransport.buildRequest(method, url); + } + }; + } + }; + + IOException thrown = assertThrows(IOException.class, credential::refreshAccessToken); + assertFalse(OAuth2Utils.isUnauthorizedException(thrown)); + assertEquals(0, thrown.getSuppressed().length); + assertEquals(1, getKeyStoreCallCount.get()); + assertEquals(1, stsCallCount.get()); + assertEquals(1, iamCallCount.get()); + assertEquals(Arrays.asList(ks1, ks1), requestKeyStores); } @Test @@ -3785,10 +4016,8 @@ public KeyStore getKeyStore() { @Test void refreshAccessToken_401RetryFailureOnSecondAttempt_attachesInitial401AsSuppressed() throws Exception { - KeyStore ksA = KeyStore.getInstance(KeyStore.getDefaultType()); - ksA.load(null, null); - KeyStore ksB = KeyStore.getInstance(KeyStore.getDefaultType()); - ksB.load(null, null); + KeyStore ksA = createPopulatedKeyStore(); + KeyStore ksB = createRotatedPopulatedKeyStore(); AtomicInteger callCount = new AtomicInteger(0); X509Provider rotatingProvider = new X509Provider(null) { diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java index 4559dcff8155..43308ce7356c 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java @@ -73,6 +73,8 @@ import java.util.Date; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -90,8 +92,11 @@ class ImpersonatedCredentialsTest extends BaseSerializationTest { + "4Az2ZkmeuN6Fk/y9H+Lcb2pskJIXjrL533vrDWGOC48LrsThMQPv8cxBky8HFSEklPpkfTF95tpD43iVwJRB/Gr" + "CtGTw65IfJ4/tI09h6zGc4yqvIo1cHX/LQ+SxKLGyir/dQM925rGt/VojxY5ryJR7GLbCzxPnJm/oQJBANwOCO6" + "D2hy1LQYJhXh7O+RLtA/tSnT1xyMQsGT+uUCMiKS2bSKx2wxo9k7h3OegNJIu1q6nZ6AbxDK8H3+d0dUCQQDTrP" - + "SXagBxzp8PecbaCHjzNRSQE2in81qYnrAFNB4o3DpHyMMY6s5ALLeHKscEWnqP8Ur6X4PvzZecCWU9BKAZAkAutLPknAuxSCsUOvUfS1i87ex77Ot+w6POp34pEX+UWb+u5iFn2cQacDTHLV1LtE80L8jVLSbrbrlH43H0DjU5AkEAgidhycxS86dxpEljnOMCw8CKoUBd5I880IUahEiUltk7OLJYS/Ts1wbn3kPOVX3wyJs8WBDtBkFrDHW2ezth2QJADj3e1YhMVdjJW5jqwlD/VNddGjgzyunmiZg0uOXsHXbytYmsA545S8KRQFaJKFXYYFo2kOjqOiC1T2cAzMDjCQ==\n" - + "-----END PRIVATE KEY-----\n"; + + "SXagBxzp8PecbaCHjzNRSQE2in81qYnrAFNB4o3DpHyMMY6s5ALLeHKscEWnqP8Ur6X4PvzZecCWU9BKAZAkAut" + + "LPknAuxSCsUOvUfS1i87ex77Ot+w6POp34pEX+UWb+u5iFn2cQacDTHLV1LtE80L8jVLSbrbrlH43H0DjU5AkEA" + + "gidhycxS86dxpEljnOMCw8CKoUBd5I880IUahEiUltk7OLJYS/Ts1wbn3kPOVX3wyJs8WBDtBkFrDHW2ezth2QJ" + + "ADj3e1YhMVdjJW5jqwlD/VNddGjgzyunmiZg0uOXsHXbytYmsA545S8KRQFaJKFXYYFo2kOjqOiC1T2cAzMDjCQ" + + "==\n-----END PRIVATE KEY-----\n"; // Id Token provided by the default IAM API that does not include the "email" claim public static final String STANDARD_ID_TOKEN = @@ -1087,8 +1092,8 @@ void universeDomain_whenExplicit_notAllowedIfNotMatchToSourceUD() { IllegalStateException illegalStateException = assertThrows(IllegalStateException.class, builder::build); assertEquals( - "Universe domain source.domain.xyz in source credentials does not match explicit.domain.com" - + " universe domain set for impersonated credentials.", + "Universe domain source.domain.xyz in source credentials" + + " does not match explicit.domain.com universe domain set for impersonated credentials.", illegalStateException.getMessage()); } @@ -1383,8 +1388,7 @@ void refreshAccessToken_withExternalAccountSource_usesProvidedTransportFactory() .getTransport() .addStatusCodeAndMessage(HttpStatusCodes.STATUS_CODE_OK, ""); - java.util.concurrent.atomic.AtomicReference capturedSourceTransport = - new java.util.concurrent.atomic.AtomicReference<>(); + AtomicReference capturedSourceTransport = new AtomicReference<>(); ExternalAccountCredentials mockExternalAccountCredentials = new IdentityPoolCredentials( IdentityPoolCredentials.newBuilder() @@ -1392,10 +1396,11 @@ void refreshAccessToken_withExternalAccountSource_usesProvidedTransportFactory() "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") .setSubjectTokenSupplier(context -> "token") + .setQuotaProjectId("test-quota-project") .setTokenUrl("https://sts.googleapis.com/v1/token")) { @Override - public AccessToken refreshAccessToken(HttpTransportFactory transportFactory) { - capturedSourceTransport.set(transportFactory); + AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) { + capturedSourceTransport.set(cycleTransportFactory); return new AccessToken("intermediate-sts-token-xyz", null); } }; @@ -1415,10 +1420,16 @@ public AccessToken refreshAccessToken(HttpTransportFactory transportFactory) { assertEquals( "Bearer intermediate-sts-token-xyz", customTransportFactory.getTransport().getRequest().getFirstHeaderValue("Authorization")); + assertEquals( + "test-quota-project", + customTransportFactory + .getTransport() + .getRequest() + .getFirstHeaderValue("x-goog-user-project")); } @Test - void refreshAccessToken_nullTransportFactory_fallsBackToCredentialsTransport() + void refreshAccessToken_nullTransportFactory_fallsBackToCredentialsTransportAndUsesCache() throws IOException { MockIAMCredentialsServiceTransportFactory credentialsTransportFactory = new MockIAMCredentialsServiceTransportFactory(); @@ -1429,8 +1440,7 @@ void refreshAccessToken_nullTransportFactory_fallsBackToCredentialsTransport() .getTransport() .addStatusCodeAndMessage(HttpStatusCodes.STATUS_CODE_OK, ""); - java.util.concurrent.atomic.AtomicBoolean sourceRefreshed = - new java.util.concurrent.atomic.AtomicBoolean(false); + AtomicBoolean sourceRefreshed = new AtomicBoolean(false); ExternalAccountCredentials mockExternalAccountCredentials = new IdentityPoolCredentials( IdentityPoolCredentials.newBuilder() @@ -1465,14 +1475,14 @@ public AccessToken refreshAccessToken() { .getRequest() .getFirstHeaderValue("Authorization")); - // Also verify public no-arg refreshAccessToken() delegates without overriding source transport + // Verify subsequent no-arg refreshAccessToken() uses refreshIfExpired() and reuses cached source token sourceRefreshed.set(false); credentialsTransportFactory .getTransport() .addStatusCodeAndMessage(HttpStatusCodes.STATUS_CODE_OK, ""); AccessToken token2 = credentials.refreshAccessToken(); assertEquals("final-iam-token-null-transport", token2.getTokenValue()); - assertTrue(sourceRefreshed.get()); + assertFalse(sourceRefreshed.get()); } @Test diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java index 873b12f0a1b3..6aad11e38704 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java @@ -184,6 +184,10 @@ public LowLevelHttpResponse execute() throws IOException { // Store STS content as multiple calls are made using this transport. stsContent = getContentAsString(); + assertEquals(EXPECTED_GRANT_TYPE, query.get("grant_type")); + assertNotNull(query.get("subject_token_type")); + assertNotNull(query.get("subject_token")); + int statusCode = !stsStatusCodeSequence.isEmpty() ? stsStatusCodeSequence.poll() : 200; if (statusCode != 200) { @@ -197,10 +201,6 @@ public LowLevelHttpResponse execute() throws IOException { .setContent(errorResponse.toPrettyString()); } - assertEquals(EXPECTED_GRANT_TYPE, query.get("grant_type")); - assertNotNull(query.get("subject_token_type")); - assertNotNull(query.get("subject_token")); - GenericJson response = new GenericJson(); response.setFactory(JSON_FACTORY); response.put("token_type", TOKEN_TYPE); diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuth2UtilsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuth2UtilsTest.java index e043b235c50c..96b55fab01d7 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuth2UtilsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuth2UtilsTest.java @@ -33,8 +33,14 @@ import static com.google.auth.oauth2.OAuth2Utils.generateBasicAuthHeader; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import com.google.api.client.http.HttpHeaders; +import com.google.api.client.http.HttpResponseException; +import java.io.IOException; +import java.security.KeyStore; import org.junit.jupiter.api.Test; /** Tests for {@link OAuth2Utils}. */ @@ -101,57 +107,59 @@ void testNullPassword_throws() { @Test void isUnauthorizedException_null_returnsFalse() { - org.junit.jupiter.api.Assertions.assertFalse(OAuth2Utils.isUnauthorizedException(null)); + assertFalse(OAuth2Utils.isUnauthorizedException(null)); } @Test void isUnauthorizedException_genericIOException_returnsFalse() { - org.junit.jupiter.api.Assertions.assertFalse( - OAuth2Utils.isUnauthorizedException(new java.io.IOException("Network error"))); + assertFalse(OAuth2Utils.isUnauthorizedException(new IOException("Network error"))); } @Test void isUnauthorizedException_oauthException401_returnsTrue() { OAuthException ex = new OAuthException("invalid_client", "Unauthorized", null, 401); - org.junit.jupiter.api.Assertions.assertTrue(OAuth2Utils.isUnauthorizedException(ex)); + assertTrue(OAuth2Utils.isUnauthorizedException(ex)); } @Test void isUnauthorizedException_oauthExceptionNon401_returnsFalse() { OAuthException ex = new OAuthException("bad_request", "Bad Request", null, 400); - org.junit.jupiter.api.Assertions.assertFalse(OAuth2Utils.isUnauthorizedException(ex)); + assertFalse(OAuth2Utils.isUnauthorizedException(ex)); } @Test void isUnauthorizedException_httpResponseException401_returnsTrue() { - com.google.api.client.http.HttpResponseException ex = - new com.google.api.client.http.HttpResponseException.Builder( - 401, "Unauthorized", new com.google.api.client.http.HttpHeaders()) - .build(); - org.junit.jupiter.api.Assertions.assertTrue(OAuth2Utils.isUnauthorizedException(ex)); + HttpResponseException ex = + new HttpResponseException.Builder(401, "Unauthorized", new HttpHeaders()).build(); + assertTrue(OAuth2Utils.isUnauthorizedException(ex)); } @Test void isUnauthorizedException_httpResponseExceptionNon401_returnsFalse() { - com.google.api.client.http.HttpResponseException ex = - new com.google.api.client.http.HttpResponseException.Builder( - 403, "Forbidden", new com.google.api.client.http.HttpHeaders()) - .build(); - org.junit.jupiter.api.Assertions.assertFalse(OAuth2Utils.isUnauthorizedException(ex)); + HttpResponseException ex = + new HttpResponseException.Builder(403, "Forbidden", new HttpHeaders()).build(); + assertFalse(OAuth2Utils.isUnauthorizedException(ex)); } @Test void isUnauthorizedException_wrappedInExceptionChain_returnsTrue() { OAuthException oauthEx = new OAuthException("invalid_client", "Unauthorized", null, 401); - java.io.IOException wrapped = new java.io.IOException("Wrapped failure", oauthEx); - org.junit.jupiter.api.Assertions.assertTrue(OAuth2Utils.isUnauthorizedException(wrapped)); - - com.google.api.client.http.HttpResponseException httpEx = - new com.google.api.client.http.HttpResponseException.Builder( - 401, "Unauthorized", new com.google.api.client.http.HttpHeaders()) - .build(); - java.io.IOException wrappedHttp = - new java.io.IOException("Outer", new java.io.IOException("Inner", httpEx)); - org.junit.jupiter.api.Assertions.assertTrue(OAuth2Utils.isUnauthorizedException(wrappedHttp)); + IOException wrapped = new IOException("Wrapped failure", oauthEx); + assertTrue(OAuth2Utils.isUnauthorizedException(wrapped)); + + HttpResponseException httpEx = + new HttpResponseException.Builder(401, "Unauthorized", new HttpHeaders()).build(); + IOException wrappedHttp = new IOException("Outer", new IOException("Inner", httpEx)); + assertTrue(OAuth2Utils.isUnauthorizedException(wrappedHttp)); + } + + @Test + void hasCertificateChanged_nullOrSameReference_returnsFalse() throws Exception { + assertFalse(OAuth2Utils.hasCertificateChanged(null, null)); + KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); + ks.load(null, null); + assertFalse(OAuth2Utils.hasCertificateChanged(ks, ks)); + assertTrue(OAuth2Utils.hasCertificateChanged(null, ks)); + assertTrue(OAuth2Utils.hasCertificateChanged(ks, null)); } } From 8034525277d390c31e79a1d8efebe132c14d0480 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Thu, 17 Sep 2026 19:28:37 +0000 Subject: [PATCH 04/13] fix(oauth2): address post-review audit edge cases for mTLS pinning --- .../oauth2/ExternalAccountCredentials.java | 5 +- .../auth/oauth2/IdentityPoolCredentials.java | 16 ++-- .../auth/oauth2/ImpersonatedCredentials.java | 45 ++++++----- .../com/google/auth/oauth2/OAuth2Utils.java | 5 +- .../oauth2/IdentityPoolCredentialsTest.java | 74 ++++++++++--------- .../oauth2/ImpersonatedCredentialsTest.java | 63 +++++++++++++++- 6 files changed, 141 insertions(+), 67 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java index e9b78230499e..3f19b0bd22fc 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java @@ -529,8 +529,7 @@ private boolean shouldBuildImpersonatedCredential() { return this.serviceAccountImpersonationUrl != null && this.impersonatedCredentials == null; } - @Nullable - ImpersonatedCredentials getImpersonatedCredentials() { + @Nullable ImpersonatedCredentials getImpersonatedCredentials() { if (this.shouldBuildImpersonatedCredential()) { this.impersonatedCredentials = this.buildImpersonatedCredentials(); } @@ -541,7 +540,7 @@ ImpersonatedCredentials getImpersonatedCredentials() { * Refreshes the access token using the specified transport factory for per-cycle transport * pinning. Internal subclasses ({@link IdentityPoolCredentials}, {@link AwsCredentials}, {@link * PluggableAuthCredentials}) delegate {@link #refreshAccessToken()} into this method. This - * default implementation delegates back to {@link #refreshAccessToken()} for any custom + * default implementation delegates back to {@link #refreshAccessToken()} for any package-private * subclasses that do not override this method. * * @param cycleTransportFactory the HTTP transport factory to use for this refresh cycle diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java index 7c7e58a40a9a..de0a992dad2e 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java @@ -73,6 +73,7 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { // Transient: not serialized directly. Reconstructed in readObject() from the credentialSource // certificate config so deserialized credentials remain usable for mTLS and refresh. private transient volatile @Nullable X509Provider x509Provider; + private transient @Nullable HttpTransportFactory defaultMtlsTransportFactory; private final ExternalAccountSupplierContext supplierContext; private final String metricsHeaderValue; @@ -114,8 +115,9 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { if (builder.transportFactory == null || builder.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY || builder.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory - || builder.transportFactory.getClass() == MtlsHttpTransportFactory.class) { + || builder.transportFactory instanceof MtlsHttpTransportFactory) { this.transportFactory = createMtlsTransportFactory(mtlsKeyStore); + this.defaultMtlsTransportFactory = this.transportFactory; } else if (!(builder.transportFactory instanceof MtlsHttpTransportFactory)) { LOGGER_PROVIDER .getLogger() @@ -232,7 +234,9 @@ private boolean shouldUseMtlsTransportFactory() { return this.transportFactory == null || this.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY || this.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory - || this.transportFactory.getClass() == MtlsHttpTransportFactory.class; + || this.transportFactory instanceof MtlsHttpTransportFactory + || (this.defaultMtlsTransportFactory != null + && this.transportFactory == this.defaultMtlsTransportFactory); } @Override @@ -315,7 +319,7 @@ && shouldUseMtlsTransportFactory()) { } catch (Exception reloadException) { IOException ioException = new IOException("Failed to reload certificate on retry", reloadException); - if (ioException != e) { + if (reloadException != e) { ioException.addSuppressed(e); } throw ioException; @@ -325,8 +329,8 @@ && shouldUseMtlsTransportFactory()) { throw e; } - HttpTransportFactory retryTransportFactory = createMtlsTransportFactory(freshKeyStore); try { + HttpTransportFactory retryTransportFactory = createMtlsTransportFactory(freshKeyStore); return refreshWithRetry(retryTransportFactory, freshKeyStore, false); } catch (IOException | RuntimeException retryException) { if (retryException != e) { @@ -407,8 +411,9 @@ private IdentityPoolSubjectTokenSupplier createCertificateSubjectTokenSupplier( if (builder.transportFactory == null || builder.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY || builder.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory - || builder.transportFactory.getClass() == MtlsHttpTransportFactory.class) { + || builder.transportFactory instanceof MtlsHttpTransportFactory) { this.transportFactory = createMtlsTransportFactory(mtlsKeyStore); + this.defaultMtlsTransportFactory = this.transportFactory; } else if (!(builder.transportFactory instanceof MtlsHttpTransportFactory)) { LOGGER_PROVIDER .getLogger() @@ -452,6 +457,7 @@ private void readObject(ObjectInputStream input) throws IOException, ClassNotFou KeyStore mtlsKeyStore = this.x509Provider.getKeyStore(); if (shouldUseMtlsTransportFactory()) { this.transportFactory = createMtlsTransportFactory(mtlsKeyStore); + this.defaultMtlsTransportFactory = this.transportFactory; } } catch (Exception e) { // Cert loading failure will be handled on refreshAccessToken() diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java index 02ab0908af99..8419e16f59ea 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java @@ -318,7 +318,7 @@ public String getAccount() { } @VisibleForTesting - String getIamEndpointOverride() { + @Nullable String getIamEndpointOverride() { return this.iamEndpointOverride; } @@ -615,30 +615,34 @@ AccessToken refreshAccessToken(@Nullable HttpTransportFactory cycleTransportFact this.sourceCredentials.createScoped( Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE)); } - AccessToken intermediateAccessToken; - try { - if (cycleTransportFactory == null) { + if (cycleTransportFactory == null) { + try { this.sourceCredentials.refreshIfExpired(); - intermediateAccessToken = this.sourceCredentials.getAccessToken(); - } else { + } catch (IOException e) { + throw new IOException("Unable to refresh sourceCredentials", e); + } + adapter = new HttpCredentialsAdapter(this.sourceCredentials); + } else { + AccessToken intermediateAccessToken; + try { intermediateAccessToken = ((ExternalAccountCredentials) this.sourceCredentials) .refreshAccessToken(effectiveTransportFactory); + } catch (IOException e) { + throw new IOException("Unable to refresh sourceCredentials", e); } - } catch (IOException e) { - throw new IOException("Unable to refresh sourceCredentials", e); + Credentials authCredentials = + new GoogleCredentials( + GoogleCredentials.newBuilder() + .setQuotaProjectId(this.sourceCredentials.getQuotaProjectId()) + .setUniverseDomain(this.sourceCredentials.getUniverseDomain())) { + @Override + public AccessToken refreshAccessToken() { + return intermediateAccessToken; + } + }; + adapter = new HttpCredentialsAdapter(authCredentials); } - Credentials authCredentials = - new GoogleCredentials( - GoogleCredentials.newBuilder() - .setQuotaProjectId(this.sourceCredentials.getQuotaProjectId()) - .setUniverseDomain(this.sourceCredentials.getUniverseDomain())) { - @Override - public AccessToken refreshAccessToken() { - return intermediateAccessToken; - } - }; - adapter = new HttpCredentialsAdapter(authCredentials); } else { if (this.sourceCredentials.getAccessToken() == null) { // Apply the `CLOUD_PLATFORM_SCOPE` to access the iamcredentials endpoint @@ -688,7 +692,8 @@ public AccessToken refreshAccessToken() { // Client Library Debug Logging via LoggingUtils is used instead. request.setLoggingEnabled(false); adapter.initialize(request); - if (this.sourceCredentials instanceof ExternalAccountCredentials) { + if (cycleTransportFactory != null + && this.sourceCredentials instanceof ExternalAccountCredentials) { request.setUnsuccessfulResponseHandler(null); } request.setParser(parser); diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java index efab5f66e092..8549ff90fb8f 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java @@ -376,8 +376,9 @@ private static List getCertificates(KeyStore keyStore) { try { Enumeration aliases = keyStore.aliases(); if (aliases != null) { - while (aliases.hasMoreElements()) { - String alias = aliases.nextElement(); + List aliasList = Collections.list(aliases); + Collections.sort(aliasList); + for (String alias : aliasList) { Certificate[] chain = keyStore.getCertificateChain(alias); if (chain != null && chain.length > 0) { Collections.addAll(certs, chain); diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java index 494871761c33..d3a96f07459e 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java @@ -49,6 +49,7 @@ import com.google.api.client.http.LowLevelHttpResponse; import com.google.api.client.json.GenericJson; import com.google.api.client.json.Json; +import com.google.api.client.json.JsonParser; import com.google.api.client.testing.http.MockHttpTransport; import com.google.api.client.testing.http.MockLowLevelHttpRequest; import com.google.api.client.testing.http.MockLowLevelHttpResponse; @@ -3352,8 +3353,8 @@ int getExchangeCallCount() { * without making real HTTP calls. */ private static class TransportCapturingCredentials extends IdentityPoolCredentials { - private final java.util.List capturedFactories = - java.util.Collections.synchronizedList(new java.util.ArrayList<>()); + private final List capturedFactories = + Collections.synchronizedList(new ArrayList<>()); TransportCapturingCredentials(IdentityPoolCredentials.Builder builder) { super(builder); @@ -3368,7 +3369,7 @@ protected AccessToken exchangeExternalCredentialForAccessToken( return new AccessToken("capturedAccessToken", null); } - java.util.List getCapturedFactories() { + List getCapturedFactories() { return capturedFactories; } } @@ -3607,36 +3608,7 @@ public KeyStore getKeyStore() throws IOException { } }; - MockHttpTransport mockTransport = - new MockHttpTransport() { - @Override - public LowLevelHttpRequest buildRequest(String method, String url) { - return new MockLowLevelHttpRequest(url) { - @Override - public LowLevelHttpResponse execute() { - if (url.contains("/v1/token")) { - GenericJson response = new GenericJson(); - response.setFactory(OAuth2Utils.JSON_FACTORY); - response.put("access_token", "intermediate-sts-token-1"); - response.put("token_type", "Bearer"); - response.put("expires_in", 3600); - response.put( - "issued_token_type", "urn:ietf:params:oauth:token-type:access_token"); - return new MockLowLevelHttpResponse() - .setContentType(Json.MEDIA_TYPE) - .setContent(response.toString()); - } else if (url.contains(":generateAccessToken")) { - return new MockLowLevelHttpResponse() - .setStatusCode(401) - .setContentType(Json.MEDIA_TYPE) - .setContent("{\"error\": {\"code\": 401, \"message\": \"Unauthorized\"}}"); - } - return new MockLowLevelHttpResponse().setStatusCode(404); - } - }; - } - }; - + List requestKeyStores = new ArrayList<>(); IdentityPoolCredentials credential = new IdentityPoolCredentials( IdentityPoolCredentials.newBuilder() @@ -3650,13 +3622,44 @@ public LowLevelHttpResponse execute() { "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken")) { @Override HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { - return () -> mockTransport; + return () -> + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + requestKeyStores.add(keyStore); + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() { + if (url.contains("/v1/token")) { + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("access_token", "intermediate-sts-token-1"); + response.put("token_type", "Bearer"); + response.put("expires_in", 3600); + response.put( + "issued_token_type", "urn:ietf:params:oauth:token-type:access_token"); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } else if (url.contains(":generateAccessToken")) { + return new MockLowLevelHttpResponse() + .setStatusCode(401) + .setContentType(Json.MEDIA_TYPE) + .setContent( + "{\"error\": {\"code\": 401, \"message\": \"Unauthorized\"}}"); + } + return new MockLowLevelHttpResponse().setStatusCode(404); + } + }; + } + }; } }; IOException thrown = assertThrows(IOException.class, credential::refreshAccessToken); assertEquals("Cert rotation reload disk error", thrown.getMessage()); assertEquals(2, getKeyStoreCallCount.get()); + assertEquals(Arrays.asList(ks, ks), requestKeyStores); Throwable[] suppressed = thrown.getSuppressed(); assertTrue(suppressed.length > 0); @@ -3971,8 +3974,7 @@ public LowLevelHttpRequest buildRequest(String method, String url) // Request 1 is IAM generateAccessToken; verify it requested the downstream target scope String iamRequestContent = transport.getRequests().get(1).getContentAsString(); - try (com.google.api.client.json.JsonParser parser = - OAuth2Utils.JSON_FACTORY.createJsonParser(iamRequestContent)) { + try (JsonParser parser = OAuth2Utils.JSON_FACTORY.createJsonParser(iamRequestContent)) { GenericJson iamBody = parser.parseAndClose(GenericJson.class); assertEquals(targetScopes, iamBody.get("scope")); } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java index 43308ce7356c..a9420ee1fe1c 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java @@ -70,10 +70,13 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Calendar; +import java.util.Collection; +import java.util.Collections; import java.util.Date; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -1475,7 +1478,8 @@ public AccessToken refreshAccessToken() { .getRequest() .getFirstHeaderValue("Authorization")); - // Verify subsequent no-arg refreshAccessToken() uses refreshIfExpired() and reuses cached source token + // Verify subsequent no-arg refreshAccessToken() uses refreshIfExpired() and reuses cached + // source token sourceRefreshed.set(false); credentialsTransportFactory .getTransport() @@ -1536,4 +1540,61 @@ public AccessToken refreshAccessToken() { .getContentAsString() .contains("https://www.googleapis.com/auth/bigquery")); } + + @Test + void refreshAccessToken_standaloneExternalAccountSource_retriesOn401FromIam() throws IOException { + AtomicInteger sourceRefreshCount = new AtomicInteger(0); + ExternalAccountCredentials mockExternalAccountCredentials = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setSubjectTokenSupplier(context -> "token") + .setScopes(Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE)) + .setTokenUrl("https://sts.googleapis.com/v1/token")) { + @Override + public AccessToken refreshAccessToken() { + int count = sourceRefreshCount.incrementAndGet(); + return new AccessToken("intermediate-sts-token-" + count, null); + } + + @Override + public IdentityPoolCredentials createScoped(Collection scopes) { + return this; + } + }; + + MockIAMCredentialsServiceTransportFactory credentialsTransportFactory = + new MockIAMCredentialsServiceTransportFactory(); + credentialsTransportFactory.getTransport().setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL); + credentialsTransportFactory.getTransport().setAccessToken("final-iam-token-after-retry"); + credentialsTransportFactory.getTransport().setExpireTime(getDefaultExpireTime()); + // First IAM call returns 401 Unauthorized, second returns 200 OK + credentialsTransportFactory + .getTransport() + .addStatusCodeAndMessage(HttpStatusCodes.STATUS_CODE_UNAUTHORIZED, "Unauthorized"); + credentialsTransportFactory + .getTransport() + .addStatusCodeAndMessage(HttpStatusCodes.STATUS_CODE_OK, ""); + + ImpersonatedCredentials credentials = + ImpersonatedCredentials.newBuilder() + .setSourceCredentials(mockExternalAccountCredentials) + .setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL) + .setScopes(IMMUTABLE_SCOPES_LIST) + .setLifetime(VALID_LIFETIME) + .setHttpTransportFactory(credentialsTransportFactory) + .build(); + + AccessToken token = credentials.refreshAccessToken(); + assertEquals("final-iam-token-after-retry", token.getTokenValue()); + assertEquals(2, sourceRefreshCount.get()); + assertEquals( + "Bearer intermediate-sts-token-2", + credentialsTransportFactory + .getTransport() + .getRequest() + .getFirstHeaderValue("Authorization")); + } } From 17aae1f65b153eb44e1919f7dcbbbd38d5c9db03 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Thu, 17 Sep 2026 19:59:02 +0000 Subject: [PATCH 05/13] fix(oauth2): preserve exact class check for MtlsHttpTransportFactory in IdentityPoolCredentials --- .../google/auth/oauth2/IdentityPoolCredentials.java | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java index de0a992dad2e..4c483ebc0439 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java @@ -115,7 +115,9 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { if (builder.transportFactory == null || builder.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY || builder.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory - || builder.transportFactory instanceof MtlsHttpTransportFactory) { + || builder.transportFactory.getClass() == MtlsHttpTransportFactory.class + || (builder.defaultMtlsTransportFactory != null + && builder.transportFactory == builder.defaultMtlsTransportFactory)) { this.transportFactory = createMtlsTransportFactory(mtlsKeyStore); this.defaultMtlsTransportFactory = this.transportFactory; } else if (!(builder.transportFactory instanceof MtlsHttpTransportFactory)) { @@ -234,7 +236,7 @@ private boolean shouldUseMtlsTransportFactory() { return this.transportFactory == null || this.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY || this.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory - || this.transportFactory instanceof MtlsHttpTransportFactory + || this.transportFactory.getClass() == MtlsHttpTransportFactory.class || (this.defaultMtlsTransportFactory != null && this.transportFactory == this.defaultMtlsTransportFactory); } @@ -411,7 +413,9 @@ private IdentityPoolSubjectTokenSupplier createCertificateSubjectTokenSupplier( if (builder.transportFactory == null || builder.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY || builder.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory - || builder.transportFactory instanceof MtlsHttpTransportFactory) { + || builder.transportFactory.getClass() == MtlsHttpTransportFactory.class + || (builder.defaultMtlsTransportFactory != null + && builder.transportFactory == builder.defaultMtlsTransportFactory)) { this.transportFactory = createMtlsTransportFactory(mtlsKeyStore); this.defaultMtlsTransportFactory = this.transportFactory; } else if (!(builder.transportFactory instanceof MtlsHttpTransportFactory)) { @@ -496,6 +500,7 @@ public static class Builder extends ExternalAccountCredentials.Builder { private @Nullable IdentityPoolActorTokenSupplier actorTokenSupplier; private @Nullable String actorTokenType; private @Nullable X509Provider x509Provider; + private @Nullable HttpTransportFactory defaultMtlsTransportFactory; Builder() {} @@ -514,6 +519,7 @@ public static class Builder extends ExternalAccountCredentials.Builder { // instance for atomic token reads. this.actorTokenType = credentials.actorTokenType; this.x509Provider = credentials.x509Provider; + this.defaultMtlsTransportFactory = credentials.defaultMtlsTransportFactory; } /** From f2d752ed18a1027b4599f32caaa7d0a7d01eb94b Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Thu, 17 Sep 2026 20:15:09 +0000 Subject: [PATCH 06/13] test(oauth2): add coverage for custom MtlsHttpTransportFactory subclass and FILE cert pinning --- .../oauth2/IdentityPoolCredentialsTest.java | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java index d3a96f07459e..7cfbc5d75249 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java @@ -4046,4 +4046,149 @@ public KeyStore getKeyStore() { assertEquals(1, thrown.getSuppressed().length); assertTrue(thrown.getSuppressed()[0] instanceof OAuthException); } + + public static class CustomMtlsHttpTransportFactory extends MtlsHttpTransportFactory { + public CustomMtlsHttpTransportFactory() { + super(); + } + } + + @Test + void customMtlsHttpTransportFactorySubclass_preservedInConstructorAndRefreshAndDeserialization() + throws Exception { + Map certificateMap = new HashMap<>(); + certificateMap.put("use_default_certificate_config", false); + certificateMap.put("certificate_config_location", "testresources/mtls/certificate_config.json"); + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", "testresources/mtls/certificate_config.json"); + credentialSourceMap.put("certificate", certificateMap); + IdentityPoolCredentialSource credentialSource = + new IdentityPoolCredentialSource(credentialSourceMap); + + CustomMtlsHttpTransportFactory customFactory = new CustomMtlsHttpTransportFactory(); + KeyStore ks = createPopulatedKeyStore(); + X509Provider x509Provider = new TestX509Provider(ks, "certificate_config_location"); + + List capturedCycleFactories = new ArrayList<>(); + IdentityPoolCredentials credentials = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(customFactory) + .setCredentialSource(credentialSource) + .setX509Provider(x509Provider) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token")) { + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) { + capturedCycleFactories.add(cycleTransportFactory); + return new AccessToken("token", null); + } + }; + + assertSame( + customFactory, + credentials.getTransportFactory(), + "Constructor must preserve custom subclass of MtlsHttpTransportFactory"); + + credentials.refreshAccessToken(); + assertEquals(1, capturedCycleFactories.size()); + assertSame( + customFactory, + capturedCycleFactories.get(0), + "refreshAccessToken must use custom MtlsHttpTransportFactory subclass without overwriting"); + + IdentityPoolCredentials regularCredentials = + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(customFactory) + .setCredentialSource(credentialSource) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build(); + IdentityPoolCredentials deserialized = serializeAndDeserialize(regularCredentials); + assertTrue( + deserialized.getTransportFactory() instanceof CustomMtlsHttpTransportFactory, + "readObject must preserve custom subclass of MtlsHttpTransportFactory"); + } + + @Test + void fileCredentialSourceWithCertConfig_overriddenCreateMtlsTransportFactory_rotatesPerCycle() + throws Exception { + File tokenFile = File.createTempFile("subject_token", ".txt"); + tokenFile.deleteOnExit(); + Files.write(tokenFile.toPath(), "test-subject-token".getBytes(StandardCharsets.UTF_8)); + + Map certificateMap = new HashMap<>(); + certificateMap.put("use_default_certificate_config", false); + certificateMap.put("certificate_config_location", "testresources/mtls/certificate_config.json"); + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", tokenFile.getAbsolutePath()); + credentialSourceMap.put("certificate", certificateMap); + IdentityPoolCredentialSource credentialSource = + new IdentityPoolCredentialSource(credentialSourceMap); + + KeyStore ksA = createPopulatedKeyStore(); + KeyStore ksB = createRotatedPopulatedKeyStore(); + AtomicInteger getKeyStoreCount = new AtomicInteger(0); + X509Provider rotatingProvider = + new X509Provider() { + @Override + public KeyStore getKeyStore() { + // Call 1: constructor; Call 2: initial refresh attempt; Call 3: 401 retry + int count = getKeyStoreCount.incrementAndGet(); + return count <= 2 ? ksA : ksB; + } + }; + + List requestKeyStores = new ArrayList<>(); + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setCredentialSource(credentialSource) + .setX509Provider(rotatingProvider) + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token")) { + @Override + HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { + return () -> + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + requestKeyStores.add(keyStore); + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() { + if (keyStore == ksA) { + return new MockLowLevelHttpResponse() + .setStatusCode(401) + .setContentType(Json.MEDIA_TYPE) + .setContent( + "{\"error\": \"invalid_client\", \"error_description\": \"Unauthorized\"}"); + } + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("access_token", "rotated-sts-token"); + response.put("token_type", "Bearer"); + response.put("expires_in", 3600); + response.put( + "issued_token_type", "urn:ietf:params:oauth:token-type:access_token"); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } + }; + } + }; + } + }; + + AccessToken token = credential.refreshAccessToken(); + assertEquals("rotated-sts-token", token.getTokenValue()); + assertEquals(Arrays.asList(ksA, ksB), requestKeyStores); + } } From a5e69ed61bdc50ab3ba4dd719951608babafafa8 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Fri, 18 Sep 2026 01:39:13 +0000 Subject: [PATCH 07/13] fix(oauth2): annotate refreshAccessToken(HttpTransportFactory) with @InternalExtensionOnly --- .../google/auth/oauth2/AwsCredentials.java | 5 ++- .../oauth2/ExternalAccountCredentials.java | 7 ++-- .../auth/oauth2/IdentityPoolCredentials.java | 5 ++- .../auth/oauth2/PluggableAuthCredentials.java | 5 ++- .../oauth2/IdentityPoolCredentialsTest.java | 32 ++++++++----------- .../oauth2/ImpersonatedCredentialsTest.java | 2 +- 6 files changed, 32 insertions(+), 24 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java index 6dec8364ea4e..6be30db02b88 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java @@ -32,6 +32,7 @@ package com.google.auth.oauth2; import com.google.api.client.json.GenericJson; +import com.google.api.core.InternalExtensionOnly; import com.google.auth.http.HttpTransportFactory; import com.google.common.annotations.VisibleForTesting; import com.google.errorprone.annotations.CanIgnoreReturnValue; @@ -123,8 +124,10 @@ public AccessToken refreshAccessToken() throws IOException { return refreshAccessToken(this.transportFactory); } + @InternalExtensionOnly @Override - AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) throws IOException { + public AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) + throws IOException { ImpersonatedCredentials impersonated = getImpersonatedCredentials(); if (impersonated != null) { return impersonated.refreshAccessToken(cycleTransportFactory); diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java index 3f19b0bd22fc..61aa5aa34949 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java @@ -36,6 +36,7 @@ import com.google.api.client.http.HttpHeaders; import com.google.api.client.json.GenericJson; import com.google.api.client.util.Data; +import com.google.api.core.InternalExtensionOnly; import com.google.auth.RequestMetadataCallback; import com.google.auth.http.HttpTransportFactory; import com.google.common.base.MoreObjects; @@ -540,14 +541,16 @@ private boolean shouldBuildImpersonatedCredential() { * Refreshes the access token using the specified transport factory for per-cycle transport * pinning. Internal subclasses ({@link IdentityPoolCredentials}, {@link AwsCredentials}, {@link * PluggableAuthCredentials}) delegate {@link #refreshAccessToken()} into this method. This - * default implementation delegates back to {@link #refreshAccessToken()} for any package-private + * default implementation delegates back to {@link #refreshAccessToken()} for any custom * subclasses that do not override this method. * * @param cycleTransportFactory the HTTP transport factory to use for this refresh cycle * @return the refreshed access token * @throws IOException if the token refresh fails */ - AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) throws IOException { + @InternalExtensionOnly + public AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) + throws IOException { return refreshAccessToken(); } diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java index 4c483ebc0439..ff1c41ed08c2 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java @@ -31,6 +31,7 @@ package com.google.auth.oauth2; +import com.google.api.core.InternalExtensionOnly; import com.google.auth.http.HttpTransportFactory; import com.google.auth.mtls.MtlsHttpTransportFactory; import com.google.auth.mtls.MtlsUtils; @@ -253,8 +254,10 @@ public AccessToken refreshAccessToken() throws IOException { return refreshWithRetry(cycleTransportFactory, pinnedKeyStore, true); } + @InternalExtensionOnly @Override - AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) throws IOException { + public AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) + throws IOException { // Retry is intentionally disabled when an explicit cycleTransportFactory is supplied to // ensure transport synchronization across multi-step token exchanges (e.g. STS and IAM) // and prevent nested retry amplification. Outer callers manage retry coordination. diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java index 7eaffdd1c253..3ff3bf19bf4e 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java @@ -31,6 +31,7 @@ package com.google.auth.oauth2; +import com.google.api.core.InternalExtensionOnly; import com.google.auth.http.HttpTransportFactory; import com.google.auth.oauth2.ExecutableHandler.ExecutableOptions; import com.google.common.annotations.VisibleForTesting; @@ -124,8 +125,10 @@ public AccessToken refreshAccessToken() throws IOException { return refreshAccessToken(this.transportFactory); } + @InternalExtensionOnly @Override - AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) throws IOException { + public AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) + throws IOException { ImpersonatedCredentials impersonated = getImpersonatedCredentials(); if (impersonated != null) { return impersonated.refreshAccessToken(cycleTransportFactory); diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java index 7cfbc5d75249..215628d48fe8 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java @@ -2373,14 +2373,22 @@ void refreshAccessToken_concurrent_401OnOneThread_doesNotAffectOther() throws Ex KeyStore ksRetry = createRotatedPopulatedKeyStore(); AtomicInteger getKeyStoreCount = new AtomicInteger(0); + CyclicBarrier barrier = new CyclicBarrier(2); X509Provider provider = new X509Provider() { @Override - public KeyStore getKeyStore() { + public KeyStore getKeyStore() throws IOException { int count = getKeyStoreCount.incrementAndGet(); - // First two calls are for the two threads' initial snapshots, - // third call is for Thread B's retry after 401. - return count <= 2 ? ksInitial : ksRetry; + if (count <= 2) { + try { + barrier.await(5, TimeUnit.SECONDS); + } catch (Exception e) { + throw new IOException(e); + } + return ksInitial; + } + // Third call is for Thread B's retry after 401. + return ksRetry; } }; @@ -2392,7 +2400,6 @@ public KeyStore getKeyStore() { // Use a credential where one thread gets a 401 (first exchange fails) and the other // succeeds. The AtomicInteger tracks per-thread exchange behavior. AtomicInteger exchangeCallCount = new AtomicInteger(0); - CyclicBarrier barrier = new CyclicBarrier(2); // Subclass that alternates: first exchange call throws 401, all others succeed. IdentityPoolCredentials credential = @@ -2422,19 +2429,8 @@ protected AccessToken exchangeExternalCredentialForAccessToken( ExecutorService executor = Executors.newFixedThreadPool(2); try { - Future futureA = - executor.submit( - () -> { - barrier.await(5, TimeUnit.SECONDS); - return credential.refreshAccessToken(); - }); - - Future futureB = - executor.submit( - () -> { - barrier.await(5, TimeUnit.SECONDS); - return credential.refreshAccessToken(); - }); + Future futureA = executor.submit(() -> credential.refreshAccessToken()); + Future futureB = executor.submit(() -> credential.refreshAccessToken()); AccessToken tokenA = futureA.get(10, TimeUnit.SECONDS); AccessToken tokenB = futureB.get(10, TimeUnit.SECONDS); diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java index a9420ee1fe1c..d16f8677bd30 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java @@ -1402,7 +1402,7 @@ void refreshAccessToken_withExternalAccountSource_usesProvidedTransportFactory() .setQuotaProjectId("test-quota-project") .setTokenUrl("https://sts.googleapis.com/v1/token")) { @Override - AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) { + public AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) { capturedSourceTransport.set(cycleTransportFactory); return new AccessToken("intermediate-sts-token-xyz", null); } From 13e8be00345c1adf4783ed3ed40ed0838893c6d7 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Mon, 21 Sep 2026 20:59:47 +0000 Subject: [PATCH 08/13] fix(oauth2): address review feedback on mTLS transport pinning and 401 recovery --- .../google/auth/oauth2/AwsCredentials.java | 3 +- .../oauth2/ExternalAccountCredentials.java | 13 +++++-- .../auth/oauth2/IdentityPoolCredentials.java | 10 +++-- .../auth/oauth2/ImpersonatedCredentials.java | 3 ++ .../auth/oauth2/PluggableAuthCredentials.java | 3 +- .../ExternalAccountCredentialsTest.java | 27 +++++++++++++ .../oauth2/IdentityPoolCredentialsTest.java | 38 +++++++++++-------- .../oauth2/ImpersonatedCredentialsTest.java | 13 +++---- .../google/auth/oauth2/OAuth2UtilsTest.java | 10 +++++ .../oauth2/PluggableAuthCredentialsTest.java | 11 +++++- 10 files changed, 94 insertions(+), 37 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java index 6be30db02b88..847eaa1cd421 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java @@ -126,8 +126,7 @@ public AccessToken refreshAccessToken() throws IOException { @InternalExtensionOnly @Override - public AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) - throws IOException { + AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) throws IOException { ImpersonatedCredentials impersonated = getImpersonatedCredentials(); if (impersonated != null) { return impersonated.refreshAccessToken(cycleTransportFactory); diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java index 61aa5aa34949..073524254b9e 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java @@ -86,6 +86,7 @@ public abstract class ExternalAccountCredentials extends GoogleCredentials { private final @Nullable String tokenInfoUrl; private final @Nullable String serviceAccountImpersonationUrl; + private transient @Nullable String targetServiceAccountEmail; private final @Nullable String clientId; private final @Nullable String clientSecret; @@ -96,7 +97,7 @@ public abstract class ExternalAccountCredentials extends GoogleCredentials { protected transient HttpTransportFactory transportFactory; - protected volatile @Nullable ImpersonatedCredentials impersonatedCredentials; + protected transient volatile @Nullable ImpersonatedCredentials impersonatedCredentials; private final EnvironmentProvider environmentProvider; private final PropertyProvider propertyProvider; @@ -237,6 +238,7 @@ protected ExternalAccountCredentials(ExternalAccountCredentials.Builder builder) this.credentialSource = builder.credentialSource; this.tokenInfoUrl = builder.tokenInfoUrl; this.serviceAccountImpersonationUrl = builder.serviceAccountImpersonationUrl; + this.targetServiceAccountEmail = builder.targetServiceAccountEmail; this.clientId = builder.clientId; this.clientSecret = builder.clientSecret; @@ -311,6 +313,7 @@ protected ExternalAccountCredentials(ExternalAccountCredentials.Builder builder) String targetPrincipal = ImpersonatedCredentials.extractTargetPrincipal(serviceAccountImpersonationUrl); + sourceCredentials.targetServiceAccountEmail = targetPrincipal; return ImpersonatedCredentials.newBuilder() .setSourceCredentials(sourceCredentials) .setHttpTransportFactory(transportFactory) @@ -549,8 +552,7 @@ private boolean shouldBuildImpersonatedCredential() { * @throws IOException if the token refresh fails */ @InternalExtensionOnly - public AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) - throws IOException { + AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) throws IOException { return refreshAccessToken(); } @@ -664,7 +666,7 @@ private void readObject(ObjectInputStream input) throws IOException, ClassNotFou */ public @Nullable String getServiceAccountEmail() { if (serviceAccountImpersonationUrl == null || serviceAccountImpersonationUrl.isEmpty()) { - return null; + return targetServiceAccountEmail; } return ImpersonatedCredentials.extractTargetPrincipal(serviceAccountImpersonationUrl); } @@ -816,6 +818,7 @@ public abstract static class Builder extends GoogleCredentials.Builder { protected @Nullable HttpTransportFactory transportFactory; protected @Nullable String serviceAccountImpersonationUrl; + private @Nullable String targetServiceAccountEmail; protected @Nullable String clientId; protected @Nullable String clientSecret; protected @Nullable Collection scopes; @@ -840,6 +843,7 @@ protected Builder(ExternalAccountCredentials credentials) { this.tokenUrl = credentials.tokenUrl; this.tokenInfoUrl = credentials.tokenInfoUrl; this.serviceAccountImpersonationUrl = credentials.serviceAccountImpersonationUrl; + this.targetServiceAccountEmail = credentials.targetServiceAccountEmail; this.credentialSource = credentials.credentialSource; this.clientId = credentials.clientId; this.clientSecret = credentials.clientSecret; @@ -938,6 +942,7 @@ public Builder setCredentialSource(CredentialSource credentialSource) { public Builder setServiceAccountImpersonationUrl( @Nullable String serviceAccountImpersonationUrl) { this.serviceAccountImpersonationUrl = serviceAccountImpersonationUrl; + this.targetServiceAccountEmail = null; return this; } diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java index ff1c41ed08c2..eef4414bbf5f 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java @@ -187,7 +187,8 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { if (this.actorTokenSupplier != null && !isMtlsConfigured()) { throw new IllegalArgumentException( "Actor tokens are only supported for mTLS token exchanges. Please configure a certificate" - + " configuration in the credential source or provide an mTLS-enabled transport."); + + " configuration in the credential source or provide an MtlsHttpTransportFactory" + + " constructed with a KeyStore."); } if (this.actorTokenSupplier != null) { @@ -256,8 +257,7 @@ public AccessToken refreshAccessToken() throws IOException { @InternalExtensionOnly @Override - public AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) - throws IOException { + AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) throws IOException { // Retry is intentionally disabled when an explicit cycleTransportFactory is supplied to // ensure transport synchronization across multi-step token exchanges (e.g. STS and IAM) // and prevent nested retry amplification. Outer callers manage retry coordination. @@ -462,7 +462,9 @@ private void readObject(ObjectInputStream input) throws IOException, ClassNotFou new X509Provider(getEnvironmentProvider(), getPropertyProvider(), explicitCertConfigPath); try { KeyStore mtlsKeyStore = this.x509Provider.getKeyStore(); - if (shouldUseMtlsTransportFactory()) { + if (shouldUseMtlsTransportFactory() + || (this.transportFactory instanceof MtlsHttpTransportFactory + && !((MtlsHttpTransportFactory) this.transportFactory).hasKeyStore())) { this.transportFactory = createMtlsTransportFactory(mtlsKeyStore); this.defaultMtlsTransportFactory = this.transportFactory; } diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java index 8419e16f59ea..e079a1641cb1 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java @@ -694,6 +694,9 @@ public AccessToken refreshAccessToken() { adapter.initialize(request); if (cycleTransportFactory != null && this.sourceCredentials instanceof ExternalAccountCredentials) { + // Disable HttpCredentialsAdapter's default 401 retry so 401 responses propagate to the + // caller (e.g. IdentityPoolCredentials) to re-snapshot the certificate and retry the full + // cycle with a newly pinned transport. request.setUnsuccessfulResponseHandler(null); } request.setParser(parser); diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java index 3ff3bf19bf4e..4c928ffb40e0 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java @@ -127,8 +127,7 @@ public AccessToken refreshAccessToken() throws IOException { @InternalExtensionOnly @Override - public AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) - throws IOException { + AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) throws IOException { ImpersonatedCredentials impersonated = getImpersonatedCredentials(); if (impersonated != null) { return impersonated.refreshAccessToken(cycleTransportFactory); diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java index 5532a97b3447..cc6b2cc280c7 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java @@ -1286,6 +1286,33 @@ void validateServiceAccountImpersonationUrls_invalidUrls() { } } + @Test + void + serialize_deserialize_withServiceAccountImpersonation_rebuildsTransientImpersonatedCredentials() + throws Exception { + IdentityPoolCredentials credential = + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(new OAuth2Utils.DefaultHttpTransportFactory()) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl(STS_URL) + .setServiceAccountImpersonationUrl(SERVICE_ACCOUNT_IMPERSONATION_URL) + .setCredentialSource(new IdentityPoolCredentialSource(FILE_CREDENTIAL_SOURCE_MAP)) + .build(); + + assertNotNull(credential.getImpersonatedCredentials()); + assertNotNull(credential.impersonatedCredentials); + + IdentityPoolCredentials deserialized = serializeAndDeserialize(credential); + assertNull(deserialized.impersonatedCredentials); + + ImpersonatedCredentials rebuilt = deserialized.getImpersonatedCredentials(); + assertNotNull(rebuilt); + assertEquals( + credential.getServiceAccountEmail(), + ((ExternalAccountCredentials) rebuilt.getSourceCredentials()).getServiceAccountEmail()); + } + private GenericJson buildJsonIdentityPoolCredential() { GenericJson json = new GenericJson(); json.put( diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java index 215628d48fe8..26b92cafe45c 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java @@ -166,7 +166,7 @@ class IdentityPoolCredentialsTest extends BaseSerializationTest { ROTATED_CERT_AND_KEY_PEM.substring( ROTATED_CERT_AND_KEY_PEM.indexOf("-----BEGIN PRIVATE KEY-----")); - private static KeyStore createPopulatedKeyStore() { + static KeyStore createPopulatedKeyStore() { try (InputStream certStream = new FileInputStream(new File("testresources/mtls/test_cert.pem")); InputStream keyStream = new FileInputStream(new File("testresources/mtls/test_key.pem")); @@ -177,7 +177,7 @@ private static KeyStore createPopulatedKeyStore() { } } - private static KeyStore createRotatedPopulatedKeyStore() { + static KeyStore createRotatedPopulatedKeyStore() { try (InputStream stream = new ByteArrayInputStream(ROTATED_CERT_AND_KEY_PEM.getBytes(StandardCharsets.UTF_8))) { return SecurityUtils.createMtlsKeyStore(stream); @@ -1476,7 +1476,8 @@ public String getActorToken(ExternalAccountSupplierContext context) { assertEquals( "Actor tokens are only supported for mTLS token exchanges. Please configure a certificate" - + " configuration in the credential source or provide an mTLS-enabled transport.", + + " configuration in the credential source or provide an MtlsHttpTransportFactory" + + " constructed with a KeyStore.", e.getMessage()); } @@ -2041,10 +2042,13 @@ public KeyStore getKeyStore() { } }; - MockExternalAccountCredentialsTransport transport = + MockExternalAccountCredentialsTransport transportA = + new MockExternalAccountCredentialsTransport(); + transportA.addStsStatusCodeSequence(401); + + MockExternalAccountCredentialsTransport transportB = new MockExternalAccountCredentialsTransport(); - // 1st STS call returns 401 Unauthorized, 2nd STS call returns 200 OK - transport.addStsStatusCodeSequence(401, 200); + transportB.addStsStatusCodeSequence(200); List usedKeyStores = new ArrayList<>(); IdentityPoolCredentials credential = @@ -2055,11 +2059,11 @@ public KeyStore getKeyStore() { .setAudience( "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") - .setTokenUrl(transport.getStsUrl())) { + .setTokenUrl(transportA.getStsUrl())) { @Override HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { usedKeyStores.add(keyStore); - return () -> transport; + return () -> keyStore == ksA ? transportA : transportB; } }; @@ -2070,8 +2074,9 @@ HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { // Verify 2 calls to X509Provider: 1st for initial snapshot, 2nd on 401 reload assertEquals(2, callCount.get()); - // Verify 2 STS requests were executed over HTTP - assertEquals(2, transport.getRequests().size()); + // Verify 1st STS request executed over transportA (ksA) and 2nd over transportB (ksB) + assertEquals(1, transportA.getRequests().size()); + assertEquals(1, transportB.getRequests().size()); // Verify initial cycle used ksA, and retry used ksB assertEquals(Arrays.asList(ksA, ksB), usedKeyStores); @@ -2439,12 +2444,10 @@ protected AccessToken exchangeExternalCredentialForAccessToken( assertNotNull(tokenB); // Both threads did initial snapshots (2 calls), plus Thread B's retry (1 more) - assertTrue( - getKeyStoreCount.get() >= 3, - "Expected at least 3 getKeyStore calls (2 initial + 1 retry), got " - + getKeyStoreCount.get()); + assertEquals(3, getKeyStoreCount.get()); // 3 exchange calls total: one 401 + one retry success + one normal success assertEquals(3, exchangeCallCount.get()); + assertSame(mtlsTransport, credential.getTransportFactory()); } finally { executor.shutdownNow(); } @@ -4106,8 +4109,11 @@ protected AccessToken exchangeExternalCredentialForAccessToken( .build(); IdentityPoolCredentials deserialized = serializeAndDeserialize(regularCredentials); assertTrue( - deserialized.getTransportFactory() instanceof CustomMtlsHttpTransportFactory, - "readObject must preserve custom subclass of MtlsHttpTransportFactory"); + deserialized.getTransportFactory() instanceof MtlsHttpTransportFactory, + "readObject must restore an MtlsHttpTransportFactory"); + assertTrue( + ((MtlsHttpTransportFactory) deserialized.getTransportFactory()).hasKeyStore(), + "readObject must restore a KeyStore-backed MtlsHttpTransportFactory"); } @Test diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java index d16f8677bd30..883b9e8a5460 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java @@ -70,7 +70,6 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Calendar; -import java.util.Collection; import java.util.Collections; import java.util.Date; import java.util.List; @@ -1402,7 +1401,7 @@ void refreshAccessToken_withExternalAccountSource_usesProvidedTransportFactory() .setQuotaProjectId("test-quota-project") .setTokenUrl("https://sts.googleapis.com/v1/token")) { @Override - public AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) { + AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) { capturedSourceTransport.set(cycleTransportFactory); return new AccessToken("intermediate-sts-token-xyz", null); } @@ -1510,6 +1509,8 @@ public AccessToken refreshAccessToken() { "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") .setSubjectTokenSupplier(context -> "subject-token") + .setScopes( + Collections.singletonList("https://www.googleapis.com/auth/devstorage.read_only")) .setTokenUrl(stsTransport.getStsUrl()) .setHttpTransportFactory(() -> stsTransport) .build(); @@ -1542,7 +1543,8 @@ public AccessToken refreshAccessToken() { } @Test - void refreshAccessToken_standaloneExternalAccountSource_retriesOn401FromIam() throws IOException { + void refreshAccessToken_withoutCycleTransportFactory_externalAccountSourceRetriesOn401FromIam() + throws IOException { AtomicInteger sourceRefreshCount = new AtomicInteger(0); ExternalAccountCredentials mockExternalAccountCredentials = new IdentityPoolCredentials( @@ -1558,11 +1560,6 @@ public AccessToken refreshAccessToken() { int count = sourceRefreshCount.incrementAndGet(); return new AccessToken("intermediate-sts-token-" + count, null); } - - @Override - public IdentityPoolCredentials createScoped(Collection scopes) { - return this; - } }; MockIAMCredentialsServiceTransportFactory credentialsTransportFactory = diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuth2UtilsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuth2UtilsTest.java index 96b55fab01d7..b5f2c8f9917a 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuth2UtilsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuth2UtilsTest.java @@ -162,4 +162,14 @@ void hasCertificateChanged_nullOrSameReference_returnsFalse() throws Exception { assertTrue(OAuth2Utils.hasCertificateChanged(null, ks)); assertTrue(OAuth2Utils.hasCertificateChanged(ks, null)); } + + @Test + void hasCertificateChanged_distinctKeyStoreInstances_comparesCertificates() { + KeyStore ks1 = IdentityPoolCredentialsTest.createPopulatedKeyStore(); + KeyStore ks2 = IdentityPoolCredentialsTest.createPopulatedKeyStore(); + KeyStore ksRotated = IdentityPoolCredentialsTest.createRotatedPopulatedKeyStore(); + + assertFalse(OAuth2Utils.hasCertificateChanged(ks1, ks2)); + assertTrue(OAuth2Utils.hasCertificateChanged(ks1, ksRotated)); + } } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java index a07d9450de35..0675d4430f6d 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java @@ -225,13 +225,22 @@ void refreshAccessToken_withServiceAccountImpersonation() throws IOException { .setHttpTransportFactory(transportFactory) .build(); + final ExecutableOptions[] providedOptions = {null}; credential = PluggableAuthCredentials.newBuilder(credential) - .setExecutableHandler(options -> "pluggableAuthToken") + .setExecutableHandler( + options -> { + providedOptions[0] = options; + return "pluggableAuthToken"; + }) .build(); AccessToken accessToken = credential.refreshAccessToken(); + assertEquals( + credential.getServiceAccountEmail(), + providedOptions[0].getEnvironmentMap().get("GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL")); + assertEquals( transportFactory.transport.getServiceAccountAccessToken(), accessToken.getTokenValue()); From 44ca6d7e99c76b7846416633a160d2cf72915ad3 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Tue, 22 Sep 2026 03:30:27 +0000 Subject: [PATCH 09/13] fix(oauth2): address PR #14212 round 2 review feedback --- .../google/auth/oauth2/AwsCredentials.java | 4 +- .../oauth2/ExternalAccountCredentials.java | 65 +++-- .../auth/oauth2/IdentityPoolCredentials.java | 67 ++---- .../auth/oauth2/ImpersonatedCredentials.java | 42 ++-- .../com/google/auth/oauth2/OAuth2Utils.java | 44 +++- .../auth/oauth2/PluggableAuthCredentials.java | 4 +- .../auth/oauth2/AwsCredentialsTest.java | 14 +- .../oauth2/IdentityPoolCredentialsTest.java | 225 ++++++++++++------ .../oauth2/ImpersonatedCredentialsTest.java | 6 +- .../google/auth/oauth2/OAuth2UtilsTest.java | 32 +++ .../oauth2/PluggableAuthCredentialsTest.java | 8 + .../testresources/mtls/test_cert_2.pem | 20 ++ .../testresources/mtls/test_key_2.pem | 28 +++ 13 files changed, 362 insertions(+), 197 deletions(-) create mode 100644 google-auth-library-java/oauth2_http/testresources/mtls/test_cert_2.pem create mode 100644 google-auth-library-java/oauth2_http/testresources/mtls/test_key_2.pem diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java index 847eaa1cd421..a1d2fe07b3ca 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java @@ -32,7 +32,6 @@ package com.google.auth.oauth2; import com.google.api.client.json.GenericJson; -import com.google.api.core.InternalExtensionOnly; import com.google.auth.http.HttpTransportFactory; import com.google.common.annotations.VisibleForTesting; import com.google.errorprone.annotations.CanIgnoreReturnValue; @@ -124,12 +123,11 @@ public AccessToken refreshAccessToken() throws IOException { return refreshAccessToken(this.transportFactory); } - @InternalExtensionOnly @Override AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) throws IOException { ImpersonatedCredentials impersonated = getImpersonatedCredentials(); if (impersonated != null) { - return impersonated.refreshAccessToken(cycleTransportFactory); + return impersonated.refreshAccessToken(null); } StsTokenExchangeRequest.Builder stsTokenExchangeRequest = diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java index 073524254b9e..4c12124dc95e 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java @@ -36,7 +36,6 @@ import com.google.api.client.http.HttpHeaders; import com.google.api.client.json.GenericJson; import com.google.api.client.util.Data; -import com.google.api.core.InternalExtensionOnly; import com.google.auth.RequestMetadataCallback; import com.google.auth.http.HttpTransportFactory; import com.google.common.base.MoreObjects; @@ -86,7 +85,7 @@ public abstract class ExternalAccountCredentials extends GoogleCredentials { private final @Nullable String tokenInfoUrl; private final @Nullable String serviceAccountImpersonationUrl; - private transient @Nullable String targetServiceAccountEmail; + private final @Nullable String targetServiceAccountEmail; private final @Nullable String clientId; private final @Nullable String clientSecret; @@ -198,6 +197,7 @@ protected ExternalAccountCredentials( this.credentialSource = checkNotNull(credentialSource); this.tokenInfoUrl = tokenInfoUrl; this.serviceAccountImpersonationUrl = serviceAccountImpersonationUrl; + this.targetServiceAccountEmail = null; this.clientId = clientId; this.clientSecret = clientSecret; this.scopes = @@ -289,31 +289,22 @@ protected ExternalAccountCredentials(ExternalAccountCredentials.Builder builder) if (serviceAccountImpersonationUrl == null) { return null; } + String targetPrincipal = + ImpersonatedCredentials.extractTargetPrincipal(serviceAccountImpersonationUrl); // Create a copy of this instance without service account impersonation. - ExternalAccountCredentials sourceCredentials; + ExternalAccountCredentials.Builder sourceBuilder; if (this instanceof AwsCredentials) { - sourceCredentials = - AwsCredentials.newBuilder((AwsCredentials) this) - .setServiceAccountImpersonationUrl(null) - .setScopes(Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE)) - .build(); + sourceBuilder = AwsCredentials.newBuilder((AwsCredentials) this); } else if (this instanceof PluggableAuthCredentials) { - sourceCredentials = - PluggableAuthCredentials.newBuilder((PluggableAuthCredentials) this) - .setServiceAccountImpersonationUrl(null) - .setScopes(Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE)) - .build(); + sourceBuilder = PluggableAuthCredentials.newBuilder((PluggableAuthCredentials) this); } else { - sourceCredentials = - IdentityPoolCredentials.newBuilder((IdentityPoolCredentials) this) - .setServiceAccountImpersonationUrl(null) - .setScopes(Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE)) - .build(); + sourceBuilder = IdentityPoolCredentials.newBuilder((IdentityPoolCredentials) this); } - - String targetPrincipal = - ImpersonatedCredentials.extractTargetPrincipal(serviceAccountImpersonationUrl); - sourceCredentials.targetServiceAccountEmail = targetPrincipal; + sourceBuilder + .setServiceAccountImpersonationUrl(null) + .setScopes(Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE)); + sourceBuilder.targetServiceAccountEmail = targetPrincipal; + ExternalAccountCredentials sourceCredentials = sourceBuilder.build(); return ImpersonatedCredentials.newBuilder() .setSourceCredentials(sourceCredentials) .setHttpTransportFactory(transportFactory) @@ -529,29 +520,34 @@ private static boolean isAwsCredential(Map credentialSource) { && ((String) credentialSource.get("environment_id")).startsWith("aws"); } - private boolean shouldBuildImpersonatedCredential() { - return this.serviceAccountImpersonationUrl != null && this.impersonatedCredentials == null; - } - @Nullable ImpersonatedCredentials getImpersonatedCredentials() { - if (this.shouldBuildImpersonatedCredential()) { - this.impersonatedCredentials = this.buildImpersonatedCredentials(); + if (this.serviceAccountImpersonationUrl == null) { + return null; + } + ImpersonatedCredentials local = this.impersonatedCredentials; + if (local == null) { + synchronized (this) { + local = this.impersonatedCredentials; + if (local == null) { + local = this.buildImpersonatedCredentials(); + this.impersonatedCredentials = local; + } + } } - return this.impersonatedCredentials; + return local; } /** * Refreshes the access token using the specified transport factory for per-cycle transport - * pinning. Internal subclasses ({@link IdentityPoolCredentials}, {@link AwsCredentials}, {@link - * PluggableAuthCredentials}) delegate {@link #refreshAccessToken()} into this method. This - * default implementation delegates back to {@link #refreshAccessToken()} for any custom - * subclasses that do not override this method. + * pinning. {@link AwsCredentials} and {@link PluggableAuthCredentials} delegate {@link + * #refreshAccessToken()} into this method, while {@link IdentityPoolCredentials} coordinates + * per-cycle transport pinning and retries directly. This default implementation delegates back to + * {@link #refreshAccessToken()} for any custom subclasses that do not override this method. * * @param cycleTransportFactory the HTTP transport factory to use for this refresh cycle * @return the refreshed access token * @throws IOException if the token refresh fails */ - @InternalExtensionOnly AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) throws IOException { return refreshAccessToken(); } @@ -654,7 +650,6 @@ private void readObject(ObjectInputStream input) throws IOException, ClassNotFou // Properly deserialize the transient transportFactory. input.defaultReadObject(); transportFactory = newInstance(transportFactoryClassName); - impersonatedCredentials = null; } public @Nullable String getServiceAccountImpersonationUrl() { diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java index eef4414bbf5f..8b8286e985af 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java @@ -31,7 +31,6 @@ package com.google.auth.oauth2; -import com.google.api.core.InternalExtensionOnly; import com.google.auth.http.HttpTransportFactory; import com.google.auth.mtls.MtlsHttpTransportFactory; import com.google.auth.mtls.MtlsUtils; @@ -100,8 +99,9 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { "A subjectTokenSupplier or a credentialSource must be provided."); } - // Store the x509Provider for per-cycle cert pinning. + // Store the x509Provider and defaultMtlsTransportFactory for per-cycle cert pinning. this.x509Provider = builder.x509Provider; + this.defaultMtlsTransportFactory = builder.defaultMtlsTransportFactory; // Initialize based on the source type if (builder.subjectTokenSupplier != null) { @@ -110,25 +110,7 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { } else if (credentialSource.credentialSourceType == IdentityPoolCredentialSourceType.FILE) { if (credentialSource.getCertificateConfig() != null) { try { - X509Provider x509Provider = getX509Provider(builder, credentialSource); - this.x509Provider = x509Provider; - KeyStore mtlsKeyStore = x509Provider.getKeyStore(); - if (builder.transportFactory == null - || builder.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY - || builder.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory - || builder.transportFactory.getClass() == MtlsHttpTransportFactory.class - || (builder.defaultMtlsTransportFactory != null - && builder.transportFactory == builder.defaultMtlsTransportFactory)) { - this.transportFactory = createMtlsTransportFactory(mtlsKeyStore); - this.defaultMtlsTransportFactory = this.transportFactory; - } else if (!(builder.transportFactory instanceof MtlsHttpTransportFactory)) { - LOGGER_PROVIDER - .getLogger() - .debug( - "Custom HttpTransportFactory provided with certificate configuration; skipping" - + " automatic MtlsHttpTransportFactory upgrade. Ensure the custom transport" - + " factory is configured for mTLS if required by the token endpoint."); - } + initializeMtlsTransport(builder, credentialSource); } catch (Exception e) { throw new RuntimeException( "Failed to initialize mTLS transport for file credential source due to certificate" @@ -239,6 +221,8 @@ private boolean shouldUseMtlsTransportFactory() { || this.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY || this.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory || this.transportFactory.getClass() == MtlsHttpTransportFactory.class + || (this.transportFactory instanceof MtlsHttpTransportFactory + && !((MtlsHttpTransportFactory) this.transportFactory).hasKeyStore()) || (this.defaultMtlsTransportFactory != null && this.transportFactory == this.defaultMtlsTransportFactory); } @@ -252,16 +236,15 @@ public AccessToken refreshAccessToken() throws IOException { pinnedKeyStore = this.x509Provider.getKeyStore(); cycleTransportFactory = createMtlsTransportFactory(pinnedKeyStore); } - return refreshWithRetry(cycleTransportFactory, pinnedKeyStore, true); + return refreshWithRetry(cycleTransportFactory, pinnedKeyStore, /* allowRetry= */ true); } - @InternalExtensionOnly @Override AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) throws IOException { // Retry is intentionally disabled when an explicit cycleTransportFactory is supplied to // ensure transport synchronization across multi-step token exchanges (e.g. STS and IAM) // and prevent nested retry amplification. Outer callers manage retry coordination. - return refreshWithRetry(cycleTransportFactory, null, false); + return refreshWithRetry(cycleTransportFactory, null, /* allowRetry= */ false); } private AccessToken refreshWithRetry( @@ -272,7 +255,8 @@ private AccessToken refreshWithRetry( try { ImpersonatedCredentials impersonated = getImpersonatedCredentials(); if (impersonated != null) { - return impersonated.refreshAccessToken(cycleTransportFactory); + return impersonated.refreshAccessToken( + pinnedKeyStore != null ? cycleTransportFactory : null); } // Read subject and actor tokens, atomically if from the same file supplier. @@ -324,9 +308,7 @@ && shouldUseMtlsTransportFactory()) { } catch (Exception reloadException) { IOException ioException = new IOException("Failed to reload certificate on retry", reloadException); - if (reloadException != e) { - ioException.addSuppressed(e); - } + ioException.addSuppressed(e); throw ioException; } @@ -336,7 +318,7 @@ && shouldUseMtlsTransportFactory()) { try { HttpTransportFactory retryTransportFactory = createMtlsTransportFactory(freshKeyStore); - return refreshWithRetry(retryTransportFactory, freshKeyStore, false); + return refreshWithRetry(retryTransportFactory, freshKeyStore, /* allowRetry= */ false); } catch (IOException | RuntimeException retryException) { if (retryException != e) { retryException.addSuppressed(e); @@ -407,18 +389,12 @@ public Builder toBuilder() { return new Builder(this); } - private IdentityPoolSubjectTokenSupplier createCertificateSubjectTokenSupplier( + private void initializeMtlsTransport( Builder builder, IdentityPoolCredentialSource credentialSource) throws IOException { - // Configure the mTLS transport with the x509 keystore if custom transport was not provided. X509Provider x509Provider = getX509Provider(builder, credentialSource); this.x509Provider = x509Provider; KeyStore mtlsKeyStore = x509Provider.getKeyStore(); - if (builder.transportFactory == null - || builder.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY - || builder.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory - || builder.transportFactory.getClass() == MtlsHttpTransportFactory.class - || (builder.defaultMtlsTransportFactory != null - && builder.transportFactory == builder.defaultMtlsTransportFactory)) { + if (builder.transportFactory == null || shouldUseMtlsTransportFactory()) { this.transportFactory = createMtlsTransportFactory(mtlsKeyStore); this.defaultMtlsTransportFactory = this.transportFactory; } else if (!(builder.transportFactory instanceof MtlsHttpTransportFactory)) { @@ -429,6 +405,12 @@ private IdentityPoolSubjectTokenSupplier createCertificateSubjectTokenSupplier( + " automatic MtlsHttpTransportFactory upgrade. Ensure the custom transport" + " factory is configured for mTLS if required by the token endpoint."); } + } + + private IdentityPoolSubjectTokenSupplier createCertificateSubjectTokenSupplier( + Builder builder, IdentityPoolCredentialSource credentialSource) throws IOException { + // Configure the mTLS transport with the x509 keystore if custom transport was not provided. + initializeMtlsTransport(builder, credentialSource); // Initialize the subject token supplier with the certificate path. String explicitCertConfigPath = getExplicitCertConfigPath(credentialSource); @@ -462,9 +444,7 @@ private void readObject(ObjectInputStream input) throws IOException, ClassNotFou new X509Provider(getEnvironmentProvider(), getPropertyProvider(), explicitCertConfigPath); try { KeyStore mtlsKeyStore = this.x509Provider.getKeyStore(); - if (shouldUseMtlsTransportFactory() - || (this.transportFactory instanceof MtlsHttpTransportFactory - && !((MtlsHttpTransportFactory) this.transportFactory).hasKeyStore())) { + if (shouldUseMtlsTransportFactory()) { this.transportFactory = createMtlsTransportFactory(mtlsKeyStore); this.defaultMtlsTransportFactory = this.transportFactory; } @@ -518,10 +498,9 @@ public static class Builder extends ExternalAccountCredentials.Builder { this.actorTokenSupplier = credentials.actorTokenSupplier; } // Note: when credentialSource is present, subjectTokenSupplier and file-based - // actorTokenSupplier - // are intentionally NOT copied here. They will be reconstructed from credentialSource - // during build(), which ensures they share the same FileIdentityPoolSubjectTokenSupplier - // instance for atomic token reads. + // actorTokenSupplier are intentionally NOT copied here. They will be reconstructed from + // credentialSource during build(), which ensures they share the same + // FileIdentityPoolSubjectTokenSupplier instance for atomic token reads. this.actorTokenType = credentials.actorTokenType; this.x509Provider = credentials.x509Provider; this.defaultMtlsTransportFactory = credentials.defaultMtlsTransportFactory; diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java index e079a1641cb1..ea557c62f7b0 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java @@ -46,7 +46,6 @@ import com.google.api.client.util.GenericData; import com.google.api.core.ObsoleteApi; import com.google.auth.CredentialTypeForMetrics; -import com.google.auth.Credentials; import com.google.auth.ServiceAccountSigner; import com.google.auth.http.HttpCredentialsAdapter; import com.google.auth.http.HttpTransportFactory; @@ -601,41 +600,40 @@ public AccessToken refreshAccessToken() throws IOException { AccessToken refreshAccessToken(@Nullable HttpTransportFactory cycleTransportFactory) throws IOException { HttpTransportFactory effectiveTransportFactory = - cycleTransportFactory != null - ? cycleTransportFactory - : (this.transportFactory != null - ? this.transportFactory - : OAuth2Utils.HTTP_TRANSPORT_FACTORY); + firstNonNull(cycleTransportFactory, this.transportFactory); HttpCredentialsAdapter adapter; if (this.sourceCredentials instanceof ExternalAccountCredentials) { - Collection currentScopes = - ((ExternalAccountCredentials) this.sourceCredentials).getScopes(); - if (currentScopes == null || !currentScopes.contains(OAuth2Utils.CLOUD_PLATFORM_SCOPE)) { - this.sourceCredentials = - this.sourceCredentials.createScoped( - Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE)); + ExternalAccountCredentials externalSource; + synchronized (this) { + Collection currentScopes = + ((ExternalAccountCredentials) this.sourceCredentials).getScopes(); + if (currentScopes == null || !currentScopes.contains(OAuth2Utils.CLOUD_PLATFORM_SCOPE)) { + List updatedScopes = + currentScopes != null ? new ArrayList<>(currentScopes) : new ArrayList<>(); + updatedScopes.add(OAuth2Utils.CLOUD_PLATFORM_SCOPE); + this.sourceCredentials = this.sourceCredentials.createScoped(updatedScopes); + } + externalSource = (ExternalAccountCredentials) this.sourceCredentials; } if (cycleTransportFactory == null) { try { - this.sourceCredentials.refreshIfExpired(); + externalSource.refreshIfExpired(); } catch (IOException e) { throw new IOException("Unable to refresh sourceCredentials", e); } - adapter = new HttpCredentialsAdapter(this.sourceCredentials); + adapter = new HttpCredentialsAdapter(externalSource); } else { AccessToken intermediateAccessToken; try { - intermediateAccessToken = - ((ExternalAccountCredentials) this.sourceCredentials) - .refreshAccessToken(effectiveTransportFactory); + intermediateAccessToken = externalSource.refreshAccessToken(effectiveTransportFactory); } catch (IOException e) { throw new IOException("Unable to refresh sourceCredentials", e); } - Credentials authCredentials = + GoogleCredentials authCredentials = new GoogleCredentials( GoogleCredentials.newBuilder() - .setQuotaProjectId(this.sourceCredentials.getQuotaProjectId()) - .setUniverseDomain(this.sourceCredentials.getUniverseDomain())) { + .setQuotaProjectId(externalSource.getQuotaProjectId()) + .setUniverseDomain(externalSource.getUniverseDomain())) { @Override public AccessToken refreshAccessToken() { return intermediateAccessToken; @@ -709,14 +707,14 @@ public AccessToken refreshAccessToken() { try { LoggingUtils.logRequest(request, LOGGER_PROVIDER, "Sending request to refresh access token"); response = request.execute(); - LoggingUtils.logResponse( - response, LOGGER_PROVIDER, "Received response for refresh access token"); } catch (IOException e) { throw new IOException("Error requesting access token", e); } GenericData responseData; try { + LoggingUtils.logResponse( + response, LOGGER_PROVIDER, "Received response for refresh access token"); responseData = response.parseAs(GenericData.class); LoggingUtils.logResponsePayload( responseData, LOGGER_PROVIDER, "Response payload for access token"); diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java index 8549ff90fb8f..b7386ca0c3ea 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java @@ -58,9 +58,10 @@ import java.math.BigDecimal; import java.net.URI; import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.Key; import java.security.KeyFactory; import java.security.KeyStore; -import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; import java.security.cert.Certificate; @@ -335,6 +336,9 @@ static String generateBasicAuthHeader(String username, String password) { * Returns whether the given throwable or any exception in its causal chain represents a 401 * Unauthorized error (either an {@link OAuthException} or {@link HttpResponseException} with * status code 401). + * + * @param t the throwable to inspect + * @return {@code true} if {@code t} or any cause in its chain is a 401 error */ static boolean isUnauthorizedException(@Nullable Throwable t) { while (t != null) { @@ -355,8 +359,13 @@ static boolean isUnauthorizedException(@Nullable Throwable t) { } /** - * Returns whether the certificate chain in {@code newKeyStore} differs from {@code oldKeyStore}. - * Used on 401 retry recovery to avoid retrying when the reloaded certificate is unchanged. + * Returns whether the certificate chain or private key in {@code newKeyStore} differs from {@code + * oldKeyStore}. Used on 401 retry recovery to avoid retrying when the reloaded certificate and + * key are unchanged. + * + * @param oldKeyStore the previously loaded keystore + * @param newKeyStore the newly reloaded keystore + * @return {@code true} if the certificates or keys differ, or if either keystore cannot be read */ static boolean hasCertificateChanged( @Nullable KeyStore oldKeyStore, @Nullable KeyStore newKeyStore) { @@ -366,13 +375,16 @@ static boolean hasCertificateChanged( if (oldKeyStore == null || newKeyStore == null) { return true; } - List oldCerts = getCertificates(oldKeyStore); - List newCerts = getCertificates(newKeyStore); - return !oldCerts.equals(newCerts); + List oldEntries = getKeyStoreEntries(oldKeyStore); + List newEntries = getKeyStoreEntries(newKeyStore); + if (oldEntries == null || newEntries == null) { + return true; + } + return !oldEntries.equals(newEntries); } - private static List getCertificates(KeyStore keyStore) { - List certs = new ArrayList<>(); + private static @Nullable List getKeyStoreEntries(KeyStore keyStore) { + List entries = new ArrayList<>(); try { Enumeration aliases = keyStore.aliases(); if (aliases != null) { @@ -381,19 +393,25 @@ private static List getCertificates(KeyStore keyStore) { for (String alias : aliasList) { Certificate[] chain = keyStore.getCertificateChain(alias); if (chain != null && chain.length > 0) { - Collections.addAll(certs, chain); + Collections.addAll(entries, chain); } else { Certificate cert = keyStore.getCertificate(alias); if (cert != null) { - certs.add(cert); + entries.add(cert); + } + } + if (keyStore.isKeyEntry(alias)) { + Key key = keyStore.getKey(alias, "".toCharArray()); + if (key != null) { + entries.add(key); } } } } - } catch (KeyStoreException e) { - // If a KeyStore cannot be inspected, treat its certificates as empty + } catch (GeneralSecurityException e) { + return null; } - return certs; + return entries; } private OAuth2Utils() {} diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java index 4c928ffb40e0..f9bea654470d 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java @@ -31,7 +31,6 @@ package com.google.auth.oauth2; -import com.google.api.core.InternalExtensionOnly; import com.google.auth.http.HttpTransportFactory; import com.google.auth.oauth2.ExecutableHandler.ExecutableOptions; import com.google.common.annotations.VisibleForTesting; @@ -125,12 +124,11 @@ public AccessToken refreshAccessToken() throws IOException { return refreshAccessToken(this.transportFactory); } - @InternalExtensionOnly @Override AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) throws IOException { ImpersonatedCredentials impersonated = getImpersonatedCredentials(); if (impersonated != null) { - return impersonated.refreshAccessToken(cycleTransportFactory); + return impersonated.refreshAccessToken(null); } String credential = retrieveSubjectToken(); diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java index 3064d993a4e9..4ea4d133f959 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java @@ -165,10 +165,19 @@ void refreshAccessToken_withServiceAccountImpersonation() throws IOException { assertEquals( transportFactory.transport.getServiceAccountAccessToken(), accessToken.getTokenValue()); - // Validate metrics header is set correctly on the sts request. + // Requests 0..2 are the AWS metadata calls from sourceCredentials (no duplicate outer fetch), + // request 3 is the STS token exchange, and request 4 is the IAM generateAccessToken call. + assertEquals(5, transportFactory.transport.getRequests().size()); + assertEquals( + transportFactory.transport.getStsUrl(), + transportFactory.transport.getRequests().get(3).getUrl()); Map> headers = transportFactory.transport.getRequests().get(3).getHeaders(); ExternalAccountCredentialsTest.validateMetricsHeader(headers, "aws", true, false); + + // A second refresh while the intermediate STS token is still valid should only call IAM. + awsCredential.refreshAccessToken(); + assertEquals(6, transportFactory.transport.getRequests().size()); } @Test @@ -1246,6 +1255,9 @@ void serialize() throws IOException, ClassNotFoundException { assertEquals(testCredentials.hashCode(), deserializedCredentials.hashCode()); assertEquals(testCredentials.toString(), deserializedCredentials.toString()); assertSame(Clock.SYSTEM, deserializedCredentials.clock); + assertNotNull(deserializedCredentials.getServiceAccountEmail()); + assertEquals( + testCredentials.getServiceAccountEmail(), deserializedCredentials.getServiceAccountEmail()); } /** diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java index 26b92cafe45c..70963d600065 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java @@ -107,65 +107,6 @@ class IdentityPoolCredentialsTest extends BaseSerializationTest { private static final IdentityPoolActorTokenSupplier testActorSupplier = (ExternalAccountSupplierContext context) -> "testActorToken"; - private static final String ROTATED_CERT_AND_KEY_PEM = - "-----BEGIN CERTIFICATE-----\n" - + "MIIDDzCCAfegAwIBAgIUcbzNP4BjFtH2pLfSr1KMZClf5eQwDQYJKoZIhvcNAQEL\n" - + "BQAwFzEVMBMGA1UEAwwMcm90YXRlZC1jZXJ0MB4XDTI2MDkxNzE2NDk1NFoXDTM2\n" - + "MDkxNDE2NDk1NFowFzEVMBMGA1UEAwwMcm90YXRlZC1jZXJ0MIIBIjANBgkqhkiG\n" - + "9w0BAQEFAAOCAQ8AMIIBCgKCAQEAi3vzaruGAex4T/FSHqzh+80RT//gWhGpm/JG\n" - + "tyK2hr54ExO5kzSeZDo+VzIJBhTdg9lf8USPTgsXcC3SNatMtWRBOMu9hg/NKLrg\n" - + "S+bCYw0iw6Wzy59XuWn+XcphD/SNUsO3Oas9vg1uj6H3BNWUuLsrPgfDYyIBtBrN\n" - + "6HEWHH7fl7/Nz8lUyj0Pv/uiAKF7bZyMeDv8Jwlv8yRVaEFpjlImWhKb+bCqPUYh\n" - + "adLI33aHF1npy1Jg1LWxecTP+VhvoFY6HJscIDJm47ENUtBSmrNKN2WJUVU7nhHw\n" - + "MYOKwXivm5J6HwxhK9rw2ifAJPStwGW0SNn0wSajvp66i5TINQIDAQABo1MwUTAd\n" - + "BgNVHQ4EFgQUI+rMQW4pBZOnwo51UrCXVFWlJ3swHwYDVR0jBBgwFoAUI+rMQW4p\n" - + "BZOnwo51UrCXVFWlJ3swDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOC\n" - + "AQEAL6LIJbZec8PNCaA176J6C7QW03ZWCgp2GSxb5V42kjgVMyqn5mrez7DQy1UY\n" - + "aDi4/n+OMOAWiJ1qWyYPe8xEKcYtG2sPkAs53wRoY8cbKYOxHr1JQkWh2v7gAwr0\n" - + "WpYsW60mGqAFjiqZz6S2xBdVRwTZ2dvONFMuJBw4JlJFFdxGU5XT3/XvGcvx5UK5\n" - + "2MzYuXkGDr3zTaLMwyBgi3paRs+46POtPZX/i4zUtpaGSG7HDAkCVWK4JMcbKiPk\n" - + "I/vV55YKOblwu8hk6qOyxbX4sSsaCXllH7YWryiyTwBOQjlUqNqdwfxe/jezdeGG\n" - + "OLTM9LO1/oNvD/2RpCH/5D2+fw==\n" - + "-----END CERTIFICATE-----\n" - + "-----BEGIN PRIVATE KEY-----\n" - + "MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCLe/Nqu4YB7HhP\n" - + "8VIerOH7zRFP/+BaEamb8ka3IraGvngTE7mTNJ5kOj5XMgkGFN2D2V/xRI9OCxdw\n" - + "LdI1q0y1ZEE4y72GD80ouuBL5sJjDSLDpbPLn1e5af5dymEP9I1Sw7c5qz2+DW6P\n" - + "ofcE1ZS4uys+B8NjIgG0Gs3ocRYcft+Xv83PyVTKPQ+/+6IAoXttnIx4O/wnCW/z\n" - + "JFVoQWmOUiZaEpv5sKo9RiFp0sjfdocXWenLUmDUtbF5xM/5WG+gVjocmxwgMmbj\n" - + "sQ1S0FKas0o3ZYlRVTueEfAxg4rBeK+bknofDGEr2vDaJ8Ak9K3AZbRI2fTBJqO+\n" - + "nrqLlMg1AgMBAAECggEAOetS5Q+QMk1MmjmFVYqJXiNFnJgOQ6hQ6xocBiDKdUIz\n" - + "HwzSQs+XM9xBlbiHqbhRUVYaslc7QHd3mJPWVYXXmPzT3m8vuDLoiJCs4aelMTc7\n" - + "p80vTw7QAQSD5NNMIbF1W5g8hZxXS4tNTSQ+rAm6M0k5SA02M3xkA7MbrHkE6vig\n" - + "/NgJ/9qZTMLIbSgQnflPKsGkv8kaXAdh/6APXnIM0pfBf5Fu7SXDUucsLPLRPkiS\n" - + "CmI062OW5/MEKehof1nuzzgXbR80yjuttIDRN1g4XSRJav2WePDxet2hTjnMaOxL\n" - + "hB8BDUMoUw5wi23nAzZgjHaxCpVDD+crBiflR5crkwKBgQDDvpY1mALyHr2Z2a0u\n" - + "bapwN+xhIv5MAq5zAt/mxQ4u8lxlJfemX1ulN4ZVxgqMsyGHHdFXS3dnhgs299Y0\n" - + "cAT6Fd0rxorRo/S/0F6G+iGbZQbFCO7HB3tpQZ06VWEF16xow10jgSoOIU05iitl\n" - + "sJbB2BuNjrHYuV6RpkcXme4UTwKBgQC2a9ZxKlJmMbZ9g+dS13t3VZISOxX/hvol\n" - + "fN1+vg2tkTnKaPgYxA0A/W28k++cgW4syS7ysNe9X9NApmERvSVJoI1g8BlQLVuh\n" - + "AZXHXK5cZknSB7iBZxuT/Ag55QE3gA0FipJHYSLDHYeoLXskiWXBUq1MHPOsSC4q\n" - + "pQHkNK/GOwKBgQCfy62aUN9OwvOrbj1nopU6CR1KayPH74R0VYttO78JakctN6KF\n" - + "SmFpbfuXeBXSqMWdJSVpqyzt8UqkdAyFQFF/y2uDuhBHdh5unG8ep4HZ9s5g+Zrc\n" - + "FeqUkcEGBv8uotOXrq0RN/eaE2uUpowo9tELrB1KIYxkTWe7ZU+yH7JxFwKBgQCH\n" - + "PQcrul6AGNbb0pAKIGoOHEhAb8FtQNnuNNXYgnmNdZ7MammTorSpSTizl1EKTAIr\n" - + "/bJqhaRLZuEsiqxoBDvCi96EQTvi7t2BTbWGqTUylzqfFM46UQBnA2/ty9LNHIeK\n" - + "1iJ//IlS8W+CxMUIXzwqyGplhQk5bgGb59yxHEY7xQKBgQCVaR/0DYDsmryP5Ntt\n" - + "uQjSYMKRCv/7ABegPcocQdLNbr+KvzB8dQUm+QRdBXUMWS69eTgwb4f7F5ilMCi6\n" - + "oPJwwyKpnYzxSxWaQQOFRB3L6b1w7MFMO7TV+5ZcFvIpTRkGqi1NwMFUMdlwQcjG\n" - + "7dyMDd4JN8ac/jwHngxJidcNGg==\n" - + "-----END PRIVATE KEY-----\n"; - - private static final String ROTATED_CERT_PEM = - ROTATED_CERT_AND_KEY_PEM.substring( - 0, - ROTATED_CERT_AND_KEY_PEM.indexOf("-----END CERTIFICATE-----") - + "-----END CERTIFICATE-----\n".length()); - - private static final String ROTATED_KEY_PEM = - ROTATED_CERT_AND_KEY_PEM.substring( - ROTATED_CERT_AND_KEY_PEM.indexOf("-----BEGIN PRIVATE KEY-----")); - static KeyStore createPopulatedKeyStore() { try (InputStream certStream = new FileInputStream(new File("testresources/mtls/test_cert.pem")); @@ -178,9 +119,11 @@ static KeyStore createPopulatedKeyStore() { } static KeyStore createRotatedPopulatedKeyStore() { - try (InputStream stream = - new ByteArrayInputStream(ROTATED_CERT_AND_KEY_PEM.getBytes(StandardCharsets.UTF_8))) { - return SecurityUtils.createMtlsKeyStore(stream); + try (InputStream certStream = + new FileInputStream(new File("testresources/mtls/test_cert_2.pem")); + InputStream keyStream = new FileInputStream(new File("testresources/mtls/test_key_2.pem")); + InputStream combined = new SequenceInputStream(certStream, keyStream)) { + return SecurityUtils.createMtlsKeyStore(combined); } catch (Exception e) { throw new RuntimeException("Failed to create rotated test KeyStore", e); } @@ -3176,8 +3119,13 @@ protected AccessToken exchangeExternalCredentialForAccessToken( HttpTransportFactory cycleTransportFactory) throws IOException { if (exchangeCount.incrementAndGet() == 1) { - Files.write(certFile, ROTATED_CERT_PEM.getBytes(StandardCharsets.UTF_8)); - Files.write(keyFile, ROTATED_KEY_PEM.getBytes(StandardCharsets.UTF_8)); + Files.write( + certFile, + Files.readAllBytes( + java.nio.file.Paths.get("testresources/mtls/test_cert_2.pem"))); + Files.write( + keyFile, + Files.readAllBytes(java.nio.file.Paths.get("testresources/mtls/test_key_2.pem"))); throw new OAuthException("invalid_client", "Unauthorized", null, 401); } return new AccessToken("rotatedRetryToken", null); @@ -4050,11 +3998,16 @@ public static class CustomMtlsHttpTransportFactory extends MtlsHttpTransportFact public CustomMtlsHttpTransportFactory() { super(); } + + public CustomMtlsHttpTransportFactory(KeyStore keyStore) { + super(keyStore); + } } @Test - void customMtlsHttpTransportFactorySubclass_preservedInConstructorAndRefreshAndDeserialization() - throws Exception { + void + customMtlsHttpTransportFactorySubclass_preservedInConstructorAndRefresh_rebuiltOnDeserialization() + throws Exception { Map certificateMap = new HashMap<>(); certificateMap.put("use_default_certificate_config", false); certificateMap.put("certificate_config_location", "testresources/mtls/certificate_config.json"); @@ -4064,8 +4017,8 @@ void customMtlsHttpTransportFactorySubclass_preservedInConstructorAndRefreshAndD IdentityPoolCredentialSource credentialSource = new IdentityPoolCredentialSource(credentialSourceMap); - CustomMtlsHttpTransportFactory customFactory = new CustomMtlsHttpTransportFactory(); KeyStore ks = createPopulatedKeyStore(); + CustomMtlsHttpTransportFactory customFactory = new CustomMtlsHttpTransportFactory(ks); X509Provider x509Provider = new TestX509Provider(ks, "certificate_config_location"); List capturedCycleFactories = new ArrayList<>(); @@ -4108,19 +4061,19 @@ protected AccessToken exchangeExternalCredentialForAccessToken( .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") .build(); IdentityPoolCredentials deserialized = serializeAndDeserialize(regularCredentials); - assertTrue( - deserialized.getTransportFactory() instanceof MtlsHttpTransportFactory, - "readObject must restore an MtlsHttpTransportFactory"); + assertEquals( + MtlsHttpTransportFactory.class, + deserialized.getTransportFactory().getClass(), + "readObject must rebuild a base MtlsHttpTransportFactory when transient KeyStore is lost"); assertTrue( ((MtlsHttpTransportFactory) deserialized.getTransportFactory()).hasKeyStore(), "readObject must restore a KeyStore-backed MtlsHttpTransportFactory"); } @Test - void fileCredentialSourceWithCertConfig_overriddenCreateMtlsTransportFactory_rotatesPerCycle() - throws Exception { - File tokenFile = File.createTempFile("subject_token", ".txt"); - tokenFile.deleteOnExit(); + void fileCredentialSourceWithCertConfig_overriddenCreateMtlsTransportFactory_rotatesPerCycle( + @TempDir Path tempDir) throws Exception { + File tokenFile = tempDir.resolve("subject_token.txt").toFile(); Files.write(tokenFile.toPath(), "test-subject-token".getBytes(StandardCharsets.UTF_8)); Map certificateMap = new HashMap<>(); @@ -4193,4 +4146,128 @@ public LowLevelHttpResponse execute() { assertEquals("rotated-sts-token", token.getTokenValue()); assertEquals(Arrays.asList(ksA, ksB), requestKeyStores); } + + @Test + void + refreshAccessToken_whenKeyStoreReloadThrowsRuntimeException_wrapsInIOExceptionAndSuppresses401() + throws Exception { + KeyStore ks1 = createPopulatedKeyStore(); + AtomicInteger getKeyStoreCount = new AtomicInteger(0); + X509Provider x509Provider = + new X509Provider(null) { + @Override + public KeyStore getKeyStore() { + if (getKeyStoreCount.incrementAndGet() == 1) { + return ks1; + } + throw new IllegalStateException("Unexpected keystore provider failure"); + } + }; + + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(x509Provider) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token")) { + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) + throws IOException { + throw new OAuthException("invalid_client", "Unauthorized", null, 401); + } + }; + + IOException thrown = assertThrows(IOException.class, credential::refreshAccessToken); + assertEquals("Failed to reload certificate on retry", thrown.getMessage()); + assertTrue(thrown.getCause() instanceof IllegalStateException); + assertEquals("Unexpected keystore provider failure", thrown.getCause().getMessage()); + assertEquals(1, thrown.getSuppressed().length); + assertTrue(thrown.getSuppressed()[0] instanceof OAuthException); + } + + @Test + void refreshAccessToken_impersonation_stsReturns401_retriesOnceViaOuterCycle() throws Exception { + KeyStore ks1 = createPopulatedKeyStore(); + KeyStore ks2 = createRotatedPopulatedKeyStore(); + AtomicInteger getKeyStoreCallCount = new AtomicInteger(0); + X509Provider x509Provider = + new X509Provider(null) { + @Override + public KeyStore getKeyStore() { + return getKeyStoreCallCount.incrementAndGet() == 1 ? ks1 : ks2; + } + }; + + AtomicInteger stsCallCount = new AtomicInteger(0); + AtomicInteger iamCallCount = new AtomicInteger(0); + HttpTransport mockTransport = + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() throws IOException { + if (url.contains("/v1/token")) { + int count = stsCallCount.incrementAndGet(); + if (count == 1) { + return new MockLowLevelHttpResponse() + .setStatusCode(401) + .setContentType(Json.MEDIA_TYPE) + .setContent( + "{\"error\":\"invalid_client\",\"error_description\":\"Cert mismatch\"}"); + } + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("access_token", "intermediate-sts-token-" + count); + response.put("token_type", "Bearer"); + response.put("expires_in", 3600); + response.put( + "issued_token_type", "urn:ietf:params:oauth:token-type:access_token"); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } else if (url.contains(":generateAccessToken")) { + int count = iamCallCount.incrementAndGet(); + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("accessToken", "final-iam-token-" + count); + response.put("expireTime", "2030-01-01T00:00:00Z"); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } + return new MockLowLevelHttpResponse().setStatusCode(404); + } + }; + } + }; + + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(x509Provider) + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .setServiceAccountImpersonationUrl( + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken")) { + @Override + HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { + return () -> mockTransport; + } + }; + + AccessToken token = credential.refreshAccessToken(); + assertEquals("final-iam-token-1", token.getTokenValue()); + // STS must be called only twice (initial attempt + 1 outer retry), not 4 times. + assertEquals(2, stsCallCount.get()); + assertEquals(1, iamCallCount.get()); + assertEquals(2, getKeyStoreCallCount.get()); + } } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java index 883b9e8a5460..cd33872d146c 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java @@ -1528,10 +1528,12 @@ public AccessToken refreshAccessToken() { AccessToken token = impersonated.refreshAccessToken(); assertEquals("final-iam-token", token.getTokenValue()); - // Verify STS request received cloud-platform scope + // Verify STS request preserved existing source scope and added cloud-platform scope String stsContent = stsTransport.getRequests().get(0).getContentAsString(); Map stsParams = TestUtils.parseQuery(stsContent); - assertEquals(OAuth2Utils.CLOUD_PLATFORM_SCOPE, stsParams.get("scope")); + assertEquals( + "https://www.googleapis.com/auth/devstorage.read_only " + OAuth2Utils.CLOUD_PLATFORM_SCOPE, + stsParams.get("scope")); // Verify IAM request received the target bigquery scope assertTrue( diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuth2UtilsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuth2UtilsTest.java index b5f2c8f9917a..ded8b0af220f 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuth2UtilsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuth2UtilsTest.java @@ -172,4 +172,36 @@ void hasCertificateChanged_distinctKeyStoreInstances_comparesCertificates() { assertFalse(OAuth2Utils.hasCertificateChanged(ks1, ks2)); assertTrue(OAuth2Utils.hasCertificateChanged(ks1, ksRotated)); } + + @Test + void hasCertificateChanged_sameCertificateDifferentPrivateKey_returnsTrue() throws Exception { + byte[] certBytes = + java.nio.file.Files.readAllBytes( + java.nio.file.Paths.get("testresources/mtls/test_cert.pem")); + byte[] key1Bytes = + java.nio.file.Files.readAllBytes( + java.nio.file.Paths.get("testresources/mtls/test_key.pem")); + byte[] key2Bytes = + java.nio.file.Files.readAllBytes( + java.nio.file.Paths.get("testresources/mtls/test_key_2.pem")); + + KeyStore ks1 = + com.google.api.client.util.SecurityUtils.createMtlsKeyStore( + new java.io.ByteArrayInputStream( + com.google.common.primitives.Bytes.concat(certBytes, "\n".getBytes(), key1Bytes))); + KeyStore ks2 = + com.google.api.client.util.SecurityUtils.createMtlsKeyStore( + new java.io.ByteArrayInputStream( + com.google.common.primitives.Bytes.concat(certBytes, "\n".getBytes(), key2Bytes))); + + assertTrue(OAuth2Utils.hasCertificateChanged(ks1, ks2)); + } + + @Test + void hasCertificateChanged_uninitializedKeyStore_returnsTrue() throws Exception { + KeyStore uninitialized1 = KeyStore.getInstance(KeyStore.getDefaultType()); + KeyStore uninitialized2 = KeyStore.getInstance(KeyStore.getDefaultType()); + + assertTrue(OAuth2Utils.hasCertificateChanged(uninitialized1, uninitialized2)); + } } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java index 0675d4430f6d..028f155b787a 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java @@ -226,16 +226,24 @@ void refreshAccessToken_withServiceAccountImpersonation() throws IOException { .build(); final ExecutableOptions[] providedOptions = {null}; + final int[] executableCallCount = {0}; credential = PluggableAuthCredentials.newBuilder(credential) .setExecutableHandler( options -> { + executableCallCount[0]++; providedOptions[0] = options; return "pluggableAuthToken"; }) .build(); AccessToken accessToken = credential.refreshAccessToken(); + assertEquals(1, executableCallCount[0]); + + // A second refresh while the intermediate STS token is still valid should reuse the cached + // sourceCredentials token without re-running the executable. + credential.refreshAccessToken(); + assertEquals(1, executableCallCount[0]); assertEquals( credential.getServiceAccountEmail(), diff --git a/google-auth-library-java/oauth2_http/testresources/mtls/test_cert_2.pem b/google-auth-library-java/oauth2_http/testresources/mtls/test_cert_2.pem new file mode 100644 index 000000000000..3198bfa7cc82 --- /dev/null +++ b/google-auth-library-java/oauth2_http/testresources/mtls/test_cert_2.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDSzCCAjOgAwIBAgIUGdqGf10PtKM9CrhxJjJWo1bwBrowDQYJKoZIhvcNAQEL +BQAwNDEyMDAGA1UEAwwpcm90YXRlZC1jbGllbnQuYXBwcy5nb29nbGV1c2VyY29u +dGVudC5jb20wIBcNMjYwOTE4MTk1NjM4WhgPMjEyNjA4MjUxOTU2MzhaMDQxMjAw +BgNVBAMMKXJvdGF0ZWQtY2xpZW50LmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29t +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyKMqPCwcqeoVtrrdfhsf +wDldPteZyTi2e9dRWQB+H0GGoTfwI5edvXBKc+ACIpoyl/fEsBLsGYhZqIyGYl90 +pVnHnLYoP4gppoeR9L1wIfY/t8AFCQb+ko9c31gVJLlHCmtPuBf9B/yRrnmwJDXe +BNEuPALdcq9y8ZklDoF+QWXiw0SoZ7HbhhgumK3juNfeOqqxaGt7JGWoZ5ub+Nv/ +fEikINB9gHyUeW33FPoUDIhwDQYRLuyNytKX6tnTHf1o5qIF9ELBBpu6T4U9/1F+ +jD9Wx0XPW59wvcu6f7k/mOswxBdJKNsTfqWeJ0iOn0mZgePmuZJWCsC3P0QPDrwe +KwIDAQABo1MwUTAdBgNVHQ4EFgQUDoOQ6j/nPz2bPi9DzMKijXnQYg0wHwYDVR0j +BBgwFoAUDoOQ6j/nPz2bPi9DzMKijXnQYg0wDwYDVR0TAQH/BAUwAwEB/zANBgkq +hkiG9w0BAQsFAAOCAQEAXf3anEUWyQfoet0e/diBeSvBAbMtG58nxB8q8JVzp4bD +4A+P9AkdS2fNTcOyuVQSQep+cviDXGTFQaAdP+6IHWUFus7bTtGV1VYWOuyTrlsh +8+PKOoAHASloJUUR8aXT0jRE+CEToZH0YnRrXr3d0UePd7BSD6lDlcpvmWpRuAbM +kJZTfOmmDavAt8SWq6fHqCt15PGrCQvyxbOnmMfDHARxH/ysJrs6dCzzNzcBqMAl +XqL8Mzcc+SwqfY4yX5RGDzFfPcwQ8yQKsM4nRYVTL1oGQDbaJXbd34OeIh5VdCmz +pgiAwbu3ubifmfGPBgtuL5FOBGYiXx++EX/+i3QskQ== +-----END CERTIFICATE----- diff --git a/google-auth-library-java/oauth2_http/testresources/mtls/test_key_2.pem b/google-auth-library-java/oauth2_http/testresources/mtls/test_key_2.pem new file mode 100644 index 000000000000..a7e7f18f50b1 --- /dev/null +++ b/google-auth-library-java/oauth2_http/testresources/mtls/test_key_2.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDIoyo8LByp6hW2 +ut1+Gx/AOV0+15nJOLZ711FZAH4fQYahN/Ajl529cEpz4AIimjKX98SwEuwZiFmo +jIZiX3SlWcectig/iCmmh5H0vXAh9j+3wAUJBv6Sj1zfWBUkuUcKa0+4F/0H/JGu +ebAkNd4E0S48At1yr3LxmSUOgX5BZeLDRKhnsduGGC6YreO41946qrFoa3skZahn +m5v42/98SKQg0H2AfJR5bfcU+hQMiHANBhEu7I3K0pfq2dMd/WjmogX0QsEGm7pP +hT3/UX6MP1bHRc9bn3C9y7p/uT+Y6zDEF0ko2xN+pZ4nSI6fSZmB4+a5klYKwLc/ +RA8OvB4rAgMBAAECggEATUph5k09+4zUMZD5628Kg7P1glRvB0/EKJ/xU/53F5ZY +BTo11Zg+KXCdFddpKyhIrjdB+2xGrK1UkLQVvsaz+lDEL1AS4CqVlPPr26KwlDy3 +HhOoPOqHXjFBHz7g4kVHozFkw+/lx1JaUqLJIqHnteZZT7/wzBHfj0emOVjUKN+m +RdtZrl23pP/QI3xVqFlfbzkA+S3RFQ0sxJx2x/P4P8XCTlwE1vqhQ5kB4UzeT68U +iF3hLvGLQ3mWnByPbHOGMOhizdLc2YNw/hyuM37K885BGDYMsNduSUGCe/4E5IHi +QgaRNHr2bZ10TgYIOYb0gEHNLxqHHyAV6VZEPR8JYQKBgQD2tp9Mq4u4vK9i5m79 +FWVcDRSyHuNYm+UR/hlaHKsb+Ps3suEaDcHBTd70Q3xUc0+ELyktw28pfLwNt1bm +do/AcdF6DZ3v7CceqmT9qKkCYb9hd0Fh9qBfysphVdCA9ADLhqTt1VhBlMi5gjop +qoq5xSppm3i2B5oKRVLd/MRdCwKBgQDQMIugrgj7tHXj30M5ZPWI81zQzwB9PK19 +SEMtY2U8CbK7UADojRgZt48jUvi+6JGwoRMGgZKNS738d5HLXomP2ixptOkP/Ojb +saRfYq3f/laEWmTmhKWuPpDRJomeBgN9RIlObCzHG8nXQpkpv3Pxquz+3XsNmqVc +EeVpzLa3YQKBgQDvFxxaGQVXCsSNe/OjoNCR3LtpwzVf8fSU80NuaMoKWchbuicc +MKjMxMKExcH/taAh4fJuPy/DbPZx9HNq9Qtq3OK8/eVJVfqzZT5p9MGVZVUrB399 +iJzzOHvYyGlADzeWBe5f7pmRNLcx0Fq9dmDql4D6x4i8H7HQsk4Ug5lo8QKBgQCQ +ieOmvq1F81gVyJ+nbUx6tFswLUctq77XSCA7ltJmCGWqXjUDP1IIKMSbxbMk99qO +QoYu8i6JIpjYezshcpUW5mYHTa6xhukz/fUctCn7ZV0zrn52/Ij6nD12t8a7i3lI +sxiQ9dFwuv47EOW9ckpQ8lZv69zO6Rr8/P/FoHcKgQKBgBwkBRCqIN+E9BP+NcKi +fIbmEJ3pyrO4FpzMIUF59cGgUZT0GqyU/GHUShk/I36LXL2wMZYPrRubUbz6ibXB ++vHChi14J+JSGHNK5nzapQGJgKvrRlHMOfy0kljnRphTGCuU33ZeRBsFts6tHYEL +dc8QzzRoO2+cylKxetiq45RE +-----END PRIVATE KEY----- From 9313161ff0464c879ac600b6e316dadad4e74534 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Tue, 22 Sep 2026 19:39:48 +0000 Subject: [PATCH 10/13] fix(oauth2): pin leaf subject token from KeyStore, cache STS token with mTLS impersonation, and retry torn cert rotations --- .../auth/mtls/MtlsHttpTransportFactory.java | 5 + ...icateIdentityPoolSubjectTokenSupplier.java | 52 +- .../oauth2/ExternalAccountCredentials.java | 10 +- .../auth/oauth2/IdentityPoolCredentials.java | 139 +++-- .../auth/oauth2/ImpersonatedCredentials.java | 83 ++- .../auth/oauth2/AwsCredentialsTest.java | 6 + .../oauth2/IdentityPoolCredentialsTest.java | 490 ++++++++++++++++-- .../google/auth/oauth2/OAuth2UtilsTest.java | 29 +- 8 files changed, 701 insertions(+), 113 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsHttpTransportFactory.java b/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsHttpTransportFactory.java index 9b20c6c8f446..8f7fd6f198aa 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsHttpTransportFactory.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsHttpTransportFactory.java @@ -96,6 +96,11 @@ public boolean hasKeyStore() { return this.hasKeyStore; } + /** Returns the {@link KeyStore} used by this factory, or {@code null} if none was configured. */ + public @Nullable KeyStore getKeyStore() { + return this.mtlsKeyStore; + } + private static boolean checkHasKeyStore(@Nullable KeyStore keyStore) { if (keyStore == null) { return false; diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java index 5b2ad1169ed0..992d9bbed86f 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java @@ -42,6 +42,8 @@ import java.nio.file.Files; import java.nio.file.NoSuchFileException; import java.nio.file.Paths; +import java.security.KeyStore; +import java.security.KeyStoreException; import java.security.cert.Certificate; import java.security.cert.CertificateEncodingException; import java.security.cert.CertificateException; @@ -49,10 +51,12 @@ import java.security.cert.X509Certificate; import java.util.ArrayList; import java.util.Base64; +import java.util.Enumeration; import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; /** * Provider for retrieving the subject tokens for {@link IdentityPoolCredentials} by reading an @@ -96,6 +100,35 @@ private static String loadAndEncodeLeafCertificate(String path) throws IOExcepti } } + private static @Nullable String extractAndEncodeLeafCertificate(@Nullable KeyStore keyStore) + throws IOException { + if (keyStore == null) { + return null; + } + try { + Enumeration aliases = keyStore.aliases(); + if (aliases == null) { + return null; + } + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + if (keyStore.isKeyEntry(alias)) { + Certificate[] chain = keyStore.getCertificateChain(alias); + if (chain != null && chain.length > 0 && chain[0] instanceof X509Certificate) { + return encodeCert((X509Certificate) chain[0]); + } + Certificate cert = keyStore.getCertificate(alias); + if (cert instanceof X509Certificate) { + return encodeCert((X509Certificate) cert); + } + } + } + return null; + } catch (KeyStoreException | CertificateEncodingException e) { + throw new IOException("Failed to extract leaf certificate from pinned KeyStore", e); + } + } + @VisibleForTesting static X509Certificate parseCertificate(byte[] certData) throws CertificateException { if (certData == null || certData.length == 0) { @@ -135,14 +168,27 @@ private static String encodeCert(X509Certificate certificate) */ @Override public String getSubjectToken(ExternalAccountSupplierContext context) throws IOException { - String leafCertPath = credentialSource.getCredentialLocation(); + return getSubjectToken(context, null); + } + + /** + * Retrieves the X509 subject token, extracting the leaf certificate directly from {@code + * pinnedKeyStore} when provided so that the TLS client certificate and {@code subject_token} are + * pinned to the exact same certificate snapshot. + */ + String getSubjectToken(ExternalAccountSupplierContext context, @Nullable KeyStore pinnedKeyStore) + throws IOException { String trustChainPath = null; if (credentialSource.getCertificateConfig() != null) { trustChainPath = credentialSource.getCertificateConfig().getTrustChainPath(); } - // Load and encode the leaf certificate. - String encodedLeafCert = loadAndEncodeLeafCertificate(leafCertPath); + // Extract the leaf certificate from the pinned KeyStore if present; otherwise read from disk. + String encodedLeafCert = extractAndEncodeLeafCertificate(pinnedKeyStore); + if (encodedLeafCert == null) { + String leafCertPath = credentialSource.getCredentialLocation(); + encodedLeafCert = loadAndEncodeLeafCertificate(leafCertPath); + } // Initialize the certificate chain for the subject token. The Security Token Service (STS) // requires that the leaf certificate (the one used for authenticating this workload) must be diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java index 4c12124dc95e..8cd26293b13d 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java @@ -46,6 +46,7 @@ import java.io.ObjectInputStream; import java.math.BigDecimal; import java.net.URI; +import java.security.KeyStore; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -552,6 +553,12 @@ AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) throw return refreshAccessToken(); } + AccessToken refreshAccessToken( + HttpTransportFactory cycleTransportFactory, @Nullable KeyStore pinnedKeyStore) + throws IOException { + return refreshAccessToken(cycleTransportFactory); + } + /** * Exchanges the external credential for a Google Cloud access token. * @@ -580,7 +587,8 @@ protected AccessToken exchangeExternalCredentialForAccessToken( // Handle service account impersonation if necessary. ImpersonatedCredentials impersonated = getImpersonatedCredentials(); if (impersonated != null) { - return impersonated.refreshAccessToken(cycleTransportFactory); + return impersonated.refreshAccessToken( + cycleTransportFactory == this.transportFactory ? null : cycleTransportFactory); } StsRequestHandler.Builder requestHandler = diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java index 8b8286e985af..cc9a34ed5258 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java @@ -32,6 +32,7 @@ package com.google.auth.oauth2; import com.google.auth.http.HttpTransportFactory; +import com.google.auth.mtls.CertificateSourceUnavailableException; import com.google.auth.mtls.MtlsHttpTransportFactory; import com.google.auth.mtls.MtlsUtils; import com.google.auth.mtls.X509Provider; @@ -45,6 +46,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Map; +import javax.net.ssl.SSLException; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -73,7 +75,7 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { // Transient: not serialized directly. Reconstructed in readObject() from the credentialSource // certificate config so deserialized credentials remain usable for mTLS and refresh. private transient volatile @Nullable X509Provider x509Provider; - private transient @Nullable HttpTransportFactory defaultMtlsTransportFactory; + private final boolean useMtlsTransportFactory; private final ExternalAccountSupplierContext supplierContext; private final String metricsHeaderValue; @@ -99,9 +101,13 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { "A subjectTokenSupplier or a credentialSource must be provided."); } - // Store the x509Provider and defaultMtlsTransportFactory for per-cycle cert pinning. + // Store the x509Provider and useMtlsTransportFactory flag for per-cycle cert pinning and + // deserialization recovery. this.x509Provider = builder.x509Provider; - this.defaultMtlsTransportFactory = builder.defaultMtlsTransportFactory; + this.useMtlsTransportFactory = + builder.useMtlsTransportFactory != null + ? builder.useMtlsTransportFactory + : isDefaultOrMtlsTransportFactory(builder.transportFactory); // Initialize based on the source type if (builder.subjectTokenSupplier != null) { @@ -216,47 +222,86 @@ private boolean isMtlsConfigured() { && ((MtlsHttpTransportFactory) this.transportFactory).hasKeyStore()); } - private boolean shouldUseMtlsTransportFactory() { - return this.transportFactory == null - || this.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY - || this.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory - || this.transportFactory.getClass() == MtlsHttpTransportFactory.class + private static boolean isDefaultOrMtlsTransportFactory( + @Nullable HttpTransportFactory transportFactory) { + return transportFactory == null + || transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY + || transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory + || transportFactory.getClass() == MtlsHttpTransportFactory.class + || (transportFactory instanceof MtlsHttpTransportFactory + && !((MtlsHttpTransportFactory) transportFactory).hasKeyStore()); + } + + @VisibleForTesting + boolean shouldUseMtlsTransportFactory() { + return this.useMtlsTransportFactory || (this.transportFactory instanceof MtlsHttpTransportFactory - && !((MtlsHttpTransportFactory) this.transportFactory).hasKeyStore()) - || (this.defaultMtlsTransportFactory != null - && this.transportFactory == this.defaultMtlsTransportFactory); + && !((MtlsHttpTransportFactory) this.transportFactory).hasKeyStore()); + } + + private static boolean isSslException(@Nullable Throwable throwable) { + Throwable current = throwable; + while (current != null) { + if (current instanceof SSLException) { + return true; + } + current = current.getCause(); + } + return false; } @Override public AccessToken refreshAccessToken() throws IOException { - // Per-cycle cert pinning: snapshot the KeyStore at the start of each refresh cycle. - HttpTransportFactory cycleTransportFactory = this.transportFactory; - KeyStore pinnedKeyStore = null; - if (this.x509Provider != null && shouldUseMtlsTransportFactory()) { - pinnedKeyStore = this.x509Provider.getKeyStore(); - cycleTransportFactory = createMtlsTransportFactory(pinnedKeyStore); - } - return refreshWithRetry(cycleTransportFactory, pinnedKeyStore, /* allowRetry= */ true); + // Per-cycle cert pinning: snapshot the KeyStore at the start of each refresh cycle inside + // refreshWithRetry so transient mid-rotation KeyStore read errors and TLS handshake errors + // can be retried once. + return refreshWithRetry( + /* explicitTransportFactory= */ null, /* pinnedKeyStore= */ null, /* allowRetry= */ true); } @Override AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) throws IOException { + KeyStore pinnedKeyStore = + cycleTransportFactory instanceof MtlsHttpTransportFactory + ? ((MtlsHttpTransportFactory) cycleTransportFactory).getKeyStore() + : null; // Retry is intentionally disabled when an explicit cycleTransportFactory is supplied to // ensure transport synchronization across multi-step token exchanges (e.g. STS and IAM) // and prevent nested retry amplification. Outer callers manage retry coordination. - return refreshWithRetry(cycleTransportFactory, null, /* allowRetry= */ false); + return refreshWithRetry(cycleTransportFactory, pinnedKeyStore, /* allowRetry= */ false); + } + + @Override + AccessToken refreshAccessToken( + HttpTransportFactory cycleTransportFactory, @Nullable KeyStore pinnedKeyStore) + throws IOException { + if (pinnedKeyStore == null) { + return refreshAccessToken(cycleTransportFactory); + } + return refreshWithRetry(cycleTransportFactory, pinnedKeyStore, /* allowRetry= */ false); } private AccessToken refreshWithRetry( - HttpTransportFactory cycleTransportFactory, + @Nullable HttpTransportFactory explicitTransportFactory, @Nullable KeyStore pinnedKeyStore, boolean allowRetry) throws IOException { try { + HttpTransportFactory cycleTransportFactory = + explicitTransportFactory != null ? explicitTransportFactory : this.transportFactory; + if (explicitTransportFactory == null + && this.x509Provider != null + && shouldUseMtlsTransportFactory()) { + if (pinnedKeyStore == null) { + pinnedKeyStore = this.x509Provider.getKeyStore(); + } + cycleTransportFactory = createMtlsTransportFactory(pinnedKeyStore); + } + ImpersonatedCredentials impersonated = getImpersonatedCredentials(); if (impersonated != null) { return impersonated.refreshAccessToken( - pinnedKeyStore != null ? cycleTransportFactory : null); + pinnedKeyStore != null ? cycleTransportFactory : null, pinnedKeyStore); } // Read subject and actor tokens, atomically if from the same file supplier. @@ -270,7 +315,14 @@ private AccessToken refreshWithRetry( subjectToken = tokens.subject; actorToken = tokens.actor; } else { - subjectToken = retrieveSubjectToken(); + if (this.subjectTokenSupplier instanceof CertificateIdentityPoolSubjectTokenSupplier + && pinnedKeyStore != null) { + subjectToken = + ((CertificateIdentityPoolSubjectTokenSupplier) this.subjectTokenSupplier) + .getSubjectToken(supplierContext, pinnedKeyStore); + } else { + subjectToken = retrieveSubjectToken(); + } if (this.actorTokenSupplier != null) { actorToken = this.actorTokenSupplier.getActorToken(supplierContext); } @@ -292,13 +344,20 @@ private AccessToken refreshWithRetry( return exchangeExternalCredentialForAccessToken( stsTokenExchangeRequest.build(), cycleTransportFactory); } catch (IOException | RuntimeException e) { + boolean isInitialKeyStoreLoadFailure = + pinnedKeyStore == null + && e instanceof IOException + && !(e instanceof CertificateSourceUnavailableException); if (allowRetry - && OAuth2Utils.isUnauthorizedException(e) && this.x509Provider != null - && shouldUseMtlsTransportFactory()) { + && shouldUseMtlsTransportFactory() + && (OAuth2Utils.isUnauthorizedException(e) + || isSslException(e) + || isInitialKeyStoreLoadFailure)) { KeyStore freshKeyStore; try { - // On 401, re-read from X509Provider for fresh certs. + // On 401, TLS handshake failure, or transient initial KeyStore load failure, re-read + // from X509Provider for fresh certs. freshKeyStore = this.x509Provider.getKeyStore(); } catch (IOException reloadException) { if (reloadException != e) { @@ -312,7 +371,8 @@ && shouldUseMtlsTransportFactory()) { throw ioException; } - if (!OAuth2Utils.hasCertificateChanged(pinnedKeyStore, freshKeyStore)) { + if (!isInitialKeyStoreLoadFailure + && !OAuth2Utils.hasCertificateChanged(pinnedKeyStore, freshKeyStore)) { throw e; } @@ -332,6 +392,12 @@ && shouldUseMtlsTransportFactory()) { @Override public String retrieveSubjectToken() throws IOException { + if (this.subjectTokenSupplier instanceof CertificateIdentityPoolSubjectTokenSupplier + && this.x509Provider != null + && shouldUseMtlsTransportFactory()) { + return ((CertificateIdentityPoolSubjectTokenSupplier) this.subjectTokenSupplier) + .getSubjectToken(supplierContext, this.x509Provider.getKeyStore()); + } return this.subjectTokenSupplier.getSubjectToken(supplierContext); } @@ -394,9 +460,8 @@ private void initializeMtlsTransport( X509Provider x509Provider = getX509Provider(builder, credentialSource); this.x509Provider = x509Provider; KeyStore mtlsKeyStore = x509Provider.getKeyStore(); - if (builder.transportFactory == null || shouldUseMtlsTransportFactory()) { + if (shouldUseMtlsTransportFactory()) { this.transportFactory = createMtlsTransportFactory(mtlsKeyStore); - this.defaultMtlsTransportFactory = this.transportFactory; } else if (!(builder.transportFactory instanceof MtlsHttpTransportFactory)) { LOGGER_PROVIDER .getLogger() @@ -442,14 +507,13 @@ private void readObject(ObjectInputStream input) throws IOException, ClassNotFou String explicitCertConfigPath = getExplicitCertConfigPath(credentialSource); this.x509Provider = new X509Provider(getEnvironmentProvider(), getPropertyProvider(), explicitCertConfigPath); - try { - KeyStore mtlsKeyStore = this.x509Provider.getKeyStore(); - if (shouldUseMtlsTransportFactory()) { + if (shouldUseMtlsTransportFactory()) { + try { + KeyStore mtlsKeyStore = this.x509Provider.getKeyStore(); this.transportFactory = createMtlsTransportFactory(mtlsKeyStore); - this.defaultMtlsTransportFactory = this.transportFactory; + } catch (Exception e) { + // Cert loading failure will be handled on refreshAccessToken() } - } catch (Exception e) { - // Cert loading failure will be handled on refreshAccessToken() } } } @@ -485,7 +549,7 @@ public static class Builder extends ExternalAccountCredentials.Builder { private @Nullable IdentityPoolActorTokenSupplier actorTokenSupplier; private @Nullable String actorTokenType; private @Nullable X509Provider x509Provider; - private @Nullable HttpTransportFactory defaultMtlsTransportFactory; + private @Nullable Boolean useMtlsTransportFactory; Builder() {} @@ -503,7 +567,7 @@ public static class Builder extends ExternalAccountCredentials.Builder { // FileIdentityPoolSubjectTokenSupplier instance for atomic token reads. this.actorTokenType = credentials.actorTokenType; this.x509Provider = credentials.x509Provider; - this.defaultMtlsTransportFactory = credentials.defaultMtlsTransportFactory; + this.useMtlsTransportFactory = credentials.useMtlsTransportFactory; } /** @@ -572,6 +636,7 @@ Builder setActorTokenType(String actorTokenType) { @CanIgnoreReturnValue public Builder setHttpTransportFactory(HttpTransportFactory transportFactory) { super.setHttpTransportFactory(transportFactory); + this.useMtlsTransportFactory = isDefaultOrMtlsTransportFactory(transportFactory); return this; } diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java index ea557c62f7b0..912e71a0a9e0 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java @@ -49,6 +49,7 @@ import com.google.auth.ServiceAccountSigner; import com.google.auth.http.HttpCredentialsAdapter; import com.google.auth.http.HttpTransportFactory; +import com.google.auth.mtls.MtlsHttpTransportFactory; import com.google.auth.oauth2.MetricsUtils.RequestType; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.MoreObjects; @@ -59,6 +60,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.ObjectInputStream; +import java.security.KeyStore; import java.time.DateTimeException; import java.time.Instant; import java.time.format.DateTimeFormatter; @@ -112,6 +114,8 @@ public class ImpersonatedCredentials extends GoogleCredentials private static final int TWELVE_HOURS_IN_SECONDS = 43200; private static final int DEFAULT_LIFETIME_IN_SECONDS = 3600; private volatile GoogleCredentials sourceCredentials; + private transient volatile @Nullable AccessToken cachedStsAccessToken; + private transient volatile @Nullable KeyStore cachedStsKeyStore; private final String targetPrincipal; private List delegates; private final List scopes; @@ -588,6 +592,26 @@ public AccessToken refreshAccessToken() throws IOException { return refreshAccessToken(null); } + private boolean isCachedStsTokenReusable( + ExternalAccountCredentials externalSource, @Nullable KeyStore currentKeyStore) { + AccessToken token = this.cachedStsAccessToken; + if (token == null) { + return false; + } + Date expirationTime = token.getExpirationTime(); + if (expirationTime != null) { + long remainingMillis = expirationTime.getTime() - externalSource.clock.currentTimeMillis(); + if (remainingMillis <= externalSource.getExpirationMargin().toMillis()) { + return false; + } + } + if (currentKeyStore == null) { + return this.cachedStsKeyStore == null; + } + return this.cachedStsKeyStore != null + && !OAuth2Utils.hasCertificateChanged(this.cachedStsKeyStore, currentKeyStore); + } + /** * Refreshes the access token using the specified transport factory for per-cycle transport * pinning. @@ -599,9 +623,31 @@ public AccessToken refreshAccessToken() throws IOException { */ AccessToken refreshAccessToken(@Nullable HttpTransportFactory cycleTransportFactory) throws IOException { + KeyStore pinnedKeyStore = + cycleTransportFactory instanceof MtlsHttpTransportFactory + ? ((MtlsHttpTransportFactory) cycleTransportFactory).getKeyStore() + : null; + return refreshAccessToken(cycleTransportFactory, pinnedKeyStore); + } + + /** + * Refreshes the access token using the specified transport factory and pinned {@link KeyStore} + * for per-cycle transport pinning. + * + * @param cycleTransportFactory the HTTP transport factory to use, or {@code null} to use this + * instance's configured transport factory without overriding source credential transport + * @param pinnedKeyStore the {@link KeyStore} snapshot associated with {@code + * cycleTransportFactory}, or {@code null} if not using per-cycle mTLS pinning + * @return the refreshed access token + * @throws IOException if token refresh fails + */ + AccessToken refreshAccessToken( + @Nullable HttpTransportFactory cycleTransportFactory, @Nullable KeyStore pinnedKeyStore) + throws IOException { HttpTransportFactory effectiveTransportFactory = firstNonNull(cycleTransportFactory, this.transportFactory); HttpCredentialsAdapter adapter; + AccessToken intermediateAccessTokenForCache = null; if (this.sourceCredentials instanceof ExternalAccountCredentials) { ExternalAccountCredentials externalSource; synchronized (this) { @@ -623,12 +669,24 @@ AccessToken refreshAccessToken(@Nullable HttpTransportFactory cycleTransportFact } adapter = new HttpCredentialsAdapter(externalSource); } else { - AccessToken intermediateAccessToken; - try { - intermediateAccessToken = externalSource.refreshAccessToken(effectiveTransportFactory); - } catch (IOException e) { - throw new IOException("Unable to refresh sourceCredentials", e); + AccessToken intermediateAccessToken = null; + synchronized (this) { + if (isCachedStsTokenReusable(externalSource, pinnedKeyStore)) { + intermediateAccessToken = this.cachedStsAccessToken; + } + } + if (intermediateAccessToken == null) { + try { + intermediateAccessToken = + pinnedKeyStore != null + ? externalSource.refreshAccessToken(effectiveTransportFactory, pinnedKeyStore) + : externalSource.refreshAccessToken(effectiveTransportFactory); + } catch (IOException e) { + throw new IOException("Unable to refresh sourceCredentials", e); + } } + intermediateAccessTokenForCache = intermediateAccessToken; + final AccessToken tokenToUse = intermediateAccessToken; GoogleCredentials authCredentials = new GoogleCredentials( GoogleCredentials.newBuilder() @@ -636,7 +694,7 @@ AccessToken refreshAccessToken(@Nullable HttpTransportFactory cycleTransportFact .setUniverseDomain(externalSource.getUniverseDomain())) { @Override public AccessToken refreshAccessToken() { - return intermediateAccessToken; + return tokenToUse; } }; adapter = new HttpCredentialsAdapter(authCredentials); @@ -708,9 +766,22 @@ public AccessToken refreshAccessToken() { LoggingUtils.logRequest(request, LOGGER_PROVIDER, "Sending request to refresh access token"); response = request.execute(); } catch (IOException e) { + if (cycleTransportFactory != null) { + synchronized (this) { + this.cachedStsAccessToken = null; + this.cachedStsKeyStore = null; + } + } throw new IOException("Error requesting access token", e); } + if (cycleTransportFactory != null && intermediateAccessTokenForCache != null) { + synchronized (this) { + this.cachedStsAccessToken = intermediateAccessTokenForCache; + this.cachedStsKeyStore = pinnedKeyStore; + } + } + GenericData responseData; try { LoggingUtils.logResponse( diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java index 4ea4d133f959..2bb3e3e789a5 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java @@ -1258,6 +1258,12 @@ void serialize() throws IOException, ClassNotFoundException { assertNotNull(deserializedCredentials.getServiceAccountEmail()); assertEquals( testCredentials.getServiceAccountEmail(), deserializedCredentials.getServiceAccountEmail()); + AwsCredentials deserializedInnerSource = + (AwsCredentials) + deserializedCredentials.getImpersonatedCredentials().getSourceCredentials(); + assertNull(deserializedInnerSource.getServiceAccountImpersonationUrl()); + assertEquals( + testCredentials.getServiceAccountEmail(), deserializedInnerSource.getServiceAccountEmail()); } /** diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java index 70963d600065..a849e3d3a297 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java @@ -68,9 +68,11 @@ import java.io.ObjectInputStream; import java.io.ObjectStreamClass; import java.io.SequenceInputStream; +import java.io.Serializable; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.Paths; import java.security.KeyStore; import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; @@ -89,6 +91,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; +import javax.net.ssl.SSLHandshakeException; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -3069,10 +3072,10 @@ void fromStream_fileCredentialSource_certRotation_401Retry_succeeds(@TempDir Pat + " \"cert_configs\": {\n" + " \"workload\": {\n" + " \"cert_path\": \"" - + certFile.toString() + + certFile.toString().replace("\\", "\\\\") + "\",\n" + " \"key_path\": \"" - + keyFile.toString() + + keyFile.toString().replace("\\", "\\\\") + "\"\n" + " }\n" + " }\n" @@ -3088,7 +3091,7 @@ void fromStream_fileCredentialSource_certRotation_401Retry_succeeds(@TempDir Pat + " \"token_url\": \"https://sts.googleapis.com/v1/token\",\n" + " \"credential_source\": {\n" + " \"file\": \"" - + tokenFile.toString() + + tokenFile.toString().replace("\\", "\\\\") + "\",\n" + " \"format\": {\n" + " \"type\": \"json\",\n" @@ -3096,7 +3099,7 @@ void fromStream_fileCredentialSource_certRotation_401Retry_succeeds(@TempDir Pat + " },\n" + " \"certificate\": {\n" + " \"certificate_config_location\": \"" - + certConfigFile.toString() + + certConfigFile.toString().replace("\\", "\\\\") + "\"\n" + " }\n" + " }\n" @@ -3120,12 +3123,9 @@ protected AccessToken exchangeExternalCredentialForAccessToken( throws IOException { if (exchangeCount.incrementAndGet() == 1) { Files.write( - certFile, - Files.readAllBytes( - java.nio.file.Paths.get("testresources/mtls/test_cert_2.pem"))); + certFile, Files.readAllBytes(Paths.get("testresources/mtls/test_cert_2.pem"))); Files.write( - keyFile, - Files.readAllBytes(java.nio.file.Paths.get("testresources/mtls/test_key_2.pem"))); + keyFile, Files.readAllBytes(Paths.get("testresources/mtls/test_key_2.pem"))); throw new OAuthException("invalid_client", "Unauthorized", null, 401); } return new AccessToken("rotatedRetryToken", null); @@ -4204,48 +4204,214 @@ public KeyStore getKeyStore() { AtomicInteger stsCallCount = new AtomicInteger(0); AtomicInteger iamCallCount = new AtomicInteger(0); - HttpTransport mockTransport = - new MockHttpTransport() { + List capturedKeyStores = new ArrayList<>(); + List requestKeyStores = new ArrayList<>(); + + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(x509Provider) + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .setServiceAccountImpersonationUrl( + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken")) { @Override - public LowLevelHttpRequest buildRequest(String method, String url) { - return new MockLowLevelHttpRequest(url) { - @Override - public LowLevelHttpResponse execute() throws IOException { - if (url.contains("/v1/token")) { - int count = stsCallCount.incrementAndGet(); - if (count == 1) { - return new MockLowLevelHttpResponse() - .setStatusCode(401) - .setContentType(Json.MEDIA_TYPE) - .setContent( - "{\"error\":\"invalid_client\",\"error_description\":\"Cert mismatch\"}"); + HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { + capturedKeyStores.add(keyStore); + return () -> + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + requestKeyStores.add(keyStore); + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() { + if (url.contains("/v1/token")) { + int count = stsCallCount.incrementAndGet(); + if (keyStore == ks1) { + return new MockLowLevelHttpResponse() + .setStatusCode(401) + .setContentType(Json.MEDIA_TYPE) + .setContent( + "{\"error\":\"invalid_client\",\"error_description\":\"Cert" + + " mismatch\"}"); + } + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("access_token", "intermediate-sts-token-" + count); + response.put("token_type", "Bearer"); + response.put("expires_in", 3600); + response.put( + "issued_token_type", "urn:ietf:params:oauth:token-type:access_token"); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } else if (url.contains(":generateAccessToken")) { + int count = iamCallCount.incrementAndGet(); + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("accessToken", "final-iam-token-" + count); + response.put("expireTime", "2030-01-01T00:00:00Z"); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } + return new MockLowLevelHttpResponse().setStatusCode(404); + } + }; } - GenericJson response = new GenericJson(); - response.setFactory(OAuth2Utils.JSON_FACTORY); - response.put("access_token", "intermediate-sts-token-" + count); - response.put("token_type", "Bearer"); - response.put("expires_in", 3600); - response.put( - "issued_token_type", "urn:ietf:params:oauth:token-type:access_token"); - return new MockLowLevelHttpResponse() - .setContentType(Json.MEDIA_TYPE) - .setContent(response.toString()); - } else if (url.contains(":generateAccessToken")) { - int count = iamCallCount.incrementAndGet(); - GenericJson response = new GenericJson(); - response.setFactory(OAuth2Utils.JSON_FACTORY); - response.put("accessToken", "final-iam-token-" + count); - response.put("expireTime", "2030-01-01T00:00:00Z"); - return new MockLowLevelHttpResponse() - .setContentType(Json.MEDIA_TYPE) - .setContent(response.toString()); - } - return new MockLowLevelHttpResponse().setStatusCode(404); + }; + } + }; + + AccessToken token = credential.refreshAccessToken(); + assertEquals("final-iam-token-1", token.getTokenValue()); + // STS must be called only twice (initial attempt with ks1 + 1 outer retry with ks2). + assertEquals(2, stsCallCount.get()); + assertEquals(1, iamCallCount.get()); + assertEquals(2, getKeyStoreCallCount.get()); + assertEquals(Arrays.asList(ks1, ks2), capturedKeyStores); + assertEquals(Arrays.asList(ks1, ks2, ks2), requestKeyStores); + } + + @Test + void + refreshAccessToken_certSubjectTokenSupplier_extractsLeafCertFromPinnedKeyStoreEvenWhenDiskRotates( + @TempDir Path tempDir) throws Exception { + Path certFile1 = tempDir.resolve("cert1.pem"); + Path keyFile1 = tempDir.resolve("key1.pem"); + Path certFile2 = tempDir.resolve("cert2.pem"); + Path keyFile2 = tempDir.resolve("key2.pem"); + Files.copy(Paths.get("testresources/mtls/test_cert.pem"), certFile1); + Files.copy(Paths.get("testresources/mtls/test_key.pem"), keyFile1); + Files.copy(Paths.get("testresources/mtls/test_cert_2.pem"), certFile2); + Files.copy(Paths.get("testresources/mtls/test_key_2.pem"), keyFile2); + + Path certConfigFile = tempDir.resolve("certificate_config.json"); + String certConfigJson1 = + "{\n" + + " \"cert_configs\": {\n" + + " \"workload\": {\n" + + " \"cert_path\": \"" + + certFile1.toString().replace("\\", "\\\\") + + "\",\n" + + " \"key_path\": \"" + + keyFile1.toString().replace("\\", "\\\\") + + "\"\n" + + " }\n" + + " }\n" + + "}"; + Files.write(certConfigFile, certConfigJson1.getBytes(StandardCharsets.UTF_8)); + + Map certificateMap = new HashMap<>(); + certificateMap.put("certificate_config_location", certConfigFile.toString()); + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("certificate", certificateMap); + IdentityPoolCredentialSource credentialSource = + new IdentityPoolCredentialSource(credentialSourceMap); + + KeyStore ks1 = createPopulatedKeyStore(); + KeyStore ks2 = createRotatedPopulatedKeyStore(); + AtomicInteger getKeyStoreCount = new AtomicInteger(0); + X509Provider x509Provider = + new X509Provider(null) { + @Override + public KeyStore getKeyStore() throws IOException { + int call = getKeyStoreCount.incrementAndGet(); + if (call <= 2) { + // On call 2 (the start of refreshAccessToken), overwrite cert1.pem on disk with + // cert2.pem AND update certificate_config.json to point to cert2.pem/key2.pem AFTER + // ks1 is loaded. This simulates a mid-cycle cert rotation between getKeyStore() and + // getSubjectToken(). + if (call == 2) { + Files.copy(certFile2, certFile1, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + String certConfigJson2 = + "{\n" + + " \"cert_configs\": {\n" + + " \"workload\": {\n" + + " \"cert_path\": \"" + + certFile2.toString().replace("\\", "\\\\") + + "\",\n" + + " \"key_path\": \"" + + keyFile2.toString().replace("\\", "\\\\") + + "\"\n" + + " }\n" + + " }\n" + + "}"; + Files.write(certConfigFile, certConfigJson2.getBytes(StandardCharsets.UTF_8)); } - }; + return ks1; + } + return ks2; } }; + List capturedSubjectTokens = new ArrayList<>(); + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setCredentialSource(credentialSource) + .setX509Provider(x509Provider) + .setAudience("audience") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:mtls") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token")) { + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) { + capturedSubjectTokens.add(stsTokenExchangeRequest.getSubjectToken()); + return new AccessToken("mtls-bound-token", null); + } + }; + + // Cycle 1: Even though cert1.pem on disk was overwritten with cert2.pem right after ks1 was + // snapshotted, subject_token MUST match ks1 (not cert2.pem on disk). + credential.refreshAccessToken(); + // Cycle 2: Now getKeyStore() returns ks2, so subject_token MUST match ks2. + credential.refreshAccessToken(); + + String expectedCert1Base64 = + Base64.getEncoder() + .encodeToString( + CertificateIdentityPoolSubjectTokenSupplier.parseCertificate( + Files.readAllBytes(Paths.get("testresources/mtls/test_cert.pem"))) + .getEncoded()); + String expectedCert2Base64 = + Base64.getEncoder() + .encodeToString( + CertificateIdentityPoolSubjectTokenSupplier.parseCertificate( + Files.readAllBytes(Paths.get("testresources/mtls/test_cert_2.pem"))) + .getEncoded()); + + assertEquals(2, capturedSubjectTokens.size()); + assertTrue(capturedSubjectTokens.get(0).contains(expectedCert1Base64)); + assertFalse(capturedSubjectTokens.get(0).contains(expectedCert2Base64)); + assertTrue(capturedSubjectTokens.get(1).contains(expectedCert2Base64)); + } + + @Test + void + refreshAccessToken_impersonation_cachesOneHourStsTokenWhileKeyStoreUnchangedAndInvalidatesOnRotation() + throws Exception { + KeyStore ks1 = createPopulatedKeyStore(); + KeyStore ks2 = createRotatedPopulatedKeyStore(); + AtomicInteger getKeyStoreCallCount = new AtomicInteger(0); + X509Provider x509Provider = + new X509Provider(null) { + @Override + public KeyStore getKeyStore() { + // Calls 1 & 2 return ks1 (unchanged cert); Call 3 returns ks2 (rotated cert). + return getKeyStoreCallCount.incrementAndGet() <= 2 ? ks1 : ks2; + } + }; + + AtomicInteger stsCallCount = new AtomicInteger(0); + AtomicInteger iamCallCount = new AtomicInteger(0); + List iamBearerHeaders = new ArrayList<>(); + IdentityPoolCredentials credential = new IdentityPoolCredentials( IdentityPoolCredentials.newBuilder() @@ -4259,15 +4425,237 @@ public LowLevelHttpResponse execute() throws IOException { "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken")) { @Override HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { - return () -> mockTransport; + return () -> + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() { + if (url.contains("/v1/token")) { + int count = stsCallCount.incrementAndGet(); + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("access_token", "cached-sts-token-" + count); + response.put("token_type", "Bearer"); + response.put("expires_in", 3600); + response.put( + "issued_token_type", "urn:ietf:params:oauth:token-type:access_token"); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } else if (url.contains(":generateAccessToken")) { + int count = iamCallCount.incrementAndGet(); + iamBearerHeaders.add(getFirstHeaderValue("Authorization")); + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("accessToken", "final-iam-token-" + count); + response.put("expireTime", "2030-01-01T00:00:00Z"); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } + return new MockLowLevelHttpResponse().setStatusCode(404); + } + }; + } + }; } }; - AccessToken token = credential.refreshAccessToken(); - assertEquals("final-iam-token-1", token.getTokenValue()); - // STS must be called only twice (initial attempt + 1 outer retry), not 4 times. - assertEquals(2, stsCallCount.get()); + // Refresh 1 (ks1): mints STS token 1 and IAM token 1. + AccessToken token1 = credential.refreshAccessToken(); + assertEquals("final-iam-token-1", token1.getTokenValue()); + assertEquals(1, stsCallCount.get()); assertEquals(1, iamCallCount.get()); - assertEquals(2, getKeyStoreCallCount.get()); + + // Refresh 2 (still ks1, STS token 1 still valid): MUST reuse cached STS token 1 without calling + // STS again. + AccessToken token2 = credential.refreshAccessToken(); + assertEquals("final-iam-token-2", token2.getTokenValue()); + assertEquals(1, stsCallCount.get()); + assertEquals(2, iamCallCount.get()); + assertEquals("Bearer cached-sts-token-1", iamBearerHeaders.get(0)); + assertEquals("Bearer cached-sts-token-1", iamBearerHeaders.get(1)); + + // Refresh 3 (ks2 rotated): MUST invalidate cached STS token 1 and mint STS token 2. + AccessToken token3 = credential.refreshAccessToken(); + assertEquals("final-iam-token-3", token3.getTokenValue()); + assertEquals(2, stsCallCount.get()); + assertEquals(3, iamCallCount.get()); + assertEquals("Bearer cached-sts-token-2", iamBearerHeaders.get(2)); + } + + @Test + void refreshAccessToken_sslHandshakeExceptionFromTornRotation_retriesWhenKeyStoreChanges() + throws Exception { + // Torn KeyStore: cert2 + key1; Completed rotation KeyStore: cert2 + key2. + byte[] cert2Bytes = Files.readAllBytes(Paths.get("testresources/mtls/test_cert_2.pem")); + byte[] key1Bytes = Files.readAllBytes(Paths.get("testresources/mtls/test_key.pem")); + byte[] key2Bytes = Files.readAllBytes(Paths.get("testresources/mtls/test_key_2.pem")); + byte[] newline = "\n".getBytes(StandardCharsets.UTF_8); + + KeyStore tornKeyStore = + SecurityUtils.createMtlsKeyStore( + new ByteArrayInputStream( + com.google.common.primitives.Bytes.concat(cert2Bytes, newline, key1Bytes))); + KeyStore validRotatedKeyStore = + SecurityUtils.createMtlsKeyStore( + new ByteArrayInputStream( + com.google.common.primitives.Bytes.concat(cert2Bytes, newline, key2Bytes))); + + AtomicInteger getKeyStoreCount = new AtomicInteger(0); + X509Provider x509Provider = + new X509Provider(null) { + @Override + public KeyStore getKeyStore() { + return getKeyStoreCount.incrementAndGet() == 1 ? tornKeyStore : validRotatedKeyStore; + } + }; + + AtomicInteger exchangeCount = new AtomicInteger(0); + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(x509Provider) + .setAudience("audience") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token")) { + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) + throws IOException { + if (exchangeCount.incrementAndGet() == 1) { + throw new IOException( + "Error writing request body to server", + new SSLHandshakeException("Received fatal alert: decrypt_error")); + } + return new AccessToken("recovered-after-ssl-handshake-retry", null); + } + }; + + AccessToken token = credential.refreshAccessToken(); + assertEquals("recovered-after-ssl-handshake-retry", token.getTokenValue()); + assertEquals(2, getKeyStoreCount.get()); + assertEquals(2, exchangeCount.get()); + } + + @Test + void refreshAccessToken_initialKeyStoreLoadIOException_retriesOnceAndSucceeds() throws Exception { + KeyStore validKeyStore = createPopulatedKeyStore(); + AtomicInteger getKeyStoreCount = new AtomicInteger(0); + X509Provider x509Provider = + new X509Provider(null) { + @Override + public KeyStore getKeyStore() throws IOException { + if (getKeyStoreCount.incrementAndGet() == 1) { + throw new IOException("X509Provider: Unexpected IOException: mid-write PEM"); + } + return validKeyStore; + } + }; + + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(x509Provider) + .setAudience("audience") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token")) { + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) { + return new AccessToken("recovered-after-initial-keystore-ioe", null); + } + }; + + AccessToken token = credential.refreshAccessToken(); + assertEquals("recovered-after-initial-keystore-ioe", token.getTokenValue()); + assertEquals(2, getKeyStoreCount.get()); + } + + public static class SerializableCustomTransportFactory + implements HttpTransportFactory, Serializable { + private static final long serialVersionUID = 1L; + private static final AtomicInteger getKeyStoreCountDuringReadObject = new AtomicInteger(0); + + public SerializableCustomTransportFactory() {} + + @Override + public HttpTransport create() { + return new MockHttpTransport(); + } + } + + private static class CountingSerializableX509Provider extends X509Provider { + private static final long serialVersionUID = 1L; + + CountingSerializableX509Provider() { + super(null); + } + + @Override + public KeyStore getKeyStore() throws IOException { + SerializableCustomTransportFactory.getKeyStoreCountDuringReadObject.incrementAndGet(); + try { + return createPopulatedKeyStore(); + } catch (Exception e) { + throw new IOException(e); + } + } + } + + @Test + void deserialization_respectsUseMtlsTransportFactoryFlag() throws Exception { + Map certMap = new HashMap<>(); + certMap.put("certificate_config_location", "testresources/mtls/certificate_config.json"); + Map sourceMap = new HashMap<>(); + sourceMap.put("file", "credential.json"); + sourceMap.put("certificate", certMap); + IdentityPoolCredentialSource credentialSource = new IdentityPoolCredentialSource(sourceMap); + + // Case 1: No custom HttpTransportFactory set -> useMtlsTransportFactory is true. + // Build initializes MtlsHttpTransportFactory, and deserialization restores + // MtlsHttpTransportFactory in readObject(). + SerializableCustomTransportFactory.getKeyStoreCountDuringReadObject.set(0); + IdentityPoolCredentials defaultMtlsCreds = + IdentityPoolCredentials.newBuilder() + .setCredentialSource(credentialSource) + .setX509Provider(new CountingSerializableX509Provider()) + .setAudience("audience") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build(); + assertEquals(1, SerializableCustomTransportFactory.getKeyStoreCountDuringReadObject.get()); + assertTrue(defaultMtlsCreds.shouldUseMtlsTransportFactory()); + assertTrue(defaultMtlsCreds.toBuilder().build().shouldUseMtlsTransportFactory()); + + IdentityPoolCredentials deserializedDefault = serializeAndDeserialize(defaultMtlsCreds); + assertTrue(deserializedDefault.shouldUseMtlsTransportFactory()); + assertTrue(deserializedDefault.getTransportFactory() instanceof MtlsHttpTransportFactory); + + // Case 2: Custom HttpTransportFactory explicitly set -> useMtlsTransportFactory is false. + // Deserialization must preserve SerializableCustomTransportFactory and NOT overwrite it with + // MtlsHttpTransportFactory. + IdentityPoolCredentials customTransportCreds = + IdentityPoolCredentials.newBuilder() + .setCredentialSource(credentialSource) + .setX509Provider(new CountingSerializableX509Provider()) + .setHttpTransportFactory(new SerializableCustomTransportFactory()) + .setAudience("audience") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build(); + assertFalse(customTransportCreds.shouldUseMtlsTransportFactory()); + assertFalse(customTransportCreds.toBuilder().build().shouldUseMtlsTransportFactory()); + + IdentityPoolCredentials deserializedCustom = serializeAndDeserialize(customTransportCreds); + assertFalse(deserializedCustom.shouldUseMtlsTransportFactory()); + assertTrue( + deserializedCustom.getTransportFactory() instanceof SerializableCustomTransportFactory); } } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuth2UtilsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuth2UtilsTest.java index ded8b0af220f..36d3261c8c31 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuth2UtilsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuth2UtilsTest.java @@ -39,7 +39,13 @@ import com.google.api.client.http.HttpHeaders; import com.google.api.client.http.HttpResponseException; +import com.google.api.client.util.SecurityUtils; +import com.google.common.primitives.Bytes; +import java.io.ByteArrayInputStream; import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; import java.security.KeyStore; import org.junit.jupiter.api.Test; @@ -175,24 +181,17 @@ void hasCertificateChanged_distinctKeyStoreInstances_comparesCertificates() { @Test void hasCertificateChanged_sameCertificateDifferentPrivateKey_returnsTrue() throws Exception { - byte[] certBytes = - java.nio.file.Files.readAllBytes( - java.nio.file.Paths.get("testresources/mtls/test_cert.pem")); - byte[] key1Bytes = - java.nio.file.Files.readAllBytes( - java.nio.file.Paths.get("testresources/mtls/test_key.pem")); - byte[] key2Bytes = - java.nio.file.Files.readAllBytes( - java.nio.file.Paths.get("testresources/mtls/test_key_2.pem")); + byte[] certBytes = Files.readAllBytes(Paths.get("testresources/mtls/test_cert.pem")); + byte[] key1Bytes = Files.readAllBytes(Paths.get("testresources/mtls/test_key.pem")); + byte[] key2Bytes = Files.readAllBytes(Paths.get("testresources/mtls/test_key_2.pem")); + byte[] newlineBytes = "\n".getBytes(StandardCharsets.UTF_8); KeyStore ks1 = - com.google.api.client.util.SecurityUtils.createMtlsKeyStore( - new java.io.ByteArrayInputStream( - com.google.common.primitives.Bytes.concat(certBytes, "\n".getBytes(), key1Bytes))); + SecurityUtils.createMtlsKeyStore( + new ByteArrayInputStream(Bytes.concat(certBytes, newlineBytes, key1Bytes))); KeyStore ks2 = - com.google.api.client.util.SecurityUtils.createMtlsKeyStore( - new java.io.ByteArrayInputStream( - com.google.common.primitives.Bytes.concat(certBytes, "\n".getBytes(), key2Bytes))); + SecurityUtils.createMtlsKeyStore( + new ByteArrayInputStream(Bytes.concat(certBytes, newlineBytes, key2Bytes))); assertTrue(OAuth2Utils.hasCertificateChanged(ks1, ks2)); } From 6e5f7fe7e66b5af803d1879ca54e666e50634177 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Tue, 22 Sep 2026 20:57:19 +0000 Subject: [PATCH 11/13] fix(oauth2): support mTLS pinning for standalone ImpersonatedCredentials and retry bare IOExceptions on split cert writes --- .../auth/oauth2/IdentityPoolCredentials.java | 64 ++-- .../auth/oauth2/ImpersonatedCredentials.java | 56 +++- .../oauth2/IdentityPoolCredentialsTest.java | 285 ++++++++++++++++++ 3 files changed, 374 insertions(+), 31 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java index cc9a34ed5258..8bd293ca5d9c 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java @@ -31,6 +31,7 @@ package com.google.auth.oauth2; +import com.google.api.client.http.HttpResponseException; import com.google.auth.http.HttpTransportFactory; import com.google.auth.mtls.CertificateSourceUnavailableException; import com.google.auth.mtls.MtlsHttpTransportFactory; @@ -46,7 +47,6 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Map; -import javax.net.ssl.SSLException; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -216,14 +216,13 @@ private static void validateMtlsEndpoint(@Nullable String url, String fieldName) * positives from a no-arg-constructed MtlsHttpTransportFactory (e.g. after deserialization) that * has no actual certificates. */ - private boolean isMtlsConfigured() { + boolean isMtlsConfigured() { return this.x509Provider != null || (this.transportFactory instanceof MtlsHttpTransportFactory && ((MtlsHttpTransportFactory) this.transportFactory).hasKeyStore()); } - private static boolean isDefaultOrMtlsTransportFactory( - @Nullable HttpTransportFactory transportFactory) { + static boolean isDefaultOrMtlsTransportFactory(@Nullable HttpTransportFactory transportFactory) { return transportFactory == null || transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY || transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory @@ -239,15 +238,32 @@ boolean shouldUseMtlsTransportFactory() { && !((MtlsHttpTransportFactory) this.transportFactory).hasKeyStore()); } - private static boolean isSslException(@Nullable Throwable throwable) { + boolean hasMtlsProviderForImpersonation() { + return this.x509Provider != null && shouldUseMtlsTransportFactory(); + } + + AccessToken refreshImpersonatedAccessTokenWithRetry(ImpersonatedCredentials impersonated) + throws IOException { + return refreshWithRetry( + /* explicitTransportFactory= */ null, + /* pinnedKeyStore= */ null, + /* targetImpersonated= */ impersonated, + /* allowRetry= */ true); + } + + private static boolean isRetryableTransportException(@Nullable Throwable throwable) { + if (!(throwable instanceof IOException) + || throwable instanceof CertificateSourceUnavailableException) { + return false; + } Throwable current = throwable; while (current != null) { - if (current instanceof SSLException) { - return true; + if (current instanceof OAuthException || current instanceof HttpResponseException) { + return false; } current = current.getCause(); } - return false; + return true; } @Override @@ -256,7 +272,10 @@ public AccessToken refreshAccessToken() throws IOException { // refreshWithRetry so transient mid-rotation KeyStore read errors and TLS handshake errors // can be retried once. return refreshWithRetry( - /* explicitTransportFactory= */ null, /* pinnedKeyStore= */ null, /* allowRetry= */ true); + /* explicitTransportFactory= */ null, + /* pinnedKeyStore= */ null, + getImpersonatedCredentials(), + /* allowRetry= */ true); } @Override @@ -268,7 +287,11 @@ AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) throw // Retry is intentionally disabled when an explicit cycleTransportFactory is supplied to // ensure transport synchronization across multi-step token exchanges (e.g. STS and IAM) // and prevent nested retry amplification. Outer callers manage retry coordination. - return refreshWithRetry(cycleTransportFactory, pinnedKeyStore, /* allowRetry= */ false); + return refreshWithRetry( + cycleTransportFactory, + pinnedKeyStore, + getImpersonatedCredentials(), + /* allowRetry= */ false); } @Override @@ -278,12 +301,17 @@ AccessToken refreshAccessToken( if (pinnedKeyStore == null) { return refreshAccessToken(cycleTransportFactory); } - return refreshWithRetry(cycleTransportFactory, pinnedKeyStore, /* allowRetry= */ false); + return refreshWithRetry( + cycleTransportFactory, + pinnedKeyStore, + getImpersonatedCredentials(), + /* allowRetry= */ false); } private AccessToken refreshWithRetry( @Nullable HttpTransportFactory explicitTransportFactory, @Nullable KeyStore pinnedKeyStore, + @Nullable ImpersonatedCredentials targetImpersonated, boolean allowRetry) throws IOException { try { @@ -298,9 +326,8 @@ && shouldUseMtlsTransportFactory()) { cycleTransportFactory = createMtlsTransportFactory(pinnedKeyStore); } - ImpersonatedCredentials impersonated = getImpersonatedCredentials(); - if (impersonated != null) { - return impersonated.refreshAccessToken( + if (targetImpersonated != null) { + return targetImpersonated.refreshAccessToken( pinnedKeyStore != null ? cycleTransportFactory : null, pinnedKeyStore); } @@ -352,12 +379,12 @@ && shouldUseMtlsTransportFactory()) { && this.x509Provider != null && shouldUseMtlsTransportFactory() && (OAuth2Utils.isUnauthorizedException(e) - || isSslException(e) + || isRetryableTransportException(e) || isInitialKeyStoreLoadFailure)) { KeyStore freshKeyStore; try { - // On 401, TLS handshake failure, or transient initial KeyStore load failure, re-read - // from X509Provider for fresh certs. + // On 401, TLS handshake/transport failure, or transient initial KeyStore load failure, + // re-read from X509Provider for fresh certs. freshKeyStore = this.x509Provider.getKeyStore(); } catch (IOException reloadException) { if (reloadException != e) { @@ -378,7 +405,8 @@ && shouldUseMtlsTransportFactory() try { HttpTransportFactory retryTransportFactory = createMtlsTransportFactory(freshKeyStore); - return refreshWithRetry(retryTransportFactory, freshKeyStore, /* allowRetry= */ false); + return refreshWithRetry( + retryTransportFactory, freshKeyStore, targetImpersonated, /* allowRetry= */ false); } catch (IOException | RuntimeException retryException) { if (retryException != e) { retryException.addSuppressed(e); diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java index 912e71a0a9e0..10391676bb6f 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java @@ -547,9 +547,16 @@ private ImpersonatedCredentials(Builder builder) throws IOException { this.delegates = builder.getDelegates(); this.scopes = ImmutableList.copyOf(builder.getScopes()); this.lifetime = builder.getLifetime(); + HttpTransportFactory builderTransportFactory = builder.getHttpTransportFactory(); + if (builderTransportFactory == null + && this.sourceCredentials instanceof IdentityPoolCredentials + && ((IdentityPoolCredentials) this.sourceCredentials).isMtlsConfigured()) { + builderTransportFactory = + ((IdentityPoolCredentials) this.sourceCredentials).getTransportFactory(); + } this.transportFactory = firstNonNull( - builder.getHttpTransportFactory(), + builderTransportFactory, getFromServiceLoader(HttpTransportFactory.class, OAuth2Utils.HTTP_TRANSPORT_FACTORY)); this.iamEndpointOverride = builder.iamEndpointOverride; this.transportFactoryClassName = this.transportFactory.getClass().getName(); @@ -587,8 +594,32 @@ public String getUniverseDomain() throws IOException { return this.sourceCredentials.getUniverseDomain(); } + private ExternalAccountCredentials ensureExternalSourceScoped() { + synchronized (this) { + Collection currentScopes = + ((ExternalAccountCredentials) this.sourceCredentials).getScopes(); + if (currentScopes == null || !currentScopes.contains(OAuth2Utils.CLOUD_PLATFORM_SCOPE)) { + List updatedScopes = + currentScopes != null ? new ArrayList<>(currentScopes) : new ArrayList<>(); + updatedScopes.add(OAuth2Utils.CLOUD_PLATFORM_SCOPE); + this.sourceCredentials = this.sourceCredentials.createScoped(updatedScopes); + } + return (ExternalAccountCredentials) this.sourceCredentials; + } + } + @Override public AccessToken refreshAccessToken() throws IOException { + if (this.sourceCredentials instanceof ExternalAccountCredentials) { + ExternalAccountCredentials externalSource = ensureExternalSourceScoped(); + if (externalSource instanceof IdentityPoolCredentials) { + IdentityPoolCredentials identityPoolSource = (IdentityPoolCredentials) externalSource; + if (identityPoolSource.hasMtlsProviderForImpersonation() + && IdentityPoolCredentials.isDefaultOrMtlsTransportFactory(this.transportFactory)) { + return identityPoolSource.refreshImpersonatedAccessTokenWithRetry(this); + } + } + } return refreshAccessToken(null); } @@ -649,18 +680,7 @@ AccessToken refreshAccessToken( HttpCredentialsAdapter adapter; AccessToken intermediateAccessTokenForCache = null; if (this.sourceCredentials instanceof ExternalAccountCredentials) { - ExternalAccountCredentials externalSource; - synchronized (this) { - Collection currentScopes = - ((ExternalAccountCredentials) this.sourceCredentials).getScopes(); - if (currentScopes == null || !currentScopes.contains(OAuth2Utils.CLOUD_PLATFORM_SCOPE)) { - List updatedScopes = - currentScopes != null ? new ArrayList<>(currentScopes) : new ArrayList<>(); - updatedScopes.add(OAuth2Utils.CLOUD_PLATFORM_SCOPE); - this.sourceCredentials = this.sourceCredentials.createScoped(updatedScopes); - } - externalSource = (ExternalAccountCredentials) this.sourceCredentials; - } + ExternalAccountCredentials externalSource = ensureExternalSourceScoped(); if (cycleTransportFactory == null) { try { externalSource.refreshIfExpired(); @@ -1073,5 +1093,15 @@ public ImpersonatedCredentials build() { private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException { input.defaultReadObject(); transportFactory = newInstance(transportFactoryClassName); + if (this.sourceCredentials instanceof IdentityPoolCredentials + && this.transportFactory instanceof MtlsHttpTransportFactory + && !((MtlsHttpTransportFactory) this.transportFactory).hasKeyStore()) { + HttpTransportFactory sourceTransportFactory = + ((IdentityPoolCredentials) this.sourceCredentials).getTransportFactory(); + if (sourceTransportFactory instanceof MtlsHttpTransportFactory + && ((MtlsHttpTransportFactory) sourceTransportFactory).hasKeyStore()) { + this.transportFactory = sourceTransportFactory; + } + } } } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java index a849e3d3a297..cb77144f792b 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java @@ -4658,4 +4658,289 @@ void deserialization_respectsUseMtlsTransportFactoryFlag() throws Exception { assertTrue( deserializedCustom.getTransportFactory() instanceof SerializableCustomTransportFactory); } + + @Test + void refreshAccessToken_bareIoExceptionFromSplitWrite_retriesWhenKeyStoreChanges() + throws Exception { + // Against live sts.mtls.googleapis.com, a split write (cert2 + key1) fails during the TLS + // handshake with a bare `new IOException("Error writing request body to server")` and an + // empty cause chain (getCause() == null). + byte[] cert2Bytes = Files.readAllBytes(Paths.get("testresources/mtls/test_cert_2.pem")); + byte[] key1Bytes = Files.readAllBytes(Paths.get("testresources/mtls/test_key.pem")); + byte[] key2Bytes = Files.readAllBytes(Paths.get("testresources/mtls/test_key_2.pem")); + byte[] newline = "\n".getBytes(StandardCharsets.UTF_8); + + KeyStore splitWriteKeyStore = + SecurityUtils.createMtlsKeyStore( + new ByteArrayInputStream( + com.google.common.primitives.Bytes.concat(cert2Bytes, newline, key1Bytes))); + KeyStore completedRotationKeyStore = + SecurityUtils.createMtlsKeyStore( + new ByteArrayInputStream( + com.google.common.primitives.Bytes.concat(cert2Bytes, newline, key2Bytes))); + + AtomicInteger getKeyStoreCount = new AtomicInteger(0); + X509Provider x509Provider = + new X509Provider(null) { + @Override + public KeyStore getKeyStore() { + return getKeyStoreCount.incrementAndGet() == 1 + ? splitWriteKeyStore + : completedRotationKeyStore; + } + }; + + AtomicInteger exchangeCount = new AtomicInteger(0); + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(x509Provider) + .setAudience("audience") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token")) { + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) + throws IOException { + if (exchangeCount.incrementAndGet() == 1) { + // Bare IOException with getCause() == null, matching HttpURLConnection behavior + throw new IOException("Error writing request body to server"); + } + return new AccessToken("recovered-after-bare-io-exception", null); + } + }; + + AccessToken token = credential.refreshAccessToken(); + assertEquals("recovered-after-bare-io-exception", token.getTokenValue()); + assertEquals(2, getKeyStoreCount.get()); + assertEquals(2, exchangeCount.get()); + } + + @Test + void + standaloneImpersonatedCredentials_wrappingMtlsIdentityPoolCredentials_usesMtlsAndRetriesOnRotation() + throws Exception { + KeyStore ks1 = createPopulatedKeyStore(); + KeyStore ks2 = createRotatedPopulatedKeyStore(); + + AtomicInteger getKeyStoreCount = new AtomicInteger(0); + X509Provider rotatingProvider = + new X509Provider(null) { + @Override + public KeyStore getKeyStore() { + int count = getKeyStoreCount.incrementAndGet(); + return count == 1 ? ks1 : ks2; + } + }; + + AtomicInteger stsCallCount = new AtomicInteger(0); + AtomicInteger iamCallCount = new AtomicInteger(0); + List capturedKeyStores = new ArrayList<>(); + + IdentityPoolCredentials sourceCredentials = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(rotatingProvider) + .setScopes(Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE)) + .setAudience("audience") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token")) { + @Override + HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { + capturedKeyStores.add(keyStore); + return () -> + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() throws IOException { + if (url.contains("sts.mtls.googleapis.com")) { + int count = stsCallCount.incrementAndGet(); + if (keyStore == ks1) { + // Simulate split write bare IOException on ks1 + throw new IOException("Error writing request body to server"); + } + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("access_token", "standalone-sts-token-" + count); + response.put( + "issued_token_type", "urn:ietf:params:oauth:token-type:access_token"); + response.put("token_type", "Bearer"); + response.put("expires_in", 3600); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } else if (url.contains("iamcredentials")) { + int count = iamCallCount.incrementAndGet(); + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("accessToken", "standalone-iam-token-" + count); + response.put("expireTime", "2030-01-01T00:00:00Z"); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } + return new MockLowLevelHttpResponse().setStatusCode(404); + } + }; + } + }; + } + }; + + // Build standalone ImpersonatedCredentials directly without setting HttpTransportFactory. + ImpersonatedCredentials standaloneImpersonated = + ImpersonatedCredentials.newBuilder() + .setSourceCredentials(sourceCredentials) + .setTargetPrincipal("sa@project.iam.gserviceaccount.com") + .setScopes(Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE)) + .build(); + + // Refresh 1: ks1 throws bare IOException -> retries once with ks2 -> both STS and IAM succeed + // over the pinned mTLS transport. + AccessToken token1 = standaloneImpersonated.refreshAccessToken(); + assertEquals("standalone-iam-token-1", token1.getTokenValue()); + assertEquals(2, stsCallCount.get()); + assertEquals(1, iamCallCount.get()); + assertEquals(Arrays.asList(ks1, ks2), capturedKeyStores); + + // Refresh 2 (still ks2): reuses the cached 1-hour STS token without calling STS again! + AccessToken token2 = standaloneImpersonated.refreshAccessToken(); + assertEquals("standalone-iam-token-2", token2.getTokenValue()); + assertEquals(2, stsCallCount.get()); + assertEquals(2, iamCallCount.get()); + } + + @Test + void refreshAccessToken_bareIoException_doesNotRetryWhenKeyStoreUnchanged() throws Exception { + KeyStore ks1 = createPopulatedKeyStore(); + KeyStore ks1Same = createPopulatedKeyStore(); + AtomicInteger getKeyStoreCount = new AtomicInteger(0); + X509Provider provider = + new X509Provider(null) { + @Override + public KeyStore getKeyStore() { + return getKeyStoreCount.incrementAndGet() == 1 ? ks1 : ks1Same; + } + }; + + AtomicInteger exchangeCount = new AtomicInteger(0); + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(provider) + .setAudience("audience") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token")) { + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) + throws IOException { + exchangeCount.incrementAndGet(); + throw new IOException("Error writing request body to server"); + } + }; + + IOException thrown = assertThrows(IOException.class, credential::refreshAccessToken); + assertEquals("Error writing request body to server", thrown.getMessage()); + assertEquals(2, getKeyStoreCount.get()); + assertEquals(1, exchangeCount.get()); + } + + @Test + void refreshAccessToken_non401OAuthException_doesNotRetryEvenWhenKeyStoreChanges() + throws Exception { + KeyStore ks1 = createPopulatedKeyStore(); + KeyStore ks2 = createRotatedPopulatedKeyStore(); + AtomicInteger getKeyStoreCount = new AtomicInteger(0); + X509Provider rotatingProvider = + new X509Provider(null) { + @Override + public KeyStore getKeyStore() { + return getKeyStoreCount.incrementAndGet() == 1 ? ks1 : ks2; + } + }; + + AtomicInteger exchangeCount = new AtomicInteger(0); + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(rotatingProvider) + .setAudience("audience") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token")) { + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) + throws IOException { + exchangeCount.incrementAndGet(); + throw new OAuthException("invalid_request", "Bad Request", null, 400); + } + }; + + OAuthException thrown = assertThrows(OAuthException.class, credential::refreshAccessToken); + assertEquals(400, thrown.getHttpStatusCode()); + assertEquals(1, getKeyStoreCount.get()); + assertEquals(1, exchangeCount.get()); + } + + @Test + void + standaloneImpersonatedCredentials_withFileCertConfig_survivesDeserializationAndAddsCloudPlatformScope() + throws Exception { + Map certMap = new HashMap<>(); + certMap.put("certificate_config_location", "testresources/mtls/certificate_config.json"); + Map sourceMap = new HashMap<>(); + sourceMap.put("file", "credential.json"); + sourceMap.put("certificate", certMap); + IdentityPoolCredentialSource credentialSource = new IdentityPoolCredentialSource(sourceMap); + + // Build IdentityPoolCredentials with a custom scope (missing cloud-platform) and file cert + // config. + IdentityPoolCredentials sourceCredentials = + IdentityPoolCredentials.newBuilder() + .setCredentialSource(credentialSource) + .setScopes(Collections.singletonList("https://www.googleapis.com/auth/CustomScope")) + .setAudience("audience") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build(); + assertTrue(sourceCredentials.getTransportFactory() instanceof MtlsHttpTransportFactory); + assertTrue(((MtlsHttpTransportFactory) sourceCredentials.getTransportFactory()).hasKeyStore()); + + // Build standalone ImpersonatedCredentials without setting HttpTransportFactory. + ImpersonatedCredentials standaloneImpersonated = + ImpersonatedCredentials.newBuilder() + .setSourceCredentials(sourceCredentials) + .setTargetPrincipal("sa@project.iam.gserviceaccount.com") + .setScopes(Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE)) + .build(); + + // Verify standaloneImpersonated inherited the populated MtlsHttpTransportFactory at build time + assertTrue( + standaloneImpersonated.toBuilder().getHttpTransportFactory() + instanceof MtlsHttpTransportFactory); + assertTrue( + ((MtlsHttpTransportFactory) standaloneImpersonated.toBuilder().getHttpTransportFactory()) + .hasKeyStore()); + + // Serialize and deserialize standaloneImpersonated and verify readObject() restores the + // populated MtlsHttpTransportFactory from sourceCredentials. + ImpersonatedCredentials deserializedImpersonated = + serializeAndDeserialize(standaloneImpersonated); + assertTrue( + deserializedImpersonated.toBuilder().getHttpTransportFactory() + instanceof MtlsHttpTransportFactory); + assertTrue( + ((MtlsHttpTransportFactory) deserializedImpersonated.toBuilder().getHttpTransportFactory()) + .hasKeyStore()); + } } From ddd5ea8176c64488a5ffd9ae63477dfc2e405887 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Wed, 23 Sep 2026 03:01:59 +0000 Subject: [PATCH 12/13] fix(oauth2): handle mid-rotation invalid_grant, cached STS 401 retry, and single cert read on impersonated refresh --- .../auth/oauth2/IdentityPoolCredentials.java | 83 ++++-- .../auth/oauth2/ImpersonatedCredentials.java | 32 +- .../com/google/auth/oauth2/OAuth2Utils.java | 24 ++ .../oauth2/IdentityPoolCredentialsTest.java | 280 +++++++++++++++++- 4 files changed, 388 insertions(+), 31 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java index 8bd293ca5d9c..92c0bda203a4 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java @@ -43,6 +43,7 @@ import java.io.IOException; import java.io.ObjectInputStream; import java.net.URI; +import java.security.GeneralSecurityException; import java.security.KeyStore; import java.util.ArrayList; import java.util.Collection; @@ -242,6 +243,13 @@ boolean hasMtlsProviderForImpersonation() { return this.x509Provider != null && shouldUseMtlsTransportFactory(); } + boolean hasInitializedMtlsTransport() { + return this.x509Provider != null + && (!shouldUseMtlsTransportFactory() + || (this.transportFactory instanceof MtlsHttpTransportFactory + && ((MtlsHttpTransportFactory) this.transportFactory).hasKeyStore())); + } + AccessToken refreshImpersonatedAccessTokenWithRetry(ImpersonatedCredentials impersonated) throws IOException { return refreshWithRetry( @@ -252,18 +260,27 @@ AccessToken refreshImpersonatedAccessTokenWithRetry(ImpersonatedCredentials impe } private static boolean isRetryableTransportException(@Nullable Throwable throwable) { - if (!(throwable instanceof IOException) - || throwable instanceof CertificateSourceUnavailableException) { + if (throwable == null || throwable instanceof CertificateSourceUnavailableException) { return false; } + boolean hasIoOrSecurityException = throwable instanceof IOException; Throwable current = throwable; while (current != null) { - if (current instanceof OAuthException || current instanceof HttpResponseException) { + if (current instanceof CertificateSourceUnavailableException + || current instanceof OAuthException + || current instanceof HttpResponseException) { return false; } - current = current.getCause(); + if (current instanceof IOException || current instanceof GeneralSecurityException) { + hasIoOrSecurityException = true; + } + Throwable cause = current.getCause(); + if (cause == current) { + break; + } + current = cause; } - return true; + return hasIoOrSecurityException; } @Override @@ -274,7 +291,7 @@ public AccessToken refreshAccessToken() throws IOException { return refreshWithRetry( /* explicitTransportFactory= */ null, /* pinnedKeyStore= */ null, - getImpersonatedCredentials(), + /* targetImpersonated= */ null, /* allowRetry= */ true); } @@ -290,7 +307,7 @@ AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) throw return refreshWithRetry( cycleTransportFactory, pinnedKeyStore, - getImpersonatedCredentials(), + /* targetImpersonated= */ null, /* allowRetry= */ false); } @@ -304,7 +321,7 @@ AccessToken refreshAccessToken( return refreshWithRetry( cycleTransportFactory, pinnedKeyStore, - getImpersonatedCredentials(), + /* targetImpersonated= */ null, /* allowRetry= */ false); } @@ -314,6 +331,7 @@ private AccessToken refreshWithRetry( @Nullable ImpersonatedCredentials targetImpersonated, boolean allowRetry) throws IOException { + ImpersonatedCredentials effectiveImpersonated = targetImpersonated; try { HttpTransportFactory cycleTransportFactory = explicitTransportFactory != null ? explicitTransportFactory : this.transportFactory; @@ -324,10 +342,16 @@ && shouldUseMtlsTransportFactory()) { pinnedKeyStore = this.x509Provider.getKeyStore(); } cycleTransportFactory = createMtlsTransportFactory(pinnedKeyStore); + if (!hasInitializedMtlsTransport()) { + this.transportFactory = cycleTransportFactory; + } } - if (targetImpersonated != null) { - return targetImpersonated.refreshAccessToken( + if (effectiveImpersonated == null) { + effectiveImpersonated = getImpersonatedCredentials(); + } + if (effectiveImpersonated != null) { + return effectiveImpersonated.refreshAccessToken( pinnedKeyStore != null ? cycleTransportFactory : null, pinnedKeyStore); } @@ -373,18 +397,23 @@ && shouldUseMtlsTransportFactory()) { } catch (IOException | RuntimeException e) { boolean isInitialKeyStoreLoadFailure = pinnedKeyStore == null - && e instanceof IOException - && !(e instanceof CertificateSourceUnavailableException); + && !(e instanceof CertificateSourceUnavailableException) + && isRetryableTransportException(e); + boolean reusedCachedStsTokenOn401 = + effectiveImpersonated != null + && effectiveImpersonated.consumeInvalidatedCachedStsTokenOn401() + && OAuth2Utils.isUnauthorizedException(e); if (allowRetry && this.x509Provider != null && shouldUseMtlsTransportFactory() && (OAuth2Utils.isUnauthorizedException(e) + || OAuth2Utils.isInvalidGrantException(e) || isRetryableTransportException(e) || isInitialKeyStoreLoadFailure)) { KeyStore freshKeyStore; try { - // On 401, TLS handshake/transport failure, or transient initial KeyStore load failure, - // re-read from X509Provider for fresh certs. + // On 401, STS invalid_grant, TLS handshake/transport failure, or transient initial + // KeyStore load failure, re-read from X509Provider for fresh certs. freshKeyStore = this.x509Provider.getKeyStore(); } catch (IOException reloadException) { if (reloadException != e) { @@ -399,14 +428,18 @@ && shouldUseMtlsTransportFactory() } if (!isInitialKeyStoreLoadFailure + && !reusedCachedStsTokenOn401 && !OAuth2Utils.hasCertificateChanged(pinnedKeyStore, freshKeyStore)) { throw e; } try { HttpTransportFactory retryTransportFactory = createMtlsTransportFactory(freshKeyStore); + if (!hasInitializedMtlsTransport()) { + this.transportFactory = retryTransportFactory; + } return refreshWithRetry( - retryTransportFactory, freshKeyStore, targetImpersonated, /* allowRetry= */ false); + retryTransportFactory, freshKeyStore, effectiveImpersonated, /* allowRetry= */ false); } catch (IOException | RuntimeException retryException) { if (retryException != e) { retryException.addSuppressed(e); @@ -487,6 +520,9 @@ private void initializeMtlsTransport( Builder builder, IdentityPoolCredentialSource credentialSource) throws IOException { X509Provider x509Provider = getX509Provider(builder, credentialSource); this.x509Provider = x509Provider; + if (builder.isClonedTransportInitialized) { + return; + } KeyStore mtlsKeyStore = x509Provider.getKeyStore(); if (shouldUseMtlsTransportFactory()) { this.transportFactory = createMtlsTransportFactory(mtlsKeyStore); @@ -505,11 +541,13 @@ private IdentityPoolSubjectTokenSupplier createCertificateSubjectTokenSupplier( // Configure the mTLS transport with the x509 keystore if custom transport was not provided. initializeMtlsTransport(builder, credentialSource); - // Initialize the subject token supplier with the certificate path. - String explicitCertConfigPath = getExplicitCertConfigPath(credentialSource); - credentialSource.setCredentialLocation( - MtlsUtils.getCertificatePath( - getEnvironmentProvider(), getPropertyProvider(), explicitCertConfigPath)); + // Initialize the subject token supplier with the certificate path if not already set. + if (credentialSource.getCredentialLocation() == null) { + String explicitCertConfigPath = getExplicitCertConfigPath(credentialSource); + credentialSource.setCredentialLocation( + MtlsUtils.getCertificatePath( + getEnvironmentProvider(), getPropertyProvider(), explicitCertConfigPath)); + } return new CertificateIdentityPoolSubjectTokenSupplier(credentialSource); } @@ -578,6 +616,7 @@ public static class Builder extends ExternalAccountCredentials.Builder { private @Nullable String actorTokenType; private @Nullable X509Provider x509Provider; private @Nullable Boolean useMtlsTransportFactory; + private boolean isClonedTransportInitialized; Builder() {} @@ -596,6 +635,7 @@ public static class Builder extends ExternalAccountCredentials.Builder { this.actorTokenType = credentials.actorTokenType; this.x509Provider = credentials.x509Provider; this.useMtlsTransportFactory = credentials.useMtlsTransportFactory; + this.isClonedTransportInitialized = credentials.hasInitializedMtlsTransport(); } /** @@ -611,6 +651,7 @@ public static class Builder extends ExternalAccountCredentials.Builder { @VisibleForTesting Builder setX509Provider(X509Provider x509Provider) { this.x509Provider = x509Provider; + this.isClonedTransportInitialized = false; return this; } @@ -665,6 +706,7 @@ Builder setActorTokenType(String actorTokenType) { public Builder setHttpTransportFactory(HttpTransportFactory transportFactory) { super.setHttpTransportFactory(transportFactory); this.useMtlsTransportFactory = isDefaultOrMtlsTransportFactory(transportFactory); + this.isClonedTransportInitialized = false; return this; } @@ -699,6 +741,7 @@ public Builder setTokenUrl(String tokenUrl) { @CanIgnoreReturnValue public Builder setCredentialSource(IdentityPoolCredentialSource credentialSource) { super.setCredentialSource(credentialSource); + this.isClonedTransportInitialized = false; return this; } diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java index 10391676bb6f..f4831a5c9be8 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java @@ -116,6 +116,7 @@ public class ImpersonatedCredentials extends GoogleCredentials private volatile GoogleCredentials sourceCredentials; private transient volatile @Nullable AccessToken cachedStsAccessToken; private transient volatile @Nullable KeyStore cachedStsKeyStore; + private transient volatile boolean invalidatedCachedStsTokenOn401; private final String targetPrincipal; private List delegates; private final List scopes; @@ -608,18 +609,24 @@ private ExternalAccountCredentials ensureExternalSourceScoped() { } } + synchronized boolean consumeInvalidatedCachedStsTokenOn401() { + boolean value = this.invalidatedCachedStsTokenOn401; + this.invalidatedCachedStsTokenOn401 = false; + return value; + } + @Override public AccessToken refreshAccessToken() throws IOException { - if (this.sourceCredentials instanceof ExternalAccountCredentials) { - ExternalAccountCredentials externalSource = ensureExternalSourceScoped(); - if (externalSource instanceof IdentityPoolCredentials) { - IdentityPoolCredentials identityPoolSource = (IdentityPoolCredentials) externalSource; - if (identityPoolSource.hasMtlsProviderForImpersonation() - && IdentityPoolCredentials.isDefaultOrMtlsTransportFactory(this.transportFactory)) { - return identityPoolSource.refreshImpersonatedAccessTokenWithRetry(this); - } + if (this.sourceCredentials instanceof IdentityPoolCredentials) { + IdentityPoolCredentials identityPoolSource = (IdentityPoolCredentials) this.sourceCredentials; + if (identityPoolSource.hasMtlsProviderForImpersonation() + && IdentityPoolCredentials.isDefaultOrMtlsTransportFactory(this.transportFactory)) { + return identityPoolSource.refreshImpersonatedAccessTokenWithRetry(this); } } + if (this.sourceCredentials instanceof ExternalAccountCredentials) { + ensureExternalSourceScoped(); + } return refreshAccessToken(null); } @@ -679,6 +686,12 @@ AccessToken refreshAccessToken( firstNonNull(cycleTransportFactory, this.transportFactory); HttpCredentialsAdapter adapter; AccessToken intermediateAccessTokenForCache = null; + boolean usedCachedStsToken = false; + if (cycleTransportFactory != null) { + synchronized (this) { + this.invalidatedCachedStsTokenOn401 = false; + } + } if (this.sourceCredentials instanceof ExternalAccountCredentials) { ExternalAccountCredentials externalSource = ensureExternalSourceScoped(); if (cycleTransportFactory == null) { @@ -693,6 +706,7 @@ AccessToken refreshAccessToken( synchronized (this) { if (isCachedStsTokenReusable(externalSource, pinnedKeyStore)) { intermediateAccessToken = this.cachedStsAccessToken; + usedCachedStsToken = true; } } if (intermediateAccessToken == null) { @@ -790,6 +804,8 @@ public AccessToken refreshAccessToken() { synchronized (this) { this.cachedStsAccessToken = null; this.cachedStsKeyStore = null; + this.invalidatedCachedStsTokenOn401 = + usedCachedStsToken && OAuth2Utils.isUnauthorizedException(e); } } throw new IOException("Error requesting access token", e); diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java index b7386ca0c3ea..e5bed067db00 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java @@ -358,6 +358,30 @@ static boolean isUnauthorizedException(@Nullable Throwable t) { return false; } + /** + * Returns whether the given throwable or any exception in its causal chain represents an OAuth + * {@code invalid_grant} error (for example, HTTP 400 {@code invalid_grant} returned by STS when a + * client certificate and {@code subject_token} mismatch during certificate rotation). + * + * @param t the throwable to inspect + * @return {@code true} if {@code t} or any cause in its chain is an {@code invalid_grant} {@link + * OAuthException} + */ + static boolean isInvalidGrantException(@Nullable Throwable t) { + while (t != null) { + if (t instanceof OAuthException + && "invalid_grant".equals(((OAuthException) t).getErrorCode())) { + return true; + } + Throwable cause = t.getCause(); + if (cause == t) { + break; + } + t = cause; + } + return false; + } + /** * Returns whether the certificate chain or private key in {@code newKeyStore} differs from {@code * oldKeyStore}. Used on 401 retry recovery to avoid retrying when the reloaded certificate and diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java index cb77144f792b..c22bff87b135 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java @@ -3239,9 +3239,14 @@ public KeyStore getKeyStore() { credential.createScoped( Collections.singletonList("https://www.googleapis.com/auth/cloud-platform")); - assertEquals(2, getKeyStoreCount.get()); + assertEquals(1, getKeyStoreCount.get()); assertTrue(scoped.getTransportFactory() instanceof MtlsHttpTransportFactory); - assertNotSame(originalTransportFactory, scoped.getTransportFactory()); + assertSame(originalTransportFactory, scoped.getTransportFactory()); + + IdentityPoolCredentials rebuiltWithProvider = + credential.toBuilder().setX509Provider(trackingProvider).build(); + assertEquals(2, getKeyStoreCount.get()); + assertNotSame(originalTransportFactory, rebuiltWithProvider.getTransportFactory()); } // ================================================================================== @@ -4123,7 +4128,8 @@ public LowLevelHttpResponse execute() { .setStatusCode(401) .setContentType(Json.MEDIA_TYPE) .setContent( - "{\"error\": \"invalid_client\", \"error_description\": \"Unauthorized\"}"); + "{\"error\": \"invalid_client\", \"error_description\":" + + " \"Unauthorized\"}"); } GenericJson response = new GenericJson(); response.setFactory(OAuth2Utils.JSON_FACTORY); @@ -4943,4 +4949,272 @@ protected AccessToken exchangeExternalCredentialForAccessToken( ((MtlsHttpTransportFactory) deserializedImpersonated.toBuilder().getHttpTransportFactory()) .hasKeyStore()); } + + @Test + void refreshAccessToken_invalidGrantFromMidRotationCertKeyMismatch_retriesWhenKeyStoreChanges() + throws Exception { + byte[] cert2Bytes = Files.readAllBytes(Paths.get("testresources/mtls/test_cert_2.pem")); + byte[] key1Bytes = Files.readAllBytes(Paths.get("testresources/mtls/test_key.pem")); + byte[] key2Bytes = Files.readAllBytes(Paths.get("testresources/mtls/test_key_2.pem")); + byte[] newline = "\n".getBytes(StandardCharsets.UTF_8); + + // Mid-rotation state: cert_path updated to cert2, key_path still has key1. + KeyStore midRotationKeyStore = + SecurityUtils.createMtlsKeyStore( + new ByteArrayInputStream( + com.google.common.primitives.Bytes.concat(cert2Bytes, newline, key1Bytes))); + // Completed rotation state: both cert_path and key_path updated to cert2 + key2. + KeyStore completedRotationKeyStore = + SecurityUtils.createMtlsKeyStore( + new ByteArrayInputStream( + com.google.common.primitives.Bytes.concat(cert2Bytes, newline, key2Bytes))); + + AtomicInteger getKeyStoreCount = new AtomicInteger(0); + X509Provider rotatingProvider = + new X509Provider(null) { + @Override + public KeyStore getKeyStore() { + return getKeyStoreCount.incrementAndGet() == 1 + ? midRotationKeyStore + : completedRotationKeyStore; + } + }; + + AtomicInteger exchangeCount = new AtomicInteger(0); + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(rotatingProvider) + .setAudience("audience") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:mtls") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token")) { + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) + throws IOException { + if (exchangeCount.incrementAndGet() == 1) { + throw new OAuthException( + "invalid_grant", + "The subject_token does not match the client certificate.", + null, + 400); + } + return new AccessToken("recovered-after-invalid-grant-retry", null); + } + }; + + AccessToken token = credential.refreshAccessToken(); + assertEquals("recovered-after-invalid-grant-retry", token.getTokenValue()); + assertEquals(2, getKeyStoreCount.get()); + assertEquals(2, exchangeCount.get()); + } + + @Test + void refreshAccessToken_invalidGrant_doesNotRetryWhenKeyStoreUnchanged() throws Exception { + KeyStore ks1 = createPopulatedKeyStore(); + KeyStore ks1Same = createPopulatedKeyStore(); + AtomicInteger getKeyStoreCount = new AtomicInteger(0); + X509Provider unchangedProvider = + new X509Provider(null) { + @Override + public KeyStore getKeyStore() { + return getKeyStoreCount.incrementAndGet() == 1 ? ks1 : ks1Same; + } + }; + + AtomicInteger exchangeCount = new AtomicInteger(0); + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(unchangedProvider) + .setAudience("audience") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:mtls") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token")) { + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) + throws IOException { + exchangeCount.incrementAndGet(); + throw new OAuthException("invalid_grant", "Invalid subject token", null, 400); + } + }; + + OAuthException thrown = assertThrows(OAuthException.class, credential::refreshAccessToken); + assertEquals("invalid_grant", thrown.getErrorCode()); + assertEquals(2, getKeyStoreCount.get()); + assertEquals(1, exchangeCount.get()); + } + + @Test + void + refreshAccessToken_impersonation_401OnIamWithCachedStsToken_retriesAndMintsFreshStsTokenEvenWhenCertUnchanged() + throws Exception { + KeyStore ks1 = createPopulatedKeyStore(); + AtomicInteger getKeyStoreCallCount = new AtomicInteger(0); + X509Provider unchangedProvider = + new X509Provider(null) { + @Override + public KeyStore getKeyStore() { + getKeyStoreCallCount.incrementAndGet(); + return ks1; + } + }; + + AtomicInteger stsCallCount = new AtomicInteger(0); + AtomicInteger iamCallCount = new AtomicInteger(0); + List iamBearerHeaders = new ArrayList<>(); + + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(unchangedProvider) + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .setServiceAccountImpersonationUrl( + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken")) { + @Override + HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { + return () -> + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() { + if (url.contains("/v1/token")) { + int count = stsCallCount.incrementAndGet(); + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("access_token", "cached-sts-token-" + count); + response.put("token_type", "Bearer"); + response.put("expires_in", 3600); + response.put( + "issued_token_type", "urn:ietf:params:oauth:token-type:access_token"); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } else if (url.contains(":generateAccessToken")) { + int count = iamCallCount.incrementAndGet(); + iamBearerHeaders.add(getFirstHeaderValue("Authorization")); + if (count == 2) { + // On the 2nd IAM call (which reuses cached-sts-token-1), IAM returns + // 401 Unauthorized even though the cert on disk has NOT changed. + return new MockLowLevelHttpResponse() + .setStatusCode(401) + .setContentType(Json.MEDIA_TYPE) + .setContent( + "{\"error\":{\"code\":401,\"status\":\"UNAUTHENTICATED\"}}"); + } + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("accessToken", "final-iam-token-" + count); + response.put("expireTime", "2030-01-01T00:00:00Z"); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } + return new MockLowLevelHttpResponse().setStatusCode(404); + } + }; + } + }; + } + }; + + // Refresh 1: mints cached-sts-token-1 and final-iam-token-1. + AccessToken token1 = credential.refreshAccessToken(); + assertEquals("final-iam-token-1", token1.getTokenValue()); + assertEquals(1, stsCallCount.get()); + assertEquals(1, iamCallCount.get()); + + // Refresh 2: reuses cached-sts-token-1 -> IAM returns 401 -> clears cachedStsAccessToken and + // retries once even though ks1 is unchanged -> mints cached-sts-token-2 -> IAM succeeds! + AccessToken token2 = credential.refreshAccessToken(); + assertEquals("final-iam-token-3", token2.getTokenValue()); + assertEquals(2, stsCallCount.get()); + assertEquals(3, iamCallCount.get()); + assertEquals( + Arrays.asList( + "Bearer cached-sts-token-1", "Bearer cached-sts-token-1", "Bearer cached-sts-token-2"), + iamBearerHeaders); + } + + @Test + void refreshAccessToken_firstImpersonatedRefresh_readsKeyStoreOnlyOncePerRefreshCycle() + throws Exception { + KeyStore ks1 = createPopulatedKeyStore(); + AtomicInteger getKeyStoreCallCount = new AtomicInteger(0); + X509Provider singleReadPerRefreshProvider = + new X509Provider(null) { + @Override + public KeyStore getKeyStore() throws IOException { + int call = getKeyStoreCallCount.incrementAndGet(); + if (call > 1) { + throw new IOException( + "Unexpected extra getKeyStore() call #" + call + " during first refresh"); + } + return ks1; + } + }; + + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(singleReadPerRefreshProvider) + .setAudience("audience") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .setServiceAccountImpersonationUrl( + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken")) { + @Override + HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { + return () -> + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() { + if (url.contains("/v1/token")) { + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("access_token", "sts-token-1"); + response.put("token_type", "Bearer"); + response.put("expires_in", 3600); + response.put( + "issued_token_type", "urn:ietf:params:oauth:token-type:access_token"); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } else if (url.contains(":generateAccessToken")) { + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("accessToken", "iam-token-1"); + response.put("expireTime", "2030-01-01T00:00:00Z"); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } + return new MockLowLevelHttpResponse().setStatusCode(404); + } + }; + } + }; + } + }; + + // First impersonated refresh calls getImpersonatedCredentials() -> createScoped() -> + // toBuilder().build(). This MUST NOT call getKeyStore() a second time! + AccessToken token = credential.refreshAccessToken(); + assertEquals("iam-token-1", token.getTokenValue()); + assertEquals(1, getKeyStoreCallCount.get()); + } } From b47ab2a911e043a4080da02146e24213f1af867b Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Wed, 23 Sep 2026 03:31:00 +0000 Subject: [PATCH 13/13] fix(auth): harden concurrency and nullability in ImpersonatedCredentials and CertificateIdentityPoolSubjectTokenSupplier --- ...icateIdentityPoolSubjectTokenSupplier.java | 5 +-- .../auth/oauth2/ImpersonatedCredentials.java | 36 +++++++++++-------- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java index 992d9bbed86f..5b07fc3d2791 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java @@ -268,7 +268,8 @@ private void populateCertChainFromTrustChain( // elsewhere in the chain. if (encodedCurrentCert.equals(encodedLeafCert)) { throw new IllegalArgumentException( - "The leaf certificate should only appear at the beginning of the trust chain file, or be omitted entirely."); + "The leaf certificate should only appear at the beginning of the trust chain file, or" + + " be omitted entirely."); } // Add the current certificate to the chain. @@ -287,7 +288,7 @@ private void populateCertChainFromTrustChain( * @throws CertificateException If an error occurs while parsing a certificate. */ @VisibleForTesting - static List readTrustChain(String trustChainPath) + static List readTrustChain(@Nullable String trustChainPath) throws IOException, CertificateException { List certificateTrustChain = new ArrayList<>(); diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java index f4831a5c9be8..8534866f30c0 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java @@ -116,7 +116,8 @@ public class ImpersonatedCredentials extends GoogleCredentials private volatile GoogleCredentials sourceCredentials; private transient volatile @Nullable AccessToken cachedStsAccessToken; private transient volatile @Nullable KeyStore cachedStsKeyStore; - private transient volatile boolean invalidatedCachedStsTokenOn401; + private static final ThreadLocal INVALIDATED_CACHED_STS_TOKEN_ON_401 = + new ThreadLocal<>(); private final String targetPrincipal; private List delegates; private final List scopes; @@ -609,9 +610,9 @@ private ExternalAccountCredentials ensureExternalSourceScoped() { } } - synchronized boolean consumeInvalidatedCachedStsTokenOn401() { - boolean value = this.invalidatedCachedStsTokenOn401; - this.invalidatedCachedStsTokenOn401 = false; + boolean consumeInvalidatedCachedStsTokenOn401() { + boolean value = Boolean.TRUE.equals(INVALIDATED_CACHED_STS_TOKEN_ON_401.get()); + INVALIDATED_CACHED_STS_TOKEN_ON_401.remove(); return value; } @@ -682,16 +683,18 @@ AccessToken refreshAccessToken(@Nullable HttpTransportFactory cycleTransportFact AccessToken refreshAccessToken( @Nullable HttpTransportFactory cycleTransportFactory, @Nullable KeyStore pinnedKeyStore) throws IOException { + if (cycleTransportFactory != null) { + INVALIDATED_CACHED_STS_TOKEN_ON_401.remove(); + if (this.transportFactory instanceof MtlsHttpTransportFactory + && !((MtlsHttpTransportFactory) this.transportFactory).hasKeyStore()) { + this.transportFactory = cycleTransportFactory; + } + } HttpTransportFactory effectiveTransportFactory = firstNonNull(cycleTransportFactory, this.transportFactory); HttpCredentialsAdapter adapter; AccessToken intermediateAccessTokenForCache = null; boolean usedCachedStsToken = false; - if (cycleTransportFactory != null) { - synchronized (this) { - this.invalidatedCachedStsTokenOn401 = false; - } - } if (this.sourceCredentials instanceof ExternalAccountCredentials) { ExternalAccountCredentials externalSource = ensureExternalSourceScoped(); if (cycleTransportFactory == null) { @@ -802,16 +805,21 @@ public AccessToken refreshAccessToken() { } catch (IOException e) { if (cycleTransportFactory != null) { synchronized (this) { - this.cachedStsAccessToken = null; - this.cachedStsKeyStore = null; - this.invalidatedCachedStsTokenOn401 = - usedCachedStsToken && OAuth2Utils.isUnauthorizedException(e); + if (!usedCachedStsToken || this.cachedStsAccessToken == intermediateAccessTokenForCache) { + this.cachedStsAccessToken = null; + this.cachedStsKeyStore = null; + } + } + if (usedCachedStsToken && OAuth2Utils.isUnauthorizedException(e)) { + INVALIDATED_CACHED_STS_TOKEN_ON_401.set(Boolean.TRUE); } } throw new IOException("Error requesting access token", e); } - if (cycleTransportFactory != null && intermediateAccessTokenForCache != null) { + if (cycleTransportFactory != null + && !usedCachedStsToken + && intermediateAccessTokenForCache != null) { synchronized (this) { this.cachedStsAccessToken = intermediateAccessTokenForCache; this.cachedStsKeyStore = pinnedKeyStore;