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/AwsCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java index 548008d4bab6..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 @@ -120,6 +120,16 @@ public class AwsCredentials extends ExternalAccountCredentials { @Override public AccessToken refreshAccessToken() throws IOException { + return refreshAccessToken(this.transportFactory); + } + + @Override + AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) throws IOException { + ImpersonatedCredentials impersonated = getImpersonatedCredentials(); + if (impersonated != null) { + return impersonated.refreshAccessToken(null); + } + StsTokenExchangeRequest.Builder stsTokenExchangeRequest = StsTokenExchangeRequest.newBuilder(retrieveSubjectToken(), getSubjectTokenType()) .setAudience(getAudience()); @@ -130,7 +140,8 @@ public AccessToken refreshAccessToken() throws IOException { stsTokenExchangeRequest.setScopes(new ArrayList<>(scopes)); } - return exchangeExternalCredentialForAccessToken(stsTokenExchangeRequest.build()); + return exchangeExternalCredentialForAccessToken( + stsTokenExchangeRequest.build(), cycleTransportFactory); } @Override 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..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 @@ -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 @@ -222,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. @@ -241,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/ExternalAccountCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java index 7191be5ca3fc..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; @@ -85,6 +86,7 @@ public abstract class ExternalAccountCredentials extends GoogleCredentials { private final @Nullable String tokenInfoUrl; private final @Nullable String serviceAccountImpersonationUrl; + private final @Nullable String targetServiceAccountEmail; private final @Nullable String clientId; private final @Nullable String clientSecret; @@ -95,7 +97,7 @@ public abstract class ExternalAccountCredentials extends GoogleCredentials { protected transient HttpTransportFactory transportFactory; - protected @Nullable ImpersonatedCredentials impersonatedCredentials; + protected transient volatile @Nullable ImpersonatedCredentials impersonatedCredentials; private final EnvironmentProvider environmentProvider; private final PropertyProvider propertyProvider; @@ -196,6 +198,7 @@ protected ExternalAccountCredentials( this.credentialSource = checkNotNull(credentialSource); this.tokenInfoUrl = tokenInfoUrl; this.serviceAccountImpersonationUrl = serviceAccountImpersonationUrl; + this.targetServiceAccountEmail = null; this.clientId = clientId; this.clientSecret = clientSecret; this.scopes = @@ -236,6 +239,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; @@ -286,27 +290,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) - .build(); + sourceBuilder = AwsCredentials.newBuilder((AwsCredentials) this); } else if (this instanceof PluggableAuthCredentials) { - sourceCredentials = - PluggableAuthCredentials.newBuilder((PluggableAuthCredentials) this) - .setServiceAccountImpersonationUrl(null) - .build(); + sourceBuilder = PluggableAuthCredentials.newBuilder((PluggableAuthCredentials) this); } else { - sourceCredentials = - IdentityPoolCredentials.newBuilder((IdentityPoolCredentials) this) - .setServiceAccountImpersonationUrl(null) - .build(); + sourceBuilder = IdentityPoolCredentials.newBuilder((IdentityPoolCredentials) this); } - - String targetPrincipal = - ImpersonatedCredentials.extractTargetPrincipal(serviceAccountImpersonationUrl); + sourceBuilder + .setServiceAccountImpersonationUrl(null) + .setScopes(Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE)); + sourceBuilder.targetServiceAccountEmail = targetPrincipal; + ExternalAccountCredentials sourceCredentials = sourceBuilder.build(); return ImpersonatedCredentials.newBuilder() .setSourceCredentials(sourceCredentials) .setHttpTransportFactory(transportFactory) @@ -522,8 +521,42 @@ 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.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 local; + } + + /** + * Refreshes the access token using the specified transport factory for per-cycle transport + * 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 + */ + AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) throws IOException { + return refreshAccessToken(); + } + + AccessToken refreshAccessToken( + HttpTransportFactory cycleTransportFactory, @Nullable KeyStore pinnedKeyStore) + throws IOException { + return refreshAccessToken(cycleTransportFactory); } /** @@ -552,11 +585,10 @@ 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(); + ImpersonatedCredentials impersonated = getImpersonatedCredentials(); + if (impersonated != null) { + return impersonated.refreshAccessToken( + cycleTransportFactory == this.transportFactory ? null : cycleTransportFactory); } StsRequestHandler.Builder requestHandler = @@ -637,7 +669,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); } @@ -789,6 +821,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; @@ -813,6 +846,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; @@ -911,6 +945,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 e6846eaee550..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 @@ -31,7 +31,9 @@ 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; import com.google.auth.mtls.MtlsUtils; import com.google.auth.mtls.X509Provider; @@ -41,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; @@ -72,7 +75,8 @@ 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 boolean useMtlsTransportFactory; private final ExternalAccountSupplierContext supplierContext; private final String metricsHeaderValue; @@ -98,8 +102,13 @@ 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 useMtlsTransportFactory flag for per-cycle cert pinning and + // deserialization recovery. this.x509Provider = builder.x509Provider; + this.useMtlsTransportFactory = + builder.useMtlsTransportFactory != null + ? builder.useMtlsTransportFactory + : isDefaultOrMtlsTransportFactory(builder.transportFactory); // Initialize based on the source type if (builder.subjectTokenSupplier != null) { @@ -108,22 +117,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) { - this.transportFactory = new MtlsHttpTransportFactory(mtlsKeyStore); - } 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" @@ -182,7 +176,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" - + " source or MtlsHttpTransportFactory."); + + " configuration in the credential source or provide an MtlsHttpTransportFactory" + + " constructed with a KeyStore."); } if (this.actorTokenSupplier != null) { @@ -222,66 +217,233 @@ 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()); } - @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) { - KeyStore pinnedKeyStore = this.x509Provider.getKeyStore(); - cycleTransportFactory = new MtlsHttpTransportFactory(pinnedKeyStore); - } - - // 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); + 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()); + } + + 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( + /* explicitTransportFactory= */ null, + /* pinnedKeyStore= */ null, + /* targetImpersonated= */ impersonated, + /* allowRetry= */ true); + } + + private static boolean isRetryableTransportException(@Nullable Throwable throwable) { + if (throwable == null || throwable instanceof CertificateSourceUnavailableException) { + return false; + } + boolean hasIoOrSecurityException = throwable instanceof IOException; + Throwable current = throwable; + while (current != null) { + if (current instanceof CertificateSourceUnavailableException + || current instanceof OAuthException + || current instanceof HttpResponseException) { + return false; + } + if (current instanceof IOException || current instanceof GeneralSecurityException) { + hasIoOrSecurityException = true; + } + Throwable cause = current.getCause(); + if (cause == current) { + break; } + current = cause; } + return hasIoOrSecurityException; + } - StsTokenExchangeRequest.Builder stsTokenExchangeRequest = - StsTokenExchangeRequest.newBuilder(subjectToken, getSubjectTokenType()) - .setAudience(getAudience()); + @Override + public AccessToken refreshAccessToken() throws IOException { + // 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, + /* targetImpersonated= */ null, + /* allowRetry= */ true); + } - if (actorToken != null && this.actorTokenType != null) { - stsTokenExchangeRequest.setActingParty(new ActingParty(actorToken, this.actorTokenType)); - } + @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, + pinnedKeyStore, + /* targetImpersonated= */ null, + /* allowRetry= */ false); + } - Collection scopes = getScopes(); - if (scopes != null && !scopes.isEmpty()) { - stsTokenExchangeRequest.setScopes(new ArrayList<>(scopes)); - } + @Override + AccessToken refreshAccessToken( + HttpTransportFactory cycleTransportFactory, @Nullable KeyStore pinnedKeyStore) + throws IOException { + if (pinnedKeyStore == null) { + return refreshAccessToken(cycleTransportFactory); + } + return refreshWithRetry( + cycleTransportFactory, + pinnedKeyStore, + /* targetImpersonated= */ null, + /* allowRetry= */ false); + } + private AccessToken refreshWithRetry( + @Nullable HttpTransportFactory explicitTransportFactory, + @Nullable KeyStore pinnedKeyStore, + @Nullable ImpersonatedCredentials targetImpersonated, + boolean allowRetry) + throws IOException { + ImpersonatedCredentials effectiveImpersonated = targetImpersonated; 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); + if (!hasInitializedMtlsTransport()) { + this.transportFactory = cycleTransportFactory; + } + } + + if (effectiveImpersonated == null) { + effectiveImpersonated = getImpersonatedCredentials(); + } + if (effectiveImpersonated != null) { + return effectiveImpersonated.refreshAccessToken( + pinnedKeyStore != null ? cycleTransportFactory : null, pinnedKeyStore); + } + + // 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 { + 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); + } + } + + StsTokenExchangeRequest.Builder stsTokenExchangeRequest = + StsTokenExchangeRequest.newBuilder(subjectToken, getSubjectTokenType()) + .setAudience(getAudience()); + + 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)); + } + return exchangeExternalCredentialForAccessToken( stsTokenExchangeRequest.build(), cycleTransportFactory); - } catch (OAuthException e) { - if (e.getHttpStatusCode() == 401 + } catch (IOException | RuntimeException e) { + boolean isInitialKeyStoreLoadFailure = + pinnedKeyStore == null + && !(e instanceof CertificateSourceUnavailableException) + && isRetryableTransportException(e); + boolean reusedCachedStsTokenOn401 = + effectiveImpersonated != null + && effectiveImpersonated.consumeInvalidatedCachedStsTokenOn401() + && OAuth2Utils.isUnauthorizedException(e); + if (allowRetry && this.x509Provider != null - && this.transportFactory instanceof MtlsHttpTransportFactory) { + && shouldUseMtlsTransportFactory() + && (OAuth2Utils.isUnauthorizedException(e) + || OAuth2Utils.isInvalidGrantException(e) + || isRetryableTransportException(e) + || isInitialKeyStoreLoadFailure)) { + 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); + // 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) { + reloadException.addSuppressed(e); + } + throw reloadException; + } catch (Exception reloadException) { + IOException ioException = + new IOException("Failed to reload certificate on retry", reloadException); + ioException.addSuppressed(e); + throw ioException; + } + + if (!isInitialKeyStoreLoadFailure + && !reusedCachedStsTokenOn401 + && !OAuth2Utils.hasCertificateChanged(pinnedKeyStore, freshKeyStore)) { + throw e; + } + + try { + HttpTransportFactory retryTransportFactory = createMtlsTransportFactory(freshKeyStore); + if (!hasInitializedMtlsTransport()) { + this.transportFactory = retryTransportFactory; + } + return refreshWithRetry( + retryTransportFactory, freshKeyStore, effectiveImpersonated, /* allowRetry= */ false); + } catch (IOException | RuntimeException retryException) { + if (retryException != e) { + retryException.addSuppressed(e); + } throw retryException; } } @@ -291,6 +453,12 @@ public AccessToken refreshAccessToken() throws IOException { @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); } @@ -324,6 +492,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) { @@ -343,17 +516,16 @@ 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; + if (builder.isClonedTransportInitialized) { + return; + } KeyStore mtlsKeyStore = x509Provider.getKeyStore(); - if (builder.transportFactory == null - || builder.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY - || builder.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory - || builder.transportFactory.getClass() == MtlsHttpTransportFactory.class) { - this.transportFactory = new MtlsHttpTransportFactory(mtlsKeyStore); + if (shouldUseMtlsTransportFactory()) { + this.transportFactory = createMtlsTransportFactory(mtlsKeyStore); } else if (!(builder.transportFactory instanceof MtlsHttpTransportFactory)) { LOGGER_PROVIDER .getLogger() @@ -362,12 +534,20 @@ 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); - 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); } @@ -393,11 +573,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(); - this.transportFactory = new MtlsHttpTransportFactory(mtlsKeyStore); - } catch (Exception e) { - // Cert loading failure will be handled on refreshAccessToken() + if (shouldUseMtlsTransportFactory()) { + try { + KeyStore mtlsKeyStore = this.x509Provider.getKeyStore(); + this.transportFactory = createMtlsTransportFactory(mtlsKeyStore); + } catch (Exception e) { + // Cert loading failure will be handled on refreshAccessToken() + } } } } @@ -433,6 +615,8 @@ public static class Builder extends ExternalAccountCredentials.Builder { private @Nullable IdentityPoolActorTokenSupplier actorTokenSupplier; private @Nullable String actorTokenType; private @Nullable X509Provider x509Provider; + private @Nullable Boolean useMtlsTransportFactory; + private boolean isClonedTransportInitialized; Builder() {} @@ -441,13 +625,17 @@ 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 - // 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. + // 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. this.actorTokenType = credentials.actorTokenType; this.x509Provider = credentials.x509Provider; + this.useMtlsTransportFactory = credentials.useMtlsTransportFactory; + this.isClonedTransportInitialized = credentials.hasInitializedMtlsTransport(); } /** @@ -463,6 +651,7 @@ public static class Builder extends ExternalAccountCredentials.Builder { @VisibleForTesting Builder setX509Provider(X509Provider x509Provider) { this.x509Provider = x509Provider; + this.isClonedTransportInitialized = false; return this; } @@ -516,6 +705,8 @@ Builder setActorTokenType(String actorTokenType) { @CanIgnoreReturnValue public Builder setHttpTransportFactory(HttpTransportFactory transportFactory) { super.setHttpTransportFactory(transportFactory); + this.useMtlsTransportFactory = isDefaultOrMtlsTransportFactory(transportFactory); + this.isClonedTransportInitialized = false; return this; } @@ -550,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 ad8a2468afe9..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 @@ -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; @@ -79,6 +81,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: * *

@@ -106,7 +113,11 @@ 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 transient volatile @Nullable AccessToken cachedStsAccessToken;
+  private transient volatile @Nullable KeyStore cachedStsKeyStore;
+  private static final ThreadLocal INVALIDATED_CACHED_STS_TOKEN_ON_401 =
+      new ThreadLocal<>();
   private final String targetPrincipal;
   private List delegates;
   private final List scopes;
@@ -116,7 +127,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;
 
@@ -312,7 +323,7 @@ public String getAccount() {
   }
 
   @VisibleForTesting
-  String getIamEndpointOverride() {
+  @Nullable String getIamEndpointOverride() {
     return this.iamEndpointOverride;
   }
 
@@ -538,9 +549,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();
@@ -578,33 +596,173 @@ 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;
+    }
+  }
+
+  boolean consumeInvalidatedCachedStsTokenOn401() {
+    boolean value = Boolean.TRUE.equals(INVALIDATED_CACHED_STS_TOKEN_ON_401.get());
+    INVALIDATED_CACHED_STS_TOKEN_ON_401.remove();
+    return value;
+  }
+
   @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);
+    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);
+  }
+
+  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.
+   *
+   * @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 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 {
+    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 (this.sourceCredentials instanceof ExternalAccountCredentials) {
+      ExternalAccountCredentials externalSource = ensureExternalSourceScoped();
+      if (cycleTransportFactory == null) {
+        try {
+          externalSource.refreshIfExpired();
+        } catch (IOException e) {
+          throw new IOException("Unable to refresh sourceCredentials", e);
+        }
+        adapter = new HttpCredentialsAdapter(externalSource);
+      } else {
+        AccessToken intermediateAccessToken = null;
+        synchronized (this) {
+          if (isCachedStsTokenReusable(externalSource, pinnedKeyStore)) {
+            intermediateAccessToken = this.cachedStsAccessToken;
+            usedCachedStsToken = true;
+          }
+        }
+        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()
+                    .setQuotaProjectId(externalSource.getQuotaProjectId())
+                    .setUniverseDomain(externalSource.getUniverseDomain())) {
+              @Override
+              public AccessToken refreshAccessToken() {
+                return tokenToUse;
+              }
+            };
+        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 +785,13 @@ public AccessToken refreshAccessToken() throws IOException {
     // Client Library Debug Logging via LoggingUtils is used instead.
     request.setLoggingEnabled(false);
     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);
     MetricsUtils.setMetricsHeader(
         request,
@@ -637,16 +802,40 @@ public AccessToken refreshAccessToken() throws IOException {
     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) {
+      if (cycleTransportFactory != null) {
+        synchronized (this) {
+          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);
     }
 
-    GenericData responseData = response.parseAs(GenericData.class);
-    LoggingUtils.logResponsePayload(
-        responseData, LOGGER_PROVIDER, "Response payload for access token");
-    response.disconnect();
+    if (cycleTransportFactory != null
+        && !usedCachedStsToken
+        && intermediateAccessTokenForCache != null) {
+      synchronized (this) {
+        this.cachedStsAccessToken = intermediateAccessTokenForCache;
+        this.cachedStsKeyStore = pinnedKeyStore;
+      }
+    }
+
+    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");
+    } finally {
+      response.disconnect();
+    }
 
     String accessToken =
         OAuth2Utils.validateString(responseData, "accessToken", "Expected to find an accessToken");
@@ -928,5 +1117,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/java/com/google/auth/oauth2/OAuth2Utils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java
index f740dd980e73..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
@@ -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;
@@ -57,13 +58,20 @@
 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.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;
@@ -324,5 +332,111 @@ 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).
+   *
+   * @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) {
+      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;
+  }
+
+  /**
+   * 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
+   * 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) {
+    if (oldKeyStore == newKeyStore) {
+      return false;
+    }
+    if (oldKeyStore == null || newKeyStore == null) {
+      return true;
+    }
+    List oldEntries = getKeyStoreEntries(oldKeyStore);
+    List newEntries = getKeyStoreEntries(newKeyStore);
+    if (oldEntries == null || newEntries == null) {
+      return true;
+    }
+    return !oldEntries.equals(newEntries);
+  }
+
+  private static @Nullable List getKeyStoreEntries(KeyStore keyStore) {
+    List entries = new ArrayList<>();
+    try {
+      Enumeration aliases = keyStore.aliases();
+      if (aliases != null) {
+        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(entries, chain);
+          } else {
+            Certificate cert = keyStore.getCertificate(alias);
+            if (cert != null) {
+              entries.add(cert);
+            }
+          }
+          if (keyStore.isKeyEntry(alias)) {
+            Key key = keyStore.getKey(alias, "".toCharArray());
+            if (key != null) {
+              entries.add(key);
+            }
+          }
+        }
+      }
+    } catch (GeneralSecurityException e) {
+      return null;
+    }
+    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 10ab650c77e5..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
@@ -121,6 +121,16 @@ public class PluggableAuthCredentials extends ExternalAccountCredentials {
 
   @Override
   public AccessToken refreshAccessToken() throws IOException {
+    return refreshAccessToken(this.transportFactory);
+  }
+
+  @Override
+  AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) throws IOException {
+    ImpersonatedCredentials impersonated = getImpersonatedCredentials();
+    if (impersonated != null) {
+      return impersonated.refreshAccessToken(null);
+    }
+
     String credential = retrieveSubjectToken();
     StsTokenExchangeRequest.Builder stsTokenExchangeRequest =
         StsTokenExchangeRequest.newBuilder(credential, getSubjectTokenType())
@@ -130,7 +140,8 @@ public AccessToken refreshAccessToken() throws IOException {
     if (scopes != null && !scopes.isEmpty()) {
       stsTokenExchangeRequest.setScopes(new ArrayList<>(scopes));
     }
-    return exchangeExternalCredentialForAccessToken(stsTokenExchangeRequest.build());
+    return exchangeExternalCredentialForAccessToken(
+        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..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
@@ -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(6).getHeaders();
+        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
@@ -206,7 +215,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);
     }
   }
@@ -1246,6 +1255,15 @@ 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());
+    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/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 a081814a9020..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
@@ -45,7 +45,14 @@
 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.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;
 import com.google.api.client.util.Clock;
 import com.google.api.client.util.SecurityUtils;
 import com.google.auth.TestUtils;
@@ -61,13 +68,16 @@
 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;
 import java.security.cert.CertificateException;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Base64;
 import java.util.Collections;
@@ -81,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;
@@ -99,7 +110,7 @@ class IdentityPoolCredentialsTest extends BaseSerializationTest {
   private static final IdentityPoolActorTokenSupplier testActorSupplier =
       (ExternalAccountSupplierContext context) -> "testActorToken";
 
-  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"));
@@ -110,6 +121,17 @@ private static KeyStore createPopulatedKeyStore() {
     }
   }
 
+  static KeyStore createRotatedPopulatedKeyStore() {
+    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);
+    }
+  }
+
   @Test
   void createdScoped_clonedCredentialWithAddedScopes() {
     IdentityPoolCredentials credentials =
@@ -484,7 +506,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);
   }
 
@@ -525,7 +547,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);
   }
 
@@ -1400,7 +1422,8 @@ 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 MtlsHttpTransportFactory"
+            + " constructed with a KeyStore.",
         e.getMessage());
   }
 
@@ -1864,7 +1887,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 =
@@ -1913,7 +1936,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 =
@@ -1951,6 +1974,60 @@ public KeyStore getKeyStore() {
     assertEquals(2, credential.getExchangeCallCount());
   }
 
+  @Test
+  void refreshAccessToken_401Retry_viaHttpTransport_retriesAndSucceeds() throws Exception {
+    KeyStore ksA = createPopulatedKeyStore();
+    KeyStore ksB = createRotatedPopulatedKeyStore();
+
+    AtomicInteger callCount = new AtomicInteger(0);
+    X509Provider rotatingProvider =
+        new X509Provider() {
+          @Override
+          public KeyStore getKeyStore() {
+            return callCount.getAndIncrement() == 0 ? ksA : ksB;
+          }
+        };
+
+    MockExternalAccountCredentialsTransport transportA =
+        new MockExternalAccountCredentialsTransport();
+    transportA.addStsStatusCodeSequence(401);
+
+    MockExternalAccountCredentialsTransport transportB =
+        new MockExternalAccountCredentialsTransport();
+    transportB.addStsStatusCodeSequence(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(transportA.getStsUrl())) {
+          @Override
+          HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) {
+            usedKeyStores.add(keyStore);
+            return () -> keyStore == ksA ? transportA : transportB;
+          }
+        };
+
+    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 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);
+  }
+
   @Test
   void refreshAccessToken_401Retry_nonMtls_bubblesUp() throws Exception {
     // When x509Provider is null (non-mTLS), a 401 should bubble up, not retry.
@@ -1976,21 +2053,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 =
@@ -2012,6 +2091,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,
@@ -2137,7 +2255,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() {
@@ -2203,17 +2321,25 @@ 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);
+    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;
           }
         };
 
@@ -2225,7 +2351,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 =
@@ -2255,19 +2380,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);
@@ -2276,12 +2390,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();
     }
@@ -2293,7 +2405,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);
@@ -2315,6 +2427,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 +2443,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 +2522,33 @@ void serialize_deserialize_withActorTokenConfig_roundTrips() throws Exception {
     assertEquals(credentials.getActorTokenType(), deserialized.getActorTokenType());
   }
 
+  @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())
+            .setCredentialSource(credentialSource)
+            .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"
@@ -2931,6 +3061,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().replace("\\", "\\\\")
+            + "\",\n"
+            + "      \"key_path\": \""
+            + keyFile.toString().replace("\\", "\\\\")
+            + "\"\n"
+            + "    }\n"
+            + "  }\n"
+            + "}";
+    Files.write(certConfigFile, certConfigJson.getBytes(StandardCharsets.UTF_8));
+
     String configJson =
         "{\n"
             + "  \"type\": \"external_account\",\n"
@@ -2940,15 +3091,16 @@ 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"
             + "      \"subject_token_field_name\": \"subject_token\"\n"
             + "    },\n"
             + "    \"certificate\": {\n"
-            + "      \"certificate_config_location\":"
-            + " \"testresources/mtls/certificate_config.json\"\n"
+            + "      \"certificate_config_location\": \""
+            + certConfigFile.toString().replace("\\", "\\\\")
+            + "\"\n"
             + "    }\n"
             + "  }\n"
             + "}";
@@ -2970,6 +3122,10 @@ protected AccessToken exchangeExternalCredentialForAccessToken(
               HttpTransportFactory cycleTransportFactory)
               throws IOException {
             if (exchangeCount.incrementAndGet() == 1) {
+              Files.write(
+                  certFile, Files.readAllBytes(Paths.get("testresources/mtls/test_cert_2.pem")));
+              Files.write(
+                  keyFile, Files.readAllBytes(Paths.get("testresources/mtls/test_key_2.pem")));
               throw new OAuthException("invalid_client", "Unauthorized", null, 401);
             }
             return new AccessToken("rotatedRetryToken", null);
@@ -3083,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());
   }
 
   // ==================================================================================
@@ -3144,8 +3305,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);
@@ -3160,8 +3321,1900 @@ protected AccessToken exchangeExternalCredentialForAccessToken(
       return new AccessToken("capturedAccessToken", null);
     }
 
-    java.util.List getCapturedFactories() {
+    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<>();
+    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) {
+            usedKeyStores.add(keyStore);
+            return () ->
+                new MockHttpTransport() {
+                  @Override
+                  public LowLevelHttpRequest buildRequest(String method, String url)
+                      throws IOException {
+                    requestKeyStores.add(keyStore);
+                    return mockTransport.buildRequest(method, url);
+                  }
+                };
+          }
+        };
+
+    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);
+    assertEquals(Arrays.asList(ks, ks), requestKeyStores);
+
+    // 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 = 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);
+    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<>();
+    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) {
+            usedKeyStores.add(keyStore);
+            return () ->
+                new MockHttpTransport() {
+                  @Override
+                  public LowLevelHttpRequest buildRequest(String method, String url)
+                      throws IOException {
+                    requestKeyStores.add(keyStore);
+                    return mockTransport.buildRequest(method, url);
+                  }
+                };
+          }
+        };
+
+    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(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());
+
+    // 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");
+          }
+        };
+
+    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
+          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 (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);
+    assertTrue(OAuth2Utils.isUnauthorizedException(suppressed[0]));
+  }
+
+  @Test
+  void refreshAccessToken_impersonation_certRotationBetweenCycles_usesNewCert() throws Exception {
+    KeyStore ksA = createPopulatedKeyStore();
+    KeyStore ksB = createRotatedPopulatedKeyStore();
+    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<>();
+    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) {
+            usedKeyStores.add(keyStore);
+            return () ->
+                new MockHttpTransport() {
+                  @Override
+                  public LowLevelHttpRequest buildRequest(String method, String url)
+                      throws IOException {
+                    requestKeyStores.add(keyStore);
+                    return mockTransport.buildRequest(method, url);
+                  }
+                };
+          }
+        };
+
+    // 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(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
+  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 (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 = createPopulatedKeyStore();
+    KeyStore ksB = createRotatedPopulatedKeyStore();
+    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);
+  }
+
+  public static class CustomMtlsHttpTransportFactory extends MtlsHttpTransportFactory {
+    public CustomMtlsHttpTransportFactory() {
+      super();
+    }
+
+    public CustomMtlsHttpTransportFactory(KeyStore keyStore) {
+      super(keyStore);
+    }
+  }
+
+  @Test
+  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");
+    Map credentialSourceMap = new HashMap<>();
+    credentialSourceMap.put("file", "testresources/mtls/certificate_config.json");
+    credentialSourceMap.put("certificate", certificateMap);
+    IdentityPoolCredentialSource credentialSource =
+        new IdentityPoolCredentialSource(credentialSourceMap);
+
+    KeyStore ks = createPopulatedKeyStore();
+    CustomMtlsHttpTransportFactory customFactory = new CustomMtlsHttpTransportFactory(ks);
+    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);
+    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(
+      @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<>();
+    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);
+  }
+
+  @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);
+    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
+          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);
+                      }
+                    };
+                  }
+                };
+          }
+        };
+
+    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()
+                .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) {
+                    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);
+                      }
+                    };
+                  }
+                };
+          }
+        };
+
+    // 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());
+
+    // 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);
+  }
+
+  @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());
+  }
+
+  @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());
+  }
 }
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..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
@@ -70,9 +70,13 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Calendar;
+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;
 
@@ -1373,4 +1377,223 @@ 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, "");
+
+    AtomicReference capturedSourceTransport = new 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")
+                .setQuotaProjectId("test-quota-project")
+                .setTokenUrl("https://sts.googleapis.com/v1/token")) {
+          @Override
+          AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) {
+            capturedSourceTransport.set(cycleTransportFactory);
+            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"));
+    assertEquals(
+        "test-quota-project",
+        customTransportFactory
+            .getTransport()
+            .getRequest()
+            .getFirstHeaderValue("x-goog-user-project"));
+  }
+
+  @Test
+  void refreshAccessToken_nullTransportFactory_fallsBackToCredentialsTransportAndUsesCache()
+      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, "");
+
+    AtomicBoolean sourceRefreshed = new 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"));
+
+    // 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());
+    assertFalse(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")
+            .setScopes(
+                Collections.singletonList("https://www.googleapis.com/auth/devstorage.read_only"))
+            .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 preserved existing source scope and added cloud-platform scope
+    String stsContent = stsTransport.getRequests().get(0).getContentAsString();
+    Map stsParams = TestUtils.parseQuery(stsContent);
+    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(
+        iamTransportFactory
+            .getTransport()
+            .getRequest()
+            .getContentAsString()
+            .contains("https://www.googleapis.com/auth/bigquery"));
+  }
+
+  @Test
+  void refreshAccessToken_withoutCycleTransportFactory_externalAccountSourceRetriesOn401FromIam()
+      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);
+          }
+        };
+
+    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"));
+  }
 }
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..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
@@ -89,10 +89,20 @@ 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;
+  private String expectedIamScope = OAuth2Utils.CLOUD_PLATFORM_SCOPE;
+
+  public void setExpectedIamScope(String expectedIamScope) {
+    this.expectedIamScope = expectedIamScope;
+  }
+
+  public void addStsStatusCodeSequence(Integer... statusCodes) {
+    Collections.addAll(stsStatusCodeSequence, statusCodes);
+  }
 
   public void addResponseErrorSequence(IOException... errors) {
     Collections.addAll(responseErrorSequence, errors);
@@ -178,6 +188,19 @@ public LowLevelHttpResponse execute() throws IOException {
               assertNotNull(query.get("subject_token_type"));
               assertNotNull(query.get("subject_token"));
 
+              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());
+              }
+
               GenericJson response = new GenericJson();
               response.setFactory(JSON_FACTORY);
               response.put("token_type", TOKEN_TYPE);
@@ -201,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));
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..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
@@ -33,8 +33,20 @@
 
 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 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;
 
 /** Tests for {@link OAuth2Utils}. */
@@ -98,4 +110,97 @@ void testNullPassword_throws() {
           generateBasicAuthHeader(username, password);
         });
   }
+
+  @Test
+  void isUnauthorizedException_null_returnsFalse() {
+    assertFalse(OAuth2Utils.isUnauthorizedException(null));
+  }
+
+  @Test
+  void isUnauthorizedException_genericIOException_returnsFalse() {
+    assertFalse(OAuth2Utils.isUnauthorizedException(new IOException("Network error")));
+  }
+
+  @Test
+  void isUnauthorizedException_oauthException401_returnsTrue() {
+    OAuthException ex = new OAuthException("invalid_client", "Unauthorized", null, 401);
+    assertTrue(OAuth2Utils.isUnauthorizedException(ex));
+  }
+
+  @Test
+  void isUnauthorizedException_oauthExceptionNon401_returnsFalse() {
+    OAuthException ex = new OAuthException("bad_request", "Bad Request", null, 400);
+    assertFalse(OAuth2Utils.isUnauthorizedException(ex));
+  }
+
+  @Test
+  void isUnauthorizedException_httpResponseException401_returnsTrue() {
+    HttpResponseException ex =
+        new HttpResponseException.Builder(401, "Unauthorized", new HttpHeaders()).build();
+    assertTrue(OAuth2Utils.isUnauthorizedException(ex));
+  }
+
+  @Test
+  void isUnauthorizedException_httpResponseExceptionNon401_returnsFalse() {
+    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);
+    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));
+  }
+
+  @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));
+  }
+
+  @Test
+  void hasCertificateChanged_sameCertificateDifferentPrivateKey_returnsTrue() throws Exception {
+    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 =
+        SecurityUtils.createMtlsKeyStore(
+            new ByteArrayInputStream(Bytes.concat(certBytes, newlineBytes, key1Bytes)));
+    KeyStore ks2 =
+        SecurityUtils.createMtlsKeyStore(
+            new ByteArrayInputStream(Bytes.concat(certBytes, newlineBytes, 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 a07d9450de35..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
@@ -225,12 +225,29 @@ void refreshAccessToken_withServiceAccountImpersonation() throws IOException {
             .setHttpTransportFactory(transportFactory)
             .build();
 
+    final ExecutableOptions[] providedOptions = {null};
+    final int[] executableCallCount = {0};
     credential =
         PluggableAuthCredentials.newBuilder(credential)
-            .setExecutableHandler(options -> "pluggableAuthToken")
+            .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(),
+        providedOptions[0].getEnvironmentMap().get("GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL"));
 
     assertEquals(
         transportFactory.transport.getServiceAccountAccessToken(), accessToken.getTokenValue());
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-----