From 1fed6d40141806bc6836b1a435ecf4a6ea4f36b7 Mon Sep 17 00:00:00 2001 From: macastelaz <34776182+macastelaz@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:54:03 -0500 Subject: [PATCH 01/10] feat(oauth2): Extract actor tokens for cert-bound OAuth2 STS exchange (#13955) ## Summary Adds support for actor tokens in `IdentityPoolCredentials` during certificate-bound OAuth2 STS token exchange, and implements per-cycle mTLS certificate snapshotting and single-retry rotation. --- ## Architectural Changes & Review Feedback Resolutions ### 1. Actor Token Extraction & Atomic Reads - Added package-private `IdentityPoolActorTokenSupplier` interface. - Enhanced `FileIdentityPoolSubjectTokenSupplier` with `readTokens()` to read and parse JSON credential files in a single atomic I/O operation, returning a `TokenPair` for subject and actor tokens. - Removed unreliable file timestamp caching; the file is read at most once per refresh cycle. - Added early validation in `IdentityPoolCredentialSource`: `actorTokenFieldName` must be non-empty, distinct from `subjectTokenFieldName`, and is supported only on JSON file sources. ### 2. Per-Cycle Certificate Pinning & 401 Rotation Retry - `refreshAccessToken()` snapshots the `KeyStore` from `X509Provider` at the start of each refresh cycle, creating a cycle-isolated `MtlsHttpTransportFactory` passed to the STS exchange. - On 401 Unauthorized from STS, `refreshAccessToken()` re-reads a fresh `KeyStore` from `x509Provider` and retries the exchange once (preserving original error context via `addSuppressed` if cert reload fails). - Relaxed mTLS endpoint validation to check for `.mtls.` to support custom universes and PSC endpoints. ### 3. Serialization & Binary Backward Compatibility - **SUID Compatibility**: Preserved `FileIdentityPoolSubjectTokenSupplier` class name and declared static `serialVersionUID = 7152208690659890358L;`, matching the synthetic SUID generated before this PR to prevent `InvalidClassException` on existing serialized credentials. - **Binary ABI Preservation**: Kept `public NetHttpTransport create()` return type on `MtlsHttpTransportFactory` to preserve binary compatibility for compiled callers via covariant return typing. - **Post-Deserialization Usability**: Implemented custom `readObject()` in `IdentityPoolCredentials` to reconstruct transient `x509Provider` and `MtlsHttpTransportFactory` from the serialized `credentialSource`, ensuring `refreshAccessToken()` and `createScoped()` remain fully functional after deserialization. - **KeyStore Validation**: `MtlsHttpTransportFactory.hasKeyStore()` checks `mtlsKeyStore != null && mtlsKeyStore.size() > 0`, ensuring uninitialized or empty KeyStores are not treated as mTLS-configured. ### 4. API Visibility - Made `setActorTokenSupplier`, `setActorTokenType`, and `setX509Provider` package-private on `IdentityPoolCredentials.Builder` for internal/testing use. --- ## Test Coverage - **1009 unit tests passing** (`oauth2_http` module). - **Production Path Tests**: End-to-end tests parsing JSON configs via `ExternalAccountCredentials.fromStream()` and executing `refreshAccessToken()`. - **Serialization Tests**: Verified backward compatibility against pre-PR serialized byte fixtures and verified token refresh on deserialized instances. - **Cert Pinning & Retry Tests**: Verified single-snapshot cert consistency and 401 cert rotation retries. - **Google Java Format**: Verified 100% compliance with `com.spotify.fmt:fmt-maven-plugin:2.25`. ### Manual Testing - Verified end-to-end execution against live Google STS endpoints, token exchange with actor tokens over mTLS, scoping, serialization round-trip with `readObject()` restoration, and multi-cycle refreshes. - Sample Test App: https://paste.googleplex.com/6209850244726784 - Sanitized Verification Output: https://paste.googleplex.com/6325994750345216 ## Next Steps The next phase (which will follow in a separate PR to keep reviews scoped) will introduce dynamic mTLS transport rotation natively in GAX and a 401 connection-draining interceptor required for downstream service retry logic. Based on design: https://docs.google.com/document/d/1NIKeJX86ETNAjoQA-lZIE_G_8mHspisBaL8gwNCO3dA/edit?resourcekey=0-z7teBZZk0WFIJEHL2ODZxw&tab=t.0 See b/542238030 for tracking --- .../auth/mtls/MtlsHttpTransportFactory.java | 61 +- .../oauth2/ExternalAccountCredentials.java | 22 +- .../FileIdentityPoolSubjectTokenSupplier.java | 175 +- .../IdentityPoolActorTokenSupplier.java | 51 + .../oauth2/IdentityPoolCredentialSource.java | 25 +- .../auth/oauth2/IdentityPoolCredentials.java | 276 ++- .../google/auth/oauth2/OAuthException.java | 16 +- .../auth/oauth2/Slf4jLoggingHelpers.java | 1 + .../UrlIdentityPoolSubjectTokenSupplier.java | 5 +- .../mtls/MtlsHttpTransportFactoryTest.java | 178 ++ .../ExternalAccountCredentialsTest.java | 50 +- ...eIdentityPoolSubjectTokenSupplierTest.java | 526 +++++ .../IdentityPoolCredentialsSourceTest.java | 117 +- .../oauth2/IdentityPoolCredentialsTest.java | 1869 ++++++++++++++++- ...ckExternalAccountCredentialsTransport.java | 7 +- .../auth/oauth2/Slf4jUtilsLogbackTest.java | 46 + 16 files changed, 3376 insertions(+), 49 deletions(-) create mode 100644 google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolActorTokenSupplier.java create mode 100644 google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsHttpTransportFactoryTest.java create mode 100644 google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplierTest.java 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 3e658322347a..9b20c6c8f446 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 @@ -34,10 +34,15 @@ import com.google.api.client.http.javanet.NetHttpTransport; import com.google.api.core.InternalApi; import com.google.auth.http.HttpTransportFactory; +import java.io.Serializable; import java.security.GeneralSecurityException; import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.cert.Certificate; +import java.util.Enumeration; import java.util.Objects; import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; /** * An HttpTransportFactory that creates {@link NetHttpTransport} instances configured for mTLS @@ -49,8 +54,25 @@ */ @NullMarked @InternalApi -public class MtlsHttpTransportFactory implements HttpTransportFactory { - private final KeyStore mtlsKeyStore; +public class MtlsHttpTransportFactory implements HttpTransportFactory, Serializable { + private static final long serialVersionUID = 1L; + private final transient @Nullable KeyStore mtlsKeyStore; + private final transient boolean hasKeyStore; + + /** + * Default no-arg constructor creating an instance without a KeyStore ({@code hasKeyStore() == + * false}). This constructor is invoked reflectively by {@code + * ExternalAccountCredentials.readObject()} during deserialization (since {@code transportFactory} + * is transient on {@code ExternalAccountCredentials}), after which {@code + * IdentityPoolCredentials.readObject()} reconstructs {@code X509Provider} from the serialized + * certificate configuration and replaces the transport factory with {@link + * #MtlsHttpTransportFactory(KeyStore)}. Not intended for direct use; callers configuring mTLS + * should use {@link #MtlsHttpTransportFactory(KeyStore)}. + */ + public MtlsHttpTransportFactory() { + this.mtlsKeyStore = null; + this.hasKeyStore = false; + } /** * Constructs a factory for mTLS transports. @@ -61,6 +83,41 @@ public class MtlsHttpTransportFactory implements HttpTransportFactory { */ public MtlsHttpTransportFactory(KeyStore mtlsKeyStore) { this.mtlsKeyStore = Objects.requireNonNull(mtlsKeyStore, "mtlsKeyStore cannot be null"); + this.hasKeyStore = checkHasKeyStore(this.mtlsKeyStore); + } + + /** + * Returns whether this factory was constructed with a non-null {@link KeyStore} containing client + * certificates for mTLS. A factory created via the no-arg constructor (e.g. during + * deserialization), with an empty KeyStore, or with a KeyStore containing only trusted CA + * certificates (without a private key entry and certificate chain) will return {@code false}. + */ + public boolean hasKeyStore() { + return this.hasKeyStore; + } + + private static boolean checkHasKeyStore(@Nullable KeyStore keyStore) { + if (keyStore == null) { + return false; + } + try { + Enumeration aliases = keyStore.aliases(); + if (aliases == null) { + return false; + } + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + if (keyStore.isKeyEntry(alias)) { + Certificate[] chain = keyStore.getCertificateChain(alias); + if (chain != null && chain.length > 0) { + return true; + } + } + } + return false; + } catch (KeyStoreException e) { + return false; + } } @Override diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java index 0489d1ad743d..7191be5ca3fc 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 @@ -432,6 +432,7 @@ static ExternalAccountCredentials fromJson( Map json, HttpTransportFactory transportFactory) { String audience = (String) json.get("audience"); String subjectTokenType = (String) json.get("subject_token_type"); + String actorTokenType = (String) json.get("actor_token_type"); String tokenUrl = (String) json.get("token_url"); Map credentialSourceMap = (Map) json.get("credential_source"); @@ -488,6 +489,7 @@ static ExternalAccountCredentials fromJson( .setHttpTransportFactory(transportFactory) .setAudience(audience) .setSubjectTokenType(subjectTokenType) + .setActorTokenType(actorTokenType) .setTokenUrl(tokenUrl) .setTokenInfoUrl(tokenInfoUrl) .setCredentialSource(new IdentityPoolCredentialSource(credentialSourceMap)) @@ -533,6 +535,22 @@ private boolean shouldBuildImpersonatedCredential() { */ protected AccessToken exchangeExternalCredentialForAccessToken( StsTokenExchangeRequest stsTokenExchangeRequest) throws IOException { + return exchangeExternalCredentialForAccessToken(stsTokenExchangeRequest, this.transportFactory); + } + + /** + * Exchanges the external credential for a Google Cloud access token using the specified transport + * factory. This overload allows callers to provide a per-cycle transport factory, for example one + * pinned to a specific mTLS certificate. + * + * @param stsTokenExchangeRequest the Security Token Service token exchange request + * @param cycleTransportFactory the HTTP transport factory to use for this exchange + * @return the access token returned by the Security Token Service + * @throws OAuthException if the call to the Security Token Service fails + */ + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, HttpTransportFactory cycleTransportFactory) + throws IOException { // Handle service account impersonation if necessary. if (this.shouldBuildImpersonatedCredential()) { this.impersonatedCredentials = this.buildImpersonatedCredentials(); @@ -543,7 +561,9 @@ protected AccessToken exchangeExternalCredentialForAccessToken( StsRequestHandler.Builder requestHandler = StsRequestHandler.newBuilder( - tokenUrl, stsTokenExchangeRequest, transportFactory.create().createRequestFactory()); + tokenUrl, + stsTokenExchangeRequest, + cycleTransportFactory.create().createRequestFactory()); // If this credential was initialized with a Workforce configuration then the // workforcePoolUserProject must be passed to the Security Token Service via the internal diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplier.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplier.java index 02654578a418..457de2bf9378 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplier.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplier.java @@ -31,73 +31,194 @@ package com.google.auth.oauth2; +import static com.google.common.base.Preconditions.checkNotNull; + import com.google.api.client.json.GenericJson; import com.google.api.client.json.JsonObjectParser; +import com.google.api.client.util.Data; import com.google.auth.oauth2.IdentityPoolCredentialSource.CredentialFormatType; import com.google.common.io.CharStreams; import java.io.BufferedReader; -import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; +import java.io.Reader; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.LinkOption; import java.nio.file.Paths; import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; /** - * Internal provider for retrieving the subject tokens for {@link IdentityPoolCredentials} to - * exchange for GCP access tokens via a local file. + * Internal provider for retrieving the subject and actor tokens for {@link IdentityPoolCredentials} + * to exchange for GCP access tokens via a local file. + * + *

Note: Despite the name, this class handles both subject and actor tokens. The class + * name retains "Subject" for serialization backward compatibility; renaming it would break + * deserialization of previously serialized credentials. */ @NullMarked -class FileIdentityPoolSubjectTokenSupplier implements IdentityPoolSubjectTokenSupplier { +class FileIdentityPoolSubjectTokenSupplier + implements IdentityPoolSubjectTokenSupplier, IdentityPoolActorTokenSupplier { - private final long serialVersionUID = 2475549052347431992L; + private static final long serialVersionUID = 7152208690659890358L; private final IdentityPoolCredentialSource credentialSource; - /** - * Constructor for FileIdentitySubjectTokenProvider - * - * @param credentialSource the credential source to use. - */ FileIdentityPoolSubjectTokenSupplier(IdentityPoolCredentialSource credentialSource) { - this.credentialSource = credentialSource; + this.credentialSource = checkNotNull(credentialSource, "credentialSource cannot be null"); } @Override public String getSubjectToken(ExternalAccountSupplierContext context) throws IOException { - String credentialFilePath = this.credentialSource.getCredentialLocation(); + return getToken(credentialSource.subjectTokenFieldName); + } + + @Override + public String getActorToken(ExternalAccountSupplierContext context) throws IOException { + if (credentialSource.credentialFormatType == CredentialFormatType.TEXT) { + throw new IllegalArgumentException( + "Actor tokens are only supported for JSON-formatted credential files with distinct field" + + " names."); + } + return getToken(credentialSource.actorTokenFieldName); + } + + /** + * Reads the credential file once and returns both the subject and actor tokens atomically. + * + *

This method ensures that both tokens are extracted from the same file read, avoiding + * potential race conditions when the file is being updated between reads. + * + * @param context the supplier context + * @return a {@link TokenPair} containing both the subject and actor tokens + * @throws IOException if the file cannot be read or the required fields are missing + */ + TokenPair readTokens(ExternalAccountSupplierContext context) throws IOException { + if (credentialSource.credentialFormatType != CredentialFormatType.JSON) { + throw new IOException( + "readTokens() is only supported for JSON-formatted credential sources."); + } + + String subjectFieldName = credentialSource.subjectTokenFieldName; + if (subjectFieldName == null) { + throw new IOException("Subject token field name must be specified for JSON credentials."); + } + + String credentialFilePath = credentialSource.getCredentialLocation(); if (!Files.exists(Paths.get(credentialFilePath), LinkOption.NOFOLLOW_LINKS)) { throw new IOException( String.format( "Invalid credential location. The file at %s does not exist.", credentialFilePath)); } - try { - return parseToken( - Files.newInputStream(new File(credentialFilePath).toPath()), this.credentialSource); - } catch (IOException e) { - throw new IOException( - "Error when attempting to read the subject token from the credential file.", e); + + GenericJson parsedJson = readAndParseJsonFile(credentialFilePath); + String subject = extractField(parsedJson, subjectFieldName); + + String actor = null; + if (credentialSource.actorTokenFieldName != null) { + actor = extractField(parsedJson, credentialSource.actorTokenFieldName); } + + return new TokenPair(subject, actor); } - static String parseToken(InputStream inputStream, IdentityPoolCredentialSource credentialSource) - throws IOException { - if (credentialSource.credentialFormatType == CredentialFormatType.TEXT) { + private String getToken(@Nullable String targetFieldName) throws IOException { + if (credentialSource.credentialFormatType == CredentialFormatType.JSON + && targetFieldName == null) { + throw new IOException("Target field name must be specified for JSON credentials."); + } + + String credentialFilePath = credentialSource.getCredentialLocation(); + if (!Files.exists(Paths.get(credentialFilePath), LinkOption.NOFOLLOW_LINKS)) { + throw new IOException( + String.format( + "Invalid credential location. The file at %s does not exist.", credentialFilePath)); + } + + if (credentialSource.credentialFormatType == CredentialFormatType.JSON) { + GenericJson parsedJson = readAndParseJsonFile(credentialFilePath); + return extractField(parsedJson, targetFieldName); + } + + try (InputStream inputStream = Files.newInputStream(Paths.get(credentialFilePath))) { BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8)); return CharStreams.toString(reader); + } catch (IOException e) { + throw new IOException("Error when attempting to read the token from the credential file.", e); + } + } + + private static GenericJson readAndParseJsonFile(String credentialFilePath) throws IOException { + try (InputStream inputStream = Files.newInputStream(Paths.get(credentialFilePath))) { + JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY); + return parser.parseAndClose(inputStream, StandardCharsets.UTF_8, GenericJson.class); + } catch (Exception e) { + throw new IOException("Error when attempting to read the token from the credential file.", e); } + } + + private static String extractField(GenericJson json, String fieldName) throws IOException { + Object value = json.get(fieldName); + if (value == null || Data.isNull(value)) { + throw new IOException("Invalid token field name. No token was found for field: " + fieldName); + } + if (!(value instanceof String)) { + throw new IOException( + "Token field value for " + + fieldName + + " must be a String but was: " + + value.getClass().getName()); + } + return (String) value; + } + + /** Used primarily for UrlIdentityPoolSubjectTokenSupplier */ + static String parseToken( + InputStream inputStream, + IdentityPoolCredentialSource credentialSource, + @Nullable String targetFieldName) + throws IOException { + try (InputStream in = inputStream; + Reader reader = new InputStreamReader(in, StandardCharsets.UTF_8)) { + if (credentialSource.credentialFormatType == CredentialFormatType.TEXT) { + return CharStreams.toString(new BufferedReader(reader)); + } + + if (targetFieldName == null) { + throw new IOException("Target field name must be specified for JSON credentials."); + } + + JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY); + GenericJson fileContents = + parser.parseAndClose(in, StandardCharsets.UTF_8, GenericJson.class); + + Object value = fileContents.get(targetFieldName); + if (value == null || Data.isNull(value)) { + throw new IOException( + "Invalid token field name. No token was found for field: " + targetFieldName); + } + if (!(value instanceof String)) { + throw new IOException( + "Token field value for " + + targetFieldName + + " must be a String but was: " + + value.getClass().getName()); + } + return (String) value; + } + } - JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY); - GenericJson fileContents = - parser.parseAndClose(inputStream, StandardCharsets.UTF_8, GenericJson.class); + /** Holds a pair of subject and actor tokens read atomically from the same file. */ + static class TokenPair { + final String subject; + final @Nullable String actor; - if (!fileContents.containsKey(credentialSource.subjectTokenFieldName)) { - throw new IOException("Invalid subject token field name. No subject token was found."); + TokenPair(String subject, @Nullable String actor) { + this.subject = subject; + this.actor = actor; } - return (String) fileContents.get(credentialSource.subjectTokenFieldName); } } diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolActorTokenSupplier.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolActorTokenSupplier.java new file mode 100644 index 000000000000..86911dee0ae5 --- /dev/null +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolActorTokenSupplier.java @@ -0,0 +1,51 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.oauth2; + +import java.io.IOException; +import java.io.Serializable; +import org.jspecify.annotations.NullMarked; + +/** Functional interface for supplying an actor token for IdentityPool credentials. */ +@NullMarked +@FunctionalInterface +interface IdentityPoolActorTokenSupplier extends Serializable { + + /** + * Returns a valid actor token as a string. + * + * @param context the context to use to fetch the actor token + * @return the actor token string + * @throws IOException if there was an error retrieving the token + */ + String getActorToken(ExternalAccountSupplierContext context) throws IOException; +} diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentialSource.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentialSource.java index c930b5c518e8..7e0d65b12ec1 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentialSource.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentialSource.java @@ -51,6 +51,7 @@ public class IdentityPoolCredentialSource extends ExternalAccountCredentials.Cre CredentialFormatType credentialFormatType; private String credentialLocation; @Nullable String subjectTokenFieldName; + @Nullable String actorTokenFieldName; @Nullable Map headers; @Nullable private CertificateConfig certificateConfig; @@ -260,16 +261,17 @@ public IdentityPoolCredentialSource(Map credentialSourceMap) { boolean urlPresent = credentialSourceMap.containsKey("url"); boolean certificatePresent = credentialSourceMap.containsKey("certificate"); - if ((filePresent && urlPresent) - || (filePresent && certificatePresent) - || (urlPresent && certificatePresent)) { + if ((filePresent && urlPresent) || (urlPresent && certificatePresent)) { throw new IllegalArgumentException( - "Only one credential source type can be set: 'file', 'url', or 'certificate'."); + "A credential source type of URL can not be used with other credential source types."); } if (filePresent) { credentialLocation = (String) credentialSourceMap.get("file"); credentialSourceType = IdentityPoolCredentialSourceType.FILE; + if (certificatePresent) { + this.certificateConfig = certificateConfigFromSourceMap(credentialSourceMap); + } } else if (urlPresent) { credentialLocation = (String) credentialSourceMap.get("url"); credentialSourceType = IdentityPoolCredentialSourceType.URL; @@ -303,8 +305,23 @@ public IdentityPoolCredentialSource(Map credentialSourceMap) { } credentialFormatType = CredentialFormatType.JSON; subjectTokenFieldName = formatMap.get("subject_token_field_name"); + actorTokenFieldName = formatMap.get("actor_token_field_name"); + if (actorTokenFieldName != null) { + if (actorTokenFieldName.trim().isEmpty()) { + throw new IllegalArgumentException("The actor_token_field_name must not be empty."); + } + if (actorTokenFieldName.equals(subjectTokenFieldName)) { + throw new IllegalArgumentException( + "The actor_token_field_name must differ from the subject_token_field_name."); + } + } } else if (type != null && "text".equals(type.toLowerCase(Locale.US))) { credentialFormatType = CredentialFormatType.TEXT; + if (formatMap.containsKey("actor_token_field_name") + && formatMap.get("actor_token_field_name") != null) { + throw new IllegalArgumentException( + "Actor tokens are only supported for JSON-formatted credential sources."); + } } else { throw new IllegalArgumentException( String.format("Invalid credential source format type: %s.", type)); 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 70b8f58e29ac..e6846eaee550 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 @@ -39,6 +39,8 @@ import com.google.common.annotations.VisibleForTesting; import com.google.errorprone.annotations.CanIgnoreReturnValue; import java.io.IOException; +import java.io.ObjectInputStream; +import java.net.URI; import java.security.KeyStore; import java.util.ArrayList; import java.util.Collection; @@ -50,6 +52,10 @@ * Url-sourced, file-sourced, or user provided supplier method-sourced external account credentials. * *

By default, attempts to exchange the external credential for a GCP access token. + * + *

Note: Actor token extraction is currently restricted to file-based JSON credential sources + * over mTLS endpoints. When configuring certificate-bound OAuth 2.0 tokens, ensure your transport + * layer is configured for mTLS in tandem. */ @NullMarked public class IdentityPoolCredentials extends ExternalAccountCredentials { @@ -58,8 +64,15 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { static final String URL_METRICS_HEADER_VALUE = "url"; static final String CERTIFICATE_METRICS_HEADER_VALUE = "certificate"; + private static final LoggerProvider LOGGER_PROVIDER = + LoggerProvider.forClazz(IdentityPoolCredentials.class); private static final long serialVersionUID = 2471046175477275881L; private final IdentityPoolSubjectTokenSupplier subjectTokenSupplier; + private final @Nullable IdentityPoolActorTokenSupplier actorTokenSupplier; + 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 final ExternalAccountSupplierContext supplierContext; private final String metricsHeaderValue; @@ -85,11 +98,39 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { "A subjectTokenSupplier or a credentialSource must be provided."); } + // Store the x509Provider for per-cycle cert pinning. + this.x509Provider = builder.x509Provider; + // Initialize based on the source type if (builder.subjectTokenSupplier != null) { this.subjectTokenSupplier = builder.subjectTokenSupplier; this.metricsHeaderValue = PROGRAMMATIC_METRICS_HEADER_VALUE; } 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."); + } + } catch (Exception e) { + throw new RuntimeException( + "Failed to initialize mTLS transport for file credential source due to certificate" + + " error.", + e); + } + } this.subjectTokenSupplier = new FileIdentityPoolSubjectTokenSupplier(credentialSource); this.metricsHeaderValue = FILE_METRICS_HEADER_VALUE; } else if (credentialSource.credentialSourceType == IdentityPoolCredentialSourceType.URL) { @@ -113,21 +154,139 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { } else { throw new IllegalArgumentException("Source type not supported."); } + + this.actorTokenType = builder.actorTokenType; + if (builder.actorTokenSupplier != null) { + this.actorTokenSupplier = builder.actorTokenSupplier; + } else if (credentialSource != null && credentialSource.actorTokenFieldName != null) { + if (this.subjectTokenSupplier instanceof FileIdentityPoolSubjectTokenSupplier) { + this.actorTokenSupplier = (FileIdentityPoolSubjectTokenSupplier) this.subjectTokenSupplier; + } else { + throw new IllegalArgumentException( + "Actor tokens are currently only supported for file-based credential sources."); + } + } else { + this.actorTokenSupplier = null; + } + + if (this.actorTokenSupplier != null + && (this.actorTokenType == null || this.actorTokenType.trim().isEmpty())) { + throw new IllegalArgumentException( + "An actorTokenType must be specified when an actorTokenSupplier is configured."); + } + if (this.actorTokenSupplier == null && this.actorTokenType != null) { + throw new IllegalArgumentException( + "An actorTokenSupplier must be specified when an actorTokenType is configured."); + } + + if (this.actorTokenSupplier != null && !isMtlsConfigured()) { + throw new IllegalArgumentException( + "Actor tokens are only supported for mTLS token exchanges. Please configure a certificate" + + " source or MtlsHttpTransportFactory."); + } + + if (this.actorTokenSupplier != null) { + validateMtlsEndpoint(getTokenUrl(), "tokenUrl"); + if (getServiceAccountImpersonationUrl() != null) { + validateMtlsEndpoint(getServiceAccountImpersonationUrl(), "serviceAccountImpersonationUrl"); + } + } + } + + private static void validateMtlsEndpoint(@Nullable String url, String fieldName) { + if (url == null) { + return; + } + String host = URI.create(url).getHost(); + // For Google Default Universe (googleapis.com), actor tokens require mTLS binding so plain + // public endpoints (lacking '.mtls.' or Private Service Connect '.p.') are rejected early. + // Non-GDU domains (e.g. custom universes or TPC) may use different hostname conventions. + if (host != null + && host.endsWith("googleapis.com") + && !host.contains(".mtls.") + && !host.contains(".p.")) { + throw new IllegalArgumentException( + "The " + + fieldName + + " endpoint (" + + url + + ") cannot be used with actor tokens because it is a plain public Google API" + + " endpoint. Please use an mTLS endpoint (e.g. containing '.mtls.') or Private" + + " Service Connect (containing '.p.')."); + } + } + + /** + * Checks whether mTLS is properly configured by verifying that an X509Provider is set or the + * transport factory is an MtlsHttpTransportFactory with a non-null KeyStore. This avoids false + * positives from a no-arg-constructed MtlsHttpTransportFactory (e.g. after deserialization) that + * has no actual certificates. + */ + private boolean isMtlsConfigured() { + return this.x509Provider != null + || (this.transportFactory instanceof MtlsHttpTransportFactory + && ((MtlsHttpTransportFactory) this.transportFactory).hasKeyStore()); } @Override public AccessToken refreshAccessToken() throws IOException { - String credential = retrieveSubjectToken(); + // 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); + } + } + StsTokenExchangeRequest.Builder stsTokenExchangeRequest = - StsTokenExchangeRequest.newBuilder(credential, getSubjectTokenType()) + 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()); + try { + return exchangeExternalCredentialForAccessToken( + stsTokenExchangeRequest.build(), cycleTransportFactory); + } catch (OAuthException e) { + if (e.getHttpStatusCode() == 401 + && this.x509Provider != null + && this.transportFactory instanceof MtlsHttpTransportFactory) { + try { + // On 401, re-read from X509Provider for fresh certs and retry once. + KeyStore freshKeyStore = this.x509Provider.getKeyStore(); + HttpTransportFactory retryTransportFactory = new MtlsHttpTransportFactory(freshKeyStore); + return exchangeExternalCredentialForAccessToken( + stsTokenExchangeRequest.build(), retryTransportFactory); + } catch (IOException retryException) { + retryException.addSuppressed(e); + throw retryException; + } + } + throw e; + } } @Override @@ -145,6 +304,26 @@ IdentityPoolSubjectTokenSupplier getIdentityPoolSubjectTokenSupplier() { return this.subjectTokenSupplier; } + @VisibleForTesting + @Nullable IdentityPoolActorTokenSupplier getIdentityPoolActorTokenSupplier() { + return this.actorTokenSupplier; + } + + @VisibleForTesting + @Nullable String getActorTokenType() { + return this.actorTokenType; + } + + @VisibleForTesting + HttpTransportFactory getTransportFactory() { + return this.transportFactory; + } + + @VisibleForTesting + @Nullable X509Provider getX509Provider() { + return this.x509Provider; + } + /** Clones the IdentityPoolCredentials with the specified scopes. */ @Override public IdentityPoolCredentials createScoped(Collection newScopes) { @@ -166,10 +345,23 @@ public Builder toBuilder() { private IdentityPoolSubjectTokenSupplier createCertificateSubjectTokenSupplier( Builder builder, IdentityPoolCredentialSource credentialSource) throws IOException { - // Configure the mTLS transport with the x509 keystore. + // Configure the mTLS transport with the x509 keystore if custom transport was not provided. X509Provider x509Provider = getX509Provider(builder, credentialSource); + this.x509Provider = x509Provider; KeyStore mtlsKeyStore = x509Provider.getKeyStore(); - this.transportFactory = new MtlsHttpTransportFactory(mtlsKeyStore); + 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."); + } // Initialize the subject token supplier with the certificate path. String explicitCertConfigPath = getExplicitCertConfigPath(credentialSource); @@ -179,6 +371,37 @@ private IdentityPoolSubjectTokenSupplier createCertificateSubjectTokenSupplier( return new CertificateIdentityPoolSubjectTokenSupplier(credentialSource); } + /** + * Reconstitutes the {@link IdentityPoolCredentials} instance from a stream. + * + *

For credential-source based credentials (file or certificate), this method reconstructs the + * transient {@link X509Provider} and mTLS {@link HttpTransportFactory} if a certificate + * configuration is present. For programmatic suppliers (where {@code subjectTokenSupplier != + * null} and {@code credentialSource == null}), the suppliers and standard transport are restored + * directly from the serialized stream, while in-memory {@link X509Provider} instances are + * non-persistent. + */ + @SuppressWarnings("unused") + private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException { + input.defaultReadObject(); + IdentityPoolCredentialSource credentialSource = + (IdentityPoolCredentialSource) getCredentialSource(); + if (credentialSource != null + && (credentialSource.getCertificateConfig() != null + || credentialSource.credentialSourceType + == IdentityPoolCredentialSourceType.CERTIFICATE)) { + 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() + } + } + } + private X509Provider getX509Provider( Builder builder, IdentityPoolCredentialSource credentialSource) { // Use the provided X509Provider if available, otherwise initialize a default one. @@ -207,6 +430,8 @@ private X509Provider getX509Provider( public static class Builder extends ExternalAccountCredentials.Builder { private @Nullable IdentityPoolSubjectTokenSupplier subjectTokenSupplier; + private @Nullable IdentityPoolActorTokenSupplier actorTokenSupplier; + private @Nullable String actorTokenType; private @Nullable X509Provider x509Provider; Builder() {} @@ -215,7 +440,14 @@ public static class Builder extends ExternalAccountCredentials.Builder { super(credentials); if (this.credentialSource == null) { this.subjectTokenSupplier = 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. + this.actorTokenType = credentials.actorTokenType; + this.x509Provider = credentials.x509Provider; } /** @@ -246,6 +478,40 @@ public Builder setSubjectTokenSupplier(IdentityPoolSubjectTokenSupplier subjectT return this; } + /** + * Sets the actor token supplier used for certificate-bound OAuth 2.0 token exchanges. The + * supplier provides an actor token representing the entity on whose behalf the subject is + * acting. + * + *

An actor token supplier must be paired with an {@link #setActorTokenType actor token type} + * and requires an mTLS-configured transport. + * + * @param actorTokenSupplier the supplier to use for retrieving actor tokens + * @return this {@code Builder} object + */ + @CanIgnoreReturnValue + Builder setActorTokenSupplier(IdentityPoolActorTokenSupplier actorTokenSupplier) { + this.actorTokenSupplier = actorTokenSupplier; + return this; + } + + /** + * Sets the actor token type for the STS token exchange request. This specifies the type of the + * actor token provided by the {@link #setActorTokenSupplier actor token supplier}, such as + * {@code "urn:ietf:params:oauth:token-type:jwt"}. + * + *

An actor token type must be paired with an {@link #setActorTokenSupplier actor token + * supplier}. + * + * @param actorTokenType the token type URI for the actor token + * @return this {@code Builder} object + */ + @CanIgnoreReturnValue + Builder setActorTokenType(String actorTokenType) { + this.actorTokenType = actorTokenType; + return this; + } + @Override @CanIgnoreReturnValue public Builder setHttpTransportFactory(HttpTransportFactory transportFactory) { diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuthException.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuthException.java index 0349227e8071..76d7fc60aa3c 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuthException.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuthException.java @@ -50,11 +50,21 @@ class OAuthException extends GoogleAuthException { private final String errorCode; @Nullable private final String errorDescription; @Nullable private final String errorUri; + private final int httpStatusCode; OAuthException(String errorCode, @Nullable String errorDescription, @Nullable String errorUri) { + this(errorCode, errorDescription, errorUri, 0); + } + + OAuthException( + String errorCode, + @Nullable String errorDescription, + @Nullable String errorUri, + int httpStatusCode) { this.errorCode = checkNotNull(errorCode); this.errorDescription = errorDescription; this.errorUri = errorUri; + this.httpStatusCode = httpStatusCode; } @Override @@ -82,6 +92,10 @@ String getErrorCode() { return errorUri; } + int getHttpStatusCode() { + return httpStatusCode; + } + static OAuthException createFromHttpResponseException(HttpResponseException e) throws IOException { JsonParser parser = OAuth2Utils.JSON_FACTORY.createJsonParser((e).getContent()); @@ -96,6 +110,6 @@ static OAuthException createFromHttpResponseException(HttpResponseException e) if (errorResponse.containsKey("error_uri")) { errorUri = (String) errorResponse.get("error_uri"); } - return new OAuthException(errorCode, errorDescription, errorUri); + return new OAuthException(errorCode, errorDescription, errorUri, e.getStatusCode()); } } diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/Slf4jLoggingHelpers.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/Slf4jLoggingHelpers.java index 02c1e2681539..510e54ba174b 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/Slf4jLoggingHelpers.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/Slf4jLoggingHelpers.java @@ -71,6 +71,7 @@ class Slf4jLoggingHelpers { "signedBlob", "authorization", "subject_token", + "actor_token", "id_token")); } diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/UrlIdentityPoolSubjectTokenSupplier.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/UrlIdentityPoolSubjectTokenSupplier.java index 9a95701371b3..79d509d85dff 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/UrlIdentityPoolSubjectTokenSupplier.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/UrlIdentityPoolSubjectTokenSupplier.java @@ -94,7 +94,10 @@ public String getSubjectToken(ExternalAccountSupplierContext context) throws IOE HttpResponse response = request.execute(); LoggingUtils.logResponse( response, LOGGER_PROVIDER, "Received response for subject token request"); - return parseToken(response.getContent(), this.credentialSource); + return parseToken( + response.getContent(), + this.credentialSource, + this.credentialSource.subjectTokenFieldName); } catch (IOException e) { throw new IOException( String.format("Error getting subject token from metadata server: %s", e.getMessage()), e); diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsHttpTransportFactoryTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsHttpTransportFactoryTest.java new file mode 100644 index 000000000000..c7c447bbe229 --- /dev/null +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsHttpTransportFactoryTest.java @@ -0,0 +1,178 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.mtls; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.api.client.util.SecurityUtils; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.SequenceInputStream; +import java.security.KeyStore; +import java.security.cert.Certificate; +import java.security.cert.CertificateFactory; +import java.util.Enumeration; +import org.junit.jupiter.api.Test; + +class MtlsHttpTransportFactoryTest { + + private static final String TEST_CERT_PATH = "testresources/mtls/test_cert.pem"; + private static final String TEST_KEY_PATH = "testresources/mtls/test_key.pem"; + + @Test + void hasKeyStore_noArgConstructor_returnsFalse() { + MtlsHttpTransportFactory factory = new MtlsHttpTransportFactory(); + assertFalse(factory.hasKeyStore()); + } + + @Test + void hasKeyStore_emptyKeyStore_returnsFalse() throws Exception { + KeyStore emptyKeyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + emptyKeyStore.load(null, null); + assertEquals(0, emptyKeyStore.size()); + + MtlsHttpTransportFactory factory = new MtlsHttpTransportFactory(emptyKeyStore); + assertFalse(factory.hasKeyStore()); + } + + @Test + void hasKeyStore_keyStoreWithOnlyCaCertificates_returnsFalse() throws Exception { + KeyStore caKeyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + caKeyStore.load(null, null); + + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + try (FileInputStream fis = new FileInputStream(new File(TEST_CERT_PATH))) { + Certificate cert = cf.generateCertificate(fis); + caKeyStore.setCertificateEntry("ca-alias", cert); + } + assertEquals(1, caKeyStore.size()); + + MtlsHttpTransportFactory factory = new MtlsHttpTransportFactory(caKeyStore); + assertFalse(factory.hasKeyStore()); + } + + @Test + void hasKeyStore_keyStoreWithPrivateKeyAndCertChain_returnsTrue() throws Exception { + KeyStore keyStore; + try (InputStream certStream = new FileInputStream(new File(TEST_CERT_PATH)); + InputStream keyStream = new FileInputStream(new File(TEST_KEY_PATH)); + InputStream combined = new SequenceInputStream(certStream, keyStream)) { + keyStore = SecurityUtils.createMtlsKeyStore(combined); + } + + MtlsHttpTransportFactory factory = new MtlsHttpTransportFactory(keyStore); + assertTrue(factory.hasKeyStore()); + } + + @Test + void hasKeyStore_uninitializedKeyStore_returnsFalse() throws Exception { + KeyStore uninitializedKeyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + // KeyStore.size() on uninitialized KeyStore throws KeyStoreException + MtlsHttpTransportFactory factory = new MtlsHttpTransportFactory(uninitializedKeyStore); + assertFalse(factory.hasKeyStore()); + } + + @Test + void constructor_nullKeyStore_throwsNullPointerException() { + assertThrows(NullPointerException.class, () -> new MtlsHttpTransportFactory(null)); + } + + @Test + void create_returnsNetHttpTransport() throws Exception { + KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + keyStore.load(null, null); + + MtlsHttpTransportFactory factory = new MtlsHttpTransportFactory(keyStore); + NetHttpTransport transport = factory.create(); + assertNotNull(transport); + } + + @Test + void serialization_roundTrip_hasKeyStoreReturnsFalse() throws Exception { + KeyStore keyStore; + try (InputStream certStream = new FileInputStream(new File(TEST_CERT_PATH)); + InputStream keyStream = new FileInputStream(new File(TEST_KEY_PATH)); + InputStream combined = new SequenceInputStream(certStream, keyStream)) { + keyStore = SecurityUtils.createMtlsKeyStore(combined); + } + + MtlsHttpTransportFactory factory = new MtlsHttpTransportFactory(keyStore); + assertTrue(factory.hasKeyStore()); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ObjectOutputStream oos = new ObjectOutputStream(baos)) { + oos.writeObject(factory); + } + + MtlsHttpTransportFactory deserialized; + try (ObjectInputStream ois = + new ObjectInputStream(new ByteArrayInputStream(baos.toByteArray()))) { + deserialized = (MtlsHttpTransportFactory) ois.readObject(); + } + + assertNotNull(deserialized); + assertFalse(deserialized.hasKeyStore()); + } + + @Test + void hasKeyStore_cachesStateAtConstructionTime() throws Exception { + KeyStore keyStore; + try (InputStream certStream = new FileInputStream(new File(TEST_CERT_PATH)); + InputStream keyStream = new FileInputStream(new File(TEST_KEY_PATH)); + InputStream combined = new SequenceInputStream(certStream, keyStream)) { + keyStore = SecurityUtils.createMtlsKeyStore(combined); + } + + MtlsHttpTransportFactory factory = new MtlsHttpTransportFactory(keyStore); + assertTrue(factory.hasKeyStore()); + + // Mutate the KeyStore after construction by deleting all entries + Enumeration aliases = keyStore.aliases(); + while (aliases.hasMoreElements()) { + keyStore.deleteEntry(aliases.nextElement()); + } + assertEquals(0, keyStore.size()); + + // hasKeyStore() should still return the cached true value evaluated at construction + assertTrue(factory.hasKeyStore()); + } +} 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 1338c0d68fe9..5532a97b3447 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 @@ -45,14 +45,20 @@ import com.google.api.client.json.GenericJson; import com.google.api.client.json.JsonParser; import com.google.api.client.util.Clock; +import com.google.api.client.util.SecurityUtils; import com.google.auth.TestUtils; import com.google.auth.http.HttpTransportFactory; import com.google.auth.oauth2.ExternalAccountCredentials.SubjectTokenTypes; import com.google.auth.oauth2.ExternalAccountCredentialsTest.TestExternalAccountCredentials.TestCredentialSource; import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileInputStream; import java.io.IOException; +import java.io.InputStream; +import java.io.SequenceInputStream; import java.math.BigDecimal; import java.net.URI; +import java.security.KeyStore; import java.util.Arrays; import java.util.Date; import java.util.HashMap; @@ -66,8 +72,20 @@ class ExternalAccountCredentialsTest extends BaseSerializationTest { private static final String STS_URL = "https://sts.googleapis.com/v1/token"; + private static final String STS_MTLS_URL = "https://sts.mtls.googleapis.com/v1/token"; private static final String GOOGLE_DEFAULT_UNIVERSE = "googleapis.com"; + private 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")); + InputStream combined = new SequenceInputStream(certStream, keyStream)) { + return SecurityUtils.createMtlsKeyStore(combined); + } catch (Exception e) { + throw new RuntimeException("Failed to create test KeyStore", e); + } + } + private static final Map FILE_CREDENTIAL_SOURCE_MAP = new HashMap() { { @@ -200,6 +218,32 @@ void fromJson_identityPoolCredentialsWorkload() { assertEquals(GOOGLE_DEFAULT_UNIVERSE, credential.getUniverseDomain()); } + @Test + void fromJson_identityPoolCredentials_withActorTokenType() throws Exception { + GenericJson json = buildJsonIdentityPoolCredential(); + json.put("token_url", STS_MTLS_URL); + json.put("actor_token_type", "actorTokenType"); + + Map credentialSource = (Map) json.get("credential_source"); + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("actor_token_field_name", "actor_token"); + formatMap.put("subject_token_field_name", "subject_token"); + credentialSource.put("format", formatMap); + + java.security.KeyStore ks = createPopulatedKeyStore(); + com.google.auth.mtls.MtlsHttpTransportFactory mockTransportFactory = + new com.google.auth.mtls.MtlsHttpTransportFactory(ks); + + ExternalAccountCredentials credential = + ExternalAccountCredentials.fromJson(json, mockTransportFactory); + + assertInstanceOf(IdentityPoolCredentials.class, credential); + IdentityPoolCredentials idpCreds = (IdentityPoolCredentials) credential; + assertEquals("subjectTokenType", idpCreds.getSubjectTokenType()); + assertEquals("actorTokenType", idpCreds.getActorTokenType()); + } + @Test void fromJson_identityPoolCredentialsWorkforce() { ExternalAccountCredentials credential = @@ -509,7 +553,8 @@ void fromJson_invalidWorkforceAudiences_throws() { ExternalAccountCredentials.fromJson( json, OAuth2Utils.HTTP_TRANSPORT_FACTORY)); assertEquals( - "The workforce_pool_user_project parameter should only be provided for a Workforce Pool configuration.", + "The workforce_pool_user_project parameter should only be provided for a Workforce Pool" + + " configuration.", e.getMessage()); } } @@ -673,7 +718,8 @@ void constructor_builderWithInvalidWorkforceAudiences_throws() { .setCredentialSource(new TestCredentialSource(credentialSource)) .build()); assertEquals( - "The workforce_pool_user_project parameter should only be provided for a Workforce Pool configuration.", + "The workforce_pool_user_project parameter should only be provided for a Workforce Pool" + + " configuration.", exception.getMessage()); } } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplierTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplierTest.java new file mode 100644 index 000000000000..7cfc4ce5719a --- /dev/null +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplierTest.java @@ -0,0 +1,526 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.oauth2; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class FileIdentityPoolSubjectTokenSupplierTest { + + @Test + void getToken_textFormat(@TempDir Path tempDir) throws IOException { + Path credentialFile = tempDir.resolve("credential.txt"); + Files.write(credentialFile, "plain_token".getBytes(StandardCharsets.UTF_8)); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", credentialFile.toString()); + + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); + FileIdentityPoolSubjectTokenSupplier supplier = + new FileIdentityPoolSubjectTokenSupplier(source); // TEXT doesn't need targetFieldName + + assertEquals("plain_token", supplier.getSubjectToken(null)); + + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> supplier.getActorToken(null)); + assertEquals( + "Actor tokens are only supported for JSON-formatted credential files with distinct field" + + " names.", + exception.getMessage()); + } + + @Test + void getToken_jsonFormat_reReadsFileOnEachCall(@TempDir Path tempDir) throws IOException { + Path credentialFile = tempDir.resolve("credential.json"); + Files.write( + credentialFile, + "{\"sub_token\": \"my_sub_token\", \"act_token\": \"my_act_token\"}" + .getBytes(StandardCharsets.UTF_8)); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", credentialFile.toString()); + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "sub_token"); + formatMap.put("actor_token_field_name", "act_token"); + credentialSourceMap.put("format", formatMap); + + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); + FileIdentityPoolSubjectTokenSupplier supplier = + new FileIdentityPoolSubjectTokenSupplier(source); + + // Initial read + assertEquals("my_sub_token", supplier.getSubjectToken(null)); + assertEquals("my_act_token", supplier.getActorToken(null)); + + // Modify file contents + Files.write( + credentialFile, + "{\"sub_token\": \"new_sub\", \"act_token\": \"new_act\"}" + .getBytes(StandardCharsets.UTF_8)); + + // Validate we read the new token after file modification + assertEquals("new_sub", supplier.getSubjectToken(null)); + assertEquals("new_act", supplier.getActorToken(null)); + } + + @Test + void getToken_jsonFormat_concurrentReads(@TempDir Path tempDir) + throws IOException, InterruptedException { + Path credentialFile = tempDir.resolve("credential.json"); + Files.write( + credentialFile, + "{\"sub_token\": \"my_sub_token\", \"act_token\": \"my_act_token\"}" + .getBytes(StandardCharsets.UTF_8)); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", credentialFile.toString()); + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "sub_token"); + formatMap.put("actor_token_field_name", "act_token"); + credentialSourceMap.put("format", formatMap); + + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); + FileIdentityPoolSubjectTokenSupplier supplier = + new FileIdentityPoolSubjectTokenSupplier(source); + + int numThreads = 10; + java.util.concurrent.ExecutorService executor = + java.util.concurrent.Executors.newFixedThreadPool(numThreads); + java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(numThreads); + java.util.List> futures = new java.util.ArrayList<>(); + + for (int i = 0; i < numThreads; i++) { + futures.add( + executor.submit( + () -> { + latch.countDown(); + latch.await(); + assertEquals("my_sub_token", supplier.getSubjectToken(null)); + assertEquals("my_act_token", supplier.getActorToken(null)); + return null; + })); + } + + // Wait for all threads to complete and verify no exceptions were thrown + for (java.util.concurrent.Future future : futures) { + try { + future.get(); + } catch (Exception e) { + throw new RuntimeException("Thread execution failed", e); + } + } + executor.shutdown(); + } + + @Test + void getToken_jsonFormat_invalidField(@TempDir Path tempDir) throws IOException { + Path credentialFile = tempDir.resolve("credential.json"); + Files.write( + credentialFile, "{\"sub_token\": \"my_sub_token\"}".getBytes(StandardCharsets.UTF_8)); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", credentialFile.toString()); + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "sub_token"); + formatMap.put("actor_token_field_name", "act_token"); + credentialSourceMap.put("format", formatMap); + + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); + FileIdentityPoolSubjectTokenSupplier actSupplier = + new FileIdentityPoolSubjectTokenSupplier(source); + + IOException exception = assertThrows(IOException.class, () -> actSupplier.getActorToken(null)); + assertEquals( + "Invalid token field name. No token was found for field: act_token", + exception.getMessage()); + } + + @Test + void parseToken_jsonFormat_nullField_throws(@TempDir Path tempDir) throws IOException { + Path credentialFile = tempDir.resolve("credential.json"); + Files.write(credentialFile, "{\"sub_token\": null}".getBytes(StandardCharsets.UTF_8)); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", credentialFile.toString()); + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "sub_token"); + credentialSourceMap.put("format", formatMap); + + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); + FileIdentityPoolSubjectTokenSupplier supplier = + new FileIdentityPoolSubjectTokenSupplier(source); + + IOException exception = assertThrows(IOException.class, () -> supplier.getSubjectToken(null)); + assertTrue(exception.getMessage().contains("No token was found for field: sub_token")); + } + + @Test + void parseToken_jsonFormat_nonStringField_throwsIOException(@TempDir Path tempDir) + throws IOException { + // Numeric value + Path credentialFile = tempDir.resolve("credential_numeric.json"); + Files.write(credentialFile, "{\"sub_token\": 12345}".getBytes(StandardCharsets.UTF_8)); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", credentialFile.toString()); + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "sub_token"); + credentialSourceMap.put("format", formatMap); + + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); + FileIdentityPoolSubjectTokenSupplier supplier = + new FileIdentityPoolSubjectTokenSupplier(source); + + IOException numException = + assertThrows(IOException.class, () -> supplier.getSubjectToken(null)); + assertTrue( + numException + .getMessage() + .contains("Token field value for sub_token must be a String but was:")); + + // Nested object value + Path objCredentialFile = tempDir.resolve("credential_object.json"); + Files.write( + objCredentialFile, + "{\"sub_token\": {\"nested\": \"val\"}}".getBytes(StandardCharsets.UTF_8)); + credentialSourceMap.put("file", objCredentialFile.toString()); + IdentityPoolCredentialSource objSource = new IdentityPoolCredentialSource(credentialSourceMap); + FileIdentityPoolSubjectTokenSupplier objSupplier = + new FileIdentityPoolSubjectTokenSupplier(objSource); + + IOException objException = + assertThrows(IOException.class, () -> objSupplier.getSubjectToken(null)); + assertTrue( + objException + .getMessage() + .contains("Token field value for sub_token must be a String but was:")); + + // Boolean value + Path boolCredentialFile = tempDir.resolve("credential_bool.json"); + Files.write(boolCredentialFile, "{\"sub_token\": true}".getBytes(StandardCharsets.UTF_8)); + credentialSourceMap.put("file", boolCredentialFile.toString()); + IdentityPoolCredentialSource boolSource = new IdentityPoolCredentialSource(credentialSourceMap); + FileIdentityPoolSubjectTokenSupplier boolSupplier = + new FileIdentityPoolSubjectTokenSupplier(boolSource); + + IOException boolException = + assertThrows(IOException.class, () -> boolSupplier.getSubjectToken(null)); + assertTrue( + boolException + .getMessage() + .contains("Token field value for sub_token must be a String but was:")); + + // Static parseToken with numeric and object inputs + ByteArrayInputStream numStream = + new ByteArrayInputStream("{\"sub_token\": 12345}".getBytes(StandardCharsets.UTF_8)); + IOException parseNumException = + assertThrows( + IOException.class, + () -> FileIdentityPoolSubjectTokenSupplier.parseToken(numStream, source, "sub_token")); + assertTrue( + parseNumException + .getMessage() + .contains("Token field value for sub_token must be a String but was:")); + + ByteArrayInputStream objStream = + new ByteArrayInputStream( + "{\"sub_token\": {\"nested\": 42}}".getBytes(StandardCharsets.UTF_8)); + IOException parseObjException = + assertThrows( + IOException.class, + () -> FileIdentityPoolSubjectTokenSupplier.parseToken(objStream, source, "sub_token")); + assertTrue( + parseObjException + .getMessage() + .contains("Token field value for sub_token must be a String but was:")); + } + + @Test + void serialization_roundTrip_succeeds(@TempDir Path tempDir) throws Exception { + Path credentialFile = tempDir.resolve("credential.json"); + Files.write( + credentialFile, + "{\"sub_token\": \"my_sub_token\", \"act_token\": \"my_act_token\"}" + .getBytes(StandardCharsets.UTF_8)); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", credentialFile.toString()); + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "sub_token"); + formatMap.put("actor_token_field_name", "act_token"); + credentialSourceMap.put("format", formatMap); + + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); + FileIdentityPoolSubjectTokenSupplier supplier = + new FileIdentityPoolSubjectTokenSupplier(source); + + // Populate cache + assertEquals("my_sub_token", supplier.getSubjectToken(null)); + + // Serialize and deserialize + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ObjectOutputStream oos = new ObjectOutputStream(baos)) { + oos.writeObject(supplier); + } + try (ObjectInputStream ois = + new ObjectInputStream(new ByteArrayInputStream(baos.toByteArray()))) { + FileIdentityPoolSubjectTokenSupplier deserialized = + (FileIdentityPoolSubjectTokenSupplier) ois.readObject(); + assertNotNull(deserialized); + assertEquals("my_sub_token", deserialized.getSubjectToken(null)); + } + } + + @Test + void serialVersionUID_matchesPrePrSyntheticSuid() { + assertEquals( + 7152208690659890358L, + java.io.ObjectStreamClass.lookup(FileIdentityPoolSubjectTokenSupplier.class) + .getSerialVersionUID()); + } + + @Test + void getToken_missingFile_throws(@TempDir Path tempDir) { + Path credentialFile = tempDir.resolve("missing_file.txt"); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", credentialFile.toString()); + + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); + FileIdentityPoolSubjectTokenSupplier supplier = + new FileIdentityPoolSubjectTokenSupplier(source); + + IOException exception = assertThrows(IOException.class, () -> supplier.getSubjectToken(null)); + assertEquals( + String.format( + "Invalid credential location. The file at %s does not exist.", credentialFile), + exception.getMessage()); + } + + @Test + void parseToken_textFormat_succeeds() throws IOException { + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", "dummy.txt"); + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); + + ByteArrayInputStream stream = + new ByteArrayInputStream("plain_text_token".getBytes(StandardCharsets.UTF_8)); + String parsed = FileIdentityPoolSubjectTokenSupplier.parseToken(stream, source, null); + assertEquals("plain_text_token", parsed); + } + + @Test + void parseToken_jsonFormat_missingFieldName_throws() { + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", "dummy.json"); + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "sub_token"); + credentialSourceMap.put("format", formatMap); + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); + + ByteArrayInputStream stream = + new ByteArrayInputStream("{\"sub_token\": \"my_token\"}".getBytes(StandardCharsets.UTF_8)); + IOException exception = + assertThrows( + IOException.class, + () -> FileIdentityPoolSubjectTokenSupplier.parseToken(stream, source, null)); + assertEquals( + "Target field name must be specified for JSON credentials.", exception.getMessage()); + } + + @Test + void readTokens_extractsBothFields(@TempDir Path tempDir) throws IOException { + Path credentialFile = tempDir.resolve("credential.json"); + Files.write( + credentialFile, + "{\"sub_token\": \"my_sub_token\", \"act_token\": \"my_act_token\"}" + .getBytes(StandardCharsets.UTF_8)); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", credentialFile.toString()); + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "sub_token"); + formatMap.put("actor_token_field_name", "act_token"); + credentialSourceMap.put("format", formatMap); + + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); + FileIdentityPoolSubjectTokenSupplier supplier = + new FileIdentityPoolSubjectTokenSupplier(source); + + FileIdentityPoolSubjectTokenSupplier.TokenPair tokens = supplier.readTokens(null); + assertEquals("my_sub_token", tokens.subject); + assertEquals("my_act_token", tokens.actor); + } + + @Test + void readTokens_missingActorField_throwsIOException(@TempDir Path tempDir) throws IOException { + Path credentialFile = tempDir.resolve("credential.json"); + Files.write( + credentialFile, "{\"sub_token\": \"my_sub_token\"}".getBytes(StandardCharsets.UTF_8)); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", credentialFile.toString()); + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "sub_token"); + formatMap.put("actor_token_field_name", "act_token"); + credentialSourceMap.put("format", formatMap); + + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); + FileIdentityPoolSubjectTokenSupplier supplier = + new FileIdentityPoolSubjectTokenSupplier(source); + + IOException exception = assertThrows(IOException.class, () -> supplier.readTokens(null)); + assertTrue(exception.getMessage().contains("No token was found for field: act_token")); + } + + @Test + void readTokens_missingSubjectField_throwsIOException(@TempDir Path tempDir) throws IOException { + Path credentialFile = tempDir.resolve("credential.json"); + Files.write( + credentialFile, "{\"act_token\": \"my_act_token\"}".getBytes(StandardCharsets.UTF_8)); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", credentialFile.toString()); + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "sub_token"); + formatMap.put("actor_token_field_name", "act_token"); + credentialSourceMap.put("format", formatMap); + + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); + FileIdentityPoolSubjectTokenSupplier supplier = + new FileIdentityPoolSubjectTokenSupplier(source); + + IOException exception = assertThrows(IOException.class, () -> supplier.readTokens(null)); + assertTrue(exception.getMessage().contains("No token was found for field: sub_token")); + } + + @Test + void readTokens_noActorFieldConfigured_returnsNullActor(@TempDir Path tempDir) + throws IOException { + Path credentialFile = tempDir.resolve("credential.json"); + Files.write( + credentialFile, "{\"sub_token\": \"my_sub_token\"}".getBytes(StandardCharsets.UTF_8)); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", credentialFile.toString()); + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "sub_token"); + credentialSourceMap.put("format", formatMap); + + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); + FileIdentityPoolSubjectTokenSupplier supplier = + new FileIdentityPoolSubjectTokenSupplier(source); + + FileIdentityPoolSubjectTokenSupplier.TokenPair tokens = supplier.readTokens(null); + assertEquals("my_sub_token", tokens.subject); + assertNull(tokens.actor); + } + + @Test + void readTokens_textFormat_throwsIOException(@TempDir Path tempDir) throws IOException { + Path credentialFile = tempDir.resolve("credential.txt"); + Files.write(credentialFile, "plain_token".getBytes(StandardCharsets.UTF_8)); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", credentialFile.toString()); + + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); + FileIdentityPoolSubjectTokenSupplier supplier = + new FileIdentityPoolSubjectTokenSupplier(source); + + IOException exception = assertThrows(IOException.class, () -> supplier.readTokens(null)); + assertTrue(exception.getMessage().contains("only supported for JSON-formatted")); + } + + @Test + void preconditionsCheckedBeforeFileExists(@TempDir Path tempDir) { + Path nonExistentFile = tempDir.resolve("does_not_exist.json"); + + // 1. readTokens() with TEXT format fails with format error before checking Files.exists() + Map textSourceMap = new HashMap<>(); + textSourceMap.put("file", nonExistentFile.toString()); + IdentityPoolCredentialSource textSource = new IdentityPoolCredentialSource(textSourceMap); + FileIdentityPoolSubjectTokenSupplier textSupplier = + new FileIdentityPoolSubjectTokenSupplier(textSource); + + IOException readTokensException = + assertThrows(IOException.class, () -> textSupplier.readTokens(null)); + assertEquals( + "readTokens() is only supported for JSON-formatted credential sources.", + readTokensException.getMessage()); + + // 2. getActorToken() with JSON format and missing actor_token_field_name fails before + // Files.exists() + Map jsonSourceMap = new HashMap<>(); + jsonSourceMap.put("file", nonExistentFile.toString()); + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "sub_token"); + jsonSourceMap.put("format", formatMap); + IdentityPoolCredentialSource jsonSource = new IdentityPoolCredentialSource(jsonSourceMap); + FileIdentityPoolSubjectTokenSupplier jsonSupplier = + new FileIdentityPoolSubjectTokenSupplier(jsonSource); + + IOException getActorTokenException = + assertThrows(IOException.class, () -> jsonSupplier.getActorToken(null)); + assertEquals( + "Target field name must be specified for JSON credentials.", + getActorTokenException.getMessage()); + } +} diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsSourceTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsSourceTest.java index aecd82f94d3d..5663a411ee17 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsSourceTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsSourceTest.java @@ -94,7 +94,8 @@ void constructor_certificateConfig_missingRequiredFields_throws() { IllegalArgumentException.class, () -> new IdentityPoolCredentialSource(credentialSourceMap)); assertEquals( - "Invalid 'certificate' configuration in credential source: Must specify either 'certificate_config_location' or set 'use_default_certificate_config' to true.", + "Invalid 'certificate' configuration in credential source: Must specify either" + + " 'certificate_config_location' or set 'use_default_certificate_config' to true.", exception.getMessage()); } @@ -113,7 +114,8 @@ void constructor_certificateConfig_bothFieldsSet_throws() { () -> new IdentityPoolCredentialSource(credentialSourceMap)); assertEquals( - "Invalid 'certificate' configuration in credential source: Cannot specify both 'certificate_config_location' and set 'use_default_certificate_config' to true.", + "Invalid 'certificate' configuration in credential source: Cannot specify both" + + " 'certificate_config_location' and set 'use_default_certificate_config' to true.", exception.getMessage()); } @@ -149,7 +151,116 @@ void constructor_certificateConfig_invalidType_throws() { () -> new IdentityPoolCredentialSource(credentialSourceMap)); assertEquals( - "Invalid type for 'use_default_certificate_config' in certificate configuration: expected Boolean, got String.", + "Invalid type for 'use_default_certificate_config' in certificate configuration: expected" + + " Boolean, got String.", + exception.getMessage()); + } + + @Test + void constructor_fileAndCertificatePresent_isSupported() { + Map certificateMap = new HashMap<>(); + certificateMap.put("use_default_certificate_config", true); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", "/path/to/file"); + credentialSourceMap.put("certificate", certificateMap); + + IdentityPoolCredentialSource credentialSource = + new IdentityPoolCredentialSource(credentialSourceMap); + assertEquals(IdentityPoolCredentialSourceType.FILE, credentialSource.credentialSourceType); + assertEquals("/path/to/file", credentialSource.getCredentialLocation()); + assertNotNull(credentialSource.getCertificateConfig()); + assertTrue(credentialSource.getCertificateConfig().useDefaultCertificateConfig()); + } + + @Test + void constructor_jsonFormat_withActorTokenFieldName() { + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "sub_field"); + formatMap.put("actor_token_field_name", "act_field"); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", "/path/to/file"); + credentialSourceMap.put("format", formatMap); + + IdentityPoolCredentialSource credentialSource = + new IdentityPoolCredentialSource(credentialSourceMap); + assertEquals("sub_field", credentialSource.subjectTokenFieldName); + assertEquals("act_field", credentialSource.actorTokenFieldName); + } + + @Test + void constructor_actorTokenFieldNameSameAsSubject_throws() { + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "same_field"); + formatMap.put("actor_token_field_name", "same_field"); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", "/path/to/file"); + credentialSourceMap.put("format", formatMap); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> new IdentityPoolCredentialSource(credentialSourceMap)); + assertEquals( + "The actor_token_field_name must differ from the subject_token_field_name.", + exception.getMessage()); + } + + @Test + void constructor_actorTokenFieldNameEmpty_throws() { + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "sub_field"); + formatMap.put("actor_token_field_name", " "); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", "/path/to/file"); + credentialSourceMap.put("format", formatMap); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> new IdentityPoolCredentialSource(credentialSourceMap)); + assertEquals("The actor_token_field_name must not be empty.", exception.getMessage()); + } + + @Test + void constructor_actorTokenFieldNameNull_succeeds() { + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "sub_field"); + // actor_token_field_name not set + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", "/path/to/file"); + credentialSourceMap.put("format", formatMap); + + IdentityPoolCredentialSource credentialSource = + new IdentityPoolCredentialSource(credentialSourceMap); + assertEquals("sub_field", credentialSource.subjectTokenFieldName); + assertEquals(null, credentialSource.actorTokenFieldName); + } + + @Test + void constructor_textFormat_withActorTokenFieldName_throws() { + Map formatMap = new HashMap<>(); + formatMap.put("type", "text"); + formatMap.put("actor_token_field_name", "act_field"); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", "/path/to/file"); + credentialSourceMap.put("format", formatMap); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> new IdentityPoolCredentialSource(credentialSourceMap)); + assertEquals( + "Actor tokens are only supported for JSON-formatted credential sources.", exception.getMessage()); } } 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 a1662cc10191..a081814a9020 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 @@ -36,7 +36,10 @@ import static com.google.auth.oauth2.OAuth2Utils.JSON_FACTORY; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -44,26 +47,44 @@ import com.google.api.client.http.HttpTransport; import com.google.api.client.json.GenericJson; import com.google.api.client.util.Clock; +import com.google.api.client.util.SecurityUtils; import com.google.auth.TestUtils; import com.google.auth.http.HttpTransportFactory; +import com.google.auth.mtls.MtlsHttpTransportFactory; import com.google.auth.mtls.X509Provider; import com.google.auth.oauth2.GoogleCredentials.GoogleCredentialsInfo; import java.io.ByteArrayInputStream; import java.io.File; +import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; +import java.io.ObjectInputStream; +import java.io.ObjectStreamClass; +import java.io.SequenceInputStream; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.security.KeyStore; import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; import java.security.cert.CertificateException; import java.util.Arrays; +import java.util.Base64; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; import org.mockito.junit.jupiter.MockitoExtension; /** Tests for {@link IdentityPoolCredentials}. */ @@ -75,6 +96,20 @@ class IdentityPoolCredentialsTest extends BaseSerializationTest { private static final IdentityPoolSubjectTokenSupplier testProvider = (ExternalAccountSupplierContext context) -> "testSubjectToken"; + private static final IdentityPoolActorTokenSupplier testActorSupplier = + (ExternalAccountSupplierContext context) -> "testActorToken"; + + private 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")); + InputStream combined = new SequenceInputStream(certStream, keyStream)) { + return SecurityUtils.createMtlsKeyStore(combined); + } catch (Exception e) { + throw new RuntimeException("Failed to create test KeyStore", e); + } + } + @Test void createdScoped_clonedCredentialWithAddedScopes() { IdentityPoolCredentials credentials = @@ -254,6 +289,35 @@ void retrieveSubjectToken_urlSourcedWithJsonFormat() throws IOException { assertEquals(transportFactory.transport.getSubjectToken(), subjectToken); } + @Test + void retrieveSubjectToken_urlSourcedWithJsonFormat_withActorTokenField() throws IOException { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + transportFactory.transport.setMetadataServerContentType("json"); + + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "subjectToken"); + formatMap.put("actor_token_field_name", "actorToken"); + + IdentityPoolCredentialSource credentialSource = + buildUrlBasedCredentialSource(transportFactory.transport.getMetadataUrl(), formatMap); + + UrlIdentityPoolSubjectTokenSupplier supplier = + new UrlIdentityPoolSubjectTokenSupplier(credentialSource, transportFactory); + + ExternalAccountSupplierContext dummyContext = + ExternalAccountSupplierContext.newBuilder() + .setAudience("aud") + .setSubjectTokenType("urn") + .build(); + + String subjectToken = supplier.getSubjectToken(dummyContext); + + assertEquals(transportFactory.transport.getSubjectToken(), subjectToken); + } + @Test void retrieveSubjectToken_urlSourcedCredential_throws() { MockExternalAccountCredentialsTransportFactory transportFactory = @@ -686,7 +750,8 @@ void identityPoolCredentialSource_invalidSourceType() { IllegalArgumentException.class, () -> new IdentityPoolCredentialSource(credentialSourceMap)); assertEquals( - "Missing credential source file location, URL, or certificate. At least one must be specified.", + "Missing credential source file location, URL, or certificate. At least one must be" + + " specified.", e.getMessage()); } @@ -825,7 +890,8 @@ void builder_invalidWorkforceAudiences_throws() { .setQuotaProjectId("quotaProjectId"); IllegalArgumentException e = assertThrows(IllegalArgumentException.class, builder::build); assertEquals( - "The workforce_pool_user_project parameter should only be provided for a Workforce Pool configuration.", + "The workforce_pool_user_project parameter should only be provided for a Workforce Pool" + + " configuration.", e.getMessage()); } } @@ -1265,6 +1331,14 @@ private IdentityPoolCredentialSource createFileCredentialSource() { return new IdentityPoolCredentialSource(fileCredentialSourceMap); } + private IdentityPoolCredentialSource createFileCredentialSource( + String filePath, Map formatMap) { + Map fileCredentialSourceMap = new HashMap<>(); + fileCredentialSourceMap.put("file", filePath); + fileCredentialSourceMap.put("format", formatMap); + return new IdentityPoolCredentialSource(fileCredentialSourceMap); + } + static class MockExternalAccountCredentialsTransportFactory implements HttpTransportFactory { MockExternalAccountCredentialsTransport transport = @@ -1299,4 +1373,1795 @@ void setShouldThrowOnGetKeyStore(boolean shouldThrow) { this.shouldThrowOnGetKeyStore = shouldThrow; } } + + @Test + void builder_actorTokenWithNonMtlsTransportFactory_throws() { + IdentityPoolCredentialSource credentialSource = createFileCredentialSource(); + + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://invalid.googleapis.com/") + .setCredentialSource(credentialSource) + .setActorTokenType("actorTokenType") + .setActorTokenSupplier( + new IdentityPoolActorTokenSupplier() { + @Override + public String getActorToken(ExternalAccountSupplierContext context) { + return "token"; + } + }) + .build()); + + assertEquals( + "Actor tokens are only supported for mTLS token exchanges. Please configure a certificate" + + " source or MtlsHttpTransportFactory.", + e.getMessage()); + } + + @Test + void builder_actorTokenWithMissingTokenType_throws() { + IdentityPoolCredentialSource credentialSource = createFileCredentialSource(); + + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/") + .setCredentialSource(credentialSource) + .setActorTokenSupplier( + new IdentityPoolActorTokenSupplier() { + @Override + public String getActorToken(ExternalAccountSupplierContext context) { + return "token"; + } + }) + .build()); + + assertEquals( + "An actorTokenType must be specified when an actorTokenSupplier is configured.", + e.getMessage()); + } + + @Test + void builder_actorTokenWithInvalidCredentialSource_throws() { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "subject_token"); + formatMap.put("actor_token_field_name", "actor_token"); + + // Not a file credential source + IdentityPoolCredentialSource credentialSource = + buildUrlBasedCredentialSource(transportFactory.transport.getMetadataUrl(), formatMap); + + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/") // Valid URL + .setCredentialSource(credentialSource) // Invalid source for actor tokens + .build()); + + assertEquals( + "Actor tokens are currently only supported for file-based credential sources.", + e.getMessage()); + } + + @Test + void builder_supplierSourcedActorToken() throws Exception { + KeyStore ks = createPopulatedKeyStore(); + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setActorTokenSupplier(testActorSupplier) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setHttpTransportFactory(mtlsTransport) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build(); + + assertNotNull(credentials); + assertEquals("urn:ietf:params:oauth:token-type:jwt", credentials.getActorTokenType()); + } + + @Test + void createScoped_supplierSourcedWithActorToken_preservesCustomSuppliers() throws Exception { + KeyStore ks = createPopulatedKeyStore(); + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(mtlsTransport) + .setSubjectTokenSupplier(testProvider) + .setActorTokenSupplier(testActorSupplier) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build(); + + List newScopes = Arrays.asList("https://www.googleapis.com/auth/cloud-platform"); + IdentityPoolCredentials scoped = credentials.createScoped(newScopes); + + assertNotNull(scoped); + assertEquals(credentials.getActorTokenType(), scoped.getActorTokenType()); + assertEquals(newScopes, scoped.getScopes()); + assertSame(testProvider, scoped.getIdentityPoolSubjectTokenSupplier()); + assertSame(testActorSupplier, scoped.getIdentityPoolActorTokenSupplier()); + } + + @Test + void createScoped_fileSourcedWithActorToken_preservesSharedSupplierInstance() throws Exception { + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "subject_token"); + formatMap.put("actor_token_field_name", "actor_token"); + + IdentityPoolCredentialSource credentialSource = + createFileCredentialSource("credential.json", formatMap); + + KeyStore ks = createPopulatedKeyStore(); + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(mtlsTransport) + .setCredentialSource(credentialSource) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build(); + + // Verify initial instance shares the single supplier instance + assertSame( + credentials.getIdentityPoolSubjectTokenSupplier(), + credentials.getIdentityPoolActorTokenSupplier()); + + // Clone with new scopes + List newScopes = Arrays.asList("https://www.googleapis.com/auth/cloud-platform"); + IdentityPoolCredentials scoped = credentials.createScoped(newScopes); + + assertNotNull(scoped); + assertEquals(credentials.getActorTokenType(), scoped.getActorTokenType()); + assertEquals(newScopes, scoped.getScopes()); + // Verify scoped clone maintains a single shared supplier instance for its own cache + assertSame( + scoped.getIdentityPoolSubjectTokenSupplier(), scoped.getIdentityPoolActorTokenSupplier()); + } + + @Test + void refreshAccessToken_withActorToken_injectsActingPartyIntoStsRequest() throws Exception { + MockExternalAccountCredentialsTransportFactory mockTransportFactory = + new MockExternalAccountCredentialsTransportFactory(); + KeyStore ks = createPopulatedKeyStore(); + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); + + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setActorTokenSupplier(testActorSupplier) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl(mockTransportFactory.transport.getStsMtlsUrl()) + .setHttpTransportFactory(mtlsTransport)) { + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) + throws IOException { + return super.exchangeExternalCredentialForAccessToken( + stsTokenExchangeRequest, mockTransportFactory); + } + }; + + AccessToken token = credential.refreshAccessToken(); + assertEquals(mockTransportFactory.transport.getAccessToken(), token.getTokenValue()); + + Map query = + TestUtils.parseQuery(mockTransportFactory.transport.getLastRequest().getContentAsString()); + assertEquals("testActorToken", query.get("actor_token")); + assertEquals("urn:ietf:params:oauth:token-type:jwt", query.get("actor_token_type")); + } + + @Test + void serialization_withX509Provider_succeeds() throws Exception { + KeyStore ks = createPopulatedKeyStore(); + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); + X509Provider x509Provider = new TestX509Provider(ks, "certificate_config_location"); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(mtlsTransport) + .setSubjectTokenSupplier(testProvider) + .setX509Provider(x509Provider) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build(); + + serializeAndDeserialize(credentials); + } + + @Test + void builder_actorTokenTypeWithoutSupplier_throws() { + IdentityPoolCredentialSource credentialSource = createFileCredentialSource(); + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> + IdentityPoolCredentials.newBuilder() + .setCredentialSource(credentialSource) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.googleapis.com/v1/token") + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .build()); + assertEquals( + "An actorTokenSupplier must be specified when an actorTokenType is configured.", + exception.getMessage()); + } + + @Test + void builder_fileWithCertificateConfig_initializesMtlsTransport() throws Exception { + Map certMap = new HashMap<>(); + certMap.put("use_default_certificate_config", true); + + Map sourceMap = new HashMap<>(); + sourceMap.put("file", "credential.json"); + sourceMap.put("certificate", certMap); + + IdentityPoolCredentialSource credentialSource = new IdentityPoolCredentialSource(sourceMap); + + KeyStore ks = createPopulatedKeyStore(); + X509Provider x509Provider = new TestX509Provider(ks, "certificate_config_location"); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setCredentialSource(credentialSource) + .setX509Provider(x509Provider) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build(); + + assertNotNull(credentials); + assertNotNull(credentials.getTransportFactory()); + assertTrue(credentials.getTransportFactory() instanceof MtlsHttpTransportFactory); + } + + @Test + void toBuilder_preservesConfiguration() throws Exception { + KeyStore ks = createPopulatedKeyStore(); + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(mtlsTransport) + .setSubjectTokenSupplier(testProvider) + .setActorTokenSupplier(testActorSupplier) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build(); + + IdentityPoolCredentials rebuilt = credentials.toBuilder().build(); + + assertNotNull(rebuilt); + assertEquals(credentials.getActorTokenType(), rebuilt.getActorTokenType()); + assertSame(testProvider, rebuilt.getIdentityPoolSubjectTokenSupplier()); + assertSame(testActorSupplier, rebuilt.getIdentityPoolActorTokenSupplier()); + } + + @Test + void builder_actorTokenWithX509Provider_succeeds() throws Exception { + KeyStore ks = createPopulatedKeyStore(); + X509Provider x509Provider = new TestX509Provider(ks, "certificate_config_location"); + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setActorTokenSupplier(testActorSupplier) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setHttpTransportFactory(mtlsTransport) + .setX509Provider(x509Provider) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build(); + + assertNotNull(credentials); + assertEquals("urn:ietf:params:oauth:token-type:jwt", credentials.getActorTokenType()); + } + + @Test + void toBuilder_preservesActorTokenType() throws Exception { + KeyStore ks = createPopulatedKeyStore(); + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(mtlsTransport) + .setSubjectTokenSupplier(testProvider) + .setActorTokenSupplier(testActorSupplier) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build(); + + IdentityPoolCredentials rebuilt = credentials.toBuilder().build(); + + assertNotNull(rebuilt); + assertEquals("urn:ietf:params:oauth:token-type:jwt", rebuilt.getActorTokenType()); + assertSame(testProvider, rebuilt.getIdentityPoolSubjectTokenSupplier()); + assertSame(testActorSupplier, rebuilt.getIdentityPoolActorTokenSupplier()); + } + + @Test + void builder_actorTokenWithoutMtls_throws() { + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setActorTokenSupplier(testActorSupplier) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.googleapis.com/v1/token") + .build()); + assertTrue( + e.getMessage().contains("Actor tokens are only supported for mTLS token exchanges.")); + } + + @Test + void builder_actorTokenWithNoArgMtlsFactory_throws() throws Exception { + // A no-arg MtlsHttpTransportFactory (e.g. from deserialization) has no KeyStore, + // so isMtlsConfigured() should return false and building should fail. + MtlsHttpTransportFactory noArgFactory = new MtlsHttpTransportFactory(); + assertFalse(noArgFactory.hasKeyStore()); + + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setActorTokenSupplier(testActorSupplier) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setHttpTransportFactory(noArgFactory) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build()); + assertTrue( + e.getMessage().contains("Actor tokens are only supported for mTLS token exchanges.")); + } + + @Test + void builder_actorTokenWithEmptyMtlsFactory_throws() throws Exception { + KeyStore emptyKeyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + emptyKeyStore.load(null, null); + MtlsHttpTransportFactory emptyFactory = new MtlsHttpTransportFactory(emptyKeyStore); + assertFalse(emptyFactory.hasKeyStore()); + + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setActorTokenSupplier(testActorSupplier) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setHttpTransportFactory(emptyFactory) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build()); + assertTrue( + e.getMessage().contains("Actor tokens are only supported for mTLS token exchanges.")); + } + + @Test + void mtlsHttpTransportFactory_hasKeyStore_withPopulatedKeyStore_returnsTrue() throws Exception { + KeyStore ks = createPopulatedKeyStore(); + MtlsHttpTransportFactory factory = new MtlsHttpTransportFactory(ks); + assertTrue(factory.hasKeyStore()); + } + + @Test + void mtlsHttpTransportFactory_hasKeyStore_withEmptyKeyStore_returnsFalse() throws Exception { + KeyStore emptyKeyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + emptyKeyStore.load(null, null); + MtlsHttpTransportFactory factory = new MtlsHttpTransportFactory(emptyKeyStore); + assertFalse(factory.hasKeyStore()); + } + + @Test + void mtlsHttpTransportFactory_hasKeyStore_noArg_returnsFalse() { + MtlsHttpTransportFactory factory = new MtlsHttpTransportFactory(); + assertFalse(factory.hasKeyStore()); + } + + // ================================================================================== + // Section A: Cert Pinning & Transport Factory Tests + // ================================================================================== + + @Test + void refreshAccessToken_pinsTransportForStsExchange() throws Exception { + // Verify that the STS exchange uses the pinned transport factory from the KeyStore snapshot + // within one refresh cycle. Threading the pinned transport to IAM impersonation is deferred + // to a follow-up PR. + KeyStore ks = createPopulatedKeyStore(); + + AtomicInteger getKeyStoreCallCount = new AtomicInteger(0); + X509Provider x509Provider = + new X509Provider() { + @Override + public KeyStore getKeyStore() { + getKeyStoreCallCount.incrementAndGet(); + return ks; + } + }; + + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); + + // Use TransportCapturingCredentials so we can capture the factory passed to exchange. + TransportCapturingCredentials credential = + new TransportCapturingCredentials( + 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(transportFactory.transport.getStsUrl()) + .setHttpTransportFactory(mtlsTransport)); + + AccessToken token = credential.refreshAccessToken(); + assertNotNull(token); + + // getKeyStore() should be called exactly once per refresh cycle for the snapshot. + assertEquals(1, getKeyStoreCallCount.get()); + // The exchange should have been called once, and a single transport factory was used. + assertEquals(1, credential.getCapturedFactories().size()); + assertTrue( + credential.getCapturedFactories().get(0) instanceof MtlsHttpTransportFactory, + "Exchange should use MtlsHttpTransportFactory from the cert snapshot"); + } + + @Test + 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(); + + AtomicInteger callCount = new AtomicInteger(0); + X509Provider rotatingProvider = + new X509Provider() { + @Override + public KeyStore getKeyStore() { + return callCount.getAndIncrement() == 0 ? ksA : ksB; + } + }; + + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ksA); + + TransportCapturingCredentials credential = + new TransportCapturingCredentials( + 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(transportFactory.transport.getStsUrl()) + .setHttpTransportFactory(mtlsTransport)); + + // First refresh — uses ksA + AccessToken token1 = credential.refreshAccessToken(); + assertNotNull(token1); + assertEquals(1, callCount.get()); + + // Second refresh — uses ksB (rotated) + AccessToken token2 = credential.refreshAccessToken(); + assertNotNull(token2); + assertEquals(2, callCount.get()); + + // Each cycle should have created a distinct MtlsHttpTransportFactory + assertEquals(2, credential.getCapturedFactories().size()); + assertNotSame( + credential.getCapturedFactories().get(0), + credential.getCapturedFactories().get(1), + "Each cycle should use a distinct transport factory"); + } + + @Test + 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(); + + AtomicInteger callCount = new AtomicInteger(0); + X509Provider rotatingProvider = + new X509Provider() { + @Override + public KeyStore getKeyStore() { + // First call: ksA (for initial snapshot) + // Second call: ksB (for retry after 401) + return callCount.getAndIncrement() == 0 ? ksA : ksB; + } + }; + + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ksA); + + // Testable credential: throws 401 on first exchange, succeeds on retry. + 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(transportFactory.transport.getStsUrl()) + .setHttpTransportFactory(mtlsTransport), + /* failOnFirstExchange= */ true); + + AccessToken token = credential.refreshAccessToken(); + assertNotNull(token); + // Verify the provider was called twice: once for initial snapshot, once for retry + assertEquals(2, callCount.get()); + assertEquals(2, credential.getExchangeCallCount()); + } + + @Test + void refreshAccessToken_401Retry_nonMtls_bubblesUp() throws Exception { + // When x509Provider is null (non-mTLS), a 401 should bubble up, not retry. + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + TestableIdentityPoolCredentials credential = + new TestableIdentityPoolCredentials( + 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(transportFactory.transport.getStsUrl()) + .setHttpTransportFactory(transportFactory), + /* failOnFirstExchange= */ true); + + // Should throw the 401 error without retry since there's no x509Provider. + OAuthException e = assertThrows(OAuthException.class, credential::refreshAccessToken); + assertEquals(401, e.getHttpStatusCode()); + assertEquals(1, credential.getExchangeCallCount()); + } + + @Test + void refreshAccessToken_401Retry_secondAttemptFails_throws() throws Exception { + // 401 → retry → retry also fails → exception propagates. + KeyStore ks = createPopulatedKeyStore(); + + X509Provider provider = + new X509Provider() { + @Override + public KeyStore getKeyStore() { + return ks; + } + }; + + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); + + // Testable credential that always throws 401 (both first and retry). + 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, + /* failOnAllExchanges= */ true); + + OAuthException e = assertThrows(OAuthException.class, credential::refreshAccessToken); + assertEquals(401, e.getHttpStatusCode()); + // First attempt + one retry = 2 + assertEquals(2, credential.getExchangeCallCount()); + } + + @Test + void refreshAccessToken_401Retry_certLoadFailure_preservesOriginalError() throws Exception { + // When a 401 triggers retry but X509Provider.getKeyStore() throws on the retry, + // the IOException from cert loading should be thrown with the original OAuthException + // as a suppressed exception. + KeyStore ks = createPopulatedKeyStore(); + + AtomicInteger providerCallCount = new AtomicInteger(0); + X509Provider failingOnRetryProvider = + new X509Provider() { + @Override + public KeyStore getKeyStore() throws IOException { + int call = providerCallCount.getAndIncrement(); + if (call == 0) { + // First call: return valid KeyStore for initial snapshot + return ks; + } + // Second call: fail during retry (simulates cert file rotation/corruption) + throw new IOException("Certificate file not found during retry"); + } + }; + + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); + + // Testable credential: throws 401 on first exchange to trigger retry path. + TestableIdentityPoolCredentials credential = + new TestableIdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(failingOnRetryProvider) + .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); + + IOException thrown = assertThrows(IOException.class, credential::refreshAccessToken); + assertEquals("Certificate file not found during retry", thrown.getMessage()); + + // Verify the original OAuthException is preserved as a suppressed exception + Throwable[] suppressed = thrown.getSuppressed(); + assertTrue(suppressed.length > 0, "Should have suppressed exceptions"); + assertTrue(suppressed[0] instanceof OAuthException); + assertEquals(401, ((OAuthException) suppressed[0]).getHttpStatusCode()); + } + + @Test + void refreshAccessToken_subjectAndActorFromSameFileParse() throws Exception { + // Verify when both subject and actor tokens come from the same file supplier, + // readTokens() is called (single file read) rather than separate getSubjectToken() + // + getActorToken() calls. + File file = File.createTempFile("ATOMIC_READ_TOKEN", /* suffix= */ null, /* directory= */ null); + file.deleteOnExit(); + + GenericJson tokenJson = new GenericJson(); + tokenJson.setFactory(JSON_FACTORY); + tokenJson.put("subject_token", "mySubjectToken"); + tokenJson.put("actor_token", "myActorToken"); + OAuth2Utils.writeInputStreamToFile( + new ByteArrayInputStream(tokenJson.toPrettyString().getBytes(StandardCharsets.UTF_8)), + file.getAbsolutePath()); + + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "subject_token"); + formatMap.put("actor_token_field_name", "actor_token"); + IdentityPoolCredentialSource credentialSource = + createFileCredentialSource(file.getAbsolutePath(), formatMap); + + MockExternalAccountCredentialsTransportFactory mockTransportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + KeyStore ks = createPopulatedKeyStore(); + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); + + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setCredentialSource(credentialSource) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl(mockTransportFactory.transport.getStsMtlsUrl()) + .setHttpTransportFactory(mtlsTransport)) { + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) + throws IOException { + return super.exchangeExternalCredentialForAccessToken( + stsTokenExchangeRequest, mockTransportFactory); + } + }; + + // The subject and actor suppliers should be the same instance (both FileIdentityPool...) + assertSame( + credential.getIdentityPoolSubjectTokenSupplier(), + credential.getIdentityPoolActorTokenSupplier(), + "Subject and actor suppliers should be the same instance for file-based sources"); + + // Refresh should succeed, reading both tokens from the single file + AccessToken token = credential.refreshAccessToken(); + assertNotNull(token); + + // Verify the STS request included the actor token from the file + Map query = + TestUtils.parseQuery(mockTransportFactory.transport.getLastRequest().getContentAsString()); + assertEquals("myActorToken", query.get("actor_token")); + assertEquals("urn:ietf:params:oauth:token-type:jwt", query.get("actor_token_type")); + } + + // ================================================================================== + // Section B: Concurrency Tests + // ================================================================================== + + @Test + 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(); + + X509Provider countingProvider = + new X509Provider() { + @Override + public KeyStore getKeyStore() { + int count = getKeyStoreCount.incrementAndGet(); + return count <= 1 ? ks1 : ks2; + } + }; + + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks1); + + TransportCapturingCredentials credential = + new TransportCapturingCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(countingProvider) + .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)); + + CyclicBarrier barrier = new CyclicBarrier(2); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future future1 = + executor.submit( + () -> { + barrier.await(5, TimeUnit.SECONDS); + return credential.refreshAccessToken(); + }); + Future future2 = + executor.submit( + () -> { + barrier.await(5, TimeUnit.SECONDS); + return credential.refreshAccessToken(); + }); + + AccessToken token1 = future1.get(10, TimeUnit.SECONDS); + AccessToken token2 = future2.get(10, TimeUnit.SECONDS); + + assertNotNull(token1); + assertNotNull(token2); + // Each thread should have called getKeyStore(), so we expect at least 2 calls. + assertTrue( + getKeyStoreCount.get() >= 2, + "Expected at least 2 getKeyStore calls, got " + getKeyStoreCount.get()); + // Each thread should get its own factory instance + assertEquals(2, credential.getCapturedFactories().size()); + } finally { + executor.shutdownNow(); + } + } + + @Test + void refreshAccessToken_concurrent_401OnOneThread_doesNotAffectOther() throws Exception { + // Thread A refreshes normally (succeeds on first exchange). + // Thread B gets a 401, causing a retry with a fresh cert from X509Provider. + // 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(); + + AtomicInteger getKeyStoreCount = new AtomicInteger(0); + X509Provider provider = + new X509Provider() { + @Override + public KeyStore getKeyStore() { + 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; + } + }; + + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ksInitial); + + // 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 = + 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 { + int count = exchangeCallCount.incrementAndGet(); + if (count == 1) { + // First exchange call (Thread B): throw 401 to trigger retry + throw new OAuthException("invalid_client", "Unauthorized", null, 401); + } + // All other calls succeed + return new AccessToken("token_" + count, null); + } + }; + + 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(); + }); + + AccessToken tokenA = futureA.get(10, TimeUnit.SECONDS); + AccessToken tokenB = futureB.get(10, TimeUnit.SECONDS); + + assertNotNull(tokenA); + 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()); + // 3 exchange calls total: one 401 + one retry success + one normal success + assertEquals(3, exchangeCallCount.get()); + } finally { + executor.shutdownNow(); + } + } + + @Test + void refreshAccessToken_certRotationDuringRefresh_pinnedCertUsed() throws Exception { + // Cert rotates mid-refresh (during the exchange call). + // Verify the transport factory used in exchange is the one pinned at snapshot time, + // not the rotated cert. + KeyStore ksOriginal = createPopulatedKeyStore(); + KeyStore ksRotated = createPopulatedKeyStore(); + + AtomicReference currentKeyStore = new AtomicReference<>(ksOriginal); + AtomicInteger snapshotCount = new AtomicInteger(0); + + X509Provider provider = + new X509Provider() { + @Override + public KeyStore getKeyStore() { + snapshotCount.incrementAndGet(); + return currentKeyStore.get(); + } + }; + + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ksOriginal); + + // 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<>(); + IdentityPoolCredentials credential = + 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 { + // 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. + capturedFactory.set(cycleTransportFactory); + return new AccessToken("pinnedCertToken", null); + } + }; + + // Call refresh — this will snapshot ksOriginal, then during exchange, rotate to ksRotated. + AccessToken token = credential.refreshAccessToken(); + assertNotNull(token); + // 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()); + assertTrue( + capturedFactory.get() 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(); + assertNotNull(token2); + // Second refresh should have taken a new snapshot + assertEquals(2, snapshotCount.get()); + + // The two factories should be different instances (different cert snapshots) + assertNotSame( + capturedFactory.get(), + secondCapturedFactory.get(), + "Each refresh cycle should create a distinct transport factory from its cert snapshot"); + } + + // ================================================================================== + // Section D: Serialization Tests + // ================================================================================== + + @Test + void serialize_deserialize_withActorTokenConfig_roundTrips() throws Exception { + KeyStore ks = createPopulatedKeyStore(); + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(mtlsTransport) + .setSubjectTokenSupplier(testProvider) + .setActorTokenSupplier(testActorSupplier) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .setQuotaProjectId("quotaProjectId") + .setClientId("clientId") + .setClientSecret("clientSecret") + .build(); + + IdentityPoolCredentials deserialized = serializeAndDeserialize(credentials); + assertEquals(credentials.getAudience(), deserialized.getAudience()); + assertEquals(credentials.getSubjectTokenType(), deserialized.getSubjectTokenType()); + assertEquals(credentials.getTokenUrl(), deserialized.getTokenUrl()); + assertEquals(credentials.getQuotaProjectId(), deserialized.getQuotaProjectId()); + assertEquals(credentials.getClientId(), deserialized.getClientId()); + assertEquals(credentials.getClientSecret(), deserialized.getClientSecret()); + assertEquals(credentials.getActorTokenType(), deserialized.getActorTokenType()); + } + + private static final String PRE_PR_SERIALIZED_BYTES_BASE64 = + "rO0ABXNyAC5jb20uZ29vZ2xlLmF1dGgub2F1dGgyLklkZW50aXR5UG9vbENyZWRlbnRpYWxzIkrrZ4jpHOkCAANMABJtZXRy" + + "aWNzSGVhZGVyVmFsdWV0ABJMamF2YS9sYW5nL1N0cmluZztMABRzdWJqZWN0VG9rZW5TdXBwbGllcnQAOUxjb20vZ29vZ2xl" + + "L2F1dGgvb2F1dGgyL0lkZW50aXR5UG9vbFN1YmplY3RUb2tlblN1cHBsaWVyO0wAD3N1cHBsaWVyQ29udGV4dHQAN0xjb20v" + + "Z29vZ2xlL2F1dGgvb2F1dGgyL0V4dGVybmFsQWNjb3VudFN1cHBsaWVyQ29udGV4dDt4cgAxY29tLmdvb2dsZS5hdXRoLm9h" + + "dXRoMi5FeHRlcm5hbEFjY291bnRDcmVkZW50aWFsc2+0PaCkD5P/AgAQTAAIYXVkaWVuY2VxAH4AAUwACGNsaWVudElkcQB+" + + "AAFMAAxjbGllbnRTZWNyZXRxAH4AAUwAEGNyZWRlbnRpYWxTb3VyY2V0AERMY29tL2dvb2dsZS9hdXRoL29hdXRoMi9FeHRl" + + "cm5hbEFjY291bnRDcmVkZW50aWFscyRDcmVkZW50aWFsU291cmNlO0wAE2Vudmlyb25tZW50UHJvdmlkZXJ0ACxMY29tL2dv" + + "b2dsZS9hdXRoL29hdXRoMi9FbnZpcm9ubWVudFByb3ZpZGVyO0wAF2ltcGVyc29uYXRlZENyZWRlbnRpYWxzdAAwTGNvbS9n" + + "b29nbGUvYXV0aC9vYXV0aDIvSW1wZXJzb25hdGVkQ3JlZGVudGlhbHM7TAAObWV0cmljc0hhbmRsZXJ0ADZMY29tL2dvb2ds" + + "ZS9hdXRoL29hdXRoMi9FeHRlcm5hbEFjY291bnRNZXRyaWNzSGFuZGxlcjtMABBwcm9wZXJ0eVByb3ZpZGVydAApTGNvbS9n" + + "b29nbGUvYXV0aC9vYXV0aDIvUHJvcGVydHlQcm92aWRlcjtMAAZzY29wZXN0ABZMamF2YS91dGlsL0NvbGxlY3Rpb247TAAi" + + "c2VydmljZUFjY291bnRJbXBlcnNvbmF0aW9uT3B0aW9uc3QAVkxjb20vZ29vZ2xlL2F1dGgvb2F1dGgyL0V4dGVybmFsQWNj" + + "b3VudENyZWRlbnRpYWxzJFNlcnZpY2VBY2NvdW50SW1wZXJzb25hdGlvbk9wdGlvbnM7TAAec2VydmljZUFjY291bnRJbXBl" + + "cnNvbmF0aW9uVXJscQB+AAFMABBzdWJqZWN0VG9rZW5UeXBlcQB+AAFMAAx0b2tlbkluZm9VcmxxAH4AAUwACHRva2VuVXJs" + + "cQB+AAFMABl0cmFuc3BvcnRGYWN0b3J5Q2xhc3NOYW1lcQB+AAFMABh3b3JrZm9yY2VQb29sVXNlclByb2plY3RxAH4AAXhy" + + "AChjb20uZ29vZ2xlLmF1dGgub2F1dGgyLkdvb2dsZUNyZWRlbnRpYWxz6t29xaLhXyUCAAVaABhpc0V4cGxpY2l0VW5pdmVy" + + "c2VEb21haW5MAARuYW1lcQB+AAFMAA5xdW90YVByb2plY3RJZHEAfgABTAAGc291cmNlcQB+AAFMAA51bml2ZXJzZURvbWFp" + + "bnEAfgABeHIAKGNvbS5nb29nbGUuYXV0aC5vYXV0aDIuT0F1dGgyQ3JlZGVudGlhbHM/PX166aVRVwIABEwAEGV4cGlyYXRp" + + "b25NYXJnaW50ABRMamF2YS90aW1lL0R1cmF0aW9uO0wABGxvY2t0ABJMamF2YS9sYW5nL09iamVjdDtMAA1yZWZyZXNoTWFy" + + "Z2lucQB+AA5MAAV2YWx1ZXQANUxjb20vZ29vZ2xlL2F1dGgvb2F1dGgyL09BdXRoMkNyZWRlbnRpYWxzJE9BdXRoVmFsdWU7" + + "eHIAG2NvbS5nb29nbGUuYXV0aC5DcmVkZW50aWFscws4oteMPZCBAgAAeHBzcgANamF2YS50aW1lLlNlcpVdhLobIkiyDAAA" + + "eHB3DQEAAAAAAAAAtAAAAAB4dXIAAltCrPMX+AYIVOACAAB4cAAAAABzcQB+ABN3DQEAAAAAAAAA4QAAAAB4cAB0ABxFeHRl" + + "cm5hbCBBY2NvdW50IENyZWRlbnRpYWxzdAAOcXVvdGFQcm9qZWN0SWRwdAAOZ29vZ2xlYXBpcy5jb210AGAvL2lhbS5nb29n" + + "bGVhcGlzLmNvbS9wcm9qZWN0cy8xMjMvbG9jYXRpb25zL2dsb2JhbC93b3JrbG9hZElkZW50aXR5UG9vbHMvcG9vbC9wcm92" + + "aWRlcnMvcHJvdmlkZXJ0AAhjbGllbnRJZHQADGNsaWVudFNlY3JldHNyADNjb20uZ29vZ2xlLmF1dGgub2F1dGgyLklkZW50" + + "aXR5UG9vbENyZWRlbnRpYWxTb3VyY2X1pjCawbfqwgIAB0wAE2FjdG9yVG9rZW5GaWVsZE5hbWVxAH4AAUwAEWNlcnRpZmlj" + + "YXRlQ29uZmlndABHTGNvbS9nb29nbGUvYXV0aC9vYXV0aDIvSWRlbnRpdHlQb29sQ3JlZGVudGlhbFNvdXJjZSRDZXJ0aWZp" + + "Y2F0ZUNvbmZpZztMABRjcmVkZW50aWFsRm9ybWF0VHlwZXQASkxjb20vZ29vZ2xlL2F1dGgvb2F1dGgyL0lkZW50aXR5UG9v" + + "bENyZWRlbnRpYWxTb3VyY2UkQ3JlZGVudGlhbEZvcm1hdFR5cGU7TAASY3JlZGVudGlhbExvY2F0aW9ucQB+AAFMABRjcmVk" + + "ZW50aWFsU291cmNlVHlwZXQAVkxjb20vZ29vZ2xlL2F1dGgvb2F1dGgyL0lkZW50aXR5UG9vbENyZWRlbnRpYWxTb3VyY2Uk" + + "SWRlbnRpdHlQb29sQ3JlZGVudGlhbFNvdXJjZVR5cGU7TAAHaGVhZGVyc3QAD0xqYXZhL3V0aWwvTWFwO0wAFXN1YmplY3RU" + + "b2tlbkZpZWxkTmFtZXEAfgABeHIAQmNvbS5nb29nbGUuYXV0aC5vYXV0aDIuRXh0ZXJuYWxBY2NvdW50Q3JlZGVudGlhbHMk" + + "Q3JlZGVudGlhbFNvdXJjZXHczM85z4jIAgAAeHBwcH5yAEhjb20uZ29vZ2xlLmF1dGgub2F1dGgyLklkZW50aXR5UG9vbENy" + + "ZWRlbnRpYWxTb3VyY2UkQ3JlZGVudGlhbEZvcm1hdFR5cGUAAAAAAAAAABIAAHhyAA5qYXZhLmxhbmcuRW51bQAAAAAAAAAA" + + "EgAAeHB0AARURVhUdAAEZmlsZX5yAFRjb20uZ29vZ2xlLmF1dGgub2F1dGgyLklkZW50aXR5UG9vbENyZWRlbnRpYWxTb3Vy" + + "Y2UkSWRlbnRpdHlQb29sQ3JlZGVudGlhbFNvdXJjZVR5cGUAAAAAAAAAABIAAHhxAH4AJnQABEZJTEVwcHNyADBjb20uZ29v" + + "Z2xlLmF1dGgub2F1dGgyLlN5c3RlbUVudmlyb25tZW50UHJvdmlkZXK+zMPWWDs8NAIAAHhwcHNyADRjb20uZ29vZ2xlLmF1" + + "dGgub2F1dGgyLkV4dGVybmFsQWNjb3VudE1ldHJpY3NIYW5kbGVyC4Qcubsxch4CAANaAA5jb25maWdMaWZldGltZVoAD3Nh" + + "SW1wZXJzb25hdGlvbkwAC2NyZWRlbnRpYWxzdAAzTGNvbS9nb29nbGUvYXV0aC9vYXV0aDIvRXh0ZXJuYWxBY2NvdW50Q3Jl" + + "ZGVudGlhbHM7eHAAAXEAfgASc3IALWNvbS5nb29nbGUuYXV0aC5vYXV0aDIuU3lzdGVtUHJvcGVydHlQcm92aWRlcgAAAAAA" + + "AAABAgAAeHBzcgAjamF2YS51dGlsLkNvbGxlY3Rpb25zJFNpbmdsZXRvbkxpc3Qq7ykQPKeblwIAAUwAB2VsZW1lbnRxAH4A" + + "D3hwdAAuaHR0cHM6Ly93d3cuZ29vZ2xlYXBpcy5jb20vYXV0aC9jbG91ZC1wbGF0Zm9ybXNyAFRjb20uZ29vZ2xlLmF1dGgu" + + "b2F1dGgyLkV4dGVybmFsQWNjb3VudENyZWRlbnRpYWxzJFNlcnZpY2VBY2NvdW50SW1wZXJzb25hdGlvbk9wdGlvbnM6/caK" + + "mTx8+QIAAloAHGN1c3RvbVRva2VuTGlmZXRpbWVSZXF1ZXN0ZWRJAAhsaWZldGltZXhwAAAADhB0AHpodHRwczovL2lhbWNy" + + "ZWRlbnRpYWxzLmdvb2dsZWFwaXMuY29tL3YxL3Byb2plY3RzLy0vc2VydmljZUFjY291bnRzL3Rlc3RuQHRlc3QuaWFtLmdz" + + "ZXJ2aWNlYWNjb3VudC5jb206Z2VuZXJhdGVBY2Nlc3NUb2tlbnQAEHN1YmplY3RUb2tlblR5cGV0AAx0b2tlbkluZm9Vcmx0" + + "ACNodHRwczovL3N0cy5nb29nbGVhcGlzLmNvbS92MS90b2tlbnQAPmNvbS5nb29nbGUuYXV0aC5vYXV0aDIuT0F1dGgyVXRp" + + "bHMkRGVmYXVsdEh0dHBUcmFuc3BvcnRGYWN0b3J5cHEAfgApc3IAO2NvbS5nb29nbGUuYXV0aC5vYXV0aDIuRmlsZUlkZW50" + + "aXR5UG9vbFN1YmplY3RUb2tlblN1cHBsaWVyY0G/6P4+lLYCAAFMABBjcmVkZW50aWFsU291cmNldAA1TGNvbS9nb29nbGUv" + + "YXV0aC9vYXV0aDIvSWRlbnRpdHlQb29sQ3JlZGVudGlhbFNvdXJjZTt4cHEAfgAkc3IANWNvbS5nb29nbGUuYXV0aC5vYXV0" + + "aDIuRXh0ZXJuYWxBY2NvdW50U3VwcGxpZXJDb250ZXh0kwegl1C5weoCAAJMAAhhdWRpZW5jZXEAfgABTAAQc3ViamVjdFRv" + + "a2VuVHlwZXEAfgABeHBxAH4AG3EAfgA6"; + + @Test + void serialize_deserialize_backwardCompatible() throws Exception { + byte[] fixtureBytes = Base64.getDecoder().decode(PRE_PR_SERIALIZED_BYTES_BASE64); + IdentityPoolCredentials deserialized; + try (ObjectInputStream input = + new ObjectInputStream(new ByteArrayInputStream(fixtureBytes)) { + @Override + protected ObjectStreamClass readClassDescriptor() + throws IOException, ClassNotFoundException { + ObjectStreamClass desc = super.readClassDescriptor(); + if ("com.google.auth.oauth2.ExternalAccountMetricsHandler".equals(desc.getName())) { + return ObjectStreamClass.lookup(ExternalAccountMetricsHandler.class); + } + return desc; + } + }) { + deserialized = (IdentityPoolCredentials) input.readObject(); + } + + assertNotNull(deserialized); + assertEquals( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider", + deserialized.getAudience()); + assertEquals("subjectTokenType", deserialized.getSubjectTokenType()); + assertEquals("https://sts.googleapis.com/v1/token", deserialized.getTokenUrl()); + assertEquals("quotaProjectId", deserialized.getQuotaProjectId()); + assertEquals("clientId", deserialized.getClientId()); + assertEquals("clientSecret", deserialized.getClientSecret()); + assertEquals( + SERVICE_ACCOUNT_IMPERSONATION_URL, deserialized.getServiceAccountImpersonationUrl()); + assertEquals(null, deserialized.getIdentityPoolActorTokenSupplier()); + assertEquals(null, deserialized.getActorTokenType()); + } + + @Test + void + serialize_deserialize_fileCredentialSource_withCertificateConfig_restoresX509ProviderAndTransport( + @TempDir Path tempDir) throws Exception { + Path tokenFile = tempDir.resolve("credential.txt"); + Files.write(tokenFile, "token_from_file".getBytes(StandardCharsets.UTF_8)); + + Map certMap = new HashMap<>(); + certMap.put("certificate_config_location", "testresources/mtls/certificate_config.json"); + + Map sourceMap = new HashMap<>(); + sourceMap.put("file", tokenFile.toString()); + sourceMap.put("certificate", certMap); + + IdentityPoolCredentialSource credentialSource = new IdentityPoolCredentialSource(sourceMap); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setCredentialSource(credentialSource) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build(); + + assertNotNull(credentials.getX509Provider()); + assertTrue(credentials.getTransportFactory() instanceof MtlsHttpTransportFactory); + assertTrue(((MtlsHttpTransportFactory) credentials.getTransportFactory()).hasKeyStore()); + + IdentityPoolCredentials deserialized = serializeAndDeserialize(credentials); + assertNotNull(deserialized); + assertNotNull(deserialized.getX509Provider()); + assertTrue(deserialized.getTransportFactory() instanceof MtlsHttpTransportFactory); + assertTrue(((MtlsHttpTransportFactory) deserialized.getTransportFactory()).hasKeyStore()); + + // createScoped() should succeed without throwing + IdentityPoolCredentials scoped = + deserialized.createScoped( + Collections.singletonList("https://www.googleapis.com/auth/cloud-platform")); + assertNotNull(scoped); + assertNotNull(scoped.getX509Provider()); + assertTrue(scoped.getTransportFactory() instanceof MtlsHttpTransportFactory); + assertTrue(((MtlsHttpTransportFactory) scoped.getTransportFactory()).hasKeyStore()); + + // refreshAccessToken() on deserialized credentials creates MtlsHttpTransportFactory from + // restored X509Provider + AtomicReference capturedFactory = new AtomicReference<>(); + IdentityPoolCredentials testable = + new IdentityPoolCredentials(deserialized.toBuilder()) { + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) { + capturedFactory.set(cycleTransportFactory); + return new AccessToken("deserializedToken", null); + } + }; + AccessToken token = testable.refreshAccessToken(); + assertEquals("deserializedToken", token.getTokenValue()); + assertNotNull(capturedFactory.get()); + assertTrue(capturedFactory.get() instanceof MtlsHttpTransportFactory); + assertTrue(((MtlsHttpTransportFactory) capturedFactory.get()).hasKeyStore()); + } + + @Test + void serialize_deserialize_certificateCredentialSource_restoresX509ProviderAndTransport() + throws Exception { + Map certMap = new HashMap<>(); + certMap.put("certificate_config_location", "testresources/mtls/certificate_config.json"); + + Map sourceMap = new HashMap<>(); + sourceMap.put("certificate", certMap); + + IdentityPoolCredentialSource credentialSource = new IdentityPoolCredentialSource(sourceMap); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setCredentialSource(credentialSource) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build(); + + assertNotNull(credentials.getX509Provider()); + assertTrue(credentials.getTransportFactory() instanceof MtlsHttpTransportFactory); + assertTrue(((MtlsHttpTransportFactory) credentials.getTransportFactory()).hasKeyStore()); + + IdentityPoolCredentials deserialized = serializeAndDeserialize(credentials); + assertNotNull(deserialized); + assertNotNull(deserialized.getX509Provider()); + assertTrue(deserialized.getTransportFactory() instanceof MtlsHttpTransportFactory); + assertTrue(((MtlsHttpTransportFactory) deserialized.getTransportFactory()).hasKeyStore()); + + // createScoped() should succeed without throwing + IdentityPoolCredentials scoped = + deserialized.createScoped( + Collections.singletonList("https://www.googleapis.com/auth/cloud-platform")); + assertNotNull(scoped); + assertNotNull(scoped.getX509Provider()); + assertTrue(scoped.getTransportFactory() instanceof MtlsHttpTransportFactory); + assertTrue(((MtlsHttpTransportFactory) scoped.getTransportFactory()).hasKeyStore()); + + // refreshAccessToken() on deserialized credentials creates MtlsHttpTransportFactory from + // restored X509Provider + AtomicReference capturedFactory = new AtomicReference<>(); + IdentityPoolCredentials testable = + new IdentityPoolCredentials(deserialized.toBuilder()) { + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) { + capturedFactory.set(cycleTransportFactory); + return new AccessToken("deserializedCertToken", null); + } + }; + AccessToken token = testable.refreshAccessToken(); + assertEquals("deserializedCertToken", token.getTokenValue()); + assertNotNull(capturedFactory.get()); + assertTrue(capturedFactory.get() instanceof MtlsHttpTransportFactory); + } + + @Test + void serialize_deserialize_programmaticFlow_andRefresh_succeeds() throws Exception { + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") + .setTokenUrl("https://sts.googleapis.com/v1/token") + .build(); + + IdentityPoolCredentials deserialized = serializeAndDeserialize(credentials); + assertNotNull(deserialized); + assertNotNull(deserialized.getTransportFactory()); + assertNull(deserialized.getX509Provider()); + assertNotNull(deserialized.getIdentityPoolSubjectTokenSupplier()); + assertNull(deserialized.getIdentityPoolActorTokenSupplier()); + assertNull(deserialized.getActorTokenType()); + + AtomicReference capturedRequest = new AtomicReference<>(); + IdentityPoolCredentials testable = + new IdentityPoolCredentials(deserialized.toBuilder()) { + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) { + capturedRequest.set(stsTokenExchangeRequest); + return new AccessToken("programmaticAccessToken", null); + } + }; + + AccessToken token = testable.refreshAccessToken(); + assertEquals("programmaticAccessToken", token.getTokenValue()); + assertNotNull(capturedRequest.get()); + assertEquals("testSubjectToken", capturedRequest.get().getSubjectToken()); + assertEquals( + "urn:ietf:params:oauth:token-type:jwt", capturedRequest.get().getSubjectTokenType()); + assertNull(capturedRequest.get().getActingParty()); + } + + @Test + void serialize_deserialize_programmaticFlow_withMtlsTransport_restoresFactoryWithoutKeyStore() + throws Exception { + KeyStore keyStore = createPopulatedKeyStore(); + HttpTransportFactory transportFactory = new MtlsHttpTransportFactory(keyStore); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(context -> "testSubjectToken") + .setActorTokenSupplier(context -> "testActorToken") + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") + .setTokenUrl(MockExternalAccountCredentialsTransport.STS_MTLS_URL) + .setHttpTransportFactory(transportFactory) + .build(); + + IdentityPoolCredentials deserialized = serializeAndDeserialize(credentials); + assertNotNull(deserialized); + // Programmatic flows restore default-constructed transportFactory from class name + assertNotNull(deserialized.getTransportFactory()); + assertTrue(deserialized.getTransportFactory() instanceof MtlsHttpTransportFactory); + assertFalse(((MtlsHttpTransportFactory) deserialized.getTransportFactory()).hasKeyStore()); + assertNull(deserialized.getX509Provider()); + assertNotNull(deserialized.getIdentityPoolSubjectTokenSupplier()); + assertNotNull(deserialized.getIdentityPoolActorTokenSupplier()); + } + + @Test + void builder_actorTokenWithPlainPublicTokenUrl_throwsIllegalArgumentException() throws Exception { + KeyStore keyStore = createPopulatedKeyStore(); + HttpTransportFactory transportFactory = new MtlsHttpTransportFactory(keyStore); + + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(context -> "testSubjectToken") + .setActorTokenSupplier(context -> "testActorToken") + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") + .setTokenUrl("https://sts.googleapis.com/v1/token") + .setHttpTransportFactory(transportFactory) + .build()); + assertTrue(e.getMessage().contains("tokenUrl")); + assertTrue(e.getMessage().contains("plain public Google API endpoint")); + } + + @Test + void builder_actorTokenWithPlainPublicImpersonationUrl_throwsIllegalArgumentException() + throws Exception { + KeyStore keyStore = createPopulatedKeyStore(); + HttpTransportFactory transportFactory = new MtlsHttpTransportFactory(keyStore); + + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(context -> "testSubjectToken") + .setActorTokenSupplier(context -> "testActorToken") + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") + .setTokenUrl(MockExternalAccountCredentialsTransport.STS_MTLS_URL) + .setServiceAccountImpersonationUrl( + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken") + .setHttpTransportFactory(transportFactory) + .build()); + assertTrue(e.getMessage().contains("serviceAccountImpersonationUrl")); + assertTrue(e.getMessage().contains("plain public Google API endpoint")); + } + + @Test + void builder_actorTokenWithMtlsTokenUrl_succeeds() throws Exception { + KeyStore keyStore = createPopulatedKeyStore(); + HttpTransportFactory transportFactory = new MtlsHttpTransportFactory(keyStore); + + IdentityPoolCredentials cred = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(context -> "testSubjectToken") + .setActorTokenSupplier(context -> "testActorToken") + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") + .setTokenUrl(MockExternalAccountCredentialsTransport.STS_MTLS_URL) + .setServiceAccountImpersonationUrl( + "https://iamcredentials.mtls.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken") + .setHttpTransportFactory(transportFactory) + .build(); + assertNotNull(cred); + } + + @Test + void builder_actorTokenWithPscUrls_succeeds() throws Exception { + KeyStore keyStore = createPopulatedKeyStore(); + HttpTransportFactory transportFactory = new MtlsHttpTransportFactory(keyStore); + + IdentityPoolCredentials cred = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(context -> "testSubjectToken") + .setActorTokenSupplier(context -> "testActorToken") + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") + .setTokenUrl("https://sts.p.googleapis.com/v1/token") + .setServiceAccountImpersonationUrl( + "https://iamcredentials.p.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken") + .setHttpTransportFactory(transportFactory) + .build(); + assertNotNull(cred); + } + + @Test + void builder_actorTokenWithCustomDomainUrls_succeeds() throws Exception { + KeyStore keyStore = createPopulatedKeyStore(); + HttpTransportFactory transportFactory = new MtlsHttpTransportFactory(keyStore); + + IdentityPoolCredentials cred = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(context -> "testSubjectToken") + .setActorTokenSupplier(context -> "testActorToken") + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience("//custom.domain.com/pool") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") + .setTokenUrl("https://auth.custom-domain.com/v1/token") + .setServiceAccountImpersonationUrl("https://iam.custom-domain.com/v1/generate") + .setHttpTransportFactory(transportFactory) + .build(); + assertNotNull(cred); + } + + @Test + void fromBuilder_withCustomTransportFactoryAndCertificateConfig_preservesCustomTransportFactory( + @TempDir Path tempDir) throws Exception { + Path tokenFile = tempDir.resolve("credential.txt"); + Files.write(tokenFile, "token_from_file".getBytes(StandardCharsets.UTF_8)); + + Map certMap = new HashMap<>(); + certMap.put("certificate_config_location", "testresources/mtls/certificate_config.json"); + + Map sourceMap = new HashMap<>(); + sourceMap.put("file", tokenFile.toString()); + sourceMap.put("certificate", certMap); + + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(sourceMap); + HttpTransportFactory customTransportFactory = + () -> new MockExternalAccountCredentialsTransport(); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") + .setTokenUrl("https://sts.googleapis.com/v1/token") + .setCredentialSource(source) + .setHttpTransportFactory(customTransportFactory) + .build(); + + assertSame(customTransportFactory, credentials.getTransportFactory()); + } + + // ================================================================================== + // Section E: Production Path (fromStream) Tests + // ================================================================================== + + @Test + void fromStream_fileCredentialSource_withCertificateConfig_andActorToken_refreshesSuccessfully( + @TempDir Path tempDir) throws Exception { + Path tokenFile = tempDir.resolve("credential.json"); + GenericJson tokenJson = new GenericJson(); + tokenJson.setFactory(JSON_FACTORY); + tokenJson.put("subject_token", "testSubjectTokenFromStream"); + tokenJson.put("actor_token", "testActorTokenFromStream"); + OAuth2Utils.writeInputStreamToFile( + new ByteArrayInputStream(tokenJson.toPrettyString().getBytes(StandardCharsets.UTF_8)), + tokenFile.toString()); + + String configJson = + "{\n" + + " \"type\": \"external_account\",\n" + + " \"audience\":" + + " \"//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider\",\n" + + " \"subject_token_type\": \"urn:ietf:params:oauth:token-type:jwt\",\n" + + " \"actor_token_type\": \"urn:ietf:params:oauth:token-type:jwt\",\n" + + " \"token_url\": \"https://sts.mtls.googleapis.com/v1/token\",\n" + + " \"credential_source\": {\n" + + " \"file\": \"" + + tokenFile.toString() + + "\",\n" + + " \"format\": {\n" + + " \"type\": \"json\",\n" + + " \"subject_token_field_name\": \"subject_token\",\n" + + " \"actor_token_field_name\": \"actor_token\"\n" + + " },\n" + + " \"certificate\": {\n" + + " \"certificate_config_location\":" + + " \"testresources/mtls/certificate_config.json\"\n" + + " }\n" + + " }\n" + + "}"; + + ExternalAccountCredentials credentials = + ExternalAccountCredentials.fromStream( + new ByteArrayInputStream(configJson.getBytes(StandardCharsets.UTF_8))); + + assertTrue(credentials instanceof IdentityPoolCredentials); + IdentityPoolCredentials idp = (IdentityPoolCredentials) credentials; + assertNotNull(idp.getX509Provider()); + assertEquals("urn:ietf:params:oauth:token-type:jwt", idp.getActorTokenType()); + assertTrue(idp.getTransportFactory() instanceof MtlsHttpTransportFactory); + assertTrue(((MtlsHttpTransportFactory) idp.getTransportFactory()).hasKeyStore()); + assertSame(idp.getIdentityPoolSubjectTokenSupplier(), idp.getIdentityPoolActorTokenSupplier()); + + // Execute refreshAccessToken() on testable credentials constructed from idp.toBuilder() + AtomicReference capturedRequest = new AtomicReference<>(); + AtomicReference capturedFactory = new AtomicReference<>(); + IdentityPoolCredentials testable = + new IdentityPoolCredentials(idp.toBuilder()) { + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) { + capturedRequest.set(stsTokenExchangeRequest); + capturedFactory.set(cycleTransportFactory); + return new AccessToken("prodAccessToken", null); + } + }; + + AccessToken token = testable.refreshAccessToken(); + assertEquals("prodAccessToken", token.getTokenValue()); + assertNotNull(capturedRequest.get()); + assertEquals("testSubjectTokenFromStream", capturedRequest.get().getSubjectToken()); + assertEquals( + "urn:ietf:params:oauth:token-type:jwt", capturedRequest.get().getSubjectTokenType()); + assertNotNull(capturedRequest.get().getActingParty()); + assertEquals( + "testActorTokenFromStream", capturedRequest.get().getActingParty().getActorToken()); + assertEquals( + "urn:ietf:params:oauth:token-type:jwt", + capturedRequest.get().getActingParty().getActorTokenType()); + assertNotNull(capturedFactory.get()); + assertTrue(capturedFactory.get() instanceof MtlsHttpTransportFactory); + assertTrue(((MtlsHttpTransportFactory) capturedFactory.get()).hasKeyStore()); + } + + @Test + void fromStream_fileCredentialSource_certRotation_401Retry_succeeds(@TempDir Path tempDir) + throws Exception { + Path tokenFile = tempDir.resolve("credential.json"); + GenericJson tokenJson = new GenericJson(); + tokenJson.setFactory(JSON_FACTORY); + tokenJson.put("subject_token", "testSubjectToken401"); + OAuth2Utils.writeInputStreamToFile( + new ByteArrayInputStream(tokenJson.toPrettyString().getBytes(StandardCharsets.UTF_8)), + tokenFile.toString()); + + String configJson = + "{\n" + + " \"type\": \"external_account\",\n" + + " \"audience\":" + + " \"//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider\",\n" + + " \"subject_token_type\": \"urn:ietf:params:oauth:token-type:jwt\",\n" + + " \"token_url\": \"https://sts.googleapis.com/v1/token\",\n" + + " \"credential_source\": {\n" + + " \"file\": \"" + + tokenFile.toString() + + "\",\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" + + " }\n" + + " }\n" + + "}"; + + ExternalAccountCredentials credentials = + ExternalAccountCredentials.fromStream( + new ByteArrayInputStream(configJson.getBytes(StandardCharsets.UTF_8))); + + assertTrue(credentials instanceof IdentityPoolCredentials); + IdentityPoolCredentials idp = (IdentityPoolCredentials) credentials; + assertNotNull(idp.getX509Provider()); + + AtomicInteger exchangeCount = new AtomicInteger(0); + IdentityPoolCredentials testable = + new IdentityPoolCredentials(idp.toBuilder()) { + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) + throws IOException { + if (exchangeCount.incrementAndGet() == 1) { + throw new OAuthException("invalid_client", "Unauthorized", null, 401); + } + return new AccessToken("rotatedRetryToken", null); + } + }; + + AccessToken token = testable.refreshAccessToken(); + assertEquals("rotatedRetryToken", token.getTokenValue()); + assertEquals(2, exchangeCount.get()); + } + + @Test + void + refreshAccessToken_customTransportFactoryWithCertificateConfig_preservesCustomTransportFactory( + @TempDir Path tempDir) throws Exception { + Path tokenFile = tempDir.resolve("credential.json"); + GenericJson tokenJson = new GenericJson(); + tokenJson.setFactory(JSON_FACTORY); + tokenJson.put("subject_token", "testSubjectToken"); + OAuth2Utils.writeInputStreamToFile( + new ByteArrayInputStream(tokenJson.toPrettyString().getBytes(StandardCharsets.UTF_8)), + tokenFile.toString()); + + Map certificateMap = new HashMap<>(); + certificateMap.put("use_default_certificate_config", false); + certificateMap.put("certificate_config_location", "testresources/mtls/certificate_config.json"); + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "subject_token"); + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", tokenFile.toString()); + credentialSourceMap.put("format", formatMap); + credentialSourceMap.put("certificate", certificateMap); + + IdentityPoolCredentialSource credentialSource = + new IdentityPoolCredentialSource(credentialSourceMap); + MockExternalAccountCredentialsTransportFactory customTransportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + TransportCapturingCredentials credential = + new TransportCapturingCredentials( + IdentityPoolCredentials.newBuilder() + .setCredentialSource(credentialSource) + .setHttpTransportFactory(customTransportFactory) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token")); + + assertSame(customTransportFactory, credential.getTransportFactory()); + IdentityPoolCredentials scopedCustom = + credential.createScoped( + Collections.singletonList("https://www.googleapis.com/auth/cloud-platform")); + assertSame(customTransportFactory, scopedCustom.getTransportFactory()); + + AccessToken token = credential.refreshAccessToken(); + assertNotNull(token); + assertEquals(1, credential.getCapturedFactories().size()); + assertSame(customTransportFactory, credential.getCapturedFactories().get(0)); + } + + @Test + void createScoped_withCertificateConfig_refreshesMtlsTransportSnapshot(@TempDir Path tempDir) + throws Exception { + Path tokenFile = tempDir.resolve("credential.json"); + GenericJson tokenJson = new GenericJson(); + tokenJson.setFactory(JSON_FACTORY); + tokenJson.put("subject_token", "testSubjectToken"); + OAuth2Utils.writeInputStreamToFile( + new ByteArrayInputStream(tokenJson.toPrettyString().getBytes(StandardCharsets.UTF_8)), + tokenFile.toString()); + + Map certificateMap = new HashMap<>(); + certificateMap.put("use_default_certificate_config", false); + certificateMap.put("certificate_config_location", "testresources/mtls/certificate_config.json"); + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "subject_token"); + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", tokenFile.toString()); + credentialSourceMap.put("format", formatMap); + credentialSourceMap.put("certificate", certificateMap); + + IdentityPoolCredentialSource credentialSource = + new IdentityPoolCredentialSource(credentialSourceMap); + + KeyStore ks = createPopulatedKeyStore(); + AtomicInteger getKeyStoreCount = new AtomicInteger(0); + X509Provider trackingProvider = + new X509Provider() { + @Override + public KeyStore getKeyStore() { + getKeyStoreCount.incrementAndGet(); + return ks; + } + }; + + IdentityPoolCredentials credential = + IdentityPoolCredentials.newBuilder() + .setCredentialSource(credentialSource) + .setX509Provider(trackingProvider) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build(); + + assertEquals(1, getKeyStoreCount.get()); + assertTrue(credential.getTransportFactory() instanceof MtlsHttpTransportFactory); + HttpTransportFactory originalTransportFactory = credential.getTransportFactory(); + + IdentityPoolCredentials scoped = + credential.createScoped( + Collections.singletonList("https://www.googleapis.com/auth/cloud-platform")); + + assertEquals(2, getKeyStoreCount.get()); + assertTrue(scoped.getTransportFactory() instanceof MtlsHttpTransportFactory); + assertNotSame(originalTransportFactory, scoped.getTransportFactory()); + } + + // ================================================================================== + // Helper: TestableIdentityPoolCredentials — overrides exchange for 401 testing + // ================================================================================== + + /** + * A test subclass that overrides exchangeExternalCredentialForAccessToken to throw + * OAuthException(401) on configurable calls, simulating the cert rotation retry path. This is + * necessary because the real STS handler wraps HttpResponseException into OAuthException, which + * is what the catch(OAuthException) in refreshAccessToken expects via normal STS flow. + */ + private static class TestableIdentityPoolCredentials extends IdentityPoolCredentials { + private final AtomicInteger exchangeCallCount = new AtomicInteger(0); + private final boolean failOnFirstExchange; + private final boolean failOnAllExchanges; + + TestableIdentityPoolCredentials( + IdentityPoolCredentials.Builder builder, boolean failOnFirstExchange) { + this(builder, failOnFirstExchange, false); + } + + TestableIdentityPoolCredentials( + IdentityPoolCredentials.Builder builder, + boolean failOnFirstExchange, + boolean failOnAllExchanges) { + super(builder); + this.failOnFirstExchange = failOnFirstExchange; + this.failOnAllExchanges = failOnAllExchanges; + } + + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, HttpTransportFactory cycleTransportFactory) + throws IOException { + int count = exchangeCallCount.incrementAndGet(); + if (failOnAllExchanges || (failOnFirstExchange && count == 1)) { + throw new OAuthException("invalid_client", "Unauthorized", null, 401); + } + // Return a dummy access token for the retry path + return new AccessToken("retryAccessToken", null); + } + + int getExchangeCallCount() { + return exchangeCallCount.get(); + } + } + + // ================================================================================== + // Helper: TransportCapturingCredentials — captures transport factory for cert tests + // ================================================================================== + + /** + * A test subclass that captures the HttpTransportFactory passed to + * exchangeExternalCredentialForAccessToken, allowing tests to verify cert pinning behavior + * 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<>()); + + TransportCapturingCredentials(IdentityPoolCredentials.Builder builder) { + super(builder); + } + + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, HttpTransportFactory cycleTransportFactory) + throws IOException { + capturedFactories.add(cycleTransportFactory); + // Return a dummy access token + return new AccessToken("capturedAccessToken", null); + } + + java.util.List getCapturedFactories() { + return capturedFactories; + } + } } 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 7719b08d2e7b..85dff97bc270 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 @@ -68,6 +68,7 @@ public class MockExternalAccountCredentialsTransport extends MockHttpTransport { private static final String AWS_IMDSV2_SESSION_TOKEN_URL = "https://169.254.169.254/imdsv2"; private static final String METADATA_SERVER_URL = "https://www.metadata.google.com"; private static final String STS_URL = "https://sts.googleapis.com/v1/token"; + static final String STS_MTLS_URL = "https://sts.mtls.googleapis.com/v1/token"; private static final String SUBJECT_TOKEN = "subjectToken"; private static final String TOKEN_TYPE = "Bearer"; @@ -167,7 +168,7 @@ public LowLevelHttpResponse execute() throws IOException { .setContentType("text/html") .setContent(SUBJECT_TOKEN); } - if (STS_URL.equals(url)) { + if (STS_URL.equals(url) || STS_MTLS_URL.equals(url)) { Map query = TestUtils.parseQuery(getContentAsString()); // Store STS content as multiple calls are made using this transport. @@ -288,6 +289,10 @@ public String getStsUrl() { return STS_URL; } + public String getStsMtlsUrl() { + return STS_MTLS_URL; + } + public String getServiceAccountImpersonationUrl() { return SERVICE_ACCOUNT_IMPERSONATION_URL; } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/Slf4jUtilsLogbackTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/Slf4jUtilsLogbackTest.java index 340b6d199176..56fe3ed76fe5 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/Slf4jUtilsLogbackTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/Slf4jUtilsLogbackTest.java @@ -32,6 +32,8 @@ package com.google.auth.oauth2; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -43,6 +45,7 @@ import com.google.api.client.http.HttpRequestFactory; import com.google.api.client.http.UrlEncodedContent; import com.google.api.client.util.GenericData; +import com.google.gson.JsonObject; import com.google.gson.JsonParser; import com.google.gson.JsonSyntaxException; import java.io.IOException; @@ -198,6 +201,49 @@ void testLogRequest() throws IOException { testAppender.stop(); } + @Test + void testLogRequest_masksActorTokenAndSubjectToken() throws IOException { + testEnvironmentProvider.setEnv(LoggingUtils.GOOGLE_SDK_JAVA_LOGGING, "true"); + LoggingUtils.setEnvironmentProvider(testEnvironmentProvider); + + TestAppender testAppender = setupTestLogger(); + + GenericData tokenRequest = new GenericData(); + tokenRequest.set("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange"); + tokenRequest.set("subject_token", "raw_secret_subject_token"); + tokenRequest.set("actor_token", "raw_secret_actor_token"); + UrlEncodedContent content = new UrlEncodedContent(tokenRequest); + + MockHttpTransportFactory mockHttpTransportFactory = new MockHttpTransportFactory(); + HttpRequestFactory requestFactory = mockHttpTransportFactory.create().createRequestFactory(); + HttpRequest request = + requestFactory.buildPostRequest(new GenericUrl(OAuth2Utils.TOKEN_SERVER_URI), content); + + LoggerProvider loggerProvider = mock(LoggerProvider.class, withSettings().withoutAnnotations()); + when(loggerProvider.getLogger()).thenReturn(LOGGER); + LoggingUtils.logRequest(request, loggerProvider, "STS Token Exchange"); + + assertEquals(1, testAppender.events.size()); + String payloadJson = null; + for (KeyValuePair kvp : testAppender.events.get(0).getKeyValuePairs()) { + if ("request.payload".equals(kvp.key)) { + payloadJson = (String) kvp.value; + } + } + assertNotNull(payloadJson); + JsonObject payload = JsonParser.parseString(payloadJson).getAsJsonObject(); + + // Verify that raw secrets are never logged in plaintext + assertNotEquals("raw_secret_subject_token", payload.get("subject_token").getAsString()); + assertNotEquals("raw_secret_actor_token", payload.get("actor_token").getAsString()); + + // Verify that masked values are 64-char SHA-256 hex hashes + assertEquals(64, payload.get("subject_token").getAsString().length()); + assertEquals(64, payload.get("actor_token").getAsString().length()); + + testAppender.stop(); + } + boolean isValidJson(String jsonString) { try { JsonParser.parseString(jsonString); From dcc49ff8903378886c09a53ce98caef682012f89 Mon Sep 17 00:00:00 2001 From: Jin Seop Kim Date: Tue, 6 Oct 2026 14:20:57 -0400 Subject: [PATCH 02/10] fix(bigquery): remove duplicate google-cloud-storage dependency (#14586) Removes a duplicate `google-cloud-storage` dependency declaration in `java-bigquery/google-cloud-bigquery/pom.xml`. The duplicate entry caused Maven project model validation failures during CI builds. The other declaration is in line 34 - 37. --- java-bigquery/google-cloud-bigquery/pom.xml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/java-bigquery/google-cloud-bigquery/pom.xml b/java-bigquery/google-cloud-bigquery/pom.xml index 765a2e650ee2..e017bc3e9caa 100644 --- a/java-bigquery/google-cloud-bigquery/pom.xml +++ b/java-bigquery/google-cloud-bigquery/pom.xml @@ -31,10 +31,6 @@ com.google.http-client google-http-client-gson - - com.google.cloud - google-cloud-storage - com.google.auto.value auto-value-annotations From cb92cd221f9b0961b3cf21d41f9e848d53a5be03 Mon Sep 17 00:00:00 2001 From: Blake Li Date: Thu, 8 Oct 2026 10:16:03 -0400 Subject: [PATCH 03/10] chore(build): disable Maven Resolver 2.x default remote repository filters for flatten-maven-plugin (#14598) ## Summary Fixes `java.lang.VerifyError` in downstream tests (such as `java-bigquery-jdbc`) on GitHub Actions runners upgraded to **Apache Maven `3.10.0`**. ## Cause Maven `3.10.0` (Maven Resolver `2.0.24`) enables `aether.remoteRepositoryFilter.prefixes` and `aether.remoteRepositoryFilter.groupId` by default. Because `flatten-maven-plugin:1.3.0` reads dependency POMs with `repositories = null` (`[]`), the active remote repository filter rejects cached POMs in `~/.m2/repository`, causing `flatten-maven-plugin` to drop `` (such as `protobuf-javalite` on `io.grpc:grpc-protobuf-lite`) when generating `.flattened-pom.xml`. ## Fix Disable the default remote repository filters in `.mvn/maven.config`: ```text -Daether.remoteRepositoryFilter.prefixes=false -Daether.remoteRepositoryFilter.groupId=false ``` --- .mvn/maven.config | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .mvn/maven.config diff --git a/.mvn/maven.config b/.mvn/maven.config new file mode 100644 index 000000000000..6c526a496b2c --- /dev/null +++ b/.mvn/maven.config @@ -0,0 +1,3 @@ +# TODO: Remove this file once org.codehaus.mojo:flatten-maven-plugin is upgraded to 1.8.0+ +-Daether.remoteRepositoryFilter.prefixes=false +-Daether.remoteRepositoryFilter.groupId=false From 35dd69001b3023575384f1323d4e3093717d66ac Mon Sep 17 00:00:00 2001 From: Sakthivel Subramanian <179120858+sakthivelmanii@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:11:37 +0530 Subject: [PATCH 04/10] chore(build): remove comment from .mvn/maven.config for Maven 3.8 compatibility (#14609) Support for '#' comments in .mvn/maven.config was introduced in Apache Maven 3.9.0 (MNG-7682). Prior versions of Maven (such as 3.8.x) and IDE Maven parsers split the entire file on whitespace and reject '#' comment tokens with: Unable to parse maven.config: Unrecognized maven.config entries: [#, TODO:, ...] Remove the comment line from .mvn/maven.config so that it parses cleanly across both Maven 3.8.x and 3.9+. --- .mvn/maven.config | 1 - 1 file changed, 1 deletion(-) diff --git a/.mvn/maven.config b/.mvn/maven.config index 6c526a496b2c..2495b1a3668f 100644 --- a/.mvn/maven.config +++ b/.mvn/maven.config @@ -1,3 +1,2 @@ -# TODO: Remove this file once org.codehaus.mojo:flatten-maven-plugin is upgraded to 1.8.0+ -Daether.remoteRepositoryFilter.prefixes=false -Daether.remoteRepositoryFilter.groupId=false From 80b8228e63770a4042c1502ca542d3e495f6a953 Mon Sep 17 00:00:00 2001 From: macastelaz <34776182+macastelaz@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:50:38 -0500 Subject: [PATCH 05/10] feat(oauth2): implement IAM impersonation mTLS transport pinning and 401 recovery (#14212) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 🥞 Stacked PRs - 👉 **This PR** (*feat: implement IAM impersonation mTLS transport pinning and 401 recovery*) - ⏳ Stacked PR #14220 (*test: add mTLS in-process socket tests and live GCP WIF integration tests*) --- ## Summary Propagates the cycle-pinned mTLS transport factory into `ImpersonatedCredentials` during Workload Identity Federation with Service Account Impersonation, ensuring the IAM `generateAccessToken` call uses the exact same pinned client certificate as the STS token exchange. Also extends 401 recovery in `IdentityPoolCredentials` to catch unauthorized responses across both STS and IAM calls, reloading fresh certificates and re-executing the full refresh cycle. ## Test Coverage - **1,030 unit tests passing** across `oauth2_http` (including transport pinning lifecycle, multi-cycle rotation, 401 retry on IAM, and suppressed error handling). - **Google Java Format**: 100% compliant (`com.spotify.fmt:fmt-maven-plugin:2.25`). ### Manual Testing Sample test "app": https://paste.googleplex.com/5495919190081536 Test results: https://paste.googleplex.com/5757145073713152 See b/542238030 for tracking. --- .../auth/mtls/MtlsHttpTransportFactory.java | 5 + .../google/auth/oauth2/AwsCredentials.java | 5 + ...icateIdentityPoolSubjectTokenSupplier.java | 59 +- .../oauth2/ExternalAccountCredentials.java | 69 +- .../auth/oauth2/IdentityPoolCredentials.java | 372 ++- .../auth/oauth2/ImpersonatedCredentials.java | 269 +- .../com/google/auth/oauth2/OAuth2Utils.java | 108 + .../google/auth/oauth2/OAuthException.java | 3 + .../auth/oauth2/PluggableAuthCredentials.java | 5 + .../auth/oauth2/AwsCredentialsTest.java | 24 +- ...eIdentityPoolSubjectTokenSupplierTest.java | 9 + .../ExternalAccountCredentialsTest.java | 27 + .../oauth2/IdentityPoolCredentialsTest.java | 2754 ++++++++++++++++- .../oauth2/ImpersonatedCredentialsTest.java | 223 ++ ...ckExternalAccountCredentialsTransport.java | 27 +- .../google/auth/oauth2/OAuth2UtilsTest.java | 134 + .../auth/oauth2/OAuthExceptionTest.java | 8 + .../oauth2/PluggableAuthCredentialsTest.java | 19 +- .../testresources/mtls/test_cert_2.pem | 20 + .../testresources/mtls/test_key_2.pem | 28 + 20 files changed, 3948 insertions(+), 220 deletions(-) create mode 100644 google-auth-library-java/oauth2_http/testresources/mtls/test_cert_2.pem create mode 100644 google-auth-library-java/oauth2_http/testresources/mtls/test_key_2.pem diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/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..0b31b67e7138 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java @@ -120,6 +120,11 @@ public class AwsCredentials extends ExternalAccountCredentials { @Override public AccessToken refreshAccessToken() throws IOException { + ImpersonatedCredentials impersonated = getImpersonatedCredentials(); + if (impersonated != null) { + return impersonated.refreshAccessToken(); + } + StsTokenExchangeRequest.Builder stsTokenExchangeRequest = StsTokenExchangeRequest.newBuilder(retrieveSubjectToken(), getSubjectTokenType()) .setAudience(getAudience()); 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..d4af08c019d9 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 @@ -64,6 +68,9 @@ public class CertificateIdentityPoolSubjectTokenSupplier implements IdentityPoolSubjectTokenSupplier { + // Matches the computed value in released versions so previously serialized instances still load. + private static final long serialVersionUID = -7726329500407809830L; + private final IdentityPoolCredentialSource credentialSource; private static final Pattern PEM_CERT_PATTERN = @@ -96,6 +103,34 @@ private static String loadAndEncodeLeafCertificate(String path) throws IOExcepti } } + private static @Nullable String extractAndEncodeLeafCertificate(@Nullable KeyStore keyStore) { + 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) { + return null; + } + } + @VisibleForTesting static X509Certificate parseCertificate(byte[] certData) throws CertificateException { if (certData == null || certData.length == 0) { @@ -135,14 +170,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 +270,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 +290,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..de329a4e0e7f 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 @@ -85,6 +85,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; @@ -93,9 +94,9 @@ public abstract class ExternalAccountCredentials extends GoogleCredentials { // Service. private final @Nullable String workforcePoolUserProject; - protected transient HttpTransportFactory transportFactory; + protected transient volatile HttpTransportFactory transportFactory; - protected @Nullable ImpersonatedCredentials impersonatedCredentials; + protected transient volatile @Nullable ImpersonatedCredentials impersonatedCredentials; private final EnvironmentProvider environmentProvider; private final PropertyProvider propertyProvider; @@ -196,6 +197,7 @@ protected ExternalAccountCredentials( this.credentialSource = checkNotNull(credentialSource); this.tokenInfoUrl = tokenInfoUrl; this.serviceAccountImpersonationUrl = serviceAccountImpersonationUrl; + this.targetServiceAccountEmail = null; this.clientId = clientId; this.clientSecret = clientSecret; this.scopes = @@ -236,6 +238,7 @@ protected ExternalAccountCredentials(ExternalAccountCredentials.Builder builder) this.credentialSource = builder.credentialSource; this.tokenInfoUrl = builder.tokenInfoUrl; this.serviceAccountImpersonationUrl = builder.serviceAccountImpersonationUrl; + this.targetServiceAccountEmail = builder.targetServiceAccountEmail; this.clientId = builder.clientId; this.clientSecret = builder.clientSecret; @@ -286,27 +289,26 @@ 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); + // The intermediate STS access token on sourceCredentials is used to authenticate to the IAM + // Credentials generateAccessToken endpoint, which requires the cloud-platform scope (whereas + // this.scopes are passed to ImpersonatedCredentials below for the final impersonated token). + sourceBuilder + .setServiceAccountImpersonationUrl(null) + .setScopes(Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE)); + sourceBuilder.targetServiceAccountEmail = targetPrincipal; + sourceBuilder.setAccessToken(null); + ExternalAccountCredentials sourceCredentials = sourceBuilder.build(); return ImpersonatedCredentials.newBuilder() .setSourceCredentials(sourceCredentials) .setHttpTransportFactory(transportFactory) @@ -522,8 +524,21 @@ 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; } /** @@ -552,11 +567,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 +651,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 +803,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 +828,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 +927,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..d1d6c4d55918 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; @@ -72,7 +74,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 +101,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 +116,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 +175,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,38 +216,249 @@ 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()); } + /** + * Returns whether the given transport factory should be managed and upgraded to {@link + * MtlsHttpTransportFactory} when mTLS is configured: + * + *

    + *
  • {@code null}, {@link OAuth2Utils#HTTP_TRANSPORT_FACTORY}, or {@link + * OAuth2Utils.DefaultHttpTransportFactory}: no custom transport factory was provided, so + * mTLS credentials should upgrade to {@link MtlsHttpTransportFactory}. + *
  • Exact class {@link MtlsHttpTransportFactory}: standard mTLS transport (including when + * cloning an already-initialized mTLS credential via {@link #toBuilder()} or {@link + * #createScoped}), which should be refreshed per cycle with the latest {@link KeyStore}. + *
  • An {@link MtlsHttpTransportFactory} (or subclass) without a {@link KeyStore} loaded yet. + *
+ * + *

Conversely, if a caller supplies a custom {@link HttpTransportFactory} or a custom {@link + * MtlsHttpTransportFactory} subclass pre-configured with its own {@link KeyStore}, this returns + * {@code false} so the caller's custom transport is preserved. + */ + 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() { + // Also check isDefaultOrMtlsTransportFactory(this.transportFactory) because a custom + // MtlsHttpTransportFactory subclass constructed with a KeyStore has useMtlsTransportFactory == + // false at build time, but deserialization in ExternalAccountCredentials.readObject() + // re-instantiates it via its no-arg constructor without a KeyStore. + return this.useMtlsTransportFactory || isDefaultOrMtlsTransportFactory(this.transportFactory); + } + + boolean hasMtlsProviderForImpersonation() { + return this.x509Provider != null && shouldUseMtlsTransportFactory(); + } + + /** + * Returns whether a clone of this credential can skip reading the KeyStore because the transport + * factory is already initialized (an mTLS factory with a KeyStore, or a custom non-mTLS factory). + */ + boolean hasInitializedMtlsTransport() { + return this.x509Provider != null + && (!shouldUseMtlsTransportFactory() + || (this.transportFactory instanceof MtlsHttpTransportFactory + && ((MtlsHttpTransportFactory) this.transportFactory).hasKeyStore())); + } + + AccessToken refreshImpersonatedAccessTokenWithRetry(ImpersonatedCredentials impersonated) + throws IOException { + return refreshWithRetry(impersonated); + } + + private static boolean isMtlsRetryableException(@Nullable Throwable throwable) { + return OAuth2Utils.isUnauthorizedException(throwable) + || OAuth2Utils.isInvalidGrantException(throwable) + || isRetryableTransportException(throwable); + } + + private static boolean isRetryableTransportException(@Nullable Throwable throwable) { + if (!(throwable instanceof IOException) + || throwable instanceof CertificateSourceUnavailableException) { + return false; + } + Throwable current = throwable; + while (current != null) { + if (current instanceof OAuthException || current instanceof HttpResponseException) { + return false; + } + current = current.getCause(); + } + return true; + } + @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); + if (this.x509Provider != null && shouldUseMtlsTransportFactory()) { + // 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(/* targetImpersonated= */ null); + } + return executeRefresh( + this.transportFactory, /* pinnedKeyStore= */ null, /* targetImpersonated= */ null); + } + + AccessToken refreshAccessToken(@Nullable HttpTransportFactory cycleTransportFactory) + throws IOException { + KeyStore pinnedKeyStore = + cycleTransportFactory instanceof MtlsHttpTransportFactory + ? ((MtlsHttpTransportFactory) cycleTransportFactory).getKeyStore() + : null; + if (cycleTransportFactory == null + && pinnedKeyStore == null + && this.x509Provider != null + && shouldUseMtlsTransportFactory()) { + pinnedKeyStore = this.x509Provider.getKeyStore(); + } + // 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. + HttpTransportFactory effectiveTransportFactory = + resolveCycleTransportFactory(cycleTransportFactory, pinnedKeyStore); + return executeRefresh( + effectiveTransportFactory, pinnedKeyStore, /* targetImpersonated= */ null); + } + + AccessToken refreshAccessToken( + @Nullable HttpTransportFactory cycleTransportFactory, @Nullable KeyStore pinnedKeyStore) + throws IOException { + if (pinnedKeyStore == null) { + return refreshAccessToken(cycleTransportFactory); } + HttpTransportFactory effectiveTransportFactory = + resolveCycleTransportFactory(cycleTransportFactory, pinnedKeyStore); + return executeRefresh( + effectiveTransportFactory, pinnedKeyStore, /* targetImpersonated= */ null); + } + private AccessToken refreshWithRetry(@Nullable ImpersonatedCredentials targetImpersonated) + throws IOException { + ImpersonatedCredentials effectiveImpersonated = + targetImpersonated != null ? targetImpersonated : getImpersonatedCredentials(); + KeyStore pinnedKeyStore = null; + try { + pinnedKeyStore = this.x509Provider.getKeyStore(); + HttpTransportFactory cycleTransportFactory = + resolveCycleTransportFactory(/* explicitTransportFactory= */ null, pinnedKeyStore); + return executeRefresh(cycleTransportFactory, pinnedKeyStore, effectiveImpersonated); + } catch (IOException | RuntimeException e) { + if (!isMtlsRetryableException(e)) { + throw e; + } + boolean isInitialKeyStoreLoadFailure = + pinnedKeyStore == null && isRetryableTransportException(e); + boolean reusedCachedStsTokenOn401 = + e instanceof ImpersonatedCredentials.CachedStsTokenRejectedException; + KeyStore freshKeyStore; + try { + // On 401, STS invalid_grant (e.g. trust_chain_path rotated ahead of cert_path), 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 executeRefresh(retryTransportFactory, freshKeyStore, effectiveImpersonated); + } catch (IOException | RuntimeException retryException) { + if (retryException != e) { + retryException.addSuppressed(e); + } + throw retryException; + } + } + } + + private AccessToken executeRefresh( + HttpTransportFactory cycleTransportFactory, + @Nullable KeyStore pinnedKeyStore, + @Nullable ImpersonatedCredentials targetImpersonated) + throws IOException { + ImpersonatedCredentials effectiveImpersonated = + targetImpersonated != null ? targetImpersonated : getImpersonatedCredentials(); + if (effectiveImpersonated != null) { + return effectiveImpersonated.refreshAccessToken( + pinnedKeyStore != null ? cycleTransportFactory : null, pinnedKeyStore); + } + FileIdentityPoolSubjectTokenSupplier.TokenPair tokens = + retrieveSubjectAndActorTokens(pinnedKeyStore); + return buildAndExchangeStsRequest(tokens.subject, tokens.actor, cycleTransportFactory); + } + + private HttpTransportFactory resolveCycleTransportFactory( + @Nullable HttpTransportFactory explicitTransportFactory, @Nullable KeyStore pinnedKeyStore) { + if (explicitTransportFactory != null) { + return explicitTransportFactory; + } + if (pinnedKeyStore != null && this.x509Provider != null && shouldUseMtlsTransportFactory()) { + HttpTransportFactory cycleTransportFactory = createMtlsTransportFactory(pinnedKeyStore); + if (!hasInitializedMtlsTransport()) { + this.transportFactory = cycleTransportFactory; + } + return cycleTransportFactory; + } + return this.transportFactory; + } + + private FileIdentityPoolSubjectTokenSupplier.TokenPair retrieveSubjectAndActorTokens( + @Nullable KeyStore pinnedKeyStore) throws IOException { // Read subject and actor tokens, atomically if from the same file supplier. - String subjectToken; - String actorToken = null; if (this.subjectTokenSupplier instanceof FileIdentityPoolSubjectTokenSupplier && this.actorTokenSupplier == this.subjectTokenSupplier) { - FileIdentityPoolSubjectTokenSupplier.TokenPair tokens = - ((FileIdentityPoolSubjectTokenSupplier) this.subjectTokenSupplier) - .readTokens(supplierContext); - subjectToken = tokens.subject; - actorToken = tokens.actor; + return ((FileIdentityPoolSubjectTokenSupplier) this.subjectTokenSupplier) + .readTokens(supplierContext); + } + String subjectToken; + 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); - } } + String actorToken = + this.actorTokenSupplier != null + ? this.actorTokenSupplier.getActorToken(supplierContext) + : null; + return new FileIdentityPoolSubjectTokenSupplier.TokenPair(subjectToken, actorToken); + } + private AccessToken buildAndExchangeStsRequest( + String subjectToken, @Nullable String actorToken, HttpTransportFactory cycleTransportFactory) + throws IOException { StsTokenExchangeRequest.Builder stsTokenExchangeRequest = StsTokenExchangeRequest.newBuilder(subjectToken, getSubjectTokenType()) .setAudience(getAudience()); @@ -267,30 +472,18 @@ public AccessToken refreshAccessToken() throws IOException { stsTokenExchangeRequest.setScopes(new ArrayList<>(scopes)); } - try { - return exchangeExternalCredentialForAccessToken( - stsTokenExchangeRequest.build(), cycleTransportFactory); - } catch (OAuthException e) { - if (e.getHttpStatusCode() == 401 - && this.x509Provider != null - && this.transportFactory instanceof MtlsHttpTransportFactory) { - try { - // On 401, re-read from X509Provider for fresh certs and retry once. - KeyStore freshKeyStore = this.x509Provider.getKeyStore(); - HttpTransportFactory retryTransportFactory = new MtlsHttpTransportFactory(freshKeyStore); - return exchangeExternalCredentialForAccessToken( - stsTokenExchangeRequest.build(), retryTransportFactory); - } catch (IOException retryException) { - retryException.addSuppressed(e); - throw retryException; - } - } - throw e; - } + return exchangeExternalCredentialForAccessToken( + stsTokenExchangeRequest.build(), cycleTransportFactory); } @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 +517,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 +541,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 +559,20 @@ private IdentityPoolSubjectTokenSupplier createCertificateSubjectTokenSupplier( + " automatic MtlsHttpTransportFactory upgrade. Ensure the custom transport" + " factory is configured for mTLS if required by the token endpoint."); } + } - // Initialize the subject token supplier with the certificate path. - String explicitCertConfigPath = getExplicitCertConfigPath(credentialSource); - credentialSource.setCredentialLocation( - MtlsUtils.getCertificatePath( - getEnvironmentProvider(), getPropertyProvider(), explicitCertConfigPath)); + 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 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 +598,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 +640,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 +650,20 @@ 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; + // Preserve the original mTLS flag across clones because super(credentials) copies + // credentials.transportFactory, which may already be resolved via ServiceLoader or + // createMtlsTransportFactory rather than null. + this.useMtlsTransportFactory = credentials.useMtlsTransportFactory; + this.isClonedTransportInitialized = credentials.hasInitializedMtlsTransport(); } /** @@ -463,6 +679,7 @@ public static class Builder extends ExternalAccountCredentials.Builder { @VisibleForTesting Builder setX509Provider(X509Provider x509Provider) { this.x509Provider = x509Provider; + this.isClonedTransportInitialized = false; return this; } @@ -516,6 +733,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 +769,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..ed62b8ab7945 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,9 @@ 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 final String targetPrincipal;
   private List delegates;
   private final List scopes;
@@ -116,7 +125,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 +321,7 @@ public String getAccount() {
   }
 
   @VisibleForTesting
-  String getIamEndpointOverride() {
+  @Nullable String getIamEndpointOverride() {
     return this.iamEndpointOverride;
   }
 
@@ -538,9 +547,16 @@ private ImpersonatedCredentials(Builder builder) throws IOException {
     this.delegates = builder.getDelegates();
     this.scopes = ImmutableList.copyOf(builder.getScopes());
     this.lifetime = builder.getLifetime();
+    HttpTransportFactory builderTransportFactory = builder.getHttpTransportFactory();
+    if (builderTransportFactory == null
+        && this.sourceCredentials instanceof IdentityPoolCredentials
+        && ((IdentityPoolCredentials) this.sourceCredentials).isMtlsConfigured()) {
+      builderTransportFactory =
+          ((IdentityPoolCredentials) this.sourceCredentials).getTransportFactory();
+    }
     this.transportFactory =
         firstNonNull(
-            builder.getHttpTransportFactory(),
+            builderTransportFactory,
             getFromServiceLoader(HttpTransportFactory.class, OAuth2Utils.HTTP_TRANSPORT_FACTORY));
     this.iamEndpointOverride = builder.iamEndpointOverride;
     this.transportFactoryClassName = this.transportFactory.getClass().getName();
@@ -578,33 +594,185 @@ 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);
+        // createScoped() copies any existing access token, which was minted without
+        // CLOUD_PLATFORM_SCOPE. Clear it so refreshIfExpired() mints a correctly scoped token.
+        this.sourceCredentials =
+            this.sourceCredentials.createScoped(updatedScopes).toBuilder()
+                .setAccessToken(null)
+                .build();
+      }
+      return (ExternalAccountCredentials) this.sourceCredentials;
+    }
+  }
+
+  /**
+   * Thrown when IAM rejects a cached STS token with a 401, signaling to {@link
+   * IdentityPoolCredentials} that a retry should mint a fresh STS token even if the certificate has
+   * not changed.
+   */
+  static final class CachedStsTokenRejectedException extends IOException {
+    private static final long serialVersionUID = 1L;
+
+    CachedStsTokenRejectedException(String message, Throwable cause) {
+      super(message, cause);
+    }
+  }
+
   @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 ExternalAccountCredentials) {
+      ensureExternalSourceScoped();
+    }
+    if (this.sourceCredentials instanceof IdentityPoolCredentials) {
+      IdentityPoolCredentials identityPoolSource = (IdentityPoolCredentials) this.sourceCredentials;
+      if (identityPoolSource.hasMtlsProviderForImpersonation()
+          && IdentityPoolCredentials.isDefaultOrMtlsTransportFactory(this.transportFactory)) {
+        return identityPoolSource.refreshImpersonatedAccessTokenWithRetry(this);
+      }
+    }
+    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) {
+      // readObject() defers the certificate load, leaving a keyless MtlsHttpTransportFactory.
+      // Upgrade it to the pinned factory so sign() and idTokenWithAudience() can use mTLS.
+      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 || !(externalSource instanceof IdentityPoolCredentials)) {
+        try {
+          externalSource.refreshIfExpired();
+        } catch (IOException e) {
+          throw new IOException("Unable to refresh sourceCredentials", e);
+        }
+        adapter = new HttpCredentialsAdapter(externalSource);
+      } else {
+        IdentityPoolCredentials identityPoolSource = (IdentityPoolCredentials) externalSource;
+        AccessToken intermediateAccessToken = null;
+        synchronized (this) {
+          if (isCachedStsTokenReusable(identityPoolSource, pinnedKeyStore)) {
+            intermediateAccessToken = this.cachedStsAccessToken;
+            usedCachedStsToken = true;
+          }
+        }
+        if (intermediateAccessToken == null) {
+          try {
+            intermediateAccessToken =
+                identityPoolSource.refreshAccessToken(effectiveTransportFactory, pinnedKeyStore);
+          } catch (IOException e) {
+            throw new IOException("Unable to refresh sourceCredentials", e);
+          }
+        }
+        intermediateAccessTokenForCache = intermediateAccessToken;
+        final AccessToken tokenToUse = intermediateAccessToken;
+        GoogleCredentials authCredentials =
+            new GoogleCredentials(
+                GoogleCredentials.newBuilder()
+                    .setQuotaProjectId(identityPoolSource.getQuotaProjectId())
+                    .setUniverseDomain(identityPoolSource.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 +795,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 +812,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)) {
+          throw new CachedStsTokenRejectedException("Error requesting access token", e);
+        }
+      }
       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 +1127,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..95fb397b71a3 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,105 @@ 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;
+      }
+      t = t.getCause();
+    }
+    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
+   * {@code trust_chain_path} is rotated before {@code cert_path} and {@code key_path}, so the
+   * pinned leaf certificate does not match the trust chain read from disk).
+   *
+   * @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;
+      }
+      t = t.getCause();
+    }
+    return false;
+  }
+
+  /**
+   * Returns whether the certificate chain or private key in {@code newKeyStore} differs from {@code
+   * oldKeyStore}. Used to avoid retrying 401, STS {@code invalid_grant}, and transport failures
+   * when the reloaded certificate and key are unchanged, and to decide whether a cached STS token
+   * can be reused.
+   *
+   * @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/OAuthException.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuthException.java
index 76d7fc60aa3c..975b8bd123d5 100644
--- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuthException.java
+++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuthException.java
@@ -47,6 +47,9 @@
 @NullMarked
 class OAuthException extends GoogleAuthException {
 
+  // Matches the computed value in released versions so previously serialized instances still load.
+  private static final long serialVersionUID = -5276727039237496975L;
+
   private final String errorCode;
   @Nullable private final String errorDescription;
   @Nullable private final String errorUri;
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..0c1bf7839287 100644
--- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java
+++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java
@@ -121,6 +121,11 @@ public class PluggableAuthCredentials extends ExternalAccountCredentials {
 
   @Override
   public AccessToken refreshAccessToken() throws IOException {
+    ImpersonatedCredentials impersonated = getImpersonatedCredentials();
+    if (impersonated != null) {
+      return impersonated.refreshAccessToken();
+    }
+
     String credential = retrieveSubjectToken();
     StsTokenExchangeRequest.Builder stsTokenExchangeRequest =
         StsTokenExchangeRequest.newBuilder(credential, getSubjectTokenType())
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..79576652cd17 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 =
+        serializeAndDeserialize(
+            (AwsCredentials) testCredentials.getImpersonatedCredentials().getSourceCredentials());
+    assertNull(deserializedInnerSource.getServiceAccountImpersonationUrl());
+    assertEquals(
+        testCredentials.getServiceAccountEmail(), deserializedInnerSource.getServiceAccountEmail());
   }
 
   /**
diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplierTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplierTest.java
index 5d9ce7d8e1dd..593f597b9133 100644
--- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplierTest.java
+++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplierTest.java
@@ -45,6 +45,7 @@
 import java.io.ByteArrayInputStream;
 import java.io.File;
 import java.io.IOException;
+import java.io.ObjectStreamClass;
 import java.net.URISyntaxException;
 import java.net.URL;
 import java.nio.charset.StandardCharsets;
@@ -440,4 +441,12 @@ void readTrustChain_emptyFile_returnsEmptyList() throws IOException, Certificate
     assertNotNull(certs);
     assertTrue(certs.isEmpty());
   }
+
+  @Test
+  void serialVersionUID_matchesReleasedVersions() {
+    assertEquals(
+        -7726329500407809830L,
+        ObjectStreamClass.lookup(CertificateIdentityPoolSubjectTokenSupplier.class)
+            .getSerialVersionUID());
+  }
 }
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..79fefa6a5cc5 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,14 +45,23 @@
 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.TestClock;
 import com.google.auth.TestUtils;
 import com.google.auth.http.HttpTransportFactory;
 import com.google.auth.mtls.MtlsHttpTransportFactory;
 import com.google.auth.mtls.X509Provider;
 import com.google.auth.oauth2.GoogleCredentials.GoogleCredentialsInfo;
+import com.google.common.primitives.Bytes;
 import java.io.ByteArrayInputStream;
 import java.io.File;
 import java.io.FileInputStream;
@@ -61,16 +70,22 @@
 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.nio.file.StandardCopyOption;
 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.Collection;
 import java.util.Collections;
+import java.util.Date;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -81,6 +96,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 +115,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 +126,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,10 +511,76 @@ 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);
   }
 
+  @Test
+  void getImpersonatedCredentials_outerHasCachedToken_sourceDoesNotCopyIt() {
+    IdentityPoolCredentials.Builder builder =
+        IdentityPoolCredentials.newBuilder()
+            .setSubjectTokenSupplier(testProvider)
+            .setAudience(
+                "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider")
+            .setSubjectTokenType("subjectTokenType")
+            .setServiceAccountImpersonationUrl(SERVICE_ACCOUNT_IMPERSONATION_URL)
+            .setTokenUrl("https://sts.googleapis.com/v1/token")
+            .setHttpTransportFactory(new MockExternalAccountCredentialsTransportFactory());
+    builder.setAccessToken(new AccessToken("serviceAccountToken", new Date(Long.MAX_VALUE)));
+    IdentityPoolCredentials credential = builder.build();
+
+    assertEquals("serviceAccountToken", credential.getAccessToken().getTokenValue());
+    assertNull(credential.getImpersonatedCredentials().getSourceCredentials().getAccessToken());
+  }
+
+  @Test
+  void refreshAccessToken_withImpersonationAfterDeserialization_sendsStsTokenToIam()
+      throws Exception {
+    MockExternalAccountCredentialsTransportFactory transportFactory =
+        new MockExternalAccountCredentialsTransportFactory();
+    transportFactory.transport.setExpireTime(TestUtils.getDefaultExpireTime());
+    IdentityPoolCredentials credential =
+        IdentityPoolCredentials.newBuilder()
+            .setSubjectTokenSupplier(testProvider)
+            .setAudience(
+                "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider")
+            .setSubjectTokenType("subjectTokenType")
+            .setServiceAccountImpersonationUrl(
+                transportFactory.transport.getServiceAccountImpersonationUrl())
+            .setTokenUrl(transportFactory.transport.getStsUrl())
+            .setHttpTransportFactory(transportFactory)
+            .build();
+    credential.refresh();
+    assertEquals(
+        transportFactory.transport.getServiceAccountAccessToken(),
+        credential.getAccessToken().getTokenValue());
+
+    // The cached service account token is serialized, but the impersonated credentials are
+    // rebuilt after deserialization.
+    IdentityPoolCredentials deserialized = serializeAndDeserialize(credential);
+    MockExternalAccountCredentialsTransport deserializedTransport =
+        ((MockExternalAccountCredentialsTransportFactory) deserialized.getTransportFactory())
+            .transport;
+    deserializedTransport.setExpireTime(TestUtils.getDefaultExpireTime());
+    deserialized.refresh();
+
+    List stsRequests = new ArrayList<>();
+    List iamAuthHeaders = new ArrayList<>();
+    for (MockLowLevelHttpRequest request : deserializedTransport.getRequests()) {
+      if (request.getUrl().equals(deserializedTransport.getStsUrl())) {
+        stsRequests.add(request.getUrl());
+      } else if (request
+          .getUrl()
+          .equals(deserializedTransport.getServiceAccountImpersonationUrl())) {
+        iamAuthHeaders.add(request.getFirstHeaderValue("Authorization"));
+      }
+    }
+    assertEquals(1, stsRequests.size());
+    assertEquals(
+        Collections.singletonList("Bearer " + deserializedTransport.getAccessToken()),
+        iamAuthHeaders);
+  }
+
   @Test
   void refreshAccessToken_withServiceAccountImpersonationOptions() throws IOException {
     MockExternalAccountCredentialsTransportFactory transportFactory =
@@ -525,7 +618,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 +1493,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 +1958,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 +2007,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 +2045,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 +2124,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 +2162,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 +2326,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 +2392,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 +2422,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 +2451,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 +2461,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 +2476,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 +2498,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,60 +2514,53 @@ 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");
+    // A new factory is created every cycle, so also check each one holds the expected KeyStore.
+    assertSame(ksOriginal, ((MtlsHttpTransportFactory) firstCycleFactory).getKeyStore());
+    assertSame(ksRotated, ((MtlsHttpTransportFactory) secondCycleFactory).getKeyStore());
   }
 
   // ==================================================================================
@@ -2419,6 +2596,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 +3135,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 +3165,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 +3196,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);
@@ -3031,7 +3261,7 @@ protected AccessToken exchangeExternalCredentialForAccessToken(
   }
 
   @Test
-  void createScoped_withCertificateConfig_refreshesMtlsTransportSnapshot(@TempDir Path tempDir)
+  void createScoped_withCertificateConfig_preservesMtlsTransportSnapshot(@TempDir Path tempDir)
       throws Exception {
     Path tokenFile = tempDir.resolve("credential.json");
     GenericJson tokenJson = new GenericJson();
@@ -3083,9 +3313,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 +3379,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 +3395,2375 @@ 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, 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(Bytes.concat(cert2Bytes, newline, key1Bytes)));
+    KeyStore validRotatedKeyStore =
+        SecurityUtils.createMtlsKeyStore(
+            new ByteArrayInputStream(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;
+
+    public SerializableCustomTransportFactory() {}
+
+    @Override
+    public HttpTransport create() {
+      return new MockHttpTransport();
+    }
+  }
+
+  @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().
+    IdentityPoolCredentials defaultMtlsCreds =
+        IdentityPoolCredentials.newBuilder()
+            .setCredentialSource(credentialSource)
+            .setX509Provider(new TestX509Provider(createPopulatedKeyStore(), "test"))
+            .setAudience("audience")
+            .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token")
+            .setTokenUrl("https://sts.mtls.googleapis.com/v1/token")
+            .build();
+    assertTrue(defaultMtlsCreds.shouldUseMtlsTransportFactory());
+    assertTrue(defaultMtlsCreds.toBuilder().build().shouldUseMtlsTransportFactory());
+
+    IdentityPoolCredentials deserializedDefault = serializeAndDeserialize(defaultMtlsCreds);
+    assertTrue(deserializedDefault.shouldUseMtlsTransportFactory());
+    assertTrue(deserializedDefault.getTransportFactory() instanceof MtlsHttpTransportFactory);
+    assertTrue(
+        ((MtlsHttpTransportFactory) deserializedDefault.getTransportFactory()).hasKeyStore());
+
+    // 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 TestX509Provider(createPopulatedKeyStore(), "test"))
+            .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(Bytes.concat(cert2Bytes, newline, key1Bytes)));
+    KeyStore completedRotationKeyStore =
+        SecurityUtils.createMtlsKeyStore(
+            new ByteArrayInputStream(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());
+
+    // refreshAccessToken() adds cloud-platform to the source scopes before refreshing. The
+    // refresh itself then fails because the subject token file does not exist.
+    assertThrows(IOException.class, deserializedImpersonated::refreshAccessToken);
+    Collection sourceScopes =
+        ((IdentityPoolCredentials) deserializedImpersonated.getSourceCredentials()).getScopes();
+    assertTrue(sourceScopes.contains(OAuth2Utils.CLOUD_PLATFORM_SCOPE));
+    assertTrue(sourceScopes.contains("https://www.googleapis.com/auth/CustomScope"));
+  }
+
+  @Test
+  void
+      refreshAccessToken_invalidGrantFromTrustChainRotatedAheadOfLeafCert_retriesWhenKeyStoreChanges()
+          throws Exception {
+    byte[] cert1Bytes = Files.readAllBytes(Paths.get("testresources/mtls/test_cert.pem"));
+    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);
+
+    // trust_chain_path has already rotated, but cert_path and key_path still hold the old leaf
+    // (cert1 + key1). The TLS handshake succeeds, but STS rejects the old leaf with invalid_grant
+    // because it no longer chains to the rotated trust chain.
+    KeyStore oldLeafKeyStore =
+        SecurityUtils.createMtlsKeyStore(
+            new ByteArrayInputStream(Bytes.concat(cert1Bytes, newline, key1Bytes)));
+    // cert_path and key_path have caught up to the new leaf (cert2 + key2).
+    KeyStore newLeafKeyStore =
+        SecurityUtils.createMtlsKeyStore(
+            new ByteArrayInputStream(Bytes.concat(cert2Bytes, newline, key2Bytes)));
+
+    AtomicInteger getKeyStoreCount = new AtomicInteger(0);
+    X509Provider rotatingProvider =
+        new X509Provider(null) {
+          @Override
+          public KeyStore getKeyStore() {
+            return getKeyStoreCount.incrementAndGet() == 1 ? oldLeafKeyStore : newLeafKeyStore;
+          }
+        };
+
+    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 client certificate does not chain to the configured trust chain.",
+                  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());
+    assertEquals(1, getKeyStoreCallCount.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());
+    // Cumulative: refresh 2 adds one KeyStore pin for the initial attempt and one reload for the
+    // retry.
+    assertEquals(3, getKeyStoreCallCount.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 {
+    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("certificate", certificateMap);
+    IdentityPoolCredentialSource credentialSource =
+        new IdentityPoolCredentialSource(credentialSourceMap);
+
+    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;
+          }
+        };
+
+    AtomicInteger createMtlsTransportFactoryCount = new AtomicInteger(0);
+    IdentityPoolCredentials credential =
+        new IdentityPoolCredentials(
+            IdentityPoolCredentials.newBuilder()
+                .setCredentialSource(credentialSource)
+                .setX509Provider(singleReadPerRefreshProvider)
+                .setAudience("audience")
+                .setSubjectTokenType("urn:ietf:params:oauth:token-type:mtls")
+                .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) {
+            if (createMtlsTransportFactoryCount.incrementAndGet() == 1) {
+              // Build time: store a KeyStore-backed MtlsHttpTransportFactory.
+              return super.createMtlsTransportFactory(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);
+                      }
+                    };
+                  }
+                };
+          }
+        };
+
+    assertTrue(credential.getTransportFactory() instanceof MtlsHttpTransportFactory);
+    assertTrue(((MtlsHttpTransportFactory) credential.getTransportFactory()).hasKeyStore());
+    // Only count KeyStore reads made during the refresh cycle.
+    getKeyStoreCallCount.set(0);
+
+    // The first impersonated refresh calls getImpersonatedCredentials() ->
+    // buildImpersonatedCredentials(), which copies the source via
+    // IdentityPoolCredentials.newBuilder(this) and sets the cloud-platform scope directly.
+    // This must not call getKeyStore() a second time.
+    AccessToken token = credential.refreshAccessToken();
+    assertEquals("iam-token-1", token.getTokenValue());
+    assertEquals(1, getKeyStoreCallCount.get());
+  }
+
+  @Test
+  void standaloneImpersonatedCredentials_401OnIamWithCachedStsToken_retriesAndMintsFreshStsToken()
+      throws Exception {
+    AtomicInteger stsCallCount = new AtomicInteger(0);
+    AtomicInteger iamCallCount = new AtomicInteger(0);
+    List iamBearerHeaders = Collections.synchronizedList(new ArrayList<>());
+    // Only the 2nd IAM call (which reuses cached sts-token-1) returns 401.
+    HttpTransportFactory transportFactory =
+        createStsAndIamTransportFactory(
+            stsCallCount, iamCallCount, iamBearerHeaders, iamCall -> iamCall == 2);
+    ImpersonatedCredentials impersonated =
+        createStandaloneImpersonatedCredentials(transportFactory);
+
+    AccessToken token1 = impersonated.refreshAccessToken();
+    assertEquals("iam-token-1", token1.getTokenValue());
+
+    AccessToken token2 = impersonated.refreshAccessToken();
+    assertEquals("iam-token-3", token2.getTokenValue());
+    assertEquals(2, stsCallCount.get());
+    assertEquals(3, iamCallCount.get());
+    assertEquals(
+        Arrays.asList("Bearer sts-token-1", "Bearer sts-token-1", "Bearer sts-token-2"),
+        iamBearerHeaders);
+  }
+
+  @Test
+  void
+      standaloneImpersonatedCredentials_second401OnRetryAfterCachedStsTokenRejected_throwsWithSuppressed()
+          throws Exception {
+    AtomicInteger stsCallCount = new AtomicInteger(0);
+    AtomicInteger iamCallCount = new AtomicInteger(0);
+    List iamBearerHeaders = Collections.synchronizedList(new ArrayList<>());
+    // Every IAM call after the first returns 401, including the retry with a fresh STS token.
+    HttpTransportFactory transportFactory =
+        createStsAndIamTransportFactory(
+            stsCallCount, iamCallCount, iamBearerHeaders, iamCall -> iamCall >= 2);
+    ImpersonatedCredentials impersonated =
+        createStandaloneImpersonatedCredentials(transportFactory);
+
+    impersonated.refreshAccessToken();
+
+    IOException thrown = assertThrows(IOException.class, impersonated::refreshAccessToken);
+    assertFalse(thrown instanceof ImpersonatedCredentials.CachedStsTokenRejectedException);
+    assertTrue(OAuth2Utils.isUnauthorizedException(thrown));
+    assertEquals(1, thrown.getSuppressed().length);
+    assertTrue(
+        thrown.getSuppressed()[0]
+            instanceof ImpersonatedCredentials.CachedStsTokenRejectedException);
+    // Exactly one retry: the rejected cached token is not reused and no further retry happens.
+    assertEquals(2, stsCallCount.get());
+    assertEquals(3, iamCallCount.get());
+    assertEquals(
+        Arrays.asList("Bearer sts-token-1", "Bearer sts-token-1", "Bearer sts-token-2"),
+        iamBearerHeaders);
+  }
+
+  @Test
+  void standaloneImpersonatedCredentials_cachedStsTokenWithinExpirationMargin_mintsFreshStsToken()
+      throws Exception {
+    AtomicInteger stsCallCount = new AtomicInteger(0);
+    AtomicInteger iamCallCount = new AtomicInteger(0);
+    List iamBearerHeaders = new ArrayList<>();
+    HttpTransportFactory mockTransportFactory =
+        createStsAndIamTransportFactory(
+            stsCallCount, iamCallCount, iamBearerHeaders, iamCall -> false);
+    ImpersonatedCredentials impersonated =
+        createStandaloneImpersonatedCredentials(mockTransportFactory);
+    IdentityPoolCredentials source = (IdentityPoolCredentials) impersonated.getSourceCredentials();
+
+    // The first refresh mints and caches sts-token-1, which expires in 3600 seconds. The second
+    // refresh reuses it because it is still outside the expiration margin.
+    assertEquals("iam-token-1", impersonated.refreshAccessToken().getTokenValue());
+    assertEquals("iam-token-2", impersonated.refreshAccessToken().getTokenValue());
+    assertEquals(1, stsCallCount.get());
+
+    // Move the source's clock to 60 seconds before sts-token-1 expires, inside the default
+    // 3-minute expiration margin.
+    TestClock clock = new TestClock();
+    clock.setCurrentTime(System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(3600 - 60));
+    source.clock = clock;
+
+    // The expiring cached token is not reused, so this refresh mints sts-token-2.
+    assertEquals("iam-token-3", impersonated.refreshAccessToken().getTokenValue());
+    assertEquals(2, stsCallCount.get());
+    assertEquals(
+        Arrays.asList("Bearer sts-token-1", "Bearer sts-token-1", "Bearer sts-token-2"),
+        iamBearerHeaders);
+  }
+
+  @Test
+  void refreshAccessToken_concurrentRefreshReusingRejectedCachedStsToken_doesNotRecacheIt()
+      throws Exception {
+    AtomicInteger stsCallCount = new AtomicInteger(0);
+    AtomicInteger iamCallCount = new AtomicInteger(0);
+    List iamBearerHeaders = Collections.synchronizedList(new ArrayList<>());
+    AtomicReference credentialRef = new AtomicReference<>();
+    ExecutorService executor = Executors.newSingleThreadExecutor();
+    try {
+      HttpTransportFactory mockTransportFactory =
+          createStsAndIamTransportFactory(
+              stsCallCount,
+              iamCallCount,
+              iamBearerHeaders,
+              iamCall -> {
+                if (iamCall == 2) {
+                  // Refresh A is using cached sts-token-1. Before its IAM call completes, refresh B
+                  // on another thread also reuses sts-token-1, gets a 401 that clears the cache,
+                  // and fails its retry with another 401.
+                  Future refreshB =
+                      executor.submit(
+                          () ->
+                              assertThrows(
+                                  IOException.class,
+                                  () -> credentialRef.get().refreshAccessToken()));
+                  refreshB.get(10, TimeUnit.SECONDS);
+                  return false;
+                }
+                return iamCall == 3 || iamCall == 4;
+              });
+      IdentityPoolCredentials credential =
+          new IdentityPoolCredentials(
+              IdentityPoolCredentials.newBuilder()
+                  .setSubjectTokenSupplier(testProvider)
+                  .setX509Provider(new TestX509Provider(createPopulatedKeyStore(), "test"))
+                  .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 mockTransportFactory;
+            }
+          };
+      credentialRef.set(credential);
+
+      // Initial refresh mints and caches sts-token-1.
+      assertEquals("iam-token-1", credential.refreshAccessToken().getTokenValue());
+
+      // Refresh A reuses sts-token-1 and succeeds after refresh B has rejected it.
+      assertEquals("iam-token-2", credential.refreshAccessToken().getTokenValue());
+      assertEquals(2, stsCallCount.get());
+      assertEquals(4, iamCallCount.get());
+
+      // Refresh A must not have re-cached the rejected sts-token-1, so this refresh mints a new
+      // STS token.
+      assertEquals("iam-token-5", credential.refreshAccessToken().getTokenValue());
+      assertEquals(3, stsCallCount.get());
+      assertEquals(
+          Arrays.asList(
+              "Bearer sts-token-1",
+              "Bearer sts-token-1",
+              "Bearer sts-token-1",
+              "Bearer sts-token-2",
+              "Bearer sts-token-3"),
+          iamBearerHeaders);
+    } finally {
+      executor.shutdownNow();
+    }
+  }
+
+  @Test
+  void refreshAccessToken_iamFailureWithFreshStsToken_doesNotClearConcurrentlyCachedStsToken()
+      throws Exception {
+    AtomicInteger stsCallCount = new AtomicInteger(0);
+    AtomicInteger iamCallCount = new AtomicInteger(0);
+    List iamBearerHeaders = Collections.synchronizedList(new ArrayList<>());
+    AtomicReference credentialRef = new AtomicReference<>();
+    ExecutorService executor = Executors.newSingleThreadExecutor();
+    try {
+      HttpTransportFactory mockTransportFactory =
+          createStsAndIamTransportFactory(
+              stsCallCount,
+              iamCallCount,
+              iamBearerHeaders,
+              iamCall -> {
+                if (iamCall == 1) {
+                  // Refresh A has freshly minted sts-token-1. Before its IAM call completes,
+                  // refresh B on another thread mints sts-token-2, succeeds, and caches it.
+                  Future refreshB =
+                      executor.submit(() -> credentialRef.get().refreshAccessToken());
+                  assertEquals("iam-token-2", refreshB.get(10, TimeUnit.SECONDS).getTokenValue());
+                  // Refresh A's IAM call then fails with a non-401 error.
+                  throw new IOException("IAM unavailable");
+                }
+                return false;
+              });
+      IdentityPoolCredentials credential =
+          new IdentityPoolCredentials(
+              IdentityPoolCredentials.newBuilder()
+                  .setSubjectTokenSupplier(testProvider)
+                  .setX509Provider(new TestX509Provider(createPopulatedKeyStore(), "test"))
+                  .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 mockTransportFactory;
+            }
+          };
+      credentialRef.set(credential);
+
+      // Refresh A fails on IAM after refresh B has cached sts-token-2.
+      assertThrows(IOException.class, credential::refreshAccessToken);
+      assertEquals(2, stsCallCount.get());
+
+      // Refresh A must not have cleared sts-token-2, so this refresh reuses it without minting.
+      assertEquals("iam-token-3", credential.refreshAccessToken().getTokenValue());
+      assertEquals(2, stsCallCount.get());
+      assertEquals(
+          Arrays.asList("Bearer sts-token-1", "Bearer sts-token-2", "Bearer sts-token-2"),
+          iamBearerHeaders);
+    } finally {
+      executor.shutdownNow();
+    }
+  }
+
+  @Test
+  void refreshAccessToken_withCycleTransportFactory_upgradesKeylessMtlsTransportFactory()
+      throws Exception {
+    AtomicInteger stsCallCount = new AtomicInteger(0);
+    AtomicInteger iamCallCount = new AtomicInteger(0);
+    HttpTransportFactory cycleTransportFactory =
+        createStsAndIamTransportFactory(
+            stsCallCount, iamCallCount, new ArrayList<>(), iamCall -> false);
+    IdentityPoolCredentials sourceCredentials =
+        IdentityPoolCredentials.newBuilder()
+            .setSubjectTokenSupplier(testProvider)
+            .setX509Provider(new TestX509Provider(createPopulatedKeyStore(), "test"))
+            .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();
+    // A keyless MtlsHttpTransportFactory is the state readObject() leaves behind when it defers
+    // the certificate load.
+    ImpersonatedCredentials impersonated =
+        ImpersonatedCredentials.newBuilder()
+            .setSourceCredentials(sourceCredentials)
+            .setHttpTransportFactory(new MtlsHttpTransportFactory())
+            .setTargetPrincipal("test@project.iam.gserviceaccount.com")
+            .setScopes(Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE))
+            .build();
+
+    impersonated.refreshAccessToken(cycleTransportFactory, createPopulatedKeyStore());
+
+    // The pinned factory replaces the keyless one so sign() and idTokenWithAudience() use mTLS.
+    assertSame(cycleTransportFactory, impersonated.toBuilder().getHttpTransportFactory());
+  }
+
+  @Test
+  void refreshAccessToken_sourceWithUnscopedAccessToken_mintsCloudPlatformScopedStsToken()
+      throws Exception {
+    AtomicInteger stsCallCount = new AtomicInteger(0);
+    AtomicInteger iamCallCount = new AtomicInteger(0);
+    List iamBearerHeaders = Collections.synchronizedList(new ArrayList<>());
+    HttpTransportFactory mockTransportFactory =
+        createStsAndIamTransportFactory(
+            stsCallCount, iamCallCount, iamBearerHeaders, iamCall -> false);
+    IdentityPoolCredentials.Builder sourceBuilder =
+        IdentityPoolCredentials.newBuilder()
+            .setSubjectTokenSupplier(testProvider)
+            .setHttpTransportFactory(mockTransportFactory)
+            .setAudience(
+                "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider")
+            .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token")
+            .setTokenUrl(STS_URL)
+            .setScopes(Collections.singletonList("https://www.googleapis.com/auth/CustomScope"));
+    // A non-expiring token minted earlier without cloud-platform.
+    sourceBuilder.setAccessToken(new AccessToken("unscoped-token", null));
+    IdentityPoolCredentials sourceCredentials = sourceBuilder.build();
+    assertEquals("unscoped-token", sourceCredentials.getAccessToken().getTokenValue());
+    ImpersonatedCredentials impersonated =
+        ImpersonatedCredentials.newBuilder()
+            .setSourceCredentials(sourceCredentials)
+            .setHttpTransportFactory(mockTransportFactory)
+            .setTargetPrincipal("test@project.iam.gserviceaccount.com")
+            .setScopes(Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE))
+            .build();
+
+    assertEquals("iam-token-1", impersonated.refreshAccessToken().getTokenValue());
+
+    // The unscoped token copied by createScoped() is dropped, so IAM is called with a newly
+    // minted STS token instead.
+    assertEquals(1, stsCallCount.get());
+    assertEquals(Collections.singletonList("Bearer sts-token-1"), iamBearerHeaders);
+  }
+
+  @Test
+  void standaloneImpersonatedCredentials_unscopedMtlsSource_pinnedRefreshRunsOnScopedSource()
+      throws Exception {
+    List pinningInstances =
+        Collections.synchronizedList(new ArrayList<>());
+    HttpTransportFactory mockTransportFactory =
+        createStsAndIamTransportFactory(
+            new AtomicInteger(0), new AtomicInteger(0), new ArrayList<>(), iamCall -> false);
+    IdentityPoolCredentials sourceCredentials =
+        new PinningInstanceRecordingCredentials(
+            IdentityPoolCredentials.newBuilder()
+                .setSubjectTokenSupplier(testProvider)
+                .setX509Provider(new TestX509Provider(createPopulatedKeyStore(), "test"))
+                .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")
+                .setScopes(
+                    Collections.singletonList("https://www.googleapis.com/auth/CustomScope")),
+            mockTransportFactory,
+            pinningInstances);
+    ImpersonatedCredentials impersonated =
+        ImpersonatedCredentials.newBuilder()
+            .setSourceCredentials(sourceCredentials)
+            .setTargetPrincipal("test@project.iam.gserviceaccount.com")
+            .setScopes(Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE))
+            .build();
+
+    assertEquals("iam-token-1", impersonated.refreshAccessToken().getTokenValue());
+
+    // The source is scoped before the pinned refresh starts, so the KeyStore is pinned on the
+    // scoped source that ImpersonatedCredentials keeps, not on the discarded unscoped one.
+    GoogleCredentials scopedSource = impersonated.getSourceCredentials();
+    assertNotSame(sourceCredentials, scopedSource);
+    assertTrue(
+        ((IdentityPoolCredentials) scopedSource)
+            .getScopes()
+            .contains(OAuth2Utils.CLOUD_PLATFORM_SCOPE));
+    assertEquals(1, pinningInstances.size());
+    assertSame(scopedSource, pinningInstances.get(0));
+  }
+
+  /**
+   * Records which instance creates the pinned mTLS transport factory, and keeps doing so on copies
+   * made by {@link #createScoped(Collection)}.
+   */
+  private static class PinningInstanceRecordingCredentials extends IdentityPoolCredentials {
+    private final HttpTransportFactory mockTransportFactory;
+    private final List pinningInstances;
+
+    PinningInstanceRecordingCredentials(
+        IdentityPoolCredentials.Builder builder,
+        HttpTransportFactory mockTransportFactory,
+        List pinningInstances) {
+      super(builder);
+      this.mockTransportFactory = mockTransportFactory;
+      this.pinningInstances = pinningInstances;
+    }
+
+    @Override
+    HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) {
+      pinningInstances.add(this);
+      return mockTransportFactory;
+    }
+
+    @Override
+    public IdentityPoolCredentials createScoped(Collection newScopes) {
+      return new PinningInstanceRecordingCredentials(
+          IdentityPoolCredentials.newBuilder(this).setScopes(newScopes),
+          mockTransportFactory,
+          pinningInstances);
+    }
+
+    @Override
+    public Builder toBuilder() {
+      return new IdentityPoolCredentials.Builder(this) {
+        @Override
+        public IdentityPoolCredentials build() {
+          return new PinningInstanceRecordingCredentials(
+              this, mockTransportFactory, pinningInstances);
+        }
+      };
+    }
+  }
+
+  /** Decides, for the Nth IAM call, whether the mock IAM endpoint returns 401. */
+  interface IamUnauthorizedDecider {
+    boolean returnsUnauthorized(int iamCall) throws Exception;
+  }
+
+  /**
+   * Returns a transport factory whose STS endpoint mints {@code sts-token-N} on the Nth call and
+   * whose IAM endpoint returns either 401 or {@code iam-token-N} on the Nth call.
+   */
+  static HttpTransportFactory createStsAndIamTransportFactory(
+      AtomicInteger stsCallCount,
+      AtomicInteger iamCallCount,
+      List iamBearerHeaders,
+      IamUnauthorizedDecider iamUnauthorizedDecider) {
+    return () ->
+        new MockHttpTransport() {
+          @Override
+          public LowLevelHttpRequest buildRequest(String method, String url) {
+            return new MockLowLevelHttpRequest(url) {
+              @Override
+              public LowLevelHttpResponse execute() throws IOException {
+                if (url.contains("/v1/token")) {
+                  int count = stsCallCount.incrementAndGet();
+                  GenericJson response = new GenericJson();
+                  response.setFactory(OAuth2Utils.JSON_FACTORY);
+                  response.put("access_token", "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"));
+                  boolean unauthorized;
+                  try {
+                    unauthorized = iamUnauthorizedDecider.returnsUnauthorized(count);
+                  } catch (Exception e) {
+                    throw new IOException(e);
+                  }
+                  if (unauthorized) {
+                    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", "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);
+              }
+            };
+          }
+        };
+  }
+
+  private static ImpersonatedCredentials createStandaloneImpersonatedCredentials(
+      HttpTransportFactory mockTransportFactory) throws IOException {
+    IdentityPoolCredentials sourceCredentials =
+        new IdentityPoolCredentials(
+            IdentityPoolCredentials.newBuilder()
+                .setSubjectTokenSupplier(testProvider)
+                .setX509Provider(new TestX509Provider(createPopulatedKeyStore(), "test"))
+                .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")
+                // Already includes cloud-platform, so ImpersonatedCredentials does not replace
+                // this source (and its createMtlsTransportFactory override) via createScoped().
+                .setScopes(Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE))) {
+          @Override
+          HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) {
+            return mockTransportFactory;
+          }
+        };
+    return ImpersonatedCredentials.newBuilder()
+        .setSourceCredentials(sourceCredentials)
+        .setTargetPrincipal("test@project.iam.gserviceaccount.com")
+        .setScopes(Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE))
+        .build();
+  }
 }
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..76d66513e113 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,126 @@ 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 isInvalidGrantException_null_returnsFalse() {
+    assertFalse(OAuth2Utils.isInvalidGrantException(null));
+  }
+
+  @Test
+  void isInvalidGrantException_genericIOException_returnsFalse() {
+    assertFalse(OAuth2Utils.isInvalidGrantException(new IOException("Network error")));
+  }
+
+  @Test
+  void isInvalidGrantException_oauthExceptionInvalidGrant_returnsTrue() {
+    OAuthException ex = new OAuthException("invalid_grant", "Invalid grant", null, 400);
+    assertTrue(OAuth2Utils.isInvalidGrantException(ex));
+  }
+
+  @Test
+  void isInvalidGrantException_oauthExceptionOtherErrorCode_returnsFalse() {
+    OAuthException ex = new OAuthException("invalid_request", "Bad Request", null, 400);
+    assertFalse(OAuth2Utils.isInvalidGrantException(ex));
+  }
+
+  @Test
+  void isInvalidGrantException_nestedInExceptionChain_returnsTrue() {
+    OAuthException oauthEx = new OAuthException("invalid_grant", "Invalid grant", null, 400);
+    IOException wrapped = new IOException("Outer", new IOException("Inner", oauthEx));
+    assertTrue(OAuth2Utils.isInvalidGrantException(wrapped));
+  }
+
+  @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/OAuthExceptionTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuthExceptionTest.java
index e70ba6851574..3b50ae9d3b6b 100644
--- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuthExceptionTest.java
+++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuthExceptionTest.java
@@ -36,6 +36,7 @@
 
 import com.google.auth.TestUtils;
 import java.io.IOException;
+import java.io.ObjectStreamClass;
 import org.junit.jupiter.api.Test;
 
 /** Tests for {@link OAuthException}. */
@@ -129,4 +130,11 @@ void createFromHttpResponseException_baseFormat() throws IOException {
     String expectedMessage = String.format(BASE_MESSAGE_FORMAT, "errorCode");
     assertEquals(expectedMessage, e.getMessage());
   }
+
+  @Test
+  void serialVersionUID_matchesReleasedVersions() {
+    assertEquals(
+        -5276727039237496975L,
+        ObjectStreamClass.lookup(OAuthException.class).getSerialVersionUID());
+  }
 }
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-----

From ed2bd40b425790e4a18a15cb3235f837ca2be978 Mon Sep 17 00:00:00 2001
From: macastelaz <34776182+macastelaz@users.noreply.github.com>
Date: Thu, 15 Oct 2026 14:38:46 -0500
Subject: [PATCH 06/10] test(oauth2): add mTLS in-process socket tests and live
 GCP WIF integration tests (#14220)

## Description
This PR adds comprehensive integration tests for Certificate-Bound OAuth
2.0 (mTLS), Actor Tokens, and Service Account Impersonation transport
pinning across both hermetic in-process socket tests and live GCP
Workload Identity Federation suites.

### Summary of Changes:
1. **Hermetic In-Process Socket Suite (`MtlsPipelineLocalTest.java`)**:
- Uses JDK `HttpsServer` on `localhost` requiring client authentication
(`setNeedClientAuth(true)`).
- `testMtlsPipeline_verifiesPeerCertAndPayload`: Verifies server
extracts client peer certificate from `SSLSession` and parses token
exchange body (`grant_type`, `subject_token`, `actor_token`,
`actor_token_type`).
- `testMtlsPipeline_withImpersonation_usesSameCertForStsAndIam`:
Verifies both STS and IAM calls execute on the exact same pinned mTLS
transport and verifies the intermediate `Authorization: Bearer `
header.
- `testMtlsPipeline_withImpersonation_401OnIam_retriesWithFreshCert`:
Verifies full-cycle recovery when IAM returns 401 (reloads fresh
KeyStore from disk, re-exchanges at STS with new cert, and calls IAM
with new token + cert).
- `testMtlsPipeline_concurrentRefreshes`: Multi-threaded refresh
verifying thread-isolated transport snapshots.
- `testMtlsPipeline_atomicTokenRead`: Verifies single-pass disk I/O for
subject + actor tokens.
2. **Live GCP Suite Extensions
(`ITWorkloadIdentityFederationTest.java`)**:
- `identityPoolCredentials_withCertificateBoundWorkloadAndActorToken`:
E2E verification of JSON config with certificate config and actor
tokens.
- `identityPoolCredentials_withProgrammaticMtlsAndActorToken`: E2E
verification of programmatic builder configuration.
3. **`OAuthException.java`**:
   - Safely handles null HTTP error response content without NPE.

### Verification:
- **Unit & Socket Tests**: 1,040 / 1,040 tests passing (including
hermetic socket tests).
- **Google Java Format**: 100% compliant (`mvn
com.spotify.fmt:fmt-maven-plugin:2.25:check`).
Source-Link: b/542238030
---
 .../google/auth/oauth2/OAuthException.java    |   75 +-
 .../ITWorkloadIdentityFederationTest.java     |  231 ++-
 .../auth/oauth2/MtlsPipelineLocalTest.java    | 1366 +++++++++++++++++
 .../auth/oauth2/OAuthExceptionTest.java       |  216 ++-
 .../testresources/mtls/test_cert.pem          |   30 +-
 .../testresources/mtls/test_key.pem           |   40 +-
 6 files changed, 1913 insertions(+), 45 deletions(-)
 create mode 100644 google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MtlsPipelineLocalTest.java

diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuthException.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuthException.java
index 975b8bd123d5..0cd177a9a215 100644
--- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuthException.java
+++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuthException.java
@@ -36,7 +36,7 @@
 import com.google.api.client.http.HttpResponseException;
 import com.google.api.client.json.GenericJson;
 import com.google.api.client.json.JsonParser;
-import java.io.IOException;
+import java.util.Map;
 import org.jspecify.annotations.NullMarked;
 import org.jspecify.annotations.Nullable;
 
@@ -99,20 +99,67 @@ int getHttpStatusCode() {
     return httpStatusCode;
   }
 
-  static OAuthException createFromHttpResponseException(HttpResponseException e)
-      throws IOException {
-    JsonParser parser = OAuth2Utils.JSON_FACTORY.createJsonParser((e).getContent());
-    GenericJson errorResponse = parser.parseAndClose(GenericJson.class);
-
-    String errorCode = (String) errorResponse.get("error");
-    String errorDescription = null;
-    String errorUri = null;
-    if (errorResponse.containsKey("error_description")) {
-      errorDescription = (String) errorResponse.get("error_description");
+  static OAuthException createFromHttpResponseException(HttpResponseException e) {
+    String content = e.getContent();
+    if (content == null || content.trim().isEmpty()) {
+      OAuthException oauthException =
+          new OAuthException(
+              "http_error_" + e.getStatusCode(), e.getStatusMessage(), null, e.getStatusCode());
+      oauthException.initCause(e);
+      return oauthException;
     }
-    if (errorResponse.containsKey("error_uri")) {
-      errorUri = (String) errorResponse.get("error_uri");
+    try {
+      JsonParser parser = OAuth2Utils.JSON_FACTORY.createJsonParser(content);
+      GenericJson errorResponse = parser.parseAndClose(GenericJson.class);
+      if (errorResponse == null) {
+        OAuthException oauthException =
+            new OAuthException(
+                "http_error_" + e.getStatusCode(), e.getStatusMessage(), null, e.getStatusCode());
+        oauthException.initCause(e);
+        return oauthException;
+      }
+
+      String errorCode = null;
+      String errorDescription = null;
+      Object rawError = errorResponse.get("error");
+      if (rawError instanceof String) {
+        errorCode = (String) rawError;
+      } else if (rawError instanceof Map) {
+        Map errorMap = (Map) rawError;
+        if (errorMap.get("status") instanceof String) {
+          errorCode = (String) errorMap.get("status");
+        }
+        if (errorMap.get("message") instanceof String) {
+          errorDescription = (String) errorMap.get("message");
+        }
+      }
+      if (errorCode == null) {
+        errorCode = "http_error_" + e.getStatusCode();
+      }
+      String errorUri = null;
+      if (errorResponse.get("error_description") instanceof String) {
+        errorDescription = (String) errorResponse.get("error_description");
+      } else if (errorDescription == null && errorResponse.get("message") instanceof String) {
+        errorDescription = (String) errorResponse.get("message");
+      }
+      if (errorDescription == null && !(rawError instanceof String)) {
+        errorDescription =
+            errorResponse.isEmpty() && e.getStatusMessage() != null
+                ? e.getStatusMessage()
+                : content;
+      }
+      if (errorResponse.get("error_uri") instanceof String) {
+        errorUri = (String) errorResponse.get("error_uri");
+      }
+      OAuthException oauthException =
+          new OAuthException(errorCode, errorDescription, errorUri, e.getStatusCode());
+      oauthException.initCause(e);
+      return oauthException;
+    } catch (Exception parseException) {
+      OAuthException oauthException =
+          new OAuthException("http_error_" + e.getStatusCode(), content, null, e.getStatusCode());
+      oauthException.initCause(e);
+      return oauthException;
     }
-    return new OAuthException(errorCode, errorDescription, errorUri, e.getStatusCode());
   }
 }
diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ITWorkloadIdentityFederationTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ITWorkloadIdentityFederationTest.java
index 0f1cdd3092f6..ef38cca471fc 100644
--- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ITWorkloadIdentityFederationTest.java
+++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ITWorkloadIdentityFederationTest.java
@@ -31,13 +31,16 @@
 
 package com.google.auth.oauth2;
 
+import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.junit.jupiter.api.Assertions.fail;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
 
 import com.google.api.client.http.GenericUrl;
 import com.google.api.client.http.HttpRequest;
 import com.google.api.client.http.HttpRequestFactory;
 import com.google.api.client.http.HttpResponse;
+import com.google.api.client.http.HttpTransport;
 import com.google.api.client.http.UrlEncodedContent;
 import com.google.api.client.http.javanet.NetHttpTransport;
 import com.google.api.client.json.GenericJson;
@@ -46,12 +49,16 @@
 import com.google.api.client.json.gson.GsonFactory;
 import com.google.api.client.util.GenericData;
 import com.google.auth.http.HttpCredentialsAdapter;
+import com.google.auth.http.HttpTransportFactory;
+import com.google.auth.mtls.MtlsHttpTransportFactory;
+import com.google.auth.mtls.X509Provider;
 import com.google.auth.oauth2.ExternalAccountCredentials.SubjectTokenTypes;
 import java.io.ByteArrayInputStream;
 import java.io.File;
 import java.io.FileInputStream;
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
+import java.security.KeyStore;
 import java.time.Instant;
 import java.util.HashMap;
 import java.util.Map;
@@ -281,6 +288,205 @@ void identityPoolCredentials_withProgrammaticAuth() throws IOException {
     callGcs(identityPoolCredentials);
   }
 
+  /**
+   * IdentityPoolCredentials (OIDC provider with certificate-bound workload and actor token): Uses
+   * the service account to generate Google ID tokens for subject and actor tokens. Writes both
+   * tokens to a temporary JSON file with subject_token and actor_token field names. Configures
+   * certificate_config_location pointing to the certificate config. Exchanges the tokens over the
+   * mTLS STS endpoint (https://sts.mtls.googleapis.com/v1/token) and calls GCS.
+   */
+  @Test
+  void identityPoolCredentials_withCertificateBoundWorkloadAndActorToken() throws Exception {
+    String certConfigPath = getMtlsCertificateConfigPath();
+    String subjectToken = generateGoogleIdToken(OIDC_AUDIENCE);
+    String actorToken = generateGoogleIdToken(OIDC_AUDIENCE);
+
+    File tokenFile =
+        File.createTempFile(
+            "ITWorkloadIdentityFederation_cert_actor", /* suffix= */ null, /* directory= */ null);
+    tokenFile.deleteOnExit();
+    try {
+      GenericJson tokenJson = new GenericJson();
+      tokenJson.setFactory(OAuth2Utils.JSON_FACTORY);
+      tokenJson.put("subject_token", subjectToken);
+      tokenJson.put("actor_token", actorToken);
+
+      OAuth2Utils.writeInputStreamToFile(
+          new ByteArrayInputStream(tokenJson.toPrettyString().getBytes(StandardCharsets.UTF_8)),
+          tokenFile.getAbsolutePath());
+
+      GenericJson config = new GenericJson();
+      config.put("type", "external_account");
+      config.put("audience", OIDC_AUDIENCE);
+      config.put("subject_token_type", "urn:ietf:params:oauth:token-type:jwt");
+      config.put("actor_token_type", "urn:ietf:params:oauth:token-type:jwt");
+      config.put("token_url", "https://sts.mtls.googleapis.com/v1/token");
+      config.put(
+          "service_account_impersonation_url",
+          String.format(
+              "https://iamcredentials.mtls.googleapis.com/v1/projects/-/serviceAccounts/%s:generateAccessToken",
+              clientEmail));
+
+      GenericJson credentialSource = new GenericJson();
+      credentialSource.put("file", tokenFile.getAbsolutePath());
+
+      GenericJson format = new GenericJson();
+      format.put("type", "json");
+      format.put("subject_token_field_name", "subject_token");
+      format.put("actor_token_field_name", "actor_token");
+      credentialSource.put("format", format);
+
+      GenericJson certificate = new GenericJson();
+      certificate.put("certificate_config_location", certConfigPath);
+      credentialSource.put("certificate", certificate);
+
+      config.put("credential_source", credentialSource);
+
+      IdentityPoolCredentials identityPoolCredentials =
+          (IdentityPoolCredentials)
+              ExternalAccountCredentials.fromJson(config, OAuth2Utils.HTTP_TRANSPORT_FACTORY);
+
+      KeyStore keyStore = new X509Provider(certConfigPath).getKeyStore();
+      HttpTransport mtlsTransport = new MtlsHttpTransportFactory(keyStore).create();
+      callGcs(
+          identityPoolCredentials,
+          mtlsTransport,
+          "https://storage.mtls.googleapis.com/storage/v1/b/");
+    } finally {
+      tokenFile.delete();
+    }
+  }
+
+  /**
+   * IdentityPoolCredentials (OIDC provider with programmatic mTLS and actor token): Uses the
+   * service account to generate Google ID tokens for subject and actor tokens via suppliers.
+   * Configures mTLS transport using MtlsHttpTransportFactory with KeyStore loaded from the mTLS
+   * certificate config. Exchanges the tokens over mTLS STS endpoint and calls GCS.
+   */
+  @Test
+  void identityPoolCredentials_withProgrammaticMtlsAndActorToken() throws Exception {
+    IdentityPoolSubjectTokenSupplier tokenSupplier =
+        context -> generateGoogleIdToken(OIDC_AUDIENCE);
+    IdentityPoolActorTokenSupplier actorSupplier = context -> generateGoogleIdToken(OIDC_AUDIENCE);
+
+    X509Provider x509Provider = new X509Provider(getMtlsCertificateConfigPath());
+    HttpTransportFactory transportFactory =
+        new MtlsHttpTransportFactory(x509Provider.getKeyStore());
+
+    IdentityPoolCredentials credentials =
+        IdentityPoolCredentials.newBuilder()
+            .setSubjectTokenSupplier(tokenSupplier)
+            .setActorTokenSupplier(actorSupplier)
+            .setActorTokenType(SubjectTokenTypes.JWT.value)
+            .setAudience(OIDC_AUDIENCE)
+            .setSubjectTokenType(SubjectTokenTypes.JWT)
+            .setTokenUrl("https://sts.mtls.googleapis.com/v1/token")
+            .setServiceAccountImpersonationUrl(
+                String.format(
+                    "https://iamcredentials.mtls.googleapis.com/v1/projects/-/serviceAccounts/%s:generateAccessToken",
+                    clientEmail))
+            .setHttpTransportFactory(transportFactory)
+            .build();
+
+    callGcs(
+        credentials,
+        transportFactory.create(),
+        "https://storage.mtls.googleapis.com/storage/v1/b/");
+  }
+
+  /**
+   * IdentityPoolCredentials (OIDC provider with certificate-bound workload and actor token, direct
+   * STS): Exchanges tokens directly over the mTLS STS endpoint
+   * (https://sts.mtls.googleapis.com/v1/token) without service account impersonation.
+   */
+  @Test
+  void identityPoolCredentials_directSts_withCertificateBoundWorkloadAndActorToken()
+      throws IOException {
+    String certConfigPath = getMtlsCertificateConfigPath();
+    String subjectToken = generateGoogleIdToken(OIDC_AUDIENCE);
+    String actorToken = generateGoogleIdToken(OIDC_AUDIENCE);
+
+    File tokenFile =
+        File.createTempFile(
+            "ITWorkloadIdentityFederation_direct_cert_actor",
+            /* suffix= */ null,
+            /* directory= */ null);
+    tokenFile.deleteOnExit();
+    try {
+      GenericJson tokenJson = new GenericJson();
+      tokenJson.setFactory(OAuth2Utils.JSON_FACTORY);
+      tokenJson.put("subject_token", subjectToken);
+      tokenJson.put("actor_token", actorToken);
+
+      OAuth2Utils.writeInputStreamToFile(
+          new ByteArrayInputStream(tokenJson.toPrettyString().getBytes(StandardCharsets.UTF_8)),
+          tokenFile.getAbsolutePath());
+
+      GenericJson config = new GenericJson();
+      config.put("type", "external_account");
+      config.put("audience", OIDC_AUDIENCE);
+      config.put("subject_token_type", "urn:ietf:params:oauth:token-type:jwt");
+      config.put("actor_token_type", "urn:ietf:params:oauth:token-type:jwt");
+      config.put("token_url", "https://sts.mtls.googleapis.com/v1/token");
+
+      GenericJson credentialSource = new GenericJson();
+      credentialSource.put("file", tokenFile.getAbsolutePath());
+
+      GenericJson format = new GenericJson();
+      format.put("type", "json");
+      format.put("subject_token_field_name", "subject_token");
+      format.put("actor_token_field_name", "actor_token");
+      credentialSource.put("format", format);
+
+      GenericJson certificate = new GenericJson();
+      certificate.put("certificate_config_location", certConfigPath);
+      credentialSource.put("certificate", certificate);
+
+      config.put("credential_source", credentialSource);
+
+      IdentityPoolCredentials identityPoolCredentials =
+          (IdentityPoolCredentials)
+              ExternalAccountCredentials.fromJson(config, OAuth2Utils.HTTP_TRANSPORT_FACTORY);
+
+      AccessToken accessToken = identityPoolCredentials.refreshAccessToken();
+      assertNotNull(accessToken);
+      assertNotNull(accessToken.getTokenValue());
+    } finally {
+      tokenFile.delete();
+    }
+  }
+
+  /**
+   * IdentityPoolCredentials (OIDC provider with programmatic mTLS and actor token, direct STS):
+   * Uses suppliers for subject and actor tokens, configuring MtlsHttpTransportFactory. Exchanges
+   * directly over mTLS STS endpoint without service account impersonation.
+   */
+  @Test
+  void identityPoolCredentials_directSts_withProgrammaticMtlsAndActorToken() throws Exception {
+    IdentityPoolSubjectTokenSupplier tokenSupplier =
+        context -> generateGoogleIdToken(OIDC_AUDIENCE);
+    IdentityPoolActorTokenSupplier actorSupplier = context -> generateGoogleIdToken(OIDC_AUDIENCE);
+
+    X509Provider x509Provider = new X509Provider(getMtlsCertificateConfigPath());
+    HttpTransportFactory transportFactory =
+        new MtlsHttpTransportFactory(x509Provider.getKeyStore());
+
+    IdentityPoolCredentials credentials =
+        IdentityPoolCredentials.newBuilder()
+            .setSubjectTokenSupplier(tokenSupplier)
+            .setActorTokenSupplier(actorSupplier)
+            .setActorTokenType(SubjectTokenTypes.JWT.value)
+            .setAudience(OIDC_AUDIENCE)
+            .setSubjectTokenType(SubjectTokenTypes.JWT)
+            .setTokenUrl("https://sts.mtls.googleapis.com/v1/token")
+            .setHttpTransportFactory(transportFactory)
+            .build();
+
+    AccessToken accessToken = credentials.refreshAccessToken();
+    assertNotNull(accessToken);
+    assertNotNull(accessToken.getTokenValue());
+  }
+
   private GenericJson buildIdentityPoolCredentialConfig() throws IOException {
     String idToken = generateGoogleIdToken(OIDC_AUDIENCE);
 
@@ -382,24 +588,41 @@ private GenericJson buildAwsCredentialConfig() {
     return config;
   }
 
+  private String getMtlsCertificateConfigPath() {
+    String certConfigPath = System.getenv("GOOGLE_API_CERTIFICATE_CONFIG");
+    assumeTrue(
+        certConfigPath != null && !certConfigPath.isEmpty(),
+        "Skipping mTLS test: GOOGLE_API_CERTIFICATE_CONFIG env variable is not set.");
+    return certConfigPath;
+  }
+
   private void callGcs(GoogleCredentials credentials) throws IOException {
+    callGcs(credentials, new NetHttpTransport(), "https://storage.googleapis.com/storage/v1/b/");
+  }
+
+  private void callGcs(
+      GoogleCredentials credentials, HttpTransport transport, String storageBaseUrl)
+      throws IOException {
     String bucketName = System.getenv("GCS_BUCKET");
     if (bucketName == null) {
       fail("GCS bucket name not set through GCS_BUCKET env variable.");
     }
 
-    String url = "https://storage.googleapis.com/storage/v1/b/" + bucketName;
+    String url = storageBaseUrl + bucketName;
 
     HttpCredentialsAdapter credentialsAdapter = new HttpCredentialsAdapter(credentials);
-    HttpRequestFactory requestFactory =
-        new NetHttpTransport().createRequestFactory(credentialsAdapter);
+    HttpRequestFactory requestFactory = transport.createRequestFactory(credentialsAdapter);
     HttpRequest request = requestFactory.buildGetRequest(new GenericUrl(url));
 
     JsonObjectParser parser = new JsonObjectParser(GsonFactory.getDefaultInstance());
     request.setParser(parser);
 
     HttpResponse response = request.execute();
-    assertTrue(response.isSuccessStatusCode());
+    try {
+      assertTrue(response.isSuccessStatusCode());
+    } finally {
+      response.disconnect();
+    }
   }
 
   /**
diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MtlsPipelineLocalTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MtlsPipelineLocalTest.java
new file mode 100644
index 000000000000..293a3a34a229
--- /dev/null
+++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MtlsPipelineLocalTest.java
@@ -0,0 +1,1366 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ *    * Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *    * Redistributions in binary form must reproduce the above
+ * copyright notice, this list of conditions and the following disclaimer
+ * in the documentation and/or other materials provided with the
+ * distribution.
+ *
+ *    * Neither the name of Google LLC nor the names of its
+ * contributors may be used to endorse or promote products derived from
+ * this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package com.google.auth.oauth2;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.google.api.client.json.GenericJson;
+import com.google.api.client.util.SecurityUtils;
+import com.google.auth.http.HttpTransportFactory;
+import com.google.auth.mtls.MtlsHttpTransportFactory;
+import com.google.auth.oauth2.ExternalAccountCredentials.SubjectTokenTypes;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpHandler;
+import com.sun.net.httpserver.HttpsConfigurator;
+import com.sun.net.httpserver.HttpsExchange;
+import com.sun.net.httpserver.HttpsParameters;
+import com.sun.net.httpserver.HttpsServer;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.SequenceInputStream;
+import java.net.InetSocketAddress;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
+import java.security.KeyStore;
+import java.security.SecureRandom;
+import java.security.cert.Certificate;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.Callable;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import javax.net.ssl.HostnameVerifier;
+import javax.net.ssl.HttpsURLConnection;
+import javax.net.ssl.KeyManagerFactory;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLEngine;
+import javax.net.ssl.SSLParameters;
+import javax.net.ssl.SSLSession;
+import javax.net.ssl.TrustManagerFactory;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.api.parallel.Isolated;
+
+/**
+ * Hermetic in-process socket test suite for the mTLS OAuth token exchange pipeline.
+ *
+ * 

Spins up an in-process JDK {@link HttpsServer} on {@code localhost} requiring client + * authentication ({@code setNeedClientAuth(true)}), validating peer certificates and request + * payloads across mTLS token exchanges, 401 retry with cert reloading, concurrent refreshes, and + * atomic token reads. + */ +@Isolated +class MtlsPipelineLocalTest { + + private static final String TEST_CERT_PATH = "testresources/mtls/test_cert.pem"; + private static final String TEST_KEY_PATH = "testresources/mtls/test_key.pem"; + private static final String TEST_CERT_2_PATH = "testresources/mtls/test_cert_2.pem"; + private static final String TEST_KEY_2_PATH = "testresources/mtls/test_key_2.pem"; + private static final String AUDIENCE = + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider"; + private static final String ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"; + + private static File tempTrustStoreFile; + private static HostnameVerifier originalHostnameVerifier; + private static String originalTrustStore; + private static String originalTrustStorePassword; + private static String originalTrustStoreType; + + private HttpsServer server; + private int serverPort; + private ExecutorService serverExecutor; + + @BeforeAll + static void beforeAll() throws Exception { + // Initialize the default SSLContext before overriding javax.net.ssl.trustStore below, so it + // keeps the JVM's default trust settings. + SSLContext.getDefault(); + originalHostnameVerifier = HttpsURLConnection.getDefaultHostnameVerifier(); + HttpsURLConnection.setDefaultHostnameVerifier((hostname, session) -> true); + + originalTrustStore = System.getProperty("javax.net.ssl.trustStore"); + originalTrustStorePassword = System.getProperty("javax.net.ssl.trustStorePassword"); + originalTrustStoreType = System.getProperty("javax.net.ssl.trustStoreType"); + + // Create a truststore containing test_cert.pem so client NetHttpTransport trusts the server + KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); + trustStore.load(null, null); + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + try (FileInputStream fis = new FileInputStream(new File(TEST_CERT_PATH))) { + Certificate cert = cf.generateCertificate(fis); + trustStore.setCertificateEntry("server-cert", cert); + } + + tempTrustStoreFile = File.createTempFile("mtls_test_truststore", ".jks"); + tempTrustStoreFile.deleteOnExit(); + try (FileOutputStream fos = new FileOutputStream(tempTrustStoreFile)) { + trustStore.store(fos, "changeit".toCharArray()); + } + + System.setProperty("javax.net.ssl.trustStore", tempTrustStoreFile.getAbsolutePath()); + System.setProperty("javax.net.ssl.trustStorePassword", "changeit"); + System.setProperty("javax.net.ssl.trustStoreType", KeyStore.getDefaultType()); + } + + @AfterAll + static void afterAll() { + if (originalHostnameVerifier != null) { + HttpsURLConnection.setDefaultHostnameVerifier(originalHostnameVerifier); + } + if (originalTrustStore != null) { + System.setProperty("javax.net.ssl.trustStore", originalTrustStore); + } else { + System.clearProperty("javax.net.ssl.trustStore"); + } + if (originalTrustStorePassword != null) { + System.setProperty("javax.net.ssl.trustStorePassword", originalTrustStorePassword); + } else { + System.clearProperty("javax.net.ssl.trustStorePassword"); + } + if (originalTrustStoreType != null) { + System.setProperty("javax.net.ssl.trustStoreType", originalTrustStoreType); + } else { + System.clearProperty("javax.net.ssl.trustStoreType"); + } + if (tempTrustStoreFile != null && tempTrustStoreFile.exists()) { + tempTrustStoreFile.delete(); + } + } + + @BeforeEach + void setUp() throws Exception { + SSLContext sslContext = createServerSSLContext(); + server = HttpsServer.create(new InetSocketAddress("localhost", 0), 0); + server.setHttpsConfigurator( + new HttpsConfigurator(sslContext) { + @Override + public void configure(HttpsParameters params) { + SSLContext context = getSSLContext(); + SSLEngine engine = context.createSSLEngine(); + SSLParameters sslParams = context.getDefaultSSLParameters(); + sslParams.setNeedClientAuth(true); + sslParams.setCipherSuites(engine.getEnabledCipherSuites()); + sslParams.setProtocols(engine.getEnabledProtocols()); + params.setSSLParameters(sslParams); + } + }); + + serverExecutor = Executors.newCachedThreadPool(); + server.setExecutor(serverExecutor); + serverPort = server.getAddress().getPort(); + } + + @AfterEach + void tearDown() throws InterruptedException { + if (server != null) { + server.stop(0); + } + if (serverExecutor != null) { + serverExecutor.shutdownNow(); + serverExecutor.awaitTermination(5, TimeUnit.SECONDS); + } + } + + private static SSLContext createServerSSLContext() throws Exception { + KeyStore serverKeyStore; + try (InputStream certStream = new FileInputStream(new File(TEST_CERT_PATH)); + InputStream keyStream = new FileInputStream(new File(TEST_KEY_PATH)); + InputStream combined = new SequenceInputStream(certStream, keyStream)) { + serverKeyStore = SecurityUtils.createMtlsKeyStore(combined); + } + + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(serverKeyStore, "".toCharArray()); + + KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); + trustStore.load(null, null); + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + try (FileInputStream fis = new FileInputStream(new File(TEST_CERT_PATH))) { + Certificate cert = cf.generateCertificate(fis); + trustStore.setCertificateEntry("client-cert", cert); + } + try (FileInputStream fis = new FileInputStream(new File(TEST_CERT_2_PATH))) { + Certificate cert2 = cf.generateCertificate(fis); + trustStore.setCertificateEntry("client-cert-2", cert2); + } + + TrustManagerFactory tmf = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(trustStore); + + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(kmf.getKeyManagers(), tmf.getTrustManagers(), new SecureRandom()); + return sslContext; + } + + private static KeyStore createClientKeyStore() throws Exception { + try (InputStream certStream = new FileInputStream(new File(TEST_CERT_PATH)); + InputStream keyStream = new FileInputStream(new File(TEST_KEY_PATH)); + InputStream combined = new SequenceInputStream(certStream, keyStream)) { + return SecurityUtils.createMtlsKeyStore(combined); + } + } + + private static Map parseFormData(String body) throws Exception { + Map params = new HashMap<>(); + for (String pair : body.split("&")) { + int idx = pair.indexOf("="); + if (idx > 0) { + String key = URLDecoder.decode(pair.substring(0, idx), "UTF-8"); + String value = URLDecoder.decode(pair.substring(idx + 1), "UTF-8"); + params.put(key, value); + } + } + return params; + } + + private static String readRequestBody(HttpExchange exchange) throws IOException { + try (InputStream is = exchange.getRequestBody(); + ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + byte[] buf = new byte[1024]; + int read; + while ((read = is.read(buf)) != -1) { + baos.write(buf, 0, read); + } + return baos.toString(StandardCharsets.UTF_8.name()); + } + } + + private static void sendJsonResponse(HttpExchange exchange, int statusCode, String json) + throws IOException { + byte[] bytes = json.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8"); + if (statusCode == 401) { + exchange.getResponseHeaders().set("WWW-Authenticate", "Bearer realm=\"oauth\""); + } + exchange.sendResponseHeaders(statusCode, bytes.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(bytes); + os.flush(); + } + } + + /** + * Scenario A: testMtlsPipeline_verifiesPeerCertAndPayload + * + *

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

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

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

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

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

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

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

Builds credentials through the public programmatic path only: subject and actor token + * suppliers plus a caller-supplied {@link MtlsHttpTransportFactory}, with no certificate config. + * Verifies the caller-supplied client certificate is presented to both STS and IAM. + */ + @Test + void testMtlsPipeline_programmaticMtlsTransportFactory_usedForStsAndIam() throws Exception { + AtomicReference capturedStsCerts = new AtomicReference<>(); + AtomicReference> capturedStsParams = new AtomicReference<>(); + + server.createContext( + "/v1/token", + new HttpHandler() { + @Override + public void handle(HttpExchange exchange) throws IOException { + try { + HttpsExchange httpsExchange = (HttpsExchange) exchange; + capturedStsCerts.set(httpsExchange.getSSLSession().getPeerCertificates()); + capturedStsParams.set(parseFormData(readRequestBody(exchange))); + + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("access_token", "intermediate_sts_token_programmatic"); + response.put("token_type", "Bearer"); + response.put("expires_in", 3600); + response.put("issued_token_type", ACCESS_TOKEN_TYPE); + sendJsonResponse(exchange, 200, response.toPrettyString()); + } catch (Exception e) { + sendJsonResponse(exchange, 500, "{\"error\": \"" + e.getMessage() + "\"}"); + } + } + }); + + AtomicReference capturedIamCerts = new AtomicReference<>(); + AtomicReference capturedIamAuthHeader = new AtomicReference<>(); + + server.createContext( + "/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken", + new HttpHandler() { + @Override + public void handle(HttpExchange exchange) throws IOException { + try { + HttpsExchange httpsExchange = (HttpsExchange) exchange; + capturedIamCerts.set(httpsExchange.getSSLSession().getPeerCertificates()); + capturedIamAuthHeader.set(exchange.getRequestHeaders().getFirst("Authorization")); + readRequestBody(exchange); + + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("accessToken", "final_target_sa_access_token_programmatic"); + response.put("expireTime", "2030-01-01T00:00:00Z"); + sendJsonResponse(exchange, 200, response.toPrettyString()); + } catch (Exception e) { + sendJsonResponse(exchange, 500, "{\"error\": \"" + e.getMessage() + "\"}"); + } + } + }); + + server.start(); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(context -> "testSubjectTokenProgrammatic") + .setActorTokenSupplier(context -> "testActorTokenProgrammatic") + .setActorTokenType(SubjectTokenTypes.JWT.value) + .setAudience(AUDIENCE) + .setSubjectTokenType(SubjectTokenTypes.JWT) + .setTokenUrl("https://localhost:" + serverPort + "/v1/token") + .setServiceAccountImpersonationUrl( + "https://localhost:" + + serverPort + + "/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken") + .setHttpTransportFactory(new MtlsHttpTransportFactory(createClientKeyStore())) + .build(); + + AccessToken accessToken = credentials.refreshAccessToken(); + assertEquals("final_target_sa_access_token_programmatic", accessToken.getTokenValue()); + + // The caller-supplied KeyStore holds Cert A, and no certificate config exists, so both STS and + // IAM must present Cert A from the supplied transport factory. + Certificate[] stsCerts = capturedStsCerts.get(); + assertNotNull(stsCerts); + assertEquals( + "CN=1009120726878.apps.googleusercontent.com", + ((X509Certificate) stsCerts[0]).getSubjectX500Principal().getName()); + Certificate[] iamCerts = capturedIamCerts.get(); + assertNotNull(iamCerts); + assertEquals( + "CN=1009120726878.apps.googleusercontent.com", + ((X509Certificate) iamCerts[0]).getSubjectX500Principal().getName()); + + assertEquals("Bearer intermediate_sts_token_programmatic", capturedIamAuthHeader.get()); + Map stsParams = capturedStsParams.get(); + assertNotNull(stsParams); + assertEquals("testSubjectTokenProgrammatic", stsParams.get("subject_token")); + assertEquals("testActorTokenProgrammatic", stsParams.get("actor_token")); + } +} diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuthExceptionTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuthExceptionTest.java index 3b50ae9d3b6b..ebad147ed3d2 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuthExceptionTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuthExceptionTest.java @@ -33,9 +33,16 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import com.google.api.client.http.HttpHeaders; +import com.google.api.client.http.HttpResponseException; import com.google.auth.TestUtils; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; import java.io.ObjectStreamClass; import org.junit.jupiter.api.Test; @@ -132,9 +139,216 @@ void createFromHttpResponseException_baseFormat() throws IOException { } @Test - void serialVersionUID_matchesReleasedVersions() { + void createFromHttpResponseException_nullContent() { + HttpResponseException httpException = + new HttpResponseException.Builder( + /* statusCode= */ 401, /* statusMessage= */ "Unauthorized", new HttpHeaders()) + .setContent(null) + .build(); + + OAuthException e = OAuthException.createFromHttpResponseException(httpException); + + assertEquals("http_error_401", e.getErrorCode()); + assertEquals("Unauthorized", e.getErrorDescription()); + assertNull(e.getErrorUri()); + assertEquals(401, e.getHttpStatusCode()); + assertSame(httpException, e.getCause()); + } + + @Test + void createFromHttpResponseException_emptyContent() { + HttpResponseException httpException = + new HttpResponseException.Builder( + /* statusCode= */ 401, /* statusMessage= */ "Unauthorized", new HttpHeaders()) + .setContent(" ") + .build(); + + OAuthException e = OAuthException.createFromHttpResponseException(httpException); + + assertEquals("http_error_401", e.getErrorCode()); + assertEquals("Unauthorized", e.getErrorDescription()); + assertNull(e.getErrorUri()); + assertEquals(401, e.getHttpStatusCode()); + assertSame(httpException, e.getCause()); + } + + @Test + void createFromHttpResponseException_nonJsonContent() { + HttpResponseException httpException = + new HttpResponseException.Builder( + /* statusCode= */ 502, /* statusMessage= */ "Bad Gateway", new HttpHeaders()) + .setContent("Bad Gateway") + .build(); + + OAuthException e = OAuthException.createFromHttpResponseException(httpException); + + assertEquals("http_error_502", e.getErrorCode()); + assertEquals("Bad Gateway", e.getErrorDescription()); + assertNull(e.getErrorUri()); + assertEquals(502, e.getHttpStatusCode()); + assertSame(httpException, e.getCause()); + } + + @Test + void createFromHttpResponseException_missingErrorField() { + HttpResponseException httpException = + new HttpResponseException.Builder( + /* statusCode= */ 400, /* statusMessage= */ "Bad Request", new HttpHeaders()) + .setContent("{\"error_description\": \"some description\"}") + .build(); + + OAuthException e = OAuthException.createFromHttpResponseException(httpException); + + assertEquals("http_error_400", e.getErrorCode()); + assertEquals("some description", e.getErrorDescription()); + assertNull(e.getErrorUri()); + assertEquals(400, e.getHttpStatusCode()); + assertSame(httpException, e.getCause()); + } + + @Test + void createFromHttpResponseException_googleApiJsonErrorObject() { + HttpResponseException httpException = + new HttpResponseException.Builder( + /* statusCode= */ 401, /* statusMessage= */ null, new HttpHeaders()) + .setContent( + "{\"error\": {\"code\": 401, \"message\": \"Request had invalid authentication" + + " credentials.\", \"status\": \"UNAUTHENTICATED\"}}") + .build(); + + OAuthException e = OAuthException.createFromHttpResponseException(httpException); + + assertEquals("UNAUTHENTICATED", e.getErrorCode()); + assertEquals("Request had invalid authentication credentials.", e.getErrorDescription()); + assertNull(e.getErrorUri()); + assertEquals(401, e.getHttpStatusCode()); + assertSame(httpException, e.getCause()); + } + + @Test + void createFromHttpResponseException_jsonLiteralNull() { + HttpResponseException httpException = + new HttpResponseException.Builder( + /* statusCode= */ 500, + /* statusMessage= */ "Internal Server Error", + new HttpHeaders()) + .setContent("null") + .build(); + + OAuthException e = OAuthException.createFromHttpResponseException(httpException); + + assertEquals("http_error_500", e.getErrorCode()); + assertEquals("Internal Server Error", e.getErrorDescription()); + assertNull(e.getErrorUri()); + assertEquals(500, e.getHttpStatusCode()); + assertSame(httpException, e.getCause()); + } + + @Test + void createFromHttpResponseException_emptyJsonObject_fallsBackToStatusMessage() { + HttpResponseException httpException = + new HttpResponseException.Builder( + /* statusCode= */ 401, /* statusMessage= */ "Unauthorized", new HttpHeaders()) + .setContent("{}") + .build(); + + OAuthException e = OAuthException.createFromHttpResponseException(httpException); + + assertEquals("http_error_401", e.getErrorCode()); + assertEquals("Unauthorized", e.getErrorDescription()); + assertNull(e.getErrorUri()); + assertEquals(401, e.getHttpStatusCode()); + assertSame(httpException, e.getCause()); + } + + @Test + void createFromHttpResponseException_topLevelMessage_extractsMessage() { + HttpResponseException httpException = + new HttpResponseException.Builder( + /* statusCode= */ 400, /* statusMessage= */ "Bad Request", new HttpHeaders()) + .setContent("{\"message\": \"Certificate expired\"}") + .build(); + + OAuthException e = OAuthException.createFromHttpResponseException(httpException); + + assertEquals("http_error_400", e.getErrorCode()); + assertEquals("Certificate expired", e.getErrorDescription()); + assertNull(e.getErrorUri()); + assertEquals(400, e.getHttpStatusCode()); + assertSame(httpException, e.getCause()); + } + + @Test + void createFromHttpResponseException_nullErrorField_fallsBackToContent() { + String content = "{\"error\": null}"; + HttpResponseException httpException = + new HttpResponseException.Builder( + /* statusCode= */ 400, /* statusMessage= */ "Bad Request", new HttpHeaders()) + .setContent(content) + .build(); + + OAuthException e = OAuthException.createFromHttpResponseException(httpException); + + assertEquals("http_error_400", e.getErrorCode()); + assertEquals(content, e.getErrorDescription()); + assertEquals(400, e.getHttpStatusCode()); + assertSame(httpException, e.getCause()); + } + + @Test + void createFromHttpResponseException_arrayErrorField_fallsBackToContent() { + String content = "{\"error\": []}"; + HttpResponseException httpException = + new HttpResponseException.Builder( + /* statusCode= */ 400, /* statusMessage= */ "Bad Request", new HttpHeaders()) + .setContent(content) + .build(); + + OAuthException e = OAuthException.createFromHttpResponseException(httpException); + + assertEquals("http_error_400", e.getErrorCode()); + assertEquals(content, e.getErrorDescription()); + assertEquals(400, e.getHttpStatusCode()); + assertSame(httpException, e.getCause()); + } + + @Test + void createFromHttpResponseException_errorObjectWithoutMessage_fallsBackToContent() { + String content = "{\"error\": {\"code\": 400}}"; + HttpResponseException httpException = + new HttpResponseException.Builder( + /* statusCode= */ 400, /* statusMessage= */ "Bad Request", new HttpHeaders()) + .setContent(content) + .build(); + + OAuthException e = OAuthException.createFromHttpResponseException(httpException); + + assertEquals("http_error_400", e.getErrorCode()); + assertEquals(content, e.getErrorDescription()); + assertEquals(400, e.getHttpStatusCode()); + assertSame(httpException, e.getCause()); + } + + @Test + void serialVersionUID_matchesReleasedUidAndRoundTrips() throws Exception { assertEquals( -5276727039237496975L, ObjectStreamClass.lookup(OAuthException.class).getSerialVersionUID()); + + OAuthException original = + new OAuthException("invalid_client", "Certificate mismatch", "https://example.com", 401); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ObjectOutputStream oos = new ObjectOutputStream(baos)) { + oos.writeObject(original); + } + OAuthException deserialized; + try (ObjectInputStream ois = + new ObjectInputStream(new ByteArrayInputStream(baos.toByteArray()))) { + deserialized = (OAuthException) ois.readObject(); + } + assertEquals("invalid_client", deserialized.getErrorCode()); + assertEquals("Certificate mismatch", deserialized.getErrorDescription()); + assertEquals("https://example.com", deserialized.getErrorUri()); + assertEquals(401, deserialized.getHttpStatusCode()); } } diff --git a/google-auth-library-java/oauth2_http/testresources/mtls/test_cert.pem b/google-auth-library-java/oauth2_http/testresources/mtls/test_cert.pem index 17fcf0227c80..6b59dc4c9723 100644 --- a/google-auth-library-java/oauth2_http/testresources/mtls/test_cert.pem +++ b/google-auth-library-java/oauth2_http/testresources/mtls/test_cert.pem @@ -1,14 +1,20 @@ -----BEGIN CERTIFICATE----- -MIICGzCCAYSgAwIBAgIIWrt6xtmHPs4wDQYJKoZIhvcNAQEFBQAwMzExMC8GA1UE -AxMoMTAwOTEyMDcyNjg3OC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbTAeFw0x -MjEyMDExNjEwNDRaFw0yMjExMjkxNjEwNDRaMDMxMTAvBgNVBAMTKDEwMDkxMjA3 -MjY4NzguYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20wgZ8wDQYJKoZIhvcNAQEB -BQADgY0AMIGJAoGBAL1SdY8jTUVU7O4/XrZLYTw0ON1lV6MQRGajFDFCqD2Fd9tQ -GLW8Iftx9wfXe1zuaehJSgLcyCxazfyJoN3RiONBihBqWY6d3lQKqkgsRTNZkdFJ -Wdzl/6CxhK9sojh2p0r3tydtv9iwq5fuuWIvtODtT98EgphhncQAqkKoF3zVAgMB -AAGjODA2MAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBYGA1UdJQEB/wQM -MAoGCCsGAQUFBwMCMA0GCSqGSIb3DQEBBQUAA4GBAD8XQEqzGePa9VrvtEGpf+R4 -fkxKbcYAzqYq202nKu0kfjhIYkYSBj6gi348YaxE64yu60TVl42l5HThmswUheW4 -uQIaq36JvwvsDP5Zoj5BgiNSnDAFQp+jJFBRUA5vooJKgKgMDf/r/DCOsbO6VJF1 -kWwa9n19NFiV0z3m6isj +MIIDSTCCAjGgAwIBAgIULHknJr/h6bKI9J/iji5DKRumlsgwDQYJKoZIhvcNAQEL +BQAwMzExMC8GA1UEAwwoMTAwOTEyMDcyNjg3OC5hcHBzLmdvb2dsZXVzZXJjb250 +ZW50LmNvbTAgFw0yNjA5MjIwMjAyMDJaGA8yMTI2MDgyOTAyMDIwMlowMzExMC8G +A1UEAwwoMTAwOTEyMDcyNjg3OC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbTCC +ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKDe3cQIOSKdJBCHHHxedeRg +7SlxL25n0rEY0IuiHvYak/tRNGV8fHcQtpEwFScgT3oKYpC0eZ/crVk4cppZ21xN +ZT81JmVtA6BbK5MHZMaimYtBngD3awwC//E5cKBC3wEr79OpV1gyERCawe6lcGVC +QzY2G3k321PH2Yv9/5b0qFpJ/51KGAaLC2AMIZOWBEcWMbxUU+iQjxFo4ZxmRvne +s9ctwqD0bAHmFUTsUHwwSMAmICAopJoEP/8AJ+l6QR4OQADvTVsBo31ZT7M/n5tO +yhCnAWVxORrq5rtkx/AL41+qklz17VvVW9V04tO4W5w9iRaqKUb7z/Z7lm9RytsC +AwEAAaNTMFEwHQYDVR0OBBYEFO+baurDiMbGFs0RW/oWP6F+FvuIMB8GA1UdIwQY +MBaAFO+baurDiMbGFs0RW/oWP6F+FvuIMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI +hvcNAQELBQADggEBAD0Lmd3K6jvXqhMofzFg+mlMWrtrvt1Tpo86mLBHmvLwk97F +dUvWam5k1cKQTuR2f58euuKcAOhYLhyYA0iAb3T35CRYQymrAyVjqfFwDpT9FnxD +uz/mYWL+lD8mi3QOB7K3JOgDLrrzZgKOo2LIliHGAYJbYYUR/vwVm34VFbtll8u1 +AxOPtL6HZbIy7Df2fnrcXhgTwRXX//BPvDeG8KzlA8283STsswmcjaySvhQvt7D8 +zaIKJkEEDHlafuVmrs8RnEKaQ2Oa8grXIwGtACAUKD05YzsjVqyNGTwEysXW2Gil +EwBTaKkIG/naV4/zAA4214ioovHmdhzRMGd+SlA= -----END CERTIFICATE----- diff --git a/google-auth-library-java/oauth2_http/testresources/mtls/test_key.pem b/google-auth-library-java/oauth2_http/testresources/mtls/test_key.pem index c6a91c3ce623..6c9d3f0c2d9c 100644 --- a/google-auth-library-java/oauth2_http/testresources/mtls/test_key.pem +++ b/google-auth-library-java/oauth2_http/testresources/mtls/test_key.pem @@ -1,16 +1,28 @@ -----BEGIN PRIVATE KEY----- -MIICdQIBADANBgkqhkiG9w0BAQEFAASCAl8wggJbAgEAAoGBAL1SdY8jTUVU7O4/ -XrZLYTw0ON1lV6MQRGajFDFCqD2Fd9tQGLW8Iftx9wfXe1zuaehJSgLcyCxazfyJ -oN3RiONBihBqWY6d3lQKqkgsRTNZkdFJWdzl/6CxhK9sojh2p0r3tydtv9iwq5fu -uWIvtODtT98EgphhncQAqkKoF3zVAgMBAAECgYB51B9cXe4yiGTzJ4pOKpHGySAy -sC1F/IjXt2eeD3PuKv4m/hL4l7kScpLx0+NJuQ4j8U2UK/kQOdrGANapB1ZbMZAK -/q0xmIUzdNIDiGSoTXGN2mEfdsEpQ/Xiv0lyhYBBPC/K4sYIpHccnhSRQUZlWLLY -lE5cFNKC9b7226mNvQJBAPt0hfCNIN0kUYOA9jdLtx7CE4ySGMPf5KPBuzPd8ty1 -fxaFm9PB7B76VZQYmHcWy8rT5XjoLJHrmGW1ZvP+iDsCQQDAvnKoarPOGb5iJfkq -RrA4flf1TOlf+1+uqIOJ94959jkkJeb0gv/TshDnm6/bWn+1kJylQaKygCizwPwB -Z84vAkA0Duur4YvsPJijoQ9YY1SGCagCcjyuUKwFOxaGpmyhRPIKt56LOJqpzyno -fy8ReKa4VyYq4eZYT249oFCwMwIBAkAROPNF2UL3x5UbcAkznd1hLujtIlI4IV4L -XUNjsJtBap7we/KHJq11XRPlniO4lf2TW7iji5neGVWJulTKS1xBAkAerktk4Hsw -ErUaUG1s/d+Sgc8e/KMeBElV+NxGhcWEeZtfHMn/6VOlbzY82JyvC9OKC80A5CAE -VUV6b25kqrcu +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCg3t3ECDkinSQQ +hxx8XnXkYO0pcS9uZ9KxGNCLoh72GpP7UTRlfHx3ELaRMBUnIE96CmKQtHmf3K1Z +OHKaWdtcTWU/NSZlbQOgWyuTB2TGopmLQZ4A92sMAv/xOXCgQt8BK+/TqVdYMhEQ +msHupXBlQkM2Nht5N9tTx9mL/f+W9KhaSf+dShgGiwtgDCGTlgRHFjG8VFPokI8R +aOGcZkb53rPXLcKg9GwB5hVE7FB8MEjAJiAgKKSaBD//ACfpekEeDkAA701bAaN9 +WU+zP5+bTsoQpwFlcTka6ua7ZMfwC+NfqpJc9e1b1VvVdOLTuFucPYkWqilG+8/2 +e5ZvUcrbAgMBAAECggEABBCdme57QxylZKWFWr8oS3UHiRyLDkchU8hEvMKD9cQG +KTuMD2Rt4LRTaKfGYQ6382V/yBhHWnrBBpgejTfoCxUJkedN6RIwHUUwfK6bJUnG +h3ZI/81ArzUEMpw49FO8PtNVZAvLYTcip0DB1b3ocSaTfDMxzoZSzHMoVBk+9BmA +PYFuYakcoLyeknlVRqrZr6sZi8qK+qrikpkUS42n6l3Em0SiLONXRTd6jt5M9BRT +toANspvmH5Qch7WxTYkdd59XCiQGrNGG2tcVszHgedEv3Uzk8t7GMoqIxM+NtrtE +cKpr3vwbLWt4kTVxmu0ypqOQORI0nZkiEIsmvtbIwQKBgQDb+4+FAyjIiy/NNYxZ +aeVrbkldwi0SVnmYx+24F4vZWDhclThHdZPzl9iE5VYVL+0zIT8RryxqtGg7Qw+a +ql6NV43JgOt/RmRSHtfAZmKVUGgSJ9NfhYrVtWXB/NMKn3O4QHET/K5+61S87Ywg +JvS1zeFgxk59vgDLbqG4oVHEVwKBgQC7NaiQYo5n7i2XWYJLQSr6KHJcL+/LRvrx +urG/rpmVM/Cz2eIWwqPYyrXKwSeSvJrsXz9zcL0KXTmuz2IsSKDNoeTX+L0vpoQE +BItet0lNvaifaLWafZ+6//8RDufrVqBVzIpqXwu8Hdn5swm3fLenr7fZrzWwgyUe +PhQaGJW7HQKBgGa1iqn48qbHzdbLRK9OsS46vQiIq9CfDYEW/9fvLn2ZIh+gEDnV +HPIkGJTcGxgjW2V/BVp1ZpCfkPbydauqFiF6GppVlh7Dt/1bpzKO20KJ2z122MsF +p+/mFQ/Awqx4DGiftew2EybxK4xWgRFV/vWPncY4cNdmyioxilKRB4NbAoGAQ5yU +fVjq34Q4uQGdufoWQHSdIMg7TWocNXNcwMKZOzagBwR40AmJzUEguNJbrXYqaZXY +v6OG0phqvcSuSxoL4VhH4uw1v2DfKuhNQW7mrYoCvVe+xsX6Czprb0i339nQXExb +7SDYhx8s2y6bIsaLOaePubG6rT4gCLUY43ffk80CgYEAhIiMuWMA7S8Ecf81OjFp +w50GO/AyUcnkM/Z7itfoYdH9LM/SvmrcXeHMk6vUNsNpXIdGJhY5sTy26nsxcx01 +q5oWLr/pHpsWKaa2aJsJ08fx/D7+EX+a+jpBTutiEjEhBo7zQfD3ai6rqwhmFKWm +9Te3km56VkF+dYukuKNS56k= -----END PRIVATE KEY----- From d06feac8bfc0c7f2096caaa2bf7b1c7379fb56aa Mon Sep 17 00:00:00 2001 From: macastelaz <34776182+macastelaz@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:35:05 -0500 Subject: [PATCH 07/10] feat(oauth2): support non-mTLS token URLs and unbound actor tokens in IdentityPoolCredentials (#14430) ## Summary Allows `actor_token` and `actor_token_type` to be used in `IdentityPoolCredentials` with standard (non-mTLS) STS and IAM impersonation endpoints and without requiring client certificate (`certificate_config`) configuration. ## Context & Rationale In #13955, client-side guardrails (`isMtlsConfigured()` and `validateMtlsEndpoint()`) were enforced in the `IdentityPoolCredentials` constructor because Google STS initially required actor tokens to be paired with certificate-bound tokens over mTLS. Per the original design discussion, this was intentionally designed as a one-way door that could be loosened in a non-breaking manner once backend support for non-mTLS actor token exchanges was ready. ## Changes 1. **Removed Constructor mTLS Restrictions:** - Removed `isMtlsConfigured()` check in `IdentityPoolCredentials(Builder)` so actor tokens can be configured with standard `HttpTransportFactory` instances and without a `certificate` block. - Removed `validateMtlsEndpoint()` checks on `tokenUrl` and `serviceAccountImpersonationUrl`, allowing standard public endpoints (e.g., `https://sts.googleapis.com/v1/token`) to be used with actor tokens. - Preserved strict pairing validation between `actorTokenSupplier` and `actorTokenType`, as well as JSON format checks for file-based actor token extraction. 2. **401 Handling & Cert Rotation:** - When `x509Provider == null` (non-mTLS credentials), `refreshAccessToken()` uses standard transport without snapshotting a `KeyStore`, and `401 Unauthorized` errors propagate immediately without retry. - When `x509Provider != null && transportFactory instanceof MtlsHttpTransportFactory` (mTLS credentials), per-cycle certificate pinning and single-retry cert reload on `401` remain unchanged. 3. **Javadoc & Test Coverage:** - Updated class-level and builder Javadocs on `IdentityPoolCredentials`. - Updated negative constructor tests to positive verification tests (`*_succeeds`). - Added `refreshAccessToken_401WithActorTokenAndNonMtlsTransport_bubblesUpWithoutRetry` and `fromStream_fileCredentialSource_withoutCertificateConfig_andActorToken_withNonMtlsUrl_refreshesSuccessfully` to verify end-to-end non-mTLS actor token exchanges. ## Verification - All **1,035** unit tests passing in `oauth2_http` module (including all **88** tests in `IdentityPoolCredentialsTest`). - 100% compliant with `fmt-maven-plugin:2.25:check`. --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../FileIdentityPoolSubjectTokenSupplier.java | 21 +- .../auth/oauth2/IdentityPoolCredentials.java | 87 +-- ...eIdentityPoolSubjectTokenSupplierTest.java | 80 +++ .../oauth2/IdentityPoolCredentialsTest.java | 565 +++++++++++++----- 4 files changed, 547 insertions(+), 206 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplier.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplier.java index 457de2bf9378..f0c84f1c52c0 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplier.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplier.java @@ -172,7 +172,12 @@ private static String extractField(GenericJson json, String fieldName) throws IO + " must be a String but was: " + value.getClass().getName()); } - return (String) value; + String token = (String) value; + if (token.trim().isEmpty()) { + throw new IOException( + "Invalid token field value. Empty token was found for field: " + fieldName); + } + return token; } /** Used primarily for UrlIdentityPoolSubjectTokenSupplier */ @@ -195,19 +200,7 @@ static String parseToken( GenericJson fileContents = parser.parseAndClose(in, StandardCharsets.UTF_8, GenericJson.class); - Object value = fileContents.get(targetFieldName); - if (value == null || Data.isNull(value)) { - throw new IOException( - "Invalid token field name. No token was found for field: " + targetFieldName); - } - if (!(value instanceof String)) { - throw new IOException( - "Token field value for " - + targetFieldName - + " must be a String but was: " - + value.getClass().getName()); - } - return (String) value; + return extractField(fileContents, targetFieldName); } } 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 d1d6c4d55918..06765b154fb1 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 @@ -42,11 +42,11 @@ import com.google.errorprone.annotations.CanIgnoreReturnValue; import java.io.IOException; import java.io.ObjectInputStream; -import java.net.URI; import java.security.KeyStore; import java.util.ArrayList; import java.util.Collection; import java.util.Map; +import java.util.Objects; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -55,9 +55,9 @@ * *

By default, attempts to exchange the external credential for a GCP access token. * - *

Note: Actor token extraction is currently restricted to file-based JSON credential sources - * over mTLS endpoints. When configuring certificate-bound OAuth 2.0 tokens, ensure your transport - * layer is configured for mTLS in tandem. + *

Note: Actor token extraction from credential configuration files is currently restricted to + * file-based JSON credential sources. When configuring certificate-bound OAuth 2.0 tokens, ensure + * your transport layer and endpoints are configured for mTLS in tandem. */ @NullMarked public class IdentityPoolCredentials extends ExternalAccountCredentials { @@ -104,10 +104,7 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { // 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); + this.useMtlsTransportFactory = builder.useMtlsTransportFactory; // Initialize based on the source type if (builder.subjectTokenSupplier != null) { @@ -171,43 +168,6 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { throw new IllegalArgumentException( "An actorTokenSupplier must be specified when an actorTokenType is configured."); } - - if (this.actorTokenSupplier != null && !isMtlsConfigured()) { - throw new IllegalArgumentException( - "Actor tokens are only supported for mTLS token exchanges. Please configure a certificate" - + " configuration in the credential source or provide an MtlsHttpTransportFactory" - + " constructed with a KeyStore."); - } - - if (this.actorTokenSupplier != null) { - validateMtlsEndpoint(getTokenUrl(), "tokenUrl"); - if (getServiceAccountImpersonationUrl() != null) { - validateMtlsEndpoint(getServiceAccountImpersonationUrl(), "serviceAccountImpersonationUrl"); - } - } - } - - private static void validateMtlsEndpoint(@Nullable String url, String fieldName) { - if (url == null) { - return; - } - String host = URI.create(url).getHost(); - // For Google Default Universe (googleapis.com), actor tokens require mTLS binding so plain - // public endpoints (lacking '.mtls.' or Private Service Connect '.p.') are rejected early. - // Non-GDU domains (e.g. custom universes or TPC) may use different hostname conventions. - if (host != null - && host.endsWith("googleapis.com") - && !host.contains(".mtls.") - && !host.contains(".p.")) { - throw new IllegalArgumentException( - "The " - + fieldName - + " endpoint (" - + url - + ") cannot be used with actor tokens because it is a plain public Google API" - + " endpoint. Please use an mTLS endpoint (e.g. containing '.mtls.') or Private" - + " Service Connect (containing '.p.')."); - } } /** @@ -242,7 +202,6 @@ boolean isMtlsConfigured() { */ 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 @@ -266,7 +225,7 @@ boolean hasMtlsProviderForImpersonation() { * Returns whether a clone of this credential can skip reading the KeyStore because the transport * factory is already initialized (an mTLS factory with a KeyStore, or a custom non-mTLS factory). */ - boolean hasInitializedMtlsTransport() { + private boolean hasInitializedMtlsTransport() { return this.x509Provider != null && (!shouldUseMtlsTransportFactory() || (this.transportFactory instanceof MtlsHttpTransportFactory @@ -463,8 +422,12 @@ private AccessToken buildAndExchangeStsRequest( StsTokenExchangeRequest.newBuilder(subjectToken, getSubjectTokenType()) .setAudience(getAudience()); - if (actorToken != null && this.actorTokenType != null) { - stsTokenExchangeRequest.setActingParty(new ActingParty(actorToken, this.actorTokenType)); + if (this.actorTokenSupplier != null) { + if (actorToken == null || actorToken.trim().isEmpty()) { + throw new IOException("The provided actor token cannot be null or empty."); + } + stsTokenExchangeRequest.setActingParty( + new ActingParty(actorToken, Objects.requireNonNull(this.actorTokenType))); } Collection scopes = getScopes(); @@ -640,7 +603,7 @@ 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 useMtlsTransportFactory = true; private boolean isClonedTransportInitialized; Builder() {} @@ -653,10 +616,11 @@ public static class Builder extends ExternalAccountCredentials.Builder { } else if (credentials.actorTokenSupplier != credentials.subjectTokenSupplier) { this.actorTokenSupplier = credentials.actorTokenSupplier; } - // Note: when credentialSource is present, subjectTokenSupplier and file-based - // actorTokenSupplier are intentionally NOT copied here. They will be reconstructed from - // credentialSource during build(), which ensures they share the same - // FileIdentityPoolSubjectTokenSupplier instance for atomic token reads. + // Note: when credentialSource is present and actorTokenSupplier was reconstructed from the + // same FileIdentityPoolSubjectTokenSupplier instance as subjectTokenSupplier, neither is + // copied here so they can be reconstructed together during build() for atomic token reads. + // When a distinct actorTokenSupplier was explicitly provided alongside credentialSource, it + // is preserved above so createScoped() / toBuilder() do not drop it. this.actorTokenType = credentials.actorTokenType; this.x509Provider = credentials.x509Provider; // Preserve the original mTLS flag across clones because super(credentials) copies @@ -696,12 +660,11 @@ public Builder setSubjectTokenSupplier(IdentityPoolSubjectTokenSupplier subjectT } /** - * Sets the actor token supplier used for certificate-bound OAuth 2.0 token exchanges. The - * supplier provides an actor token representing the entity on whose behalf the subject is - * acting. + * Sets the actor token supplier used for OAuth 2.0 token exchanges. The supplier provides an + * actor token representing the acting party that acts on behalf of the subject. * - *

An actor token supplier must be paired with an {@link #setActorTokenType actor token type} - * and requires an mTLS-configured transport. + *

An actor token supplier must be paired with an {@link #setActorTokenType actor token + * type}. * * @param actorTokenSupplier the supplier to use for retrieving actor tokens * @return this {@code Builder} object @@ -768,6 +731,12 @@ public Builder setTokenUrl(String tokenUrl) { @CanIgnoreReturnValue public Builder setCredentialSource(IdentityPoolCredentialSource credentialSource) { + if (this.credentialSource != null && this.credentialSource != credentialSource) { + this.x509Provider = null; + if (this.useMtlsTransportFactory) { + this.transportFactory = null; + } + } super.setCredentialSource(credentialSource); this.isClonedTransportInitialized = false; return this; diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplierTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplierTest.java index 7cfc4ce5719a..5564116ee245 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplierTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplierTest.java @@ -380,6 +380,36 @@ void parseToken_jsonFormat_missingFieldName_throws() { "Target field name must be specified for JSON credentials.", exception.getMessage()); } + @Test + void parseToken_jsonFormat_emptyOrWhitespaceField_throws(@TempDir Path tempDir) + throws IOException { + Path credentialFile = tempDir.resolve("credential.json"); + Files.write(credentialFile, "{\"sub_token\": \" \"}".getBytes(StandardCharsets.UTF_8)); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", credentialFile.toString()); + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "sub_token"); + credentialSourceMap.put("format", formatMap); + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); + + FileIdentityPoolSubjectTokenSupplier supplier = + new FileIdentityPoolSubjectTokenSupplier(source); + IOException getSubjectException = + assertThrows(IOException.class, () -> supplier.getSubjectToken(null)); + assertTrue( + getSubjectException.getMessage().contains("Empty token was found for field: sub_token")); + + ByteArrayInputStream stream = + new ByteArrayInputStream("{\"sub_token\": \" \"}".getBytes(StandardCharsets.UTF_8)); + IOException parseException = + assertThrows( + IOException.class, + () -> FileIdentityPoolSubjectTokenSupplier.parseToken(stream, source, "sub_token")); + assertTrue(parseException.getMessage().contains("Empty token was found for field: sub_token")); + } + @Test void readTokens_extractsBothFields(@TempDir Path tempDir) throws IOException { Path credentialFile = tempDir.resolve("credential.json"); @@ -449,6 +479,56 @@ void readTokens_missingSubjectField_throwsIOException(@TempDir Path tempDir) thr assertTrue(exception.getMessage().contains("No token was found for field: sub_token")); } + @Test + void readTokens_emptyOrWhitespaceActorField_throwsIOException(@TempDir Path tempDir) + throws IOException { + Path credentialFile = tempDir.resolve("credential.json"); + Files.write( + credentialFile, + "{\"sub_token\": \"my_sub_token\", \"act_token\": \" \"}" + .getBytes(StandardCharsets.UTF_8)); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", credentialFile.toString()); + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "sub_token"); + formatMap.put("actor_token_field_name", "act_token"); + credentialSourceMap.put("format", formatMap); + + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); + FileIdentityPoolSubjectTokenSupplier supplier = + new FileIdentityPoolSubjectTokenSupplier(source); + + IOException exception = assertThrows(IOException.class, () -> supplier.readTokens(null)); + assertTrue(exception.getMessage().contains("Empty token was found for field: act_token")); + } + + @Test + void readTokens_emptyOrWhitespaceSubjectField_throwsIOException(@TempDir Path tempDir) + throws IOException { + Path credentialFile = tempDir.resolve("credential.json"); + Files.write( + credentialFile, + "{\"sub_token\": \" \", \"act_token\": \"my_act_token\"}" + .getBytes(StandardCharsets.UTF_8)); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", credentialFile.toString()); + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "sub_token"); + formatMap.put("actor_token_field_name", "act_token"); + credentialSourceMap.put("format", formatMap); + + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); + FileIdentityPoolSubjectTokenSupplier supplier = + new FileIdentityPoolSubjectTokenSupplier(source); + + IOException exception = assertThrows(IOException.class, () -> supplier.readTokens(null)); + assertTrue(exception.getMessage().contains("Empty token was found for field: sub_token")); + } + @Test void readTokens_noActorFieldConfigured_returnsNullActor(@TempDir Path tempDir) throws IOException { 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 79fefa6a5cc5..8ccf8916baab 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 @@ -1468,34 +1468,30 @@ void setShouldThrowOnGetKeyStore(boolean shouldThrow) { } @Test - void builder_actorTokenWithNonMtlsTransportFactory_throws() { + void builder_actorTokenWithNonMtlsTransportFactory_succeeds() { IdentityPoolCredentialSource credentialSource = createFileCredentialSource(); + IdentityPoolActorTokenSupplier actorSupplier = context -> "token"; - IllegalArgumentException e = - assertThrows( - IllegalArgumentException.class, - () -> - IdentityPoolCredentials.newBuilder() - .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY) - .setAudience("audience") - .setSubjectTokenType("subjectTokenType") - .setTokenUrl("https://invalid.googleapis.com/") - .setCredentialSource(credentialSource) - .setActorTokenType("actorTokenType") - .setActorTokenSupplier( - new IdentityPoolActorTokenSupplier() { - @Override - public String getActorToken(ExternalAccountSupplierContext context) { - return "token"; - } - }) - .build()); + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.googleapis.com/v1/token") + .setCredentialSource(credentialSource) + .setActorTokenType("actorTokenType") + .setActorTokenSupplier(actorSupplier) + .build(); - assertEquals( - "Actor tokens are only supported for mTLS token exchanges. Please configure a certificate" - + " configuration in the credential source or provide an MtlsHttpTransportFactory" - + " constructed with a KeyStore.", - e.getMessage()); + assertNotNull(credentials); + assertSame(actorSupplier, credentials.getIdentityPoolActorTokenSupplier()); + assertEquals("actorTokenType", credentials.getActorTokenType()); + + IdentityPoolCredentials scoped = + credentials.createScoped( + Collections.singletonList("https://www.googleapis.com/auth/cloud-platform")); + assertSame(actorSupplier, scoped.getIdentityPoolActorTokenSupplier()); + assertEquals("actorTokenType", scoped.getActorTokenType()); } @Test @@ -1817,91 +1813,66 @@ void toBuilder_preservesActorTokenType() throws Exception { } @Test - void builder_actorTokenWithoutMtls_throws() { - IllegalArgumentException e = - assertThrows( - IllegalArgumentException.class, - () -> - IdentityPoolCredentials.newBuilder() - .setSubjectTokenSupplier(testProvider) - .setActorTokenSupplier(testActorSupplier) - .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") - .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY) - .setAudience("audience") - .setSubjectTokenType("subjectTokenType") - .setTokenUrl("https://sts.googleapis.com/v1/token") - .build()); - assertTrue( - e.getMessage().contains("Actor tokens are only supported for mTLS token exchanges.")); + void builder_actorTokenWithoutMtls_succeeds() { + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setActorTokenSupplier(testActorSupplier) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.googleapis.com/v1/token") + .build(); + assertNotNull(credentials); + assertSame(testActorSupplier, credentials.getIdentityPoolActorTokenSupplier()); + assertEquals("urn:ietf:params:oauth:token-type:jwt", credentials.getActorTokenType()); } @Test - void builder_actorTokenWithNoArgMtlsFactory_throws() throws Exception { - // A no-arg MtlsHttpTransportFactory (e.g. from deserialization) has no KeyStore, - // so isMtlsConfigured() should return false and building should fail. + void builder_actorTokenWithNoArgMtlsFactory_succeeds() { MtlsHttpTransportFactory noArgFactory = new MtlsHttpTransportFactory(); assertFalse(noArgFactory.hasKeyStore()); - IllegalArgumentException e = - assertThrows( - IllegalArgumentException.class, - () -> - IdentityPoolCredentials.newBuilder() - .setSubjectTokenSupplier(testProvider) - .setActorTokenSupplier(testActorSupplier) - .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") - .setHttpTransportFactory(noArgFactory) - .setAudience("audience") - .setSubjectTokenType("subjectTokenType") - .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") - .build()); - assertTrue( - e.getMessage().contains("Actor tokens are only supported for mTLS token exchanges.")); + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setActorTokenSupplier(testActorSupplier) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setHttpTransportFactory(noArgFactory) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.googleapis.com/v1/token") + .build(); + assertNotNull(credentials); + assertSame(noArgFactory, credentials.getTransportFactory()); + assertSame(testActorSupplier, credentials.getIdentityPoolActorTokenSupplier()); + assertEquals("urn:ietf:params:oauth:token-type:jwt", credentials.getActorTokenType()); + assertEquals("https://sts.googleapis.com/v1/token", credentials.getTokenUrl()); } @Test - void builder_actorTokenWithEmptyMtlsFactory_throws() throws Exception { + void builder_actorTokenWithEmptyMtlsFactory_succeeds() throws Exception { KeyStore emptyKeyStore = KeyStore.getInstance(KeyStore.getDefaultType()); emptyKeyStore.load(null, null); MtlsHttpTransportFactory emptyFactory = new MtlsHttpTransportFactory(emptyKeyStore); assertFalse(emptyFactory.hasKeyStore()); - IllegalArgumentException e = - assertThrows( - IllegalArgumentException.class, - () -> - IdentityPoolCredentials.newBuilder() - .setSubjectTokenSupplier(testProvider) - .setActorTokenSupplier(testActorSupplier) - .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") - .setHttpTransportFactory(emptyFactory) - .setAudience("audience") - .setSubjectTokenType("subjectTokenType") - .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") - .build()); - assertTrue( - e.getMessage().contains("Actor tokens are only supported for mTLS token exchanges.")); - } - - @Test - void mtlsHttpTransportFactory_hasKeyStore_withPopulatedKeyStore_returnsTrue() throws Exception { - KeyStore ks = createPopulatedKeyStore(); - MtlsHttpTransportFactory factory = new MtlsHttpTransportFactory(ks); - assertTrue(factory.hasKeyStore()); - } - - @Test - void mtlsHttpTransportFactory_hasKeyStore_withEmptyKeyStore_returnsFalse() throws Exception { - KeyStore emptyKeyStore = KeyStore.getInstance(KeyStore.getDefaultType()); - emptyKeyStore.load(null, null); - MtlsHttpTransportFactory factory = new MtlsHttpTransportFactory(emptyKeyStore); - assertFalse(factory.hasKeyStore()); - } - - @Test - void mtlsHttpTransportFactory_hasKeyStore_noArg_returnsFalse() { - MtlsHttpTransportFactory factory = new MtlsHttpTransportFactory(); - assertFalse(factory.hasKeyStore()); + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setActorTokenSupplier(testActorSupplier) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setHttpTransportFactory(emptyFactory) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.googleapis.com/v1/token") + .build(); + assertNotNull(credentials); + assertSame(emptyFactory, credentials.getTransportFactory()); + assertSame(testActorSupplier, credentials.getIdentityPoolActorTokenSupplier()); + assertEquals("urn:ietf:params:oauth:token-type:jwt", credentials.getActorTokenType()); + assertEquals("https://sts.googleapis.com/v1/token", credentials.getTokenUrl()); } // ================================================================================== @@ -2901,52 +2872,50 @@ void serialize_deserialize_programmaticFlow_withMtlsTransport_restoresFactoryWit } @Test - void builder_actorTokenWithPlainPublicTokenUrl_throwsIllegalArgumentException() throws Exception { + void builder_actorTokenWithPlainPublicTokenUrl_succeeds() throws Exception { KeyStore keyStore = createPopulatedKeyStore(); HttpTransportFactory transportFactory = new MtlsHttpTransportFactory(keyStore); - IllegalArgumentException e = - assertThrows( - IllegalArgumentException.class, - () -> - IdentityPoolCredentials.newBuilder() - .setSubjectTokenSupplier(context -> "testSubjectToken") - .setActorTokenSupplier(context -> "testActorToken") - .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") - .setAudience( - "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") - .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") - .setTokenUrl("https://sts.googleapis.com/v1/token") - .setHttpTransportFactory(transportFactory) - .build()); - assertTrue(e.getMessage().contains("tokenUrl")); - assertTrue(e.getMessage().contains("plain public Google API endpoint")); + IdentityPoolCredentials cred = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(context -> "testSubjectToken") + .setActorTokenSupplier(context -> "testActorToken") + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") + .setTokenUrl("https://sts.googleapis.com/v1/token") + .setHttpTransportFactory(transportFactory) + .build(); + assertNotNull(cred); + assertEquals("urn:ietf:params:oauth:token-type:jwt", cred.getActorTokenType()); + assertEquals("https://sts.googleapis.com/v1/token", cred.getTokenUrl()); } @Test - void builder_actorTokenWithPlainPublicImpersonationUrl_throwsIllegalArgumentException() - throws Exception { + void builder_actorTokenWithPlainPublicImpersonationUrl_succeeds() throws Exception { KeyStore keyStore = createPopulatedKeyStore(); HttpTransportFactory transportFactory = new MtlsHttpTransportFactory(keyStore); - IllegalArgumentException e = - assertThrows( - IllegalArgumentException.class, - () -> - IdentityPoolCredentials.newBuilder() - .setSubjectTokenSupplier(context -> "testSubjectToken") - .setActorTokenSupplier(context -> "testActorToken") - .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") - .setAudience( - "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") - .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") - .setTokenUrl(MockExternalAccountCredentialsTransport.STS_MTLS_URL) - .setServiceAccountImpersonationUrl( - "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken") - .setHttpTransportFactory(transportFactory) - .build()); - assertTrue(e.getMessage().contains("serviceAccountImpersonationUrl")); - assertTrue(e.getMessage().contains("plain public Google API endpoint")); + IdentityPoolCredentials cred = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(context -> "testSubjectToken") + .setActorTokenSupplier(context -> "testActorToken") + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") + .setTokenUrl(MockExternalAccountCredentialsTransport.STS_MTLS_URL) + .setServiceAccountImpersonationUrl( + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken") + .setHttpTransportFactory(transportFactory) + .build(); + assertNotNull(cred); + assertEquals("urn:ietf:params:oauth:token-type:jwt", cred.getActorTokenType()); + assertEquals(MockExternalAccountCredentialsTransport.STS_MTLS_URL, cred.getTokenUrl()); + assertEquals( + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken", + cred.getServiceAccountImpersonationUrl()); } @Test @@ -3323,6 +3292,336 @@ public KeyStore getKeyStore() { assertNotSame(originalTransportFactory, rebuiltWithProvider.getTransportFactory()); } + @Test + void toBuilder_setCredentialSource_withNewSource_clearsCopiedX509Provider() throws Exception { + Map certificateMap = new HashMap<>(); + certificateMap.put("use_default_certificate_config", false); + certificateMap.put("certificate_config_location", "testresources/mtls/certificate_config.json"); + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "subject_token"); + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", "credential.json"); + credentialSourceMap.put("format", formatMap); + credentialSourceMap.put("certificate", certificateMap); + + KeyStore ks = createPopulatedKeyStore(); + AtomicInteger getKeyStoreCount = new AtomicInteger(0); + X509Provider trackingProvider = + new X509Provider() { + @Override + public KeyStore getKeyStore() { + getKeyStoreCount.incrementAndGet(); + return ks; + } + }; + + IdentityPoolCredentials credential = + IdentityPoolCredentials.newBuilder() + .setCredentialSource(new IdentityPoolCredentialSource(credentialSourceMap)) + .setX509Provider(trackingProvider) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build(); + assertSame(trackingProvider, credential.getX509Provider()); + assertEquals(1, getKeyStoreCount.get()); + + IdentityPoolCredentials rebuilt = + credential.toBuilder() + .setCredentialSource(new IdentityPoolCredentialSource(credentialSourceMap)) + .build(); + + assertNotNull(rebuilt.getX509Provider()); + assertNotSame(trackingProvider, rebuilt.getX509Provider()); + assertNotSame(credential.getTransportFactory(), rebuilt.getTransportFactory()); + assertEquals(1, getKeyStoreCount.get()); + + Map sourceMapWithoutCert = new HashMap<>(); + sourceMapWithoutCert.put("file", "credential.json"); + sourceMapWithoutCert.put("format", formatMap); + IdentityPoolCredentials rebuiltWithoutCert = + credential.toBuilder() + .setCredentialSource(new IdentityPoolCredentialSource(sourceMapWithoutCert)) + .build(); + + assertNull(rebuiltWithoutCert.getX509Provider()); + assertFalse(rebuiltWithoutCert.getTransportFactory() instanceof MtlsHttpTransportFactory); + } + + @Test + void toBuilder_setHttpTransportFactory_withCustomFactoryAndCertConfig_preservesCustomFactory() + throws Exception { + Map certMap = new HashMap<>(); + certMap.put("use_default_certificate_config", false); + certMap.put("certificate_config_location", "testresources/mtls/certificate_config.json"); + Map sourceMap = new HashMap<>(); + sourceMap.put("certificate", certMap); + IdentityPoolCredentialSource credentialSource = new IdentityPoolCredentialSource(sourceMap); + + MockExternalAccountCredentialsTransportFactory customFactory = + new MockExternalAccountCredentialsTransportFactory(); + IdentityPoolCredentials credential = + IdentityPoolCredentials.newBuilder() + .setCredentialSource(credentialSource) + .setAudience("audience") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:mtls") + .setTokenUrl(customFactory.transport.getStsUrl()) + .build(); + assertTrue(credential.getTransportFactory() instanceof MtlsHttpTransportFactory); + + IdentityPoolCredentials rebuilt = + credential.toBuilder().setHttpTransportFactory(customFactory).build(); + + assertSame(customFactory, rebuilt.getTransportFactory()); + rebuilt.refreshAccessToken(); + assertSame(customFactory, rebuilt.getTransportFactory()); + assertEquals(1, customFactory.transport.getRequests().size()); + } + + @Test + void refreshAccessToken_withoutImpersonation_readsKeyStoreOncePerCycle() throws Exception { + AtomicInteger getKeyStoreCount = new AtomicInteger(0); + X509Provider countingProvider = + new X509Provider() { + @Override + public KeyStore getKeyStore() { + getKeyStoreCount.incrementAndGet(); + return createPopulatedKeyStore(); + } + }; + + TransportCapturingCredentials credential = + new TransportCapturingCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(countingProvider) + .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")); + assertEquals(0, getKeyStoreCount.get()); + + // First cycle: lazy initialization loads the KeyStore once and the exchange reuses it. + credential.refreshAccessToken(); + assertEquals(1, getKeyStoreCount.get()); + assertSame(credential.getTransportFactory(), credential.getCapturedFactories().get(0)); + + // Later cycles: one fresh per-cycle snapshot each. + credential.refreshAccessToken(); + assertEquals(2, getKeyStoreCount.get()); + assertNotSame(credential.getTransportFactory(), credential.getCapturedFactories().get(1)); + assertTrue(credential.getCapturedFactories().get(1) instanceof MtlsHttpTransportFactory); + } + + @Test + void refreshAccessToken_401WithActorTokenAndNonMtlsTransport_bubblesUpWithoutRetry() { + IdentityPoolCredentials.Builder builder = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(context -> "testSubjectToken") + .setActorTokenSupplier(context -> "testActorToken") + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/p/providers/pr") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") + .setTokenUrl("https://sts.googleapis.com/v1/token") + .setX509Provider( + new TestX509Provider(createPopulatedKeyStore(), "certificate_config_location")) + .setHttpTransportFactory(new MockExternalAccountCredentialsTransportFactory()); + + TestableIdentityPoolCredentials testable = new TestableIdentityPoolCredentials(builder, true); + + OAuthException thrown = assertThrows(OAuthException.class, testable::refreshAccessToken); + assertEquals(401, thrown.getHttpStatusCode()); + assertEquals(0, thrown.getSuppressed().length); + assertEquals(1, testable.getExchangeCallCount()); + } + + @Test + void + fromStream_fileCredentialSource_withoutCertificateConfig_andActorToken_withNonMtlsUrl_refreshesSuccessfully( + @TempDir Path tempDir) throws Exception { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + Path tokenFile = tempDir.resolve("credential_non_mtls.json"); + GenericJson tokenJson = new GenericJson(); + tokenJson.setFactory(JSON_FACTORY); + tokenJson.put("subject_token", "nonMtlsSubjectToken"); + tokenJson.put("actor_token", "nonMtlsActorToken"); + OAuth2Utils.writeInputStreamToFile( + new ByteArrayInputStream(tokenJson.toPrettyString().getBytes(StandardCharsets.UTF_8)), + tokenFile.toString()); + + String configJson = + "{\n" + + " \"type\": \"external_account\",\n" + + " \"audience\":" + + " \"//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider\",\n" + + " \"subject_token_type\": \"urn:ietf:params:oauth:token-type:jwt\",\n" + + " \"actor_token_type\": \"urn:ietf:params:oauth:token-type:jwt\",\n" + + " \"token_url\": \"" + + transportFactory.transport.getStsUrl() + + "\",\n" + + " \"credential_source\": {\n" + + " \"file\": \"" + + tokenFile.toString().replace("\\", "\\\\") + + "\",\n" + + " \"format\": {\n" + + " \"type\": \"json\",\n" + + " \"subject_token_field_name\": \"subject_token\",\n" + + " \"actor_token_field_name\": \"actor_token\"\n" + + " }\n" + + " }\n" + + "}"; + + ExternalAccountCredentials credentials = + ExternalAccountCredentials.fromStream( + new ByteArrayInputStream(configJson.getBytes(StandardCharsets.UTF_8)), + transportFactory); + + assertTrue(credentials instanceof IdentityPoolCredentials); + IdentityPoolCredentials idp = (IdentityPoolCredentials) credentials; + assertNull(idp.getX509Provider()); + assertEquals("urn:ietf:params:oauth:token-type:jwt", idp.getActorTokenType()); + assertFalse(idp.getTransportFactory() instanceof MtlsHttpTransportFactory); + assertSame(idp.getIdentityPoolSubjectTokenSupplier(), idp.getIdentityPoolActorTokenSupplier()); + + AccessToken token = idp.refreshAccessToken(); + assertNotNull(token); + Map query = + TestUtils.parseQuery(transportFactory.transport.getLastRequest().getContentAsString()); + assertEquals("nonMtlsSubjectToken", query.get("subject_token")); + assertEquals("nonMtlsActorToken", query.get("actor_token")); + assertEquals("urn:ietf:params:oauth:token-type:jwt", query.get("actor_token_type")); + + // Verify createScoped preserves actor token config and atomic supplier sharing + IdentityPoolCredentials scoped = + idp.createScoped( + Collections.singletonList("https://www.googleapis.com/auth/cloud-platform")); + assertEquals("urn:ietf:params:oauth:token-type:jwt", scoped.getActorTokenType()); + assertNotNull(scoped.getIdentityPoolActorTokenSupplier()); + assertSame( + scoped.getIdentityPoolSubjectTokenSupplier(), scoped.getIdentityPoolActorTokenSupplier()); + + // Verify Java serialization/deserialization round-trip preserves actor token config + IdentityPoolCredentials deserialized = serializeAndDeserialize(idp); + assertNull(deserialized.getX509Provider()); + assertFalse(deserialized.getTransportFactory() instanceof MtlsHttpTransportFactory); + assertEquals("urn:ietf:params:oauth:token-type:jwt", deserialized.getActorTokenType()); + assertNotNull(deserialized.getIdentityPoolActorTokenSupplier()); + assertSame( + deserialized.getIdentityPoolSubjectTokenSupplier(), + deserialized.getIdentityPoolActorTokenSupplier()); + } + + @Test + void refreshAccessToken_withImpersonationAndActorToken_fetchesTokensOnceAndCachesStsToken() + throws Exception { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + transportFactory.transport.setExpireTime(TestUtils.getDefaultExpireTime()); + AtomicInteger subjectTokenCount = new AtomicInteger(0); + AtomicInteger actorTokenCount = new AtomicInteger(0); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier( + context -> "subjectToken-" + subjectTokenCount.incrementAndGet()) + .setActorTokenSupplier(context -> "actorToken-" + actorTokenCount.incrementAndGet()) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") + .setTokenUrl(transportFactory.transport.getStsUrl()) + .setServiceAccountImpersonationUrl( + transportFactory.transport.getServiceAccountImpersonationUrl()) + .setHttpTransportFactory(transportFactory) + .build(); + + AccessToken firstToken = credentials.refreshAccessToken(); + assertNotNull(firstToken); + assertEquals(1, subjectTokenCount.get()); + assertEquals(1, actorTokenCount.get()); + + // Second refresh while intermediate STS token is still cached should not re-fetch subject or + // actor tokens. + AccessToken secondToken = credentials.refreshAccessToken(); + assertNotNull(secondToken); + assertEquals(1, subjectTokenCount.get()); + assertEquals(1, actorTokenCount.get()); + } + + @Test + void refreshAccessToken_withNullOrWhitespaceActorToken_throwsIOException() { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + IdentityPoolCredentials nullActorCredentials = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(context -> "testSubjectToken") + .setActorTokenSupplier(context -> null) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience("audience") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") + .setTokenUrl(STS_URL) + .setHttpTransportFactory(transportFactory) + .build(); + + IOException nullException = + assertThrows(IOException.class, nullActorCredentials::refreshAccessToken); + assertEquals("The provided actor token cannot be null or empty.", nullException.getMessage()); + + IdentityPoolCredentials blankActorCredentials = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(context -> "testSubjectToken") + .setActorTokenSupplier(context -> " ") + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience("audience") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") + .setTokenUrl(STS_URL) + .setHttpTransportFactory(transportFactory) + .build(); + + IOException blankException = + assertThrows(IOException.class, blankActorCredentials::refreshAccessToken); + assertEquals("The provided actor token cannot be null or empty.", blankException.getMessage()); + } + + @Test + void serialize_deserialize_withCustomTransportFactoryAndCertConfig_preservesCustomFactory() + throws Exception { + MockExternalAccountCredentialsTransportFactory customFactory = + new MockExternalAccountCredentialsTransportFactory(); + + Map certMap = new HashMap<>(); + certMap.put("use_default_certificate_config", false); + 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); + + KeyStore ks = createPopulatedKeyStore(); + X509Provider x509Provider = new TestX509Provider(ks, "certificate_config_location"); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setCredentialSource(credentialSource) + .setX509Provider(x509Provider) + .setHttpTransportFactory(customFactory) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build(); + + assertFalse(credentials.getTransportFactory() instanceof MtlsHttpTransportFactory); + + IdentityPoolCredentials deserialized = serializeAndDeserialize(credentials); + assertNotNull(deserialized.getX509Provider()); + assertFalse(deserialized.getTransportFactory() instanceof MtlsHttpTransportFactory); + assertEquals(customFactory.getClass(), deserialized.getTransportFactory().getClass()); + } + // ================================================================================== // Helper: TestableIdentityPoolCredentials — overrides exchange for 401 testing // ================================================================================== From 47374cf86815ef7afc6efe7fe8bbf7b4c3c4c001 Mon Sep 17 00:00:00 2001 From: macastelaz <34776182+macastelaz@users.noreply.github.com> Date: Fri, 16 Oct 2026 09:51:13 -0500 Subject: [PATCH 08/10] test(auth): enable mTLS and actor_token WIF integration tests by default (#14554) ### Summary Enables the 4 mTLS + `actor_token` Workload Identity Federation integration tests in `ITWorkloadIdentityFederationTest` to run by default in CI without requiring `GOOGLE_API_CERTIFICATE_CONFIG` or external Secret Manager secrets: 1. **Default mTLS Certificate Config Fallback:** Falls back to the checked-in `testresources/mtls/certificate_config.json` (`test_cert.pem` / `test_key.pem`) when `GOOGLE_API_CERTIFICATE_CONFIG` is unset, removing the `assumeTrue` skip. 2. **Signed OIDC JWTs with `cnf.x5t#S256` and `may_act`:** Targets `oidc-jwt-1` (`OIDC_JWT_AUDIENCE`) and uses the `GOOGLE_APPLICATION_CREDENTIALS` service account private key (whose public key is registered in `oidc-jwt-1`'s `jwksJson`) to sign OIDC JWTs containing RFC 8705 certificate thumbprint binding (`cnf.x5t#S256`) and delegation (`may_act`) claims. --- .../ITWorkloadIdentityFederationTest.java | 152 ++++++++++++++---- .../workloadidentityfederation-setup.sh | 23 +++ 2 files changed, 141 insertions(+), 34 deletions(-) diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ITWorkloadIdentityFederationTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ITWorkloadIdentityFederationTest.java index ef38cca471fc..b36ecee88ae6 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ITWorkloadIdentityFederationTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ITWorkloadIdentityFederationTest.java @@ -31,10 +31,10 @@ package com.google.auth.oauth2; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; -import static org.junit.jupiter.api.Assumptions.assumeTrue; import com.google.api.client.http.GenericUrl; import com.google.api.client.http.HttpRequest; @@ -47,6 +47,8 @@ import com.google.api.client.json.JsonFactory; import com.google.api.client.json.JsonObjectParser; import com.google.api.client.json.gson.GsonFactory; +import com.google.api.client.json.webtoken.JsonWebSignature; +import com.google.api.client.json.webtoken.JsonWebToken; import com.google.api.client.util.GenericData; import com.google.auth.http.HttpCredentialsAdapter; import com.google.auth.http.HttpTransportFactory; @@ -58,8 +60,15 @@ import java.io.FileInputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; import java.security.KeyStore; +import java.security.MessageDigest; +import java.security.PrivateKey; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; import java.time.Instant; +import java.util.Base64; +import java.util.Enumeration; import java.util.HashMap; import java.util.Map; import org.junit.jupiter.api.BeforeEach; @@ -71,7 +80,8 @@ *

The only requirements for this test suite to run is to set the environment variable * GOOGLE_APPLICATION_CREDENTIALS to point to the same service account keys used in the setup script * (workloadidentityfederation-setup). These tests call GCS to get bucket information. The bucket - * name must be provided through the GCS_BUCKET environment variable. + * name must be provided through the GCS_BUCKET environment variable. The mTLS tests use {@code + * testresources/mtls/certificate_config.json}. */ final class ITWorkloadIdentityFederationTest { @@ -83,13 +93,24 @@ final class ITWorkloadIdentityFederationTest { private static final String AWS_AUDIENCE = AUDIENCE_PREFIX + "aws-1"; private static final String OIDC_AUDIENCE = AUDIENCE_PREFIX + "oidc-1"; + private static final String OIDC_JWT_AUDIENCE = AUDIENCE_PREFIX + "oidc-jwt-1"; + private static final String OIDC_JWT_ISSUER = + "https://storage.googleapis.com/example-oidc-issuer"; + private static final String DEFAULT_MTLS_CERT_CONFIG_PATH = + "testresources/mtls/certificate_config.json"; private String clientEmail; + private String clientId; + private String privateKeyId; + private PrivateKey privateKey; @BeforeEach void setup() throws IOException { GenericJson keys = getServiceAccountKeyFileAsJson(); clientEmail = (String) keys.get("client_email"); + clientId = (String) keys.get("client_id"); + privateKeyId = (String) keys.get("private_key_id"); + privateKey = OAuth2Utils.privateKeyFromPkcs8((String) keys.get("private_key")); } /** @@ -290,16 +311,20 @@ void identityPoolCredentials_withProgrammaticAuth() throws IOException { /** * IdentityPoolCredentials (OIDC provider with certificate-bound workload and actor token): Uses - * the service account to generate Google ID tokens for subject and actor tokens. Writes both + * the service account private key to sign OIDC JWTs for subject and actor tokens with RFC 8705 + * certificate thumbprint binding (cnf.x5t#S256) and delegation (may_act) claims. Writes both * tokens to a temporary JSON file with subject_token and actor_token field names. Configures * certificate_config_location pointing to the certificate config. Exchanges the tokens over the * mTLS STS endpoint (https://sts.mtls.googleapis.com/v1/token) and calls GCS. */ @Test void identityPoolCredentials_withCertificateBoundWorkloadAndActorToken() throws Exception { - String certConfigPath = getMtlsCertificateConfigPath(); - String subjectToken = generateGoogleIdToken(OIDC_AUDIENCE); - String actorToken = generateGoogleIdToken(OIDC_AUDIENCE); + KeyStore keyStore = new X509Provider(DEFAULT_MTLS_CERT_CONFIG_PATH).getKeyStore(); + String certThumbprint = computeCertThumbprint(keyStore); + String subjectToken = + generateSignedOidcJwt(OIDC_JWT_AUDIENCE, certThumbprint, /* includeMayAct= */ true); + String actorToken = + generateSignedOidcJwt(OIDC_JWT_AUDIENCE, certThumbprint, /* includeMayAct= */ false); File tokenFile = File.createTempFile( @@ -317,7 +342,7 @@ void identityPoolCredentials_withCertificateBoundWorkloadAndActorToken() throws GenericJson config = new GenericJson(); config.put("type", "external_account"); - config.put("audience", OIDC_AUDIENCE); + config.put("audience", OIDC_JWT_AUDIENCE); config.put("subject_token_type", "urn:ietf:params:oauth:token-type:jwt"); config.put("actor_token_type", "urn:ietf:params:oauth:token-type:jwt"); config.put("token_url", "https://sts.mtls.googleapis.com/v1/token"); @@ -337,7 +362,7 @@ void identityPoolCredentials_withCertificateBoundWorkloadAndActorToken() throws credentialSource.put("format", format); GenericJson certificate = new GenericJson(); - certificate.put("certificate_config_location", certConfigPath); + certificate.put("certificate_config_location", DEFAULT_MTLS_CERT_CONFIG_PATH); credentialSource.put("certificate", certificate); config.put("credential_source", credentialSource); @@ -346,7 +371,6 @@ void identityPoolCredentials_withCertificateBoundWorkloadAndActorToken() throws (IdentityPoolCredentials) ExternalAccountCredentials.fromJson(config, OAuth2Utils.HTTP_TRANSPORT_FACTORY); - KeyStore keyStore = new X509Provider(certConfigPath).getKeyStore(); HttpTransport mtlsTransport = new MtlsHttpTransportFactory(keyStore).create(); callGcs( identityPoolCredentials, @@ -359,26 +383,30 @@ void identityPoolCredentials_withCertificateBoundWorkloadAndActorToken() throws /** * IdentityPoolCredentials (OIDC provider with programmatic mTLS and actor token): Uses the - * service account to generate Google ID tokens for subject and actor tokens via suppliers. + * service account private key to sign OIDC JWTs for subject and actor tokens via suppliers. * Configures mTLS transport using MtlsHttpTransportFactory with KeyStore loaded from the mTLS * certificate config. Exchanges the tokens over mTLS STS endpoint and calls GCS. */ @Test void identityPoolCredentials_withProgrammaticMtlsAndActorToken() throws Exception { + X509Provider x509Provider = new X509Provider(DEFAULT_MTLS_CERT_CONFIG_PATH); + KeyStore keyStore = x509Provider.getKeyStore(); + String certThumbprint = computeCertThumbprint(keyStore); IdentityPoolSubjectTokenSupplier tokenSupplier = - context -> generateGoogleIdToken(OIDC_AUDIENCE); - IdentityPoolActorTokenSupplier actorSupplier = context -> generateGoogleIdToken(OIDC_AUDIENCE); + context -> + generateSignedOidcJwt(OIDC_JWT_AUDIENCE, certThumbprint, /* includeMayAct= */ true); + IdentityPoolActorTokenSupplier actorSupplier = + context -> + generateSignedOidcJwt(OIDC_JWT_AUDIENCE, certThumbprint, /* includeMayAct= */ false); - X509Provider x509Provider = new X509Provider(getMtlsCertificateConfigPath()); - HttpTransportFactory transportFactory = - new MtlsHttpTransportFactory(x509Provider.getKeyStore()); + HttpTransportFactory transportFactory = new MtlsHttpTransportFactory(keyStore); IdentityPoolCredentials credentials = IdentityPoolCredentials.newBuilder() .setSubjectTokenSupplier(tokenSupplier) .setActorTokenSupplier(actorSupplier) .setActorTokenType(SubjectTokenTypes.JWT.value) - .setAudience(OIDC_AUDIENCE) + .setAudience(OIDC_JWT_AUDIENCE) .setSubjectTokenType(SubjectTokenTypes.JWT) .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") .setServiceAccountImpersonationUrl( @@ -401,10 +429,13 @@ void identityPoolCredentials_withProgrammaticMtlsAndActorToken() throws Exceptio */ @Test void identityPoolCredentials_directSts_withCertificateBoundWorkloadAndActorToken() - throws IOException { - String certConfigPath = getMtlsCertificateConfigPath(); - String subjectToken = generateGoogleIdToken(OIDC_AUDIENCE); - String actorToken = generateGoogleIdToken(OIDC_AUDIENCE); + throws Exception { + KeyStore keyStore = new X509Provider(DEFAULT_MTLS_CERT_CONFIG_PATH).getKeyStore(); + String certThumbprint = computeCertThumbprint(keyStore); + String subjectToken = + generateSignedOidcJwt(OIDC_JWT_AUDIENCE, certThumbprint, /* includeMayAct= */ true); + String actorToken = + generateSignedOidcJwt(OIDC_JWT_AUDIENCE, certThumbprint, /* includeMayAct= */ false); File tokenFile = File.createTempFile( @@ -424,7 +455,7 @@ void identityPoolCredentials_directSts_withCertificateBoundWorkloadAndActorToken GenericJson config = new GenericJson(); config.put("type", "external_account"); - config.put("audience", OIDC_AUDIENCE); + config.put("audience", OIDC_JWT_AUDIENCE); config.put("subject_token_type", "urn:ietf:params:oauth:token-type:jwt"); config.put("actor_token_type", "urn:ietf:params:oauth:token-type:jwt"); config.put("token_url", "https://sts.mtls.googleapis.com/v1/token"); @@ -439,7 +470,7 @@ void identityPoolCredentials_directSts_withCertificateBoundWorkloadAndActorToken credentialSource.put("format", format); GenericJson certificate = new GenericJson(); - certificate.put("certificate_config_location", certConfigPath); + certificate.put("certificate_config_location", DEFAULT_MTLS_CERT_CONFIG_PATH); credentialSource.put("certificate", certificate); config.put("credential_source", credentialSource); @@ -451,6 +482,7 @@ void identityPoolCredentials_directSts_withCertificateBoundWorkloadAndActorToken AccessToken accessToken = identityPoolCredentials.refreshAccessToken(); assertNotNull(accessToken); assertNotNull(accessToken.getTokenValue()); + assertFalse(accessToken.getTokenValue().isEmpty()); } finally { tokenFile.delete(); } @@ -463,20 +495,24 @@ void identityPoolCredentials_directSts_withCertificateBoundWorkloadAndActorToken */ @Test void identityPoolCredentials_directSts_withProgrammaticMtlsAndActorToken() throws Exception { + X509Provider x509Provider = new X509Provider(DEFAULT_MTLS_CERT_CONFIG_PATH); + KeyStore keyStore = x509Provider.getKeyStore(); + String certThumbprint = computeCertThumbprint(keyStore); IdentityPoolSubjectTokenSupplier tokenSupplier = - context -> generateGoogleIdToken(OIDC_AUDIENCE); - IdentityPoolActorTokenSupplier actorSupplier = context -> generateGoogleIdToken(OIDC_AUDIENCE); + context -> + generateSignedOidcJwt(OIDC_JWT_AUDIENCE, certThumbprint, /* includeMayAct= */ true); + IdentityPoolActorTokenSupplier actorSupplier = + context -> + generateSignedOidcJwt(OIDC_JWT_AUDIENCE, certThumbprint, /* includeMayAct= */ false); - X509Provider x509Provider = new X509Provider(getMtlsCertificateConfigPath()); - HttpTransportFactory transportFactory = - new MtlsHttpTransportFactory(x509Provider.getKeyStore()); + HttpTransportFactory transportFactory = new MtlsHttpTransportFactory(keyStore); IdentityPoolCredentials credentials = IdentityPoolCredentials.newBuilder() .setSubjectTokenSupplier(tokenSupplier) .setActorTokenSupplier(actorSupplier) .setActorTokenType(SubjectTokenTypes.JWT.value) - .setAudience(OIDC_AUDIENCE) + .setAudience(OIDC_JWT_AUDIENCE) .setSubjectTokenType(SubjectTokenTypes.JWT) .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") .setHttpTransportFactory(transportFactory) @@ -485,6 +521,7 @@ void identityPoolCredentials_directSts_withProgrammaticMtlsAndActorToken() throw AccessToken accessToken = credentials.refreshAccessToken(); assertNotNull(accessToken); assertNotNull(accessToken.getTokenValue()); + assertFalse(accessToken.getTokenValue().isEmpty()); } private GenericJson buildIdentityPoolCredentialConfig() throws IOException { @@ -588,12 +625,59 @@ private GenericJson buildAwsCredentialConfig() { return config; } - private String getMtlsCertificateConfigPath() { - String certConfigPath = System.getenv("GOOGLE_API_CERTIFICATE_CONFIG"); - assumeTrue( - certConfigPath != null && !certConfigPath.isEmpty(), - "Skipping mTLS test: GOOGLE_API_CERTIFICATE_CONFIG env variable is not set."); - return certConfigPath; + private String computeCertThumbprint(KeyStore keyStore) throws IOException { + try { + Enumeration aliases = keyStore.aliases(); + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + if (keyStore.isKeyEntry(alias)) { + Certificate[] chain = keyStore.getCertificateChain(alias); + Certificate cert = + (chain != null && chain.length > 0) ? chain[0] : keyStore.getCertificate(alias); + if (cert instanceof X509Certificate) { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(cert.getEncoded()); + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + } + } + } + } catch (GeneralSecurityException e) { + throw new IOException("Failed to compute X.509 certificate SHA-256 thumbprint.", e); + } + throw new IOException("No X509Certificate found in KeyStore."); + } + + private String generateSignedOidcJwt(String audience, String x5tS256, boolean includeMayAct) + throws IOException { + JsonWebSignature.Header header = new JsonWebSignature.Header(); + header.setAlgorithm("RS256"); + header.setType("JWT"); + header.setKeyId(privateKeyId); + + long nowSeconds = Instant.now().getEpochSecond(); + JsonWebToken.Payload payload = new JsonWebToken.Payload(); + payload.setIssuer(OIDC_JWT_ISSUER); + payload.setSubject(clientId); + payload.setAudience(audience); + payload.setIssuedAtTimeSeconds(nowSeconds); + payload.setExpirationTimeSeconds(nowSeconds + 3600); + + Map cnf = new HashMap<>(); + cnf.put("x5t#S256", x5tS256); + payload.put("cnf", cnf); + + if (includeMayAct) { + Map mayAct = new HashMap<>(); + mayAct.put("sub", clientId); + mayAct.put("iss", OIDC_JWT_ISSUER); + payload.put("may_act", mayAct); + } + + try { + return JsonWebSignature.signUsingRsaSha256( + privateKey, OAuth2Utils.JSON_FACTORY, header, payload); + } catch (GeneralSecurityException e) { + throw new IOException("Error signing OIDC JWT with service account private key.", e); + } } private void callGcs(GoogleCredentials credentials) throws IOException { diff --git a/google-auth-library-java/scripts/workloadidentityfederation-setup.sh b/google-auth-library-java/scripts/workloadidentityfederation-setup.sh index 2d7692d6d3c1..f7aac99ede0b 100755 --- a/google-auth-library-java/scripts/workloadidentityfederation-setup.sh +++ b/google-auth-library-java/scripts/workloadidentityfederation-setup.sh @@ -104,6 +104,7 @@ generate_random_string pool_id="pool-"${suffix} oidc_provider_id="oidc-"${suffix} +oidc_jwt_provider_id="oidc-jwt-"${suffix} aws_provider_id="aws-"${suffix} # Fill in. @@ -115,6 +116,7 @@ service_account_email="" sub=""; # client_id from service account key file oidc_aud="//iam.googleapis.com/projects/${project_number}/locations/global/workloadIdentityPools/${pool_id}/providers/${oidc_provider_id}" +oidc_jwt_aud="//iam.googleapis.com/projects/${project_number}/locations/global/workloadIdentityPools/${pool_id}/providers/${oidc_jwt_provider_id}" aws_aud="//iam.googleapis.com/projects/${project_number}/locations/global/workloadIdentityPools/${pool_id}/providers/${aws_provider_id}" gcloud config set project ${project_id} @@ -132,6 +134,26 @@ gcloud beta iam workload-identity-pools providers create-oidc ${oidc_provider_id --location="global" \ --attribute-mapping="google.subject=assertion.sub" +# Create the OIDC JWT Provider for mTLS / certificate-bound and actor token tests. +jwk_file=$(mktemp) +curl -sS "https://www.googleapis.com/service_accounts/v1/jwk/${service_account_email}" > "${jwk_file}" +gcloud beta iam workload-identity-pools providers create-oidc ${oidc_jwt_provider_id} \ + --workload-identity-pool=${pool_id} \ + --issuer-uri="https://storage.googleapis.com/example-oidc-issuer" \ + --jwk-json-path="${jwk_file}" \ + --location="global" \ + --attribute-mapping="google.subject=assertion.sub" +rm -f "${jwk_file}" + +# Configure actorProviders on the OIDC JWT Provider to allow actor_token delegation. +oidc_jwt_provider_resource="projects/${project_number}/locations/global/workloadIdentityPools/${pool_id}/providers/${oidc_jwt_provider_id}" +curl -sS -X PATCH \ + -H "Authorization: Bearer $(gcloud auth print-access-token)" \ + -H "X-Goog-User-Project: ${project_id}" \ + -H "Content-Type: application/json" \ + -d "{\"actorProviders\": [\"${oidc_jwt_provider_resource}\"]}" \ + "https://iam.googleapis.com/v1/${oidc_jwt_provider_resource}?updateMask=actorProviders" + # Create the AWS Provider. gcloud beta iam workload-identity-pools providers create-aws ${aws_provider_id} \ --workload-identity-pool=${pool_id} \ @@ -148,6 +170,7 @@ gcloud iam service-accounts add-iam-policy-binding ${service_account_email} \ --member "principalSet://iam.googleapis.com/projects/${project_number}/locations/global/workloadIdentityPools/${pool_id}/attribute.aws_role/arn:aws:sts::${aws_account_id}:assumed-role/${aws_role_name}" echo "OIDC audience:"${oidc_aud} +echo "OIDC JWT audience:"${oidc_jwt_aud} echo "AWS audience:"${aws_aud} echo "AWS role name:"${aws_role_name} echo "AWS role ARN: arn:aws:iam::${aws_account_id}:role/${aws_role_name}" From a2778ef09e710a17825ff1320cde8aaef2858cde Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Tue, 27 Oct 2026 02:28:38 +0000 Subject: [PATCH 09/10] fix(oauth2): address pre-merge review findings for certificate-bound tokens --- .../oauth2/ExternalAccountCredentials.java | 2 +- .../oauth2/IdentityPoolCredentialSource.java | 8 +- .../auth/oauth2/IdentityPoolCredentials.java | 29 ++++--- ...eIdentityPoolSubjectTokenSupplierTest.java | 38 +++++---- .../oauth2/IdentityPoolCredentialsTest.java | 77 ++++++++++++------- .../auth/oauth2/MtlsPipelineLocalTest.java | 10 +-- 6 files changed, 100 insertions(+), 64 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java index de329a4e0e7f..25a9a86773c7 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 @@ -434,7 +434,7 @@ static ExternalAccountCredentials fromJson( Map json, HttpTransportFactory transportFactory) { String audience = (String) json.get("audience"); String subjectTokenType = (String) json.get("subject_token_type"); - String actorTokenType = (String) json.get("actor_token_type"); + String actorTokenType = getOptional(json, "actor_token_type", String.class); String tokenUrl = (String) json.get("token_url"); Map credentialSourceMap = (Map) json.get("credential_source"); diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentialSource.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentialSource.java index 7e0d65b12ec1..f06dbc754802 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentialSource.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentialSource.java @@ -33,6 +33,7 @@ import static com.google.common.base.Preconditions.checkArgument; +import com.google.api.client.util.Data; import java.util.HashMap; import java.util.Locale; import java.util.Map; @@ -306,6 +307,9 @@ public IdentityPoolCredentialSource(Map credentialSourceMap) { credentialFormatType = CredentialFormatType.JSON; subjectTokenFieldName = formatMap.get("subject_token_field_name"); actorTokenFieldName = formatMap.get("actor_token_field_name"); + if (Data.isNull(actorTokenFieldName)) { + actorTokenFieldName = null; + } if (actorTokenFieldName != null) { if (actorTokenFieldName.trim().isEmpty()) { throw new IllegalArgumentException("The actor_token_field_name must not be empty."); @@ -317,8 +321,8 @@ public IdentityPoolCredentialSource(Map credentialSourceMap) { } } else if (type != null && "text".equals(type.toLowerCase(Locale.US))) { credentialFormatType = CredentialFormatType.TEXT; - if (formatMap.containsKey("actor_token_field_name") - && formatMap.get("actor_token_field_name") != null) { + String textActorTokenFieldName = formatMap.get("actor_token_field_name"); + if (textActorTokenFieldName != null && !Data.isNull(textActorTokenFieldName)) { throw new IllegalArgumentException( "Actor tokens are only supported for JSON-formatted credential sources."); } 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 06765b154fb1..28c7156a9d63 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 @@ -114,7 +114,7 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { if (credentialSource.getCertificateConfig() != null) { try { initializeMtlsTransport(builder, credentialSource); - } catch (Exception e) { + } catch (IOException e) { throw new RuntimeException( "Failed to initialize mTLS transport for file credential source due to certificate" + " error.", @@ -226,10 +226,16 @@ boolean hasMtlsProviderForImpersonation() { * factory is already initialized (an mTLS factory with a KeyStore, or a custom non-mTLS factory). */ private boolean hasInitializedMtlsTransport() { - return this.x509Provider != null - && (!shouldUseMtlsTransportFactory() - || (this.transportFactory instanceof MtlsHttpTransportFactory - && ((MtlsHttpTransportFactory) this.transportFactory).hasKeyStore())); + if (this.x509Provider == null) { + return false; + } + if (!shouldUseMtlsTransportFactory()) { + return true; + } + if (this.transportFactory instanceof MtlsHttpTransportFactory) { + return ((MtlsHttpTransportFactory) this.transportFactory).hasKeyStore(); + } + return !(this.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory); } AccessToken refreshImpersonatedAccessTokenWithRetry(ImpersonatedCredentials impersonated) @@ -305,13 +311,15 @@ AccessToken refreshAccessToken( private AccessToken refreshWithRetry(@Nullable ImpersonatedCredentials targetImpersonated) throws IOException { - ImpersonatedCredentials effectiveImpersonated = - targetImpersonated != null ? targetImpersonated : getImpersonatedCredentials(); + ImpersonatedCredentials effectiveImpersonated = targetImpersonated; KeyStore pinnedKeyStore = null; try { pinnedKeyStore = this.x509Provider.getKeyStore(); HttpTransportFactory cycleTransportFactory = resolveCycleTransportFactory(/* explicitTransportFactory= */ null, pinnedKeyStore); + if (effectiveImpersonated == null) { + effectiveImpersonated = getImpersonatedCredentials(); + } return executeRefresh(cycleTransportFactory, pinnedKeyStore, effectiveImpersonated); } catch (IOException | RuntimeException e) { if (!isMtlsRetryableException(e)) { @@ -350,6 +358,9 @@ private AccessToken refreshWithRetry(@Nullable ImpersonatedCredentials targetImp if (!hasInitializedMtlsTransport()) { this.transportFactory = retryTransportFactory; } + if (effectiveImpersonated == null) { + effectiveImpersonated = getImpersonatedCredentials(); + } return executeRefresh(retryTransportFactory, freshKeyStore, effectiveImpersonated); } catch (IOException | RuntimeException retryException) { if (retryException != e) { @@ -670,7 +681,7 @@ public Builder setSubjectTokenSupplier(IdentityPoolSubjectTokenSupplier subjectT * @return this {@code Builder} object */ @CanIgnoreReturnValue - Builder setActorTokenSupplier(IdentityPoolActorTokenSupplier actorTokenSupplier) { + Builder setActorTokenSupplier(@Nullable IdentityPoolActorTokenSupplier actorTokenSupplier) { this.actorTokenSupplier = actorTokenSupplier; return this; } @@ -687,7 +698,7 @@ Builder setActorTokenSupplier(IdentityPoolActorTokenSupplier actorTokenSupplier) * @return this {@code Builder} object */ @CanIgnoreReturnValue - Builder setActorTokenType(String actorTokenType) { + Builder setActorTokenType(@Nullable String actorTokenType) { this.actorTokenType = actorTokenType; return this; } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplierTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplierTest.java index 5564116ee245..e26bf9142a9c 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplierTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplierTest.java @@ -110,8 +110,7 @@ void getToken_jsonFormat_reReadsFileOnEachCall(@TempDir Path tempDir) throws IOE } @Test - void getToken_jsonFormat_concurrentReads(@TempDir Path tempDir) - throws IOException, InterruptedException { + void getToken_jsonFormat_concurrentReads(@TempDir Path tempDir) throws Exception { Path credentialFile = tempDir.resolve("credential.json"); Files.write( credentialFile, @@ -136,27 +135,26 @@ void getToken_jsonFormat_concurrentReads(@TempDir Path tempDir) java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(numThreads); java.util.List> futures = new java.util.ArrayList<>(); - for (int i = 0; i < numThreads; i++) { - futures.add( - executor.submit( - () -> { - latch.countDown(); - latch.await(); - assertEquals("my_sub_token", supplier.getSubjectToken(null)); - assertEquals("my_act_token", supplier.getActorToken(null)); - return null; - })); - } + try { + for (int i = 0; i < numThreads; i++) { + futures.add( + executor.submit( + () -> { + latch.countDown(); + assertTrue(latch.await(5, java.util.concurrent.TimeUnit.SECONDS)); + assertEquals("my_sub_token", supplier.getSubjectToken(null)); + assertEquals("my_act_token", supplier.getActorToken(null)); + return null; + })); + } - // Wait for all threads to complete and verify no exceptions were thrown - for (java.util.concurrent.Future future : futures) { - try { - future.get(); - } catch (Exception e) { - throw new RuntimeException("Thread execution failed", e); + // Wait for all threads to complete and verify no exceptions were thrown + for (java.util.concurrent.Future future : futures) { + future.get(10, java.util.concurrent.TimeUnit.SECONDS); } + } finally { + executor.shutdownNow(); } - executor.shutdown(); } @Test 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 45f88378393c..9332dd00690a 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 @@ -1863,30 +1863,6 @@ void builder_actorTokenWithX509Provider_succeeds() throws Exception { assertEquals("urn:ietf:params:oauth:token-type:jwt", credentials.getActorTokenType()); } - @Test - void toBuilder_preservesActorTokenType() throws Exception { - KeyStore ks = createPopulatedKeyStore(); - MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); - - IdentityPoolCredentials credentials = - IdentityPoolCredentials.newBuilder() - .setHttpTransportFactory(mtlsTransport) - .setSubjectTokenSupplier(testProvider) - .setActorTokenSupplier(testActorSupplier) - .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") - .setAudience("audience") - .setSubjectTokenType("subjectTokenType") - .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") - .build(); - - IdentityPoolCredentials rebuilt = credentials.toBuilder().build(); - - assertNotNull(rebuilt); - assertEquals("urn:ietf:params:oauth:token-type:jwt", rebuilt.getActorTokenType()); - assertSame(testProvider, rebuilt.getIdentityPoolSubjectTokenSupplier()); - assertSame(testActorSupplier, rebuilt.getIdentityPoolActorTokenSupplier()); - } - @Test void builder_actorTokenWithoutMtls_succeeds() { IdentityPoolCredentials credentials = @@ -1957,8 +1933,7 @@ void builder_actorTokenWithEmptyMtlsFactory_succeeds() throws Exception { @Test void refreshAccessToken_pinsTransportForStsExchange() throws Exception { // Verify that the STS exchange uses the pinned transport factory from the KeyStore snapshot - // within one refresh cycle. Threading the pinned transport to IAM impersonation is deferred - // to a follow-up PR. + // within one refresh cycle. KeyStore ks = createPopulatedKeyStore(); AtomicInteger getKeyStoreCallCount = new AtomicInteger(0); @@ -3110,7 +3085,7 @@ void fromStream_fileCredentialSource_withCertificateConfig_andActorToken_refresh + " \"token_url\": \"https://sts.mtls.googleapis.com/v1/token\",\n" + " \"credential_source\": {\n" + " \"file\": \"" - + tokenFile.toString() + + tokenFile.toString().replace("\\", "\\\\") + "\",\n" + " \"format\": {\n" + " \"type\": \"json\",\n" @@ -3489,6 +3464,54 @@ public KeyStore getKeyStore() { assertTrue(credential.getCapturedFactories().get(1) instanceof MtlsHttpTransportFactory); } + @Test + void + refreshAccessToken_withImpersonation_readsKeyStoreOnceOnInitialRefreshAndRetriesInitialLoadFailure() + throws Exception { + KeyStore ks = createPopulatedKeyStore(); + AtomicInteger getKeyStoreCount = new AtomicInteger(0); + X509Provider flakyInitialProvider = + new X509Provider() { + @Override + public KeyStore getKeyStore() throws IOException { + if (getKeyStoreCount.incrementAndGet() == 1) { + throw new IOException("Transient split write during initial KeyStore load"); + } + return ks; + } + }; + + MockExternalAccountCredentialsTransport transport = + new MockExternalAccountCredentialsTransport(); + transport.setExpireTime(TestUtils.getDefaultExpireTime()); + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(flakyInitialProvider) + .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())) { + @Override + HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { + return () -> transport; + } + }; + + // Initial attempt fails in getKeyStore(), retry succeeds and initializes + // impersonatedCredentials + // without a duplicate getKeyStore() call (total 2 calls: 1 failed + 1 retry). + AccessToken token = credential.refreshAccessToken(); + assertNotNull(token); + assertEquals(2, getKeyStoreCount.get()); + + // Subsequent refresh reads the KeyStore exactly once per cycle. + credential.refreshAccessToken(); + assertEquals(3, getKeyStoreCount.get()); + } + @Test void refreshAccessToken_401WithActorTokenAndNonMtlsTransport_bubblesUpWithoutRetry() { IdentityPoolCredentials.Builder builder = diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MtlsPipelineLocalTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MtlsPipelineLocalTest.java index 293a3a34a229..898ec0444a1b 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MtlsPipelineLocalTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MtlsPipelineLocalTest.java @@ -128,16 +128,16 @@ class MtlsPipelineLocalTest { @BeforeAll static void beforeAll() throws Exception { - // Initialize the default SSLContext before overriding javax.net.ssl.trustStore below, so it - // keeps the JVM's default trust settings. - SSLContext.getDefault(); originalHostnameVerifier = HttpsURLConnection.getDefaultHostnameVerifier(); - HttpsURLConnection.setDefaultHostnameVerifier((hostname, session) -> true); - originalTrustStore = System.getProperty("javax.net.ssl.trustStore"); originalTrustStorePassword = System.getProperty("javax.net.ssl.trustStorePassword"); originalTrustStoreType = System.getProperty("javax.net.ssl.trustStoreType"); + // Initialize the default SSLContext before overriding javax.net.ssl.trustStore below, so it + // keeps the JVM's default trust settings. + SSLContext.getDefault(); + HttpsURLConnection.setDefaultHostnameVerifier((hostname, session) -> true); + // Create a truststore containing test_cert.pem so client NetHttpTransport trusts the server KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); trustStore.load(null, null); From 56331e581f484827423b312e02e70b4bb765625a Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Tue, 27 Oct 2026 03:28:56 +0000 Subject: [PATCH 10/10] Revert "fix(oauth2): address pre-merge review findings for certificate-bound tokens" This reverts commit a2778ef09e710a17825ff1320cde8aaef2858cde. --- .../oauth2/ExternalAccountCredentials.java | 2 +- .../oauth2/IdentityPoolCredentialSource.java | 8 +- .../auth/oauth2/IdentityPoolCredentials.java | 29 +++---- ...eIdentityPoolSubjectTokenSupplierTest.java | 38 ++++----- .../oauth2/IdentityPoolCredentialsTest.java | 77 +++++++------------ .../auth/oauth2/MtlsPipelineLocalTest.java | 10 +-- 6 files changed, 64 insertions(+), 100 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java index 25a9a86773c7..de329a4e0e7f 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 @@ -434,7 +434,7 @@ static ExternalAccountCredentials fromJson( Map json, HttpTransportFactory transportFactory) { String audience = (String) json.get("audience"); String subjectTokenType = (String) json.get("subject_token_type"); - String actorTokenType = getOptional(json, "actor_token_type", String.class); + String actorTokenType = (String) json.get("actor_token_type"); String tokenUrl = (String) json.get("token_url"); Map credentialSourceMap = (Map) json.get("credential_source"); diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentialSource.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentialSource.java index f06dbc754802..7e0d65b12ec1 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentialSource.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentialSource.java @@ -33,7 +33,6 @@ import static com.google.common.base.Preconditions.checkArgument; -import com.google.api.client.util.Data; import java.util.HashMap; import java.util.Locale; import java.util.Map; @@ -307,9 +306,6 @@ public IdentityPoolCredentialSource(Map credentialSourceMap) { credentialFormatType = CredentialFormatType.JSON; subjectTokenFieldName = formatMap.get("subject_token_field_name"); actorTokenFieldName = formatMap.get("actor_token_field_name"); - if (Data.isNull(actorTokenFieldName)) { - actorTokenFieldName = null; - } if (actorTokenFieldName != null) { if (actorTokenFieldName.trim().isEmpty()) { throw new IllegalArgumentException("The actor_token_field_name must not be empty."); @@ -321,8 +317,8 @@ public IdentityPoolCredentialSource(Map credentialSourceMap) { } } else if (type != null && "text".equals(type.toLowerCase(Locale.US))) { credentialFormatType = CredentialFormatType.TEXT; - String textActorTokenFieldName = formatMap.get("actor_token_field_name"); - if (textActorTokenFieldName != null && !Data.isNull(textActorTokenFieldName)) { + if (formatMap.containsKey("actor_token_field_name") + && formatMap.get("actor_token_field_name") != null) { throw new IllegalArgumentException( "Actor tokens are only supported for JSON-formatted credential sources."); } 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 28c7156a9d63..06765b154fb1 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 @@ -114,7 +114,7 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { if (credentialSource.getCertificateConfig() != null) { try { initializeMtlsTransport(builder, credentialSource); - } catch (IOException e) { + } catch (Exception e) { throw new RuntimeException( "Failed to initialize mTLS transport for file credential source due to certificate" + " error.", @@ -226,16 +226,10 @@ boolean hasMtlsProviderForImpersonation() { * factory is already initialized (an mTLS factory with a KeyStore, or a custom non-mTLS factory). */ private boolean hasInitializedMtlsTransport() { - if (this.x509Provider == null) { - return false; - } - if (!shouldUseMtlsTransportFactory()) { - return true; - } - if (this.transportFactory instanceof MtlsHttpTransportFactory) { - return ((MtlsHttpTransportFactory) this.transportFactory).hasKeyStore(); - } - return !(this.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory); + return this.x509Provider != null + && (!shouldUseMtlsTransportFactory() + || (this.transportFactory instanceof MtlsHttpTransportFactory + && ((MtlsHttpTransportFactory) this.transportFactory).hasKeyStore())); } AccessToken refreshImpersonatedAccessTokenWithRetry(ImpersonatedCredentials impersonated) @@ -311,15 +305,13 @@ AccessToken refreshAccessToken( private AccessToken refreshWithRetry(@Nullable ImpersonatedCredentials targetImpersonated) throws IOException { - ImpersonatedCredentials effectiveImpersonated = targetImpersonated; + ImpersonatedCredentials effectiveImpersonated = + targetImpersonated != null ? targetImpersonated : getImpersonatedCredentials(); KeyStore pinnedKeyStore = null; try { pinnedKeyStore = this.x509Provider.getKeyStore(); HttpTransportFactory cycleTransportFactory = resolveCycleTransportFactory(/* explicitTransportFactory= */ null, pinnedKeyStore); - if (effectiveImpersonated == null) { - effectiveImpersonated = getImpersonatedCredentials(); - } return executeRefresh(cycleTransportFactory, pinnedKeyStore, effectiveImpersonated); } catch (IOException | RuntimeException e) { if (!isMtlsRetryableException(e)) { @@ -358,9 +350,6 @@ private AccessToken refreshWithRetry(@Nullable ImpersonatedCredentials targetImp if (!hasInitializedMtlsTransport()) { this.transportFactory = retryTransportFactory; } - if (effectiveImpersonated == null) { - effectiveImpersonated = getImpersonatedCredentials(); - } return executeRefresh(retryTransportFactory, freshKeyStore, effectiveImpersonated); } catch (IOException | RuntimeException retryException) { if (retryException != e) { @@ -681,7 +670,7 @@ public Builder setSubjectTokenSupplier(IdentityPoolSubjectTokenSupplier subjectT * @return this {@code Builder} object */ @CanIgnoreReturnValue - Builder setActorTokenSupplier(@Nullable IdentityPoolActorTokenSupplier actorTokenSupplier) { + Builder setActorTokenSupplier(IdentityPoolActorTokenSupplier actorTokenSupplier) { this.actorTokenSupplier = actorTokenSupplier; return this; } @@ -698,7 +687,7 @@ Builder setActorTokenSupplier(@Nullable IdentityPoolActorTokenSupplier actorToke * @return this {@code Builder} object */ @CanIgnoreReturnValue - Builder setActorTokenType(@Nullable String actorTokenType) { + Builder setActorTokenType(String actorTokenType) { this.actorTokenType = actorTokenType; return this; } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplierTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplierTest.java index e26bf9142a9c..5564116ee245 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplierTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplierTest.java @@ -110,7 +110,8 @@ void getToken_jsonFormat_reReadsFileOnEachCall(@TempDir Path tempDir) throws IOE } @Test - void getToken_jsonFormat_concurrentReads(@TempDir Path tempDir) throws Exception { + void getToken_jsonFormat_concurrentReads(@TempDir Path tempDir) + throws IOException, InterruptedException { Path credentialFile = tempDir.resolve("credential.json"); Files.write( credentialFile, @@ -135,26 +136,27 @@ void getToken_jsonFormat_concurrentReads(@TempDir Path tempDir) throws Exception java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(numThreads); java.util.List> futures = new java.util.ArrayList<>(); - try { - for (int i = 0; i < numThreads; i++) { - futures.add( - executor.submit( - () -> { - latch.countDown(); - assertTrue(latch.await(5, java.util.concurrent.TimeUnit.SECONDS)); - assertEquals("my_sub_token", supplier.getSubjectToken(null)); - assertEquals("my_act_token", supplier.getActorToken(null)); - return null; - })); - } + for (int i = 0; i < numThreads; i++) { + futures.add( + executor.submit( + () -> { + latch.countDown(); + latch.await(); + assertEquals("my_sub_token", supplier.getSubjectToken(null)); + assertEquals("my_act_token", supplier.getActorToken(null)); + return null; + })); + } - // Wait for all threads to complete and verify no exceptions were thrown - for (java.util.concurrent.Future future : futures) { - future.get(10, java.util.concurrent.TimeUnit.SECONDS); + // Wait for all threads to complete and verify no exceptions were thrown + for (java.util.concurrent.Future future : futures) { + try { + future.get(); + } catch (Exception e) { + throw new RuntimeException("Thread execution failed", e); } - } finally { - executor.shutdownNow(); } + executor.shutdown(); } @Test 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 9332dd00690a..45f88378393c 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 @@ -1863,6 +1863,30 @@ void builder_actorTokenWithX509Provider_succeeds() throws Exception { assertEquals("urn:ietf:params:oauth:token-type:jwt", credentials.getActorTokenType()); } + @Test + void toBuilder_preservesActorTokenType() throws Exception { + KeyStore ks = createPopulatedKeyStore(); + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(mtlsTransport) + .setSubjectTokenSupplier(testProvider) + .setActorTokenSupplier(testActorSupplier) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build(); + + IdentityPoolCredentials rebuilt = credentials.toBuilder().build(); + + assertNotNull(rebuilt); + assertEquals("urn:ietf:params:oauth:token-type:jwt", rebuilt.getActorTokenType()); + assertSame(testProvider, rebuilt.getIdentityPoolSubjectTokenSupplier()); + assertSame(testActorSupplier, rebuilt.getIdentityPoolActorTokenSupplier()); + } + @Test void builder_actorTokenWithoutMtls_succeeds() { IdentityPoolCredentials credentials = @@ -1933,7 +1957,8 @@ void builder_actorTokenWithEmptyMtlsFactory_succeeds() throws Exception { @Test void refreshAccessToken_pinsTransportForStsExchange() throws Exception { // Verify that the STS exchange uses the pinned transport factory from the KeyStore snapshot - // within one refresh cycle. + // within one refresh cycle. Threading the pinned transport to IAM impersonation is deferred + // to a follow-up PR. KeyStore ks = createPopulatedKeyStore(); AtomicInteger getKeyStoreCallCount = new AtomicInteger(0); @@ -3085,7 +3110,7 @@ void fromStream_fileCredentialSource_withCertificateConfig_andActorToken_refresh + " \"token_url\": \"https://sts.mtls.googleapis.com/v1/token\",\n" + " \"credential_source\": {\n" + " \"file\": \"" - + tokenFile.toString().replace("\\", "\\\\") + + tokenFile.toString() + "\",\n" + " \"format\": {\n" + " \"type\": \"json\",\n" @@ -3464,54 +3489,6 @@ public KeyStore getKeyStore() { assertTrue(credential.getCapturedFactories().get(1) instanceof MtlsHttpTransportFactory); } - @Test - void - refreshAccessToken_withImpersonation_readsKeyStoreOnceOnInitialRefreshAndRetriesInitialLoadFailure() - throws Exception { - KeyStore ks = createPopulatedKeyStore(); - AtomicInteger getKeyStoreCount = new AtomicInteger(0); - X509Provider flakyInitialProvider = - new X509Provider() { - @Override - public KeyStore getKeyStore() throws IOException { - if (getKeyStoreCount.incrementAndGet() == 1) { - throw new IOException("Transient split write during initial KeyStore load"); - } - return ks; - } - }; - - MockExternalAccountCredentialsTransport transport = - new MockExternalAccountCredentialsTransport(); - transport.setExpireTime(TestUtils.getDefaultExpireTime()); - IdentityPoolCredentials credential = - new IdentityPoolCredentials( - IdentityPoolCredentials.newBuilder() - .setSubjectTokenSupplier(testProvider) - .setX509Provider(flakyInitialProvider) - .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())) { - @Override - HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { - return () -> transport; - } - }; - - // Initial attempt fails in getKeyStore(), retry succeeds and initializes - // impersonatedCredentials - // without a duplicate getKeyStore() call (total 2 calls: 1 failed + 1 retry). - AccessToken token = credential.refreshAccessToken(); - assertNotNull(token); - assertEquals(2, getKeyStoreCount.get()); - - // Subsequent refresh reads the KeyStore exactly once per cycle. - credential.refreshAccessToken(); - assertEquals(3, getKeyStoreCount.get()); - } - @Test void refreshAccessToken_401WithActorTokenAndNonMtlsTransport_bubblesUpWithoutRetry() { IdentityPoolCredentials.Builder builder = diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MtlsPipelineLocalTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MtlsPipelineLocalTest.java index 898ec0444a1b..293a3a34a229 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MtlsPipelineLocalTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MtlsPipelineLocalTest.java @@ -128,16 +128,16 @@ class MtlsPipelineLocalTest { @BeforeAll static void beforeAll() throws Exception { - originalHostnameVerifier = HttpsURLConnection.getDefaultHostnameVerifier(); - originalTrustStore = System.getProperty("javax.net.ssl.trustStore"); - originalTrustStorePassword = System.getProperty("javax.net.ssl.trustStorePassword"); - originalTrustStoreType = System.getProperty("javax.net.ssl.trustStoreType"); - // Initialize the default SSLContext before overriding javax.net.ssl.trustStore below, so it // keeps the JVM's default trust settings. SSLContext.getDefault(); + originalHostnameVerifier = HttpsURLConnection.getDefaultHostnameVerifier(); HttpsURLConnection.setDefaultHostnameVerifier((hostname, session) -> true); + originalTrustStore = System.getProperty("javax.net.ssl.trustStore"); + originalTrustStorePassword = System.getProperty("javax.net.ssl.trustStorePassword"); + originalTrustStoreType = System.getProperty("javax.net.ssl.trustStoreType"); + // Create a truststore containing test_cert.pem so client NetHttpTransport trusts the server KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); trustStore.load(null, null);