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..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 @@ -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,46 @@ 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; + } + + /** 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; + } + 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/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 dae2ddb57f7b..db1481bfad7a 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,9 +51,11 @@ 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 org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; /** * Provider for retrieving the subject tokens for {@link IdentityPoolCredentials} by reading an @@ -63,6 +67,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; CertificateIdentityPoolSubjectTokenSupplier(IdentityPoolCredentialSource credentialSource) { @@ -92,6 +99,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) { @@ -131,14 +166,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 @@ -238,7 +286,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 0489d1ad743d..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) @@ -432,6 +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 tokenUrl = (String) json.get("token_url"); Map credentialSourceMap = (Map) json.get("credential_source"); @@ -488,6 +491,7 @@ static ExternalAccountCredentials fromJson( .setHttpTransportFactory(transportFactory) .setAudience(audience) .setSubjectTokenType(subjectTokenType) + .setActorTokenType(actorTokenType) .setTokenUrl(tokenUrl) .setTokenInfoUrl(tokenInfoUrl) .setCredentialSource(new IdentityPoolCredentialSource(credentialSourceMap)) @@ -520,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; } /** @@ -533,17 +550,34 @@ 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(); - } - 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 = 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 @@ -617,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); } @@ -769,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; @@ -793,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; @@ -891,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/FileIdentityPoolSubjectTokenSupplier.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplier.java index 02654578a418..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 @@ -31,73 +31,187 @@ 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()); + } + 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 */ + 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); + + return extractField(fileContents, targetFieldName); } + } - 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..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 @@ -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; @@ -39,10 +41,12 @@ import com.google.common.annotations.VisibleForTesting; import com.google.errorprone.annotations.CanIgnoreReturnValue; import java.io.IOException; +import java.io.ObjectInputStream; 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; @@ -50,6 +54,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 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 { @@ -58,8 +66,16 @@ 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 volatile @Nullable X509Provider x509Provider; + private final boolean useMtlsTransportFactory; private final ExternalAccountSupplierContext supplierContext; private final String metricsHeaderValue; @@ -85,11 +101,26 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { "A subjectTokenSupplier or a credentialSource must be provided."); } + // Store the x509Provider and useMtlsTransportFactory flag for per-cycle cert pinning and + // deserialization recovery. + this.x509Provider = builder.x509Provider; + this.useMtlsTransportFactory = builder.useMtlsTransportFactory; + // 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 { + initializeMtlsTransport(builder, credentialSource); + } 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,25 +144,309 @@ 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."); + } + } + + /** + * 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. + */ + 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 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). + */ + private 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 { - String credential = retrieveSubjectToken(); + 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. + if (this.subjectTokenSupplier instanceof FileIdentityPoolSubjectTokenSupplier + && this.actorTokenSupplier == this.subjectTokenSupplier) { + 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(); + } + 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(credential, getSubjectTokenType()) + StsTokenExchangeRequest.newBuilder(subjectToken, getSubjectTokenType()) .setAudience(getAudience()); + 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(); if (scopes != null && !scopes.isEmpty()) { stsTokenExchangeRequest.setScopes(new ArrayList<>(scopes)); } - return exchangeExternalCredentialForAccessToken(stsTokenExchangeRequest.build()); + 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); } @@ -145,6 +460,31 @@ 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; + } + + @VisibleForTesting + HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { + return new MtlsHttpTransportFactory(keyStore); + } + /** Clones the IdentityPoolCredentials with the specified scopes. */ @Override public IdentityPoolCredentials createScoped(Collection newScopes) { @@ -164,21 +504,74 @@ 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. X509Provider x509Provider = getX509Provider(builder, credentialSource); + this.x509Provider = x509Provider; + if (builder.isClonedTransportInitialized) { + return; + } KeyStore mtlsKeyStore = x509Provider.getKeyStore(); - this.transportFactory = new MtlsHttpTransportFactory(mtlsKeyStore); + if (shouldUseMtlsTransportFactory()) { + this.transportFactory = createMtlsTransportFactory(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); - 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); } + /** + * 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); + if (shouldUseMtlsTransportFactory()) { + try { + KeyStore mtlsKeyStore = this.x509Provider.getKeyStore(); + this.transportFactory = createMtlsTransportFactory(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,7 +600,11 @@ 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; + private boolean useMtlsTransportFactory = true; + private boolean isClonedTransportInitialized; Builder() {} @@ -215,7 +612,22 @@ public static class Builder extends ExternalAccountCredentials.Builder { super(credentials); 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 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 + // credentials.transportFactory, which may already be resolved via ServiceLoader or + // createMtlsTransportFactory rather than null. + this.useMtlsTransportFactory = credentials.useMtlsTransportFactory; + this.isClonedTransportInitialized = credentials.hasInitializedMtlsTransport(); } /** @@ -231,6 +643,7 @@ public static class Builder extends ExternalAccountCredentials.Builder { @VisibleForTesting Builder setX509Provider(X509Provider x509Provider) { this.x509Provider = x509Provider; + this.isClonedTransportInitialized = false; return this; } @@ -246,10 +659,45 @@ public Builder setSubjectTokenSupplier(IdentityPoolSubjectTokenSupplier subjectT return this; } + /** + * 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}. + * + * @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) { super.setHttpTransportFactory(transportFactory); + this.useMtlsTransportFactory = isDefaultOrMtlsTransportFactory(transportFactory); + this.isClonedTransportInitialized = false; return this; } @@ -283,7 +731,14 @@ 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/java/com/google/auth/oauth2/ImpersonatedCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java index 3451c5dbbf7c..4a6ef62362b7 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;
 
@@ -318,7 +327,7 @@ public String getAccount() {
   }
 
   @VisibleForTesting
-  String getIamEndpointOverride() {
+  @Nullable String getIamEndpointOverride() {
     return this.iamEndpointOverride;
   }
 
@@ -544,9 +553,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();
@@ -584,33 +600,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 =
@@ -633,6 +801,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,
@@ -643,16 +818,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");
@@ -953,5 +1152,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 6d068c173dfb..e4b73ea6cfb9 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;
@@ -328,5 +336,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 0349227e8071..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;
 
@@ -47,14 +47,27 @@
 @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;
+  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,20 +95,71 @@ String getErrorCode() {
     return errorUri;
   }
 
-  static OAuthException createFromHttpResponseException(HttpResponseException e)
-      throws IOException {
-    JsonParser parser = OAuth2Utils.JSON_FACTORY.createJsonParser((e).getContent());
-    GenericJson errorResponse = parser.parseAndClose(GenericJson.class);
+  int getHttpStatusCode() {
+    return httpStatusCode;
+  }
 
-    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);
   }
 }
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/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 e2ea10ce6cf0..d2491c5b9196 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
@@ -105,7 +105,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/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 1338c0d68fe9..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
@@ -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());
     }
   }
@@ -1240,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/FileIdentityPoolSubjectTokenSupplierTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplierTest.java
new file mode 100644
index 000000000000..5564116ee245
--- /dev/null
+++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplierTest.java
@@ -0,0 +1,606 @@
+/*
+ * 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 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");
+    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_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 {
+    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/ITWorkloadIdentityFederationTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ITWorkloadIdentityFederationTest.java
index 0f1cdd3092f6..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,6 +31,8 @@
 
 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;
 
@@ -38,21 +40,35 @@
 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;
 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;
+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.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;
@@ -64,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 { @@ -76,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")); } /** @@ -281,6 +309,221 @@ void identityPoolCredentials_withProgrammaticAuth() throws IOException { callGcs(identityPoolCredentials); } + /** + * IdentityPoolCredentials (OIDC provider with certificate-bound workload and actor token): Uses + * 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 { + 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( + "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_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"); + 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", DEFAULT_MTLS_CERT_CONFIG_PATH); + credentialSource.put("certificate", certificate); + + config.put("credential_source", credentialSource); + + IdentityPoolCredentials identityPoolCredentials = + (IdentityPoolCredentials) + ExternalAccountCredentials.fromJson(config, OAuth2Utils.HTTP_TRANSPORT_FACTORY); + + 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 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 -> + generateSignedOidcJwt(OIDC_JWT_AUDIENCE, certThumbprint, /* includeMayAct= */ true); + IdentityPoolActorTokenSupplier actorSupplier = + context -> + generateSignedOidcJwt(OIDC_JWT_AUDIENCE, certThumbprint, /* includeMayAct= */ false); + + HttpTransportFactory transportFactory = new MtlsHttpTransportFactory(keyStore); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(tokenSupplier) + .setActorTokenSupplier(actorSupplier) + .setActorTokenType(SubjectTokenTypes.JWT.value) + .setAudience(OIDC_JWT_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 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( + "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_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"); + + 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", DEFAULT_MTLS_CERT_CONFIG_PATH); + 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()); + assertFalse(accessToken.getTokenValue().isEmpty()); + } 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 { + X509Provider x509Provider = new X509Provider(DEFAULT_MTLS_CERT_CONFIG_PATH); + KeyStore keyStore = x509Provider.getKeyStore(); + String certThumbprint = computeCertThumbprint(keyStore); + IdentityPoolSubjectTokenSupplier tokenSupplier = + context -> + generateSignedOidcJwt(OIDC_JWT_AUDIENCE, certThumbprint, /* includeMayAct= */ true); + IdentityPoolActorTokenSupplier actorSupplier = + context -> + generateSignedOidcJwt(OIDC_JWT_AUDIENCE, certThumbprint, /* includeMayAct= */ false); + + HttpTransportFactory transportFactory = new MtlsHttpTransportFactory(keyStore); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(tokenSupplier) + .setActorTokenSupplier(actorSupplier) + .setActorTokenType(SubjectTokenTypes.JWT.value) + .setAudience(OIDC_JWT_AUDIENCE) + .setSubjectTokenType(SubjectTokenTypes.JWT) + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .setHttpTransportFactory(transportFactory) + .build(); + + AccessToken accessToken = credentials.refreshAccessToken(); + assertNotNull(accessToken); + assertNotNull(accessToken.getTokenValue()); + assertFalse(accessToken.getTokenValue().isEmpty()); + } + private GenericJson buildIdentityPoolCredentialConfig() throws IOException { String idToken = generateGoogleIdToken(OIDC_AUDIENCE); @@ -382,24 +625,88 @@ private GenericJson buildAwsCredentialConfig() { return config; } + 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 { + 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/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 1d686ce7247a..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 @@ -36,34 +36,71 @@ 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; 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; import java.io.IOException; import java.io.InputStream; +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; +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 javax.net.ssl.SSLHandshakeException; 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 +112,31 @@ class IdentityPoolCredentialsTest extends BaseSerializationTest { private static final IdentityPoolSubjectTokenSupplier testProvider = (ExternalAccountSupplierContext context) -> "testSubjectToken"; + private static final IdentityPoolActorTokenSupplier testActorSupplier = + (ExternalAccountSupplierContext context) -> "testActorToken"; + + 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); + } + } + + 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 = @@ -254,6 +316,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 = @@ -420,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 = @@ -461,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); } @@ -686,7 +843,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 +983,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()); } } @@ -1326,6 +1485,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 = @@ -1374,4 +1541,4603 @@ void setShouldThrowOnGetKeyStore(boolean shouldThrow) { this.shouldThrowOnGetKeyStore = shouldThrow; } } + + @Test + void builder_actorTokenWithNonMtlsTransportFactory_succeeds() { + IdentityPoolCredentialSource credentialSource = createFileCredentialSource(); + IdentityPoolActorTokenSupplier actorSupplier = context -> "token"; + + 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(); + + 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 + 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_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_succeeds() { + MtlsHttpTransportFactory noArgFactory = new MtlsHttpTransportFactory(); + assertFalse(noArgFactory.hasKeyStore()); + + 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_succeeds() throws Exception { + KeyStore emptyKeyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + emptyKeyStore.load(null, null); + MtlsHttpTransportFactory emptyFactory = new MtlsHttpTransportFactory(emptyKeyStore); + assertFalse(emptyFactory.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()); + } + + // ================================================================================== + // 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 = createRotatedPopulatedKeyStore(); + + 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 = createRotatedPopulatedKeyStore(); + + 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_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. + 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 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 callCount.getAndIncrement() == 0 ? ksA : ksB; + } + }; + + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ksA); + + // 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_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, + // 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 = createRotatedPopulatedKeyStore(); + + 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 = createRotatedPopulatedKeyStore(); + + AtomicInteger getKeyStoreCount = new AtomicInteger(0); + CyclicBarrier barrier = new CyclicBarrier(2); + X509Provider provider = + new X509Provider() { + @Override + public KeyStore getKeyStore() throws IOException { + int count = getKeyStoreCount.incrementAndGet(); + 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; + } + }; + + 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); + + // 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(() -> credential.refreshAccessToken()); + Future futureB = executor.submit(() -> 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) + 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(); + } + } + + @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 = createRotatedPopulatedKeyStore(); + + 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<>(); + AtomicInteger exchangeCallCount = new AtomicInteger(0); + 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 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("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. + HttpTransportFactory firstCycleFactory = capturedFactory.get(); + assertNotNull(firstCycleFactory); + assertTrue( + firstCycleFactory instanceof MtlsHttpTransportFactory, + "Exchange should use MtlsHttpTransportFactory pinned to original cert"); + + // 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( + 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()); + } + + // ================================================================================== + // 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()); + } + + @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" + + "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_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.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_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.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 + 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()); + + 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" + + " \"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().replace("\\", "\\\\") + + "\",\n" + + " \"format\": {\n" + + " \"type\": \"json\",\n" + + " \"subject_token_field_name\": \"subject_token\"\n" + + " },\n" + + " \"certificate\": {\n" + + " \"certificate_config_location\": \"" + + certConfigFile.toString().replace("\\", "\\\\") + + "\"\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) { + 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); + } + }; + + 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_preservesMtlsTransportSnapshot(@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(1, getKeyStoreCount.get()); + assertTrue(scoped.getTransportFactory() instanceof MtlsHttpTransportFactory); + assertSame(originalTransportFactory, scoped.getTransportFactory()); + + IdentityPoolCredentials rebuiltWithProvider = + credential.toBuilder().setX509Provider(trackingProvider).build(); + assertEquals(2, getKeyStoreCount.get()); + 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 + // ================================================================================== + + /** + * 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 List capturedFactories = + Collections.synchronizedList(new 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); + } + + 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 7719b08d2e7b..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 @@ -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"; @@ -88,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); @@ -167,7 +178,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. @@ -177,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); @@ -200,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)); @@ -288,6 +310,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/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/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..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,17 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import com.google.api.client.http.HttpHeaders; +import com.google.api.client.http.HttpResponseException; import com.google.auth.TestUtils; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.ObjectStreamClass; import org.junit.jupiter.api.Test; /** Tests for {@link OAuthException}. */ @@ -129,4 +137,218 @@ void createFromHttpResponseException_baseFormat() throws IOException { String expectedMessage = String.format(BASE_MESSAGE_FORMAT, "errorCode"); assertEquals(expectedMessage, e.getMessage()); } + + @Test + 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/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/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); 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_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.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----- 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----- 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}"