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..5f8b7e233bf0 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 @@ -36,8 +36,12 @@ import com.google.auth.http.HttpTransportFactory; 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 @@ -50,7 +54,17 @@ @NullMarked @InternalApi public class MtlsHttpTransportFactory implements HttpTransportFactory { - private final KeyStore mtlsKeyStore; + @Nullable private final KeyStore mtlsKeyStore; + + /** + * No-arg constructor required for Java serialization. {@link IdentityPoolCredentials} stores this + * factory in its serializable {@code transportFactory} field, and {@link + * java.io.ObjectInputStream} needs a no-arg constructor to reconstruct it during deserialization. + * Not intended for direct use; callers should use {@link #MtlsHttpTransportFactory(KeyStore)}. + */ + public MtlsHttpTransportFactory() { + this.mtlsKeyStore = null; + } /** * Constructs a factory for mTLS transports. @@ -63,6 +77,36 @@ public MtlsHttpTransportFactory(KeyStore mtlsKeyStore) { this.mtlsKeyStore = Objects.requireNonNull(mtlsKeyStore, "mtlsKeyStore cannot be null"); } + /** + * 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() { + if (this.mtlsKeyStore == null) { + return false; + } + try { + Enumeration aliases = this.mtlsKeyStore.aliases(); + if (aliases == null) { + return false; + } + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + if (this.mtlsKeyStore.isKeyEntry(alias)) { + Certificate[] chain = this.mtlsKeyStore.getCertificateChain(alias); + if (chain != null && chain.length > 0) { + return true; + } + } + } + return false; + } catch (KeyStoreException e) { + return false; + } + } + @Override public NetHttpTransport create() { try { 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 917f01fe89e0..0cc8e3847594 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 @@ -265,7 +265,8 @@ protected ExternalAccountCredentials(ExternalAccountCredentials.Builder builder) this.workforcePoolUserProject = builder.workforcePoolUserProject; if (workforcePoolUserProject != null && !isWorkforcePoolConfiguration()) { throw new IllegalArgumentException( - "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."); } validateTokenUrl(tokenUrl); @@ -431,6 +432,7 @@ static ExternalAccountCredentials fromJson( Map json, HttpTransportFactory transportFactory) { String audience = (String) json.get("audience"); String subjectTokenType = (String) json.get("subject_token_type"); + String actorTokenType = (String) json.get("actor_token_type"); String tokenUrl = (String) json.get("token_url"); Map credentialSourceMap = (Map) json.get("credential_source"); @@ -487,6 +489,7 @@ static ExternalAccountCredentials fromJson( .setHttpTransportFactory(transportFactory) .setAudience(audience) .setSubjectTokenType(subjectTokenType) + .setActorTokenType(actorTokenType) .setTokenUrl(tokenUrl) .setTokenInfoUrl(tokenInfoUrl) .setCredentialSource(new IdentityPoolCredentialSource(credentialSourceMap)) @@ -531,6 +534,22 @@ private boolean shouldBuildImpersonatedCredential() { */ protected AccessToken exchangeExternalCredentialForAccessToken( StsTokenExchangeRequest stsTokenExchangeRequest) throws IOException { + return exchangeExternalCredentialForAccessToken(stsTokenExchangeRequest, this.transportFactory); + } + + /** + * Exchanges the external credential for a Google Cloud access token using the specified transport + * factory. This overload allows callers to provide a per-cycle transport factory, for example one + * pinned to a specific mTLS certificate. + * + * @param stsTokenExchangeRequest the Security Token Service token exchange request + * @param cycleTransportFactory the HTTP transport factory to use for this exchange + * @return the access token returned by the Security Token Service + * @throws OAuthException if the call to the Security Token Service fails + */ + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, HttpTransportFactory cycleTransportFactory) + throws IOException { // Handle service account impersonation if necessary. if (this.shouldBuildImpersonatedCredential()) { this.impersonatedCredentials = this.buildImpersonatedCredentials(); @@ -541,7 +560,9 @@ protected AccessToken exchangeExternalCredentialForAccessToken( StsRequestHandler.Builder requestHandler = StsRequestHandler.newBuilder( - tokenUrl, stsTokenExchangeRequest, transportFactory.create().createRequestFactory()); + tokenUrl, + stsTokenExchangeRequest, + cycleTransportFactory.create().createRequestFactory()); // If this credential was initialized with a Workforce configuration then the // workforcePoolUserProject must be passed to the Security Token Service via the internal diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplier.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplier.java index 02654578a418..8da013451af8 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 @@ -1,5 +1,5 @@ /* - * Copyright 2024 Google LLC + * Copyright 2026 Google LLC * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -31,12 +31,14 @@ 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; @@ -45,59 +47,175 @@ 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 { + 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) { + + if (credentialSource.credentialFormatType != CredentialFormatType.JSON) { throw new IOException( - "Error when attempting to read the subject token from the credential file.", e); + "readTokens() is only supported for JSON-formatted credential sources."); } + + GenericJson parsedJson = readAndParseJsonFile(credentialFilePath); + + String subjectFieldName = credentialSource.subjectTokenFieldName; + if (subjectFieldName == null) { + throw new IOException("Subject token field name must be specified for JSON credentials."); + } + 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 { + 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) { + if (targetFieldName == null) { + throw new IOException("Target field name must be specified for JSON credentials."); + } + GenericJson parsedJson = readAndParseJsonFile(credentialFilePath); + return extractField(parsedJson, targetFieldName); + } + + try (InputStream inputStream = Files.newInputStream(Paths.get(credentialFilePath))) { BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8)); return CharStreams.toString(reader); + } catch (IOException e) { + throw new IOException("Error when attempting to read the token from the credential file.", e); + } + } + + private static GenericJson readAndParseJsonFile(String credentialFilePath) throws IOException { + try (InputStream inputStream = Files.newInputStream(Paths.get(credentialFilePath))) { + JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY); + return parser.parseAndClose(inputStream, StandardCharsets.UTF_8, GenericJson.class); + } catch (Exception e) { + throw new IOException("Error when attempting to read the token from the credential file.", e); } + } + + private static String extractField(GenericJson json, String fieldName) throws IOException { + Object value = json.get(fieldName); + if (value == null || Data.isNull(value)) { + throw new IOException("Invalid token field name. No token was found for field: " + fieldName); + } + if (!(value instanceof String)) { + throw new IOException( + "Token field value for " + + fieldName + + " must be a String but was: " + + value.getClass().getName()); + } + return (String) value; + } + + /** Used primarily for UrlIdentityPoolSubjectTokenSupplier */ + static String parseToken( + InputStream inputStream, + IdentityPoolCredentialSource credentialSource, + @Nullable String targetFieldName) + throws IOException { + try (InputStream in = inputStream; + java.io.Reader reader = new InputStreamReader(in, StandardCharsets.UTF_8)) { + if (credentialSource.credentialFormatType == CredentialFormatType.TEXT) { + return CharStreams.toString(new BufferedReader(reader)); + } + + if (targetFieldName == null) { + throw new IOException("Target field name must be specified for JSON credentials."); + } + + JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY); + GenericJson fileContents = + parser.parseAndClose(in, StandardCharsets.UTF_8, GenericJson.class); + + Object value = fileContents.get(targetFieldName); + if (value == null || Data.isNull(value)) { + throw new IOException( + "Invalid token field name. No token was found for field: " + targetFieldName); + } + if (!(value instanceof String)) { + throw new IOException( + "Token field value for " + + targetFieldName + + " must be a String but was: " + + value.getClass().getName()); + } + return (String) value; + } + } - JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY); - GenericJson fileContents = - parser.parseAndClose(inputStream, StandardCharsets.UTF_8, GenericJson.class); + /** Holds a pair of subject and actor tokens read atomically from the same file. */ + static class TokenPair { + final String subject; + @Nullable final 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..9e19a0eba575 --- /dev/null +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolActorTokenSupplier.java @@ -0,0 +1,50 @@ +/* + * 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 org.jspecify.annotations.NullMarked; + +/** Functional interface for supplying an actor token for IdentityPool credentials. */ +@NullMarked +@FunctionalInterface +interface IdentityPoolActorTokenSupplier extends java.io.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 5ade1458b8d7..1c2a8ccfd606 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; @@ -208,11 +209,13 @@ public static class CertificateConfig implements java.io.Serializable { checkArgument( (useDefault || locationIsPresent), - "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."); checkArgument( !(useDefault && locationIsPresent), - "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."); this.useDefaultCertificateConfig = useDefault; this.certificateConfigLocation = certificateConfigLocation; @@ -261,16 +264,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; @@ -279,7 +283,8 @@ public IdentityPoolCredentialSource(Map credentialSourceMap) { this.certificateConfig = certificateConfigFromSourceMap(credentialSourceMap); } else { throw new IllegalArgumentException( - "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."); } Map headersMap = (Map) credentialSourceMap.get("headers"); @@ -303,8 +308,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 10f216139d7b..4b4569304a47 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java @@ -39,6 +39,8 @@ import com.google.common.annotations.VisibleForTesting; import com.google.errorprone.annotations.CanIgnoreReturnValue; import java.io.IOException; +import java.io.ObjectInputStream; +import java.net.URI; import java.security.KeyStore; import java.util.ArrayList; import java.util.Collection; @@ -50,6 +52,10 @@ * Url-sourced, file-sourced, or user provided supplier method-sourced external account credentials. * *

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

Note: Actor token extraction is currently restricted to file-based JSON credential sources + * over mTLS endpoints. When configuring certificate-bound OAuth 2.0 tokens, ensure your transport + * layer is configured for mTLS in tandem. */ @NullMarked public class IdentityPoolCredentials extends ExternalAccountCredentials { @@ -60,6 +66,11 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { private static final long serialVersionUID = 2471046175477275881L; private final IdentityPoolSubjectTokenSupplier subjectTokenSupplier; + @Nullable private final IdentityPoolActorTokenSupplier actorTokenSupplier; + @Nullable private final String actorTokenType; + // Transient: not serialized directly. Reconstructed in readObject() from the credentialSource + // certificate config so deserialized credentials remain usable for mTLS and refresh. + @Nullable private transient X509Provider x509Provider; private final ExternalAccountSupplierContext supplierContext; private final String metricsHeaderValue; @@ -77,18 +88,39 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { // Check that one and only one of supplier or credential source are provided. if (builder.subjectTokenSupplier != null && credentialSource != null) { throw new IllegalArgumentException( - "IdentityPoolCredentials cannot have both a subjectTokenSupplier and a credentialSource."); + "IdentityPoolCredentials cannot have both a subjectTokenSupplier and a" + + " credentialSource."); } if (builder.subjectTokenSupplier == null && credentialSource == null) { throw new IllegalArgumentException( "A subjectTokenSupplier or a credentialSource must be provided."); } + // Store the x509Provider for per-cycle cert pinning. + this.x509Provider = builder.x509Provider; + // Initialize based on the source type if (builder.subjectTokenSupplier != null) { this.subjectTokenSupplier = builder.subjectTokenSupplier; this.metricsHeaderValue = PROGRAMMATIC_METRICS_HEADER_VALUE; } else if (credentialSource.credentialSourceType == IdentityPoolCredentialSourceType.FILE) { + if (credentialSource.getCertificateConfig() != null) { + try { + X509Provider x509Provider = getX509Provider(builder, credentialSource); + this.x509Provider = x509Provider; + KeyStore mtlsKeyStore = x509Provider.getKeyStore(); + if (builder.transportFactory == null + || builder.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY + || builder.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory) { + this.transportFactory = new MtlsHttpTransportFactory(mtlsKeyStore); + } + } 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) { @@ -104,28 +136,149 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { throw new RuntimeException( // Wrap IOException in RuntimeException because constructors cannot throw checked // exceptions. - "Failed to initialize IdentityPoolCredentials from certificate source due to an I/O error.", + "Failed to initialize IdentityPoolCredentials from certificate source due to an I/O" + + " error.", e); } this.metricsHeaderValue = CERTIFICATE_METRICS_HEADER_VALUE; } else { throw new IllegalArgumentException("Source type not supported."); } + + this.actorTokenType = builder.actorTokenType; + if (builder.actorTokenSupplier != null) { + this.actorTokenSupplier = builder.actorTokenSupplier; + } else if (credentialSource != null && credentialSource.actorTokenFieldName != null) { + if (this.subjectTokenSupplier instanceof FileIdentityPoolSubjectTokenSupplier) { + this.actorTokenSupplier = (FileIdentityPoolSubjectTokenSupplier) this.subjectTokenSupplier; + } else { + throw new IllegalArgumentException( + "Actor tokens are currently only supported for file-based credential sources."); + } + } else { + this.actorTokenSupplier = null; + } + + if (this.actorTokenSupplier != null + && (this.actorTokenType == null || this.actorTokenType.trim().isEmpty())) { + throw new IllegalArgumentException( + "An actorTokenType must be specified when an actorTokenSupplier is configured."); + } + if (this.actorTokenSupplier == null && this.actorTokenType != null) { + throw new IllegalArgumentException( + "An actorTokenSupplier must be specified when an actorTokenType is configured."); + } + + if (this.actorTokenSupplier != null && !isMtlsConfigured()) { + throw new IllegalArgumentException( + "Actor tokens are only supported for mTLS token exchanges. Please configure a certificate" + + " source or MtlsHttpTransportFactory."); + } + + if (this.actorTokenSupplier != null) { + validateMtlsEndpoint(getTokenUrl(), "tokenUrl"); + if (getServiceAccountImpersonationUrl() != null) { + validateMtlsEndpoint(getServiceAccountImpersonationUrl(), "serviceAccountImpersonationUrl"); + } + } + } + + private static void validateMtlsEndpoint(@Nullable String url, String fieldName) { + if (url == null) { + return; + } + try { + URI uri = URI.create(url); + String host = uri.getHost(); + if (host != null + && host.endsWith("googleapis.com") + && !host.contains(".mtls.") + && !host.contains(".p.")) { + throw new IllegalArgumentException( + "The " + + fieldName + + " endpoint (" + + url + + ") cannot be used with actor tokens because it is a plain public Google API" + + " endpoint. Please use an mTLS endpoint (e.g. containing '.mtls.') or Private" + + " Service Connect (containing '.p.')."); + } + } catch (IllegalArgumentException e) { + throw e; + } catch (Exception ignored) { + // Ignored: non-parseable URIs will fail downstream on HTTP execute. + } + } + + /** + * Checks whether mTLS is properly configured by verifying that an X509Provider is set or the + * transport factory is an MtlsHttpTransportFactory with a non-null KeyStore. This avoids false + * positives from a no-arg-constructed MtlsHttpTransportFactory (e.g. after deserialization) that + * has no actual certificates. + */ + private boolean isMtlsConfigured() { + return this.x509Provider != null + || (this.transportFactory instanceof MtlsHttpTransportFactory + && ((MtlsHttpTransportFactory) this.transportFactory).hasKeyStore()); } @Override public AccessToken refreshAccessToken() throws IOException { - String credential = retrieveSubjectToken(); + // Per-cycle cert pinning: snapshot the KeyStore at the start of each refresh cycle. + HttpTransportFactory cycleTransportFactory = this.transportFactory; + if (this.x509Provider != null) { + KeyStore pinnedKeyStore = this.x509Provider.getKeyStore(); + cycleTransportFactory = new MtlsHttpTransportFactory(pinnedKeyStore); + } + + // Read subject and actor tokens, atomically if from the same file supplier. + String subjectToken; + String actorToken = null; + if (this.subjectTokenSupplier instanceof FileIdentityPoolSubjectTokenSupplier + && this.actorTokenSupplier == this.subjectTokenSupplier) { + FileIdentityPoolSubjectTokenSupplier.TokenPair tokens = + ((FileIdentityPoolSubjectTokenSupplier) this.subjectTokenSupplier) + .readTokens(supplierContext); + subjectToken = tokens.subject; + actorToken = tokens.actor; + } else { + subjectToken = retrieveSubjectToken(); + if (this.actorTokenSupplier != null) { + actorToken = this.actorTokenSupplier.getActorToken(supplierContext); + } + } + StsTokenExchangeRequest.Builder stsTokenExchangeRequest = - StsTokenExchangeRequest.newBuilder(credential, getSubjectTokenType()) + StsTokenExchangeRequest.newBuilder(subjectToken, getSubjectTokenType()) .setAudience(getAudience()); + if (actorToken != null && this.actorTokenType != null) { + stsTokenExchangeRequest.setActingParty(new ActingParty(actorToken, this.actorTokenType)); + } + Collection scopes = getScopes(); if (scopes != null && !scopes.isEmpty()) { stsTokenExchangeRequest.setScopes(new ArrayList<>(scopes)); } - return exchangeExternalCredentialForAccessToken(stsTokenExchangeRequest.build()); + try { + return exchangeExternalCredentialForAccessToken( + stsTokenExchangeRequest.build(), cycleTransportFactory); + } catch (OAuthException e) { + if (e.getHttpStatusCode() == 401 && this.x509Provider != null) { + try { + // On 401, re-read from X509Provider for fresh certs and retry once. + KeyStore freshKeyStore = this.x509Provider.getKeyStore(); + HttpTransportFactory retryTransportFactory = new MtlsHttpTransportFactory(freshKeyStore); + return exchangeExternalCredentialForAccessToken( + stsTokenExchangeRequest.build(), retryTransportFactory); + } catch (IOException retryException) { + retryException.addSuppressed(e); + throw retryException; + } + } + throw e; + } } @Override @@ -143,6 +296,26 @@ IdentityPoolSubjectTokenSupplier getIdentityPoolSubjectTokenSupplier() { return this.subjectTokenSupplier; } + @VisibleForTesting + @Nullable IdentityPoolActorTokenSupplier getIdentityPoolActorTokenSupplier() { + return this.actorTokenSupplier; + } + + @VisibleForTesting + String getActorTokenType() { + return this.actorTokenType; + } + + @VisibleForTesting + HttpTransportFactory getTransportFactory() { + return this.transportFactory; + } + + @VisibleForTesting + @Nullable X509Provider getX509Provider() { + return this.x509Provider; + } + /** Clones the IdentityPoolCredentials with the specified scopes. */ @Override public IdentityPoolCredentials createScoped(Collection newScopes) { @@ -164,10 +337,15 @@ public Builder toBuilder() { private IdentityPoolSubjectTokenSupplier createCertificateSubjectTokenSupplier( Builder builder, IdentityPoolCredentialSource credentialSource) throws IOException { - // Configure the mTLS transport with the x509 keystore. + // Configure the mTLS transport with the x509 keystore if custom transport was not provided. X509Provider x509Provider = getX509Provider(builder, credentialSource); + this.x509Provider = x509Provider; KeyStore mtlsKeyStore = x509Provider.getKeyStore(); - this.transportFactory = new MtlsHttpTransportFactory(mtlsKeyStore); + if (builder.transportFactory == null + || builder.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY + || builder.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory) { + this.transportFactory = new MtlsHttpTransportFactory(mtlsKeyStore); + } // Initialize the subject token supplier with the certificate path. String explicitCertConfigPath = getExplicitCertConfigPath(credentialSource); @@ -177,6 +355,37 @@ private IdentityPoolSubjectTokenSupplier createCertificateSubjectTokenSupplier( return new CertificateIdentityPoolSubjectTokenSupplier(credentialSource); } + /** + * Reconstitutes the {@link IdentityPoolCredentials} instance from a stream. + * + *

For credential-source based credentials (file or certificate), this method reconstructs the + * transient {@link X509Provider} and mTLS {@link HttpTransportFactory} if a certificate + * configuration is present. For programmatic suppliers (where {@code subjectTokenSupplier != + * null} and {@code credentialSource == null}), the suppliers and standard transport are restored + * directly from the serialized stream, while in-memory {@link X509Provider} instances are + * non-persistent. + */ + @SuppressWarnings("unused") + private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException { + input.defaultReadObject(); + IdentityPoolCredentialSource credentialSource = + (IdentityPoolCredentialSource) getCredentialSource(); + if (credentialSource != null + && (credentialSource.getCertificateConfig() != null + || credentialSource.credentialSourceType + == IdentityPoolCredentialSourceType.CERTIFICATE)) { + String explicitCertConfigPath = getExplicitCertConfigPath(credentialSource); + this.x509Provider = + new X509Provider(getEnvironmentProvider(), getPropertyProvider(), explicitCertConfigPath); + try { + KeyStore mtlsKeyStore = this.x509Provider.getKeyStore(); + this.transportFactory = new MtlsHttpTransportFactory(mtlsKeyStore); + } catch (Exception e) { + // Cert loading failure will be handled on refreshAccessToken() + } + } + } + private X509Provider getX509Provider( Builder builder, IdentityPoolCredentialSource credentialSource) { // Use the provided X509Provider if available, otherwise initialize a default one. @@ -205,6 +414,8 @@ private X509Provider getX509Provider( public static class Builder extends ExternalAccountCredentials.Builder { private IdentityPoolSubjectTokenSupplier subjectTokenSupplier; + private IdentityPoolActorTokenSupplier actorTokenSupplier; + private String actorTokenType; private X509Provider x509Provider; Builder() {} @@ -213,7 +424,14 @@ public static class Builder extends ExternalAccountCredentials.Builder { super(credentials); if (this.credentialSource == null) { this.subjectTokenSupplier = credentials.subjectTokenSupplier; + this.actorTokenSupplier = credentials.actorTokenSupplier; } + // Note: when credentialSource is present, subjectTokenSupplier and actorTokenSupplier + // are intentionally NOT copied here. They will be reconstructed from credentialSource + // during build(), which ensures they share the same FileIdentityPoolSubjectTokenSupplier + // instance for atomic token reads. + this.actorTokenType = credentials.actorTokenType; + this.x509Provider = credentials.x509Provider; } /** @@ -244,6 +462,40 @@ public Builder setSubjectTokenSupplier(IdentityPoolSubjectTokenSupplier subjectT return this; } + /** + * Sets the actor token supplier used for certificate-bound OAuth 2.0 token exchanges. The + * supplier provides an actor token representing the entity on whose behalf the subject is + * acting. + * + *

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

An actor token type must be paired with an {@link #setActorTokenSupplier actor token + * supplier}. + * + * @param actorTokenType the token type URI for the actor token + * @return this {@code Builder} object + */ + @CanIgnoreReturnValue + Builder setActorTokenType(String actorTokenType) { + this.actorTokenType = actorTokenType; + return this; + } + @CanIgnoreReturnValue public Builder setHttpTransportFactory(HttpTransportFactory transportFactory) { super.setHttpTransportFactory(transportFactory); diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuthException.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuthException.java index 0349227e8071..76d7fc60aa3c 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuthException.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuthException.java @@ -50,11 +50,21 @@ class OAuthException extends GoogleAuthException { private final String errorCode; @Nullable private final String errorDescription; @Nullable private final String errorUri; + private final int httpStatusCode; OAuthException(String errorCode, @Nullable String errorDescription, @Nullable String errorUri) { + this(errorCode, errorDescription, errorUri, 0); + } + + OAuthException( + String errorCode, + @Nullable String errorDescription, + @Nullable String errorUri, + int httpStatusCode) { this.errorCode = checkNotNull(errorCode); this.errorDescription = errorDescription; this.errorUri = errorUri; + this.httpStatusCode = httpStatusCode; } @Override @@ -82,6 +92,10 @@ String getErrorCode() { return errorUri; } + int getHttpStatusCode() { + return httpStatusCode; + } + static OAuthException createFromHttpResponseException(HttpResponseException e) throws IOException { JsonParser parser = OAuth2Utils.JSON_FACTORY.createJsonParser((e).getContent()); @@ -96,6 +110,6 @@ static OAuthException createFromHttpResponseException(HttpResponseException e) if (errorResponse.containsKey("error_uri")) { errorUri = (String) errorResponse.get("error_uri"); } - return new OAuthException(errorCode, errorDescription, errorUri); + return new OAuthException(errorCode, errorDescription, errorUri, e.getStatusCode()); } } diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/Slf4jLoggingHelpers.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/Slf4jLoggingHelpers.java index 02c1e2681539..510e54ba174b 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/Slf4jLoggingHelpers.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/Slf4jLoggingHelpers.java @@ -71,6 +71,7 @@ class Slf4jLoggingHelpers { "signedBlob", "authorization", "subject_token", + "actor_token", "id_token")); } diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/UrlIdentityPoolSubjectTokenSupplier.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/UrlIdentityPoolSubjectTokenSupplier.java index 9a95701371b3..79d509d85dff 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/UrlIdentityPoolSubjectTokenSupplier.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/UrlIdentityPoolSubjectTokenSupplier.java @@ -94,7 +94,10 @@ public String getSubjectToken(ExternalAccountSupplierContext context) throws IOE HttpResponse response = request.execute(); LoggingUtils.logResponse( response, LOGGER_PROVIDER, "Received response for subject token request"); - return parseToken(response.getContent(), this.credentialSource); + return parseToken( + response.getContent(), + this.credentialSource, + this.credentialSource.subjectTokenFieldName); } catch (IOException e) { throw new IOException( String.format("Error getting subject token from metadata server: %s", e.getMessage()), e); diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsHttpTransportFactoryTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsHttpTransportFactoryTest.java new file mode 100644 index 000000000000..f917af477bee --- /dev/null +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsHttpTransportFactoryTest.java @@ -0,0 +1,123 @@ +/* + * 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.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.io.SequenceInputStream; +import java.security.KeyStore; +import java.security.cert.Certificate; +import java.security.cert.CertificateFactory; +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); + } +} 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..2cf24e5491ba 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 = 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..8c239a87f84a --- /dev/null +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplierTest.java @@ -0,0 +1,490 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.oauth2; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class FileIdentityPoolSubjectTokenSupplierTest { + + @Test + void getToken_textFormat(@TempDir Path tempDir) throws IOException { + Path credentialFile = tempDir.resolve("credential.txt"); + Files.write(credentialFile, "plain_token".getBytes(StandardCharsets.UTF_8)); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", credentialFile.toString()); + + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); + FileIdentityPoolSubjectTokenSupplier supplier = + new FileIdentityPoolSubjectTokenSupplier(source); // TEXT doesn't need targetFieldName + + assertEquals("plain_token", supplier.getSubjectToken(null)); + + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> supplier.getActorToken(null)); + assertEquals( + "Actor tokens are only supported for JSON-formatted credential files with distinct field" + + " names.", + exception.getMessage()); + } + + @Test + void getToken_jsonFormat_reReadsFileOnEachCall(@TempDir Path tempDir) throws IOException { + Path credentialFile = tempDir.resolve("credential.json"); + Files.write( + credentialFile, + "{\"sub_token\": \"my_sub_token\", \"act_token\": \"my_act_token\"}" + .getBytes(StandardCharsets.UTF_8)); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", credentialFile.toString()); + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "sub_token"); + formatMap.put("actor_token_field_name", "act_token"); + credentialSourceMap.put("format", formatMap); + + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); + FileIdentityPoolSubjectTokenSupplier supplier = + new FileIdentityPoolSubjectTokenSupplier(source); + + // Initial read + assertEquals("my_sub_token", supplier.getSubjectToken(null)); + assertEquals("my_act_token", supplier.getActorToken(null)); + + // Modify file contents + Files.write( + credentialFile, + "{\"sub_token\": \"new_sub\", \"act_token\": \"new_act\"}" + .getBytes(StandardCharsets.UTF_8)); + + // Validate we read the new token after file modification + assertEquals("new_sub", supplier.getSubjectToken(null)); + assertEquals("new_act", supplier.getActorToken(null)); + } + + @Test + void getToken_jsonFormat_concurrentReads(@TempDir Path tempDir) + throws IOException, InterruptedException { + Path credentialFile = tempDir.resolve("credential.json"); + Files.write( + credentialFile, + "{\"sub_token\": \"my_sub_token\", \"act_token\": \"my_act_token\"}" + .getBytes(StandardCharsets.UTF_8)); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", credentialFile.toString()); + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "sub_token"); + formatMap.put("actor_token_field_name", "act_token"); + credentialSourceMap.put("format", formatMap); + + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); + FileIdentityPoolSubjectTokenSupplier supplier = + new FileIdentityPoolSubjectTokenSupplier(source); + + int numThreads = 10; + java.util.concurrent.ExecutorService executor = + java.util.concurrent.Executors.newFixedThreadPool(numThreads); + java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(numThreads); + java.util.List> futures = new java.util.ArrayList<>(); + + for (int i = 0; i < numThreads; i++) { + futures.add( + executor.submit( + () -> { + latch.countDown(); + latch.await(); + assertEquals("my_sub_token", supplier.getSubjectToken(null)); + assertEquals("my_act_token", supplier.getActorToken(null)); + return null; + })); + } + + // Wait for all threads to complete and verify no exceptions were thrown + for (java.util.concurrent.Future future : futures) { + try { + future.get(); + } catch (Exception e) { + throw new RuntimeException("Thread execution failed", e); + } + } + executor.shutdown(); + } + + @Test + void getToken_jsonFormat_invalidField(@TempDir Path tempDir) throws IOException { + Path credentialFile = tempDir.resolve("credential.json"); + Files.write( + credentialFile, "{\"sub_token\": \"my_sub_token\"}".getBytes(StandardCharsets.UTF_8)); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", credentialFile.toString()); + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "sub_token"); + formatMap.put("actor_token_field_name", "act_token"); + credentialSourceMap.put("format", formatMap); + + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); + FileIdentityPoolSubjectTokenSupplier actSupplier = + new FileIdentityPoolSubjectTokenSupplier(source); + + IOException exception = assertThrows(IOException.class, () -> actSupplier.getActorToken(null)); + assertEquals( + "Invalid token field name. No token was found for field: act_token", + exception.getMessage()); + } + + @Test + void parseToken_jsonFormat_nullField_throws(@TempDir Path tempDir) throws IOException { + Path credentialFile = tempDir.resolve("credential.json"); + Files.write(credentialFile, "{\"sub_token\": null}".getBytes(StandardCharsets.UTF_8)); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", credentialFile.toString()); + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "sub_token"); + credentialSourceMap.put("format", formatMap); + + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); + FileIdentityPoolSubjectTokenSupplier supplier = + new FileIdentityPoolSubjectTokenSupplier(source); + + IOException exception = assertThrows(IOException.class, () -> supplier.getSubjectToken(null)); + assertTrue(exception.getMessage().contains("No token was found for field: sub_token")); + } + + @Test + void parseToken_jsonFormat_nonStringField_throwsIOException(@TempDir Path tempDir) + throws IOException { + // Numeric value + Path credentialFile = tempDir.resolve("credential_numeric.json"); + Files.write(credentialFile, "{\"sub_token\": 12345}".getBytes(StandardCharsets.UTF_8)); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", credentialFile.toString()); + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "sub_token"); + credentialSourceMap.put("format", formatMap); + + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); + FileIdentityPoolSubjectTokenSupplier supplier = + new FileIdentityPoolSubjectTokenSupplier(source); + + IOException numException = + assertThrows(IOException.class, () -> supplier.getSubjectToken(null)); + assertTrue( + numException + .getMessage() + .contains("Token field value for sub_token must be a String but was:")); + + // Nested object value + Path objCredentialFile = tempDir.resolve("credential_object.json"); + Files.write( + objCredentialFile, + "{\"sub_token\": {\"nested\": \"val\"}}".getBytes(StandardCharsets.UTF_8)); + credentialSourceMap.put("file", objCredentialFile.toString()); + IdentityPoolCredentialSource objSource = new IdentityPoolCredentialSource(credentialSourceMap); + FileIdentityPoolSubjectTokenSupplier objSupplier = + new FileIdentityPoolSubjectTokenSupplier(objSource); + + IOException objException = + assertThrows(IOException.class, () -> objSupplier.getSubjectToken(null)); + assertTrue( + objException + .getMessage() + .contains("Token field value for sub_token must be a String but was:")); + + // Boolean value + Path boolCredentialFile = tempDir.resolve("credential_bool.json"); + Files.write(boolCredentialFile, "{\"sub_token\": true}".getBytes(StandardCharsets.UTF_8)); + credentialSourceMap.put("file", boolCredentialFile.toString()); + IdentityPoolCredentialSource boolSource = new IdentityPoolCredentialSource(credentialSourceMap); + FileIdentityPoolSubjectTokenSupplier boolSupplier = + new FileIdentityPoolSubjectTokenSupplier(boolSource); + + IOException boolException = + assertThrows(IOException.class, () -> boolSupplier.getSubjectToken(null)); + assertTrue( + boolException + .getMessage() + .contains("Token field value for sub_token must be a String but was:")); + + // Static parseToken with numeric and object inputs + ByteArrayInputStream numStream = + new ByteArrayInputStream("{\"sub_token\": 12345}".getBytes(StandardCharsets.UTF_8)); + IOException parseNumException = + assertThrows( + IOException.class, + () -> FileIdentityPoolSubjectTokenSupplier.parseToken(numStream, source, "sub_token")); + assertTrue( + parseNumException + .getMessage() + .contains("Token field value for sub_token must be a String but was:")); + + ByteArrayInputStream objStream = + new ByteArrayInputStream( + "{\"sub_token\": {\"nested\": 42}}".getBytes(StandardCharsets.UTF_8)); + IOException parseObjException = + assertThrows( + IOException.class, + () -> FileIdentityPoolSubjectTokenSupplier.parseToken(objStream, source, "sub_token")); + assertTrue( + parseObjException + .getMessage() + .contains("Token field value for sub_token must be a String but was:")); + } + + @Test + void serialization_roundTrip_succeeds(@TempDir Path tempDir) throws Exception { + Path credentialFile = tempDir.resolve("credential.json"); + Files.write( + credentialFile, + "{\"sub_token\": \"my_sub_token\", \"act_token\": \"my_act_token\"}" + .getBytes(StandardCharsets.UTF_8)); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", credentialFile.toString()); + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "sub_token"); + formatMap.put("actor_token_field_name", "act_token"); + credentialSourceMap.put("format", formatMap); + + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); + FileIdentityPoolSubjectTokenSupplier supplier = + new FileIdentityPoolSubjectTokenSupplier(source); + + // Populate cache + assertEquals("my_sub_token", supplier.getSubjectToken(null)); + + // Serialize and deserialize + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ObjectOutputStream oos = new ObjectOutputStream(baos)) { + oos.writeObject(supplier); + } + try (ObjectInputStream ois = + new ObjectInputStream(new ByteArrayInputStream(baos.toByteArray()))) { + FileIdentityPoolSubjectTokenSupplier deserialized = + (FileIdentityPoolSubjectTokenSupplier) ois.readObject(); + assertNotNull(deserialized); + assertEquals("my_sub_token", deserialized.getSubjectToken(null)); + } + } + + @Test + void serialVersionUID_matchesPrePrSyntheticSuid() { + assertEquals( + 7152208690659890358L, + java.io.ObjectStreamClass.lookup(FileIdentityPoolSubjectTokenSupplier.class) + .getSerialVersionUID()); + } + + @Test + void getToken_missingFile_throws(@TempDir Path tempDir) { + Path credentialFile = tempDir.resolve("missing_file.txt"); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", credentialFile.toString()); + + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); + FileIdentityPoolSubjectTokenSupplier supplier = + new FileIdentityPoolSubjectTokenSupplier(source); + + IOException exception = assertThrows(IOException.class, () -> supplier.getSubjectToken(null)); + assertEquals( + String.format( + "Invalid credential location. The file at %s does not exist.", credentialFile), + exception.getMessage()); + } + + @Test + void parseToken_textFormat_succeeds() throws IOException { + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", "dummy.txt"); + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); + + ByteArrayInputStream stream = + new ByteArrayInputStream("plain_text_token".getBytes(StandardCharsets.UTF_8)); + String parsed = FileIdentityPoolSubjectTokenSupplier.parseToken(stream, source, null); + assertEquals("plain_text_token", parsed); + } + + @Test + void parseToken_jsonFormat_missingFieldName_throws() { + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", "dummy.json"); + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "sub_token"); + credentialSourceMap.put("format", formatMap); + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); + + ByteArrayInputStream stream = + new ByteArrayInputStream("{\"sub_token\": \"my_token\"}".getBytes(StandardCharsets.UTF_8)); + IOException exception = + assertThrows( + IOException.class, + () -> FileIdentityPoolSubjectTokenSupplier.parseToken(stream, source, null)); + assertEquals( + "Target field name must be specified for JSON credentials.", exception.getMessage()); + } + + @Test + void readTokens_extractsBothFields(@TempDir Path tempDir) throws IOException { + Path credentialFile = tempDir.resolve("credential.json"); + Files.write( + credentialFile, + "{\"sub_token\": \"my_sub_token\", \"act_token\": \"my_act_token\"}" + .getBytes(StandardCharsets.UTF_8)); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", credentialFile.toString()); + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "sub_token"); + formatMap.put("actor_token_field_name", "act_token"); + credentialSourceMap.put("format", formatMap); + + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); + FileIdentityPoolSubjectTokenSupplier supplier = + new FileIdentityPoolSubjectTokenSupplier(source); + + FileIdentityPoolSubjectTokenSupplier.TokenPair tokens = supplier.readTokens(null); + assertEquals("my_sub_token", tokens.subject); + assertEquals("my_act_token", tokens.actor); + } + + @Test + void readTokens_missingActorField_throwsIOException(@TempDir Path tempDir) throws IOException { + Path credentialFile = tempDir.resolve("credential.json"); + Files.write( + credentialFile, "{\"sub_token\": \"my_sub_token\"}".getBytes(StandardCharsets.UTF_8)); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", credentialFile.toString()); + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "sub_token"); + formatMap.put("actor_token_field_name", "act_token"); + credentialSourceMap.put("format", formatMap); + + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); + FileIdentityPoolSubjectTokenSupplier supplier = + new FileIdentityPoolSubjectTokenSupplier(source); + + IOException exception = assertThrows(IOException.class, () -> supplier.readTokens(null)); + assertTrue(exception.getMessage().contains("No token was found for field: act_token")); + } + + @Test + void readTokens_missingSubjectField_throwsIOException(@TempDir Path tempDir) throws IOException { + Path credentialFile = tempDir.resolve("credential.json"); + Files.write( + credentialFile, "{\"act_token\": \"my_act_token\"}".getBytes(StandardCharsets.UTF_8)); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", credentialFile.toString()); + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "sub_token"); + formatMap.put("actor_token_field_name", "act_token"); + credentialSourceMap.put("format", formatMap); + + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); + FileIdentityPoolSubjectTokenSupplier supplier = + new FileIdentityPoolSubjectTokenSupplier(source); + + IOException exception = assertThrows(IOException.class, () -> supplier.readTokens(null)); + assertTrue(exception.getMessage().contains("No token was found for field: sub_token")); + } + + @Test + void readTokens_noActorFieldConfigured_returnsNullActor(@TempDir Path tempDir) + throws IOException { + Path credentialFile = tempDir.resolve("credential.json"); + Files.write( + credentialFile, "{\"sub_token\": \"my_sub_token\"}".getBytes(StandardCharsets.UTF_8)); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", credentialFile.toString()); + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "sub_token"); + credentialSourceMap.put("format", formatMap); + + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); + FileIdentityPoolSubjectTokenSupplier supplier = + new FileIdentityPoolSubjectTokenSupplier(source); + + FileIdentityPoolSubjectTokenSupplier.TokenPair tokens = supplier.readTokens(null); + assertEquals("my_sub_token", tokens.subject); + assertNull(tokens.actor); + } + + @Test + void readTokens_textFormat_throwsIOException(@TempDir Path tempDir) throws IOException { + Path credentialFile = tempDir.resolve("credential.txt"); + Files.write(credentialFile, "plain_token".getBytes(StandardCharsets.UTF_8)); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", credentialFile.toString()); + + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); + FileIdentityPoolSubjectTokenSupplier supplier = + new FileIdentityPoolSubjectTokenSupplier(source); + + IOException exception = assertThrows(IOException.class, () -> supplier.readTokens(null)); + assertTrue(exception.getMessage().contains("only supported for JSON-formatted")); + } +} 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..72fba3459a68 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 @@ -152,4 +152,112 @@ void constructor_certificateConfig_invalidType_throws() { "Invalid type for 'use_default_certificate_config' in certificate configuration: expected Boolean, got String.", exception.getMessage()); } + + @Test + void constructor_fileAndCertificatePresent_isSupported() { + Map certificateMap = new HashMap<>(); + certificateMap.put("use_default_certificate_config", true); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", "/path/to/file"); + credentialSourceMap.put("certificate", certificateMap); + + IdentityPoolCredentialSource credentialSource = + new IdentityPoolCredentialSource(credentialSourceMap); + assertEquals(IdentityPoolCredentialSourceType.FILE, credentialSource.credentialSourceType); + assertEquals("/path/to/file", credentialSource.getCredentialLocation()); + assertNotNull(credentialSource.getCertificateConfig()); + assertTrue(credentialSource.getCertificateConfig().useDefaultCertificateConfig()); + } + + @Test + void constructor_jsonFormat_withActorTokenFieldName() { + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "sub_field"); + formatMap.put("actor_token_field_name", "act_field"); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", "/path/to/file"); + credentialSourceMap.put("format", formatMap); + + IdentityPoolCredentialSource credentialSource = + new IdentityPoolCredentialSource(credentialSourceMap); + assertEquals("sub_field", credentialSource.subjectTokenFieldName); + assertEquals("act_field", credentialSource.actorTokenFieldName); + } + + @Test + void constructor_actorTokenFieldNameSameAsSubject_throws() { + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "same_field"); + formatMap.put("actor_token_field_name", "same_field"); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", "/path/to/file"); + credentialSourceMap.put("format", formatMap); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> new IdentityPoolCredentialSource(credentialSourceMap)); + assertEquals( + "The actor_token_field_name must differ from the subject_token_field_name.", + exception.getMessage()); + } + + @Test + void constructor_actorTokenFieldNameEmpty_throws() { + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "sub_field"); + formatMap.put("actor_token_field_name", " "); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", "/path/to/file"); + credentialSourceMap.put("format", formatMap); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> new IdentityPoolCredentialSource(credentialSourceMap)); + assertEquals("The actor_token_field_name must not be empty.", exception.getMessage()); + } + + @Test + void constructor_actorTokenFieldNameNull_succeeds() { + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "sub_field"); + // actor_token_field_name not set + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", "/path/to/file"); + credentialSourceMap.put("format", formatMap); + + IdentityPoolCredentialSource credentialSource = + new IdentityPoolCredentialSource(credentialSourceMap); + assertEquals("sub_field", credentialSource.subjectTokenFieldName); + assertEquals(null, credentialSource.actorTokenFieldName); + } + + @Test + void constructor_textFormat_withActorTokenFieldName_throws() { + Map formatMap = new HashMap<>(); + formatMap.put("type", "text"); + formatMap.put("actor_token_field_name", "act_field"); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", "/path/to/file"); + credentialSourceMap.put("format", formatMap); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> new IdentityPoolCredentialSource(credentialSourceMap)); + assertEquals( + "Actor tokens are only supported for JSON-formatted credential sources.", + exception.getMessage()); + } } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java index a1662cc10191..364194f7cb53 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java @@ -36,7 +36,10 @@ import static com.google.auth.oauth2.OAuth2Utils.JSON_FACTORY; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -44,26 +47,44 @@ import com.google.api.client.http.HttpTransport; import com.google.api.client.json.GenericJson; import com.google.api.client.util.Clock; +import com.google.api.client.util.SecurityUtils; import com.google.auth.TestUtils; import com.google.auth.http.HttpTransportFactory; +import com.google.auth.mtls.MtlsHttpTransportFactory; import com.google.auth.mtls.X509Provider; import com.google.auth.oauth2.GoogleCredentials.GoogleCredentialsInfo; import java.io.ByteArrayInputStream; import java.io.File; +import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; +import java.io.ObjectInputStream; +import java.io.ObjectStreamClass; +import java.io.SequenceInputStream; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.security.KeyStore; import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; import java.security.cert.CertificateException; import java.util.Arrays; +import java.util.Base64; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; import org.mockito.junit.jupiter.MockitoExtension; /** Tests for {@link IdentityPoolCredentials}. */ @@ -75,6 +96,20 @@ class IdentityPoolCredentialsTest extends BaseSerializationTest { private static final IdentityPoolSubjectTokenSupplier testProvider = (ExternalAccountSupplierContext context) -> "testSubjectToken"; + private static final IdentityPoolActorTokenSupplier testActorSupplier = + (ExternalAccountSupplierContext context) -> "testActorToken"; + + private static KeyStore createPopulatedKeyStore() { + try (InputStream certStream = + new FileInputStream(new File("testresources/mtls/test_cert.pem")); + InputStream keyStream = new FileInputStream(new File("testresources/mtls/test_key.pem")); + InputStream combined = new SequenceInputStream(certStream, keyStream)) { + return SecurityUtils.createMtlsKeyStore(combined); + } catch (Exception e) { + throw new RuntimeException("Failed to create test KeyStore", e); + } + } + @Test void createdScoped_clonedCredentialWithAddedScopes() { IdentityPoolCredentials credentials = @@ -254,6 +289,35 @@ void retrieveSubjectToken_urlSourcedWithJsonFormat() throws IOException { assertEquals(transportFactory.transport.getSubjectToken(), subjectToken); } + @Test + void retrieveSubjectToken_urlSourcedWithJsonFormat_withActorTokenField() throws IOException { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + transportFactory.transport.setMetadataServerContentType("json"); + + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "subjectToken"); + formatMap.put("actor_token_field_name", "actorToken"); + + IdentityPoolCredentialSource credentialSource = + buildUrlBasedCredentialSource(transportFactory.transport.getMetadataUrl(), formatMap); + + UrlIdentityPoolSubjectTokenSupplier supplier = + new UrlIdentityPoolSubjectTokenSupplier(credentialSource, transportFactory); + + ExternalAccountSupplierContext dummyContext = + ExternalAccountSupplierContext.newBuilder() + .setAudience("aud") + .setSubjectTokenType("urn") + .build(); + + String subjectToken = supplier.getSubjectToken(dummyContext); + + assertEquals(transportFactory.transport.getSubjectToken(), subjectToken); + } + @Test void retrieveSubjectToken_urlSourcedCredential_throws() { MockExternalAccountCredentialsTransportFactory transportFactory = @@ -686,7 +750,8 @@ void identityPoolCredentialSource_invalidSourceType() { IllegalArgumentException.class, () -> new IdentityPoolCredentialSource(credentialSourceMap)); assertEquals( - "Missing credential source file location, URL, or certificate. At least one must be specified.", + "Missing credential source file location, URL, or certificate. At least one must be" + + " specified.", e.getMessage()); } @@ -825,7 +890,8 @@ void builder_invalidWorkforceAudiences_throws() { .setQuotaProjectId("quotaProjectId"); IllegalArgumentException e = assertThrows(IllegalArgumentException.class, builder::build); assertEquals( - "The workforce_pool_user_project parameter should only be provided for a Workforce Pool configuration.", + "The workforce_pool_user_project parameter should only be provided for a Workforce Pool" + + " configuration.", e.getMessage()); } } @@ -1265,6 +1331,14 @@ private IdentityPoolCredentialSource createFileCredentialSource() { return new IdentityPoolCredentialSource(fileCredentialSourceMap); } + private IdentityPoolCredentialSource createFileCredentialSource( + String filePath, Map formatMap) { + Map fileCredentialSourceMap = new HashMap<>(); + fileCredentialSourceMap.put("file", filePath); + fileCredentialSourceMap.put("format", formatMap); + return new IdentityPoolCredentialSource(fileCredentialSourceMap); + } + static class MockExternalAccountCredentialsTransportFactory implements HttpTransportFactory { MockExternalAccountCredentialsTransport transport = @@ -1299,4 +1373,1686 @@ void setShouldThrowOnGetKeyStore(boolean shouldThrow) { this.shouldThrowOnGetKeyStore = shouldThrow; } } + + @Test + void builder_actorTokenWithNonMtlsTransportFactory_throws() { + IdentityPoolCredentialSource credentialSource = createFileCredentialSource(); + + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://invalid.googleapis.com/") + .setCredentialSource(credentialSource) + .setActorTokenType("actorTokenType") + .setActorTokenSupplier( + new IdentityPoolActorTokenSupplier() { + @Override + public String getActorToken(ExternalAccountSupplierContext context) { + return "token"; + } + }) + .build()); + + assertEquals( + "Actor tokens are only supported for mTLS token exchanges. Please configure a certificate" + + " source or MtlsHttpTransportFactory.", + e.getMessage()); + } + + @Test + void builder_actorTokenWithMissingTokenType_throws() { + IdentityPoolCredentialSource credentialSource = createFileCredentialSource(); + + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/") + .setCredentialSource(credentialSource) + .setActorTokenSupplier( + new IdentityPoolActorTokenSupplier() { + @Override + public String getActorToken(ExternalAccountSupplierContext context) { + return "token"; + } + }) + .build()); + + assertEquals( + "An actorTokenType must be specified when an actorTokenSupplier is configured.", + e.getMessage()); + } + + @Test + void builder_actorTokenWithInvalidCredentialSource_throws() { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "subject_token"); + formatMap.put("actor_token_field_name", "actor_token"); + + // Not a file credential source + IdentityPoolCredentialSource credentialSource = + buildUrlBasedCredentialSource(transportFactory.transport.getMetadataUrl(), formatMap); + + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/") // Valid URL + .setCredentialSource(credentialSource) // Invalid source for actor tokens + .build()); + + assertEquals( + "Actor tokens are currently only supported for file-based credential sources.", + e.getMessage()); + } + + @Test + void builder_supplierSourcedActorToken() throws Exception { + KeyStore ks = createPopulatedKeyStore(); + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setActorTokenSupplier(testActorSupplier) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setHttpTransportFactory(mtlsTransport) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build(); + + assertNotNull(credentials); + assertEquals("urn:ietf:params:oauth:token-type:jwt", credentials.getActorTokenType()); + } + + @Test + void createScoped_supplierSourcedWithActorToken_preservesCustomSuppliers() throws Exception { + KeyStore ks = createPopulatedKeyStore(); + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(mtlsTransport) + .setSubjectTokenSupplier(testProvider) + .setActorTokenSupplier(testActorSupplier) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build(); + + List newScopes = Arrays.asList("https://www.googleapis.com/auth/cloud-platform"); + IdentityPoolCredentials scoped = credentials.createScoped(newScopes); + + assertNotNull(scoped); + assertEquals(credentials.getActorTokenType(), scoped.getActorTokenType()); + assertEquals(newScopes, scoped.getScopes()); + assertSame(testProvider, scoped.getIdentityPoolSubjectTokenSupplier()); + assertSame(testActorSupplier, scoped.getIdentityPoolActorTokenSupplier()); + } + + @Test + void createScoped_fileSourcedWithActorToken_preservesSharedSupplierInstance() throws Exception { + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "subject_token"); + formatMap.put("actor_token_field_name", "actor_token"); + + IdentityPoolCredentialSource credentialSource = + createFileCredentialSource("credential.json", formatMap); + + KeyStore ks = createPopulatedKeyStore(); + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(mtlsTransport) + .setCredentialSource(credentialSource) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build(); + + // Verify initial instance shares the single supplier instance + assertSame( + credentials.getIdentityPoolSubjectTokenSupplier(), + credentials.getIdentityPoolActorTokenSupplier()); + + // Clone with new scopes + List newScopes = Arrays.asList("https://www.googleapis.com/auth/cloud-platform"); + IdentityPoolCredentials scoped = credentials.createScoped(newScopes); + + assertNotNull(scoped); + assertEquals(credentials.getActorTokenType(), scoped.getActorTokenType()); + assertEquals(newScopes, scoped.getScopes()); + // Verify scoped clone maintains a single shared supplier instance for its own cache + assertSame( + scoped.getIdentityPoolSubjectTokenSupplier(), scoped.getIdentityPoolActorTokenSupplier()); + } + + @Test + void refreshAccessToken_withActorToken_injectsActingPartyIntoStsRequest() throws Exception { + MockExternalAccountCredentialsTransportFactory mockTransportFactory = + new MockExternalAccountCredentialsTransportFactory(); + KeyStore ks = createPopulatedKeyStore(); + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); + + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setActorTokenSupplier(testActorSupplier) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl(mockTransportFactory.transport.getStsMtlsUrl()) + .setHttpTransportFactory(mtlsTransport)) { + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) + throws IOException { + return super.exchangeExternalCredentialForAccessToken( + stsTokenExchangeRequest, mockTransportFactory); + } + }; + + AccessToken token = credential.refreshAccessToken(); + assertEquals(mockTransportFactory.transport.getAccessToken(), token.getTokenValue()); + + Map query = + TestUtils.parseQuery(mockTransportFactory.transport.getLastRequest().getContentAsString()); + assertEquals("testActorToken", query.get("actor_token")); + assertEquals("urn:ietf:params:oauth:token-type:jwt", query.get("actor_token_type")); + } + + @Test + void serialization_withX509Provider_succeeds() throws Exception { + KeyStore ks = createPopulatedKeyStore(); + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); + X509Provider x509Provider = new TestX509Provider(ks, "certificate_config_location"); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(mtlsTransport) + .setSubjectTokenSupplier(testProvider) + .setX509Provider(x509Provider) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build(); + + serializeAndDeserialize(credentials); + } + + @Test + void builder_actorTokenTypeWithoutSupplier_throws() { + IdentityPoolCredentialSource credentialSource = createFileCredentialSource(); + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> + IdentityPoolCredentials.newBuilder() + .setCredentialSource(credentialSource) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.googleapis.com/v1/token") + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .build()); + assertEquals( + "An actorTokenSupplier must be specified when an actorTokenType is configured.", + exception.getMessage()); + } + + @Test + void builder_fileWithCertificateConfig_initializesMtlsTransport() throws Exception { + Map certMap = new HashMap<>(); + certMap.put("use_default_certificate_config", true); + + Map sourceMap = new HashMap<>(); + sourceMap.put("file", "credential.json"); + sourceMap.put("certificate", certMap); + + IdentityPoolCredentialSource credentialSource = new IdentityPoolCredentialSource(sourceMap); + + KeyStore ks = createPopulatedKeyStore(); + X509Provider x509Provider = new TestX509Provider(ks, "certificate_config_location"); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setCredentialSource(credentialSource) + .setX509Provider(x509Provider) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build(); + + assertNotNull(credentials); + assertNotNull(credentials.getTransportFactory()); + assertTrue(credentials.getTransportFactory() instanceof MtlsHttpTransportFactory); + } + + @Test + void toBuilder_preservesConfiguration() throws Exception { + KeyStore ks = createPopulatedKeyStore(); + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(mtlsTransport) + .setSubjectTokenSupplier(testProvider) + .setActorTokenSupplier(testActorSupplier) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build(); + + IdentityPoolCredentials rebuilt = credentials.toBuilder().build(); + + assertNotNull(rebuilt); + assertEquals(credentials.getActorTokenType(), rebuilt.getActorTokenType()); + assertSame(testProvider, rebuilt.getIdentityPoolSubjectTokenSupplier()); + assertSame(testActorSupplier, rebuilt.getIdentityPoolActorTokenSupplier()); + } + + @Test + void builder_actorTokenWithX509Provider_succeeds() throws Exception { + KeyStore ks = createPopulatedKeyStore(); + X509Provider x509Provider = new TestX509Provider(ks, "certificate_config_location"); + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setActorTokenSupplier(testActorSupplier) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setHttpTransportFactory(mtlsTransport) + .setX509Provider(x509Provider) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build(); + + assertNotNull(credentials); + assertEquals("urn:ietf:params:oauth:token-type:jwt", credentials.getActorTokenType()); + } + + @Test + void toBuilder_preservesActorTokenType() throws Exception { + KeyStore ks = createPopulatedKeyStore(); + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(mtlsTransport) + .setSubjectTokenSupplier(testProvider) + .setActorTokenSupplier(testActorSupplier) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build(); + + IdentityPoolCredentials rebuilt = credentials.toBuilder().build(); + + assertNotNull(rebuilt); + assertEquals("urn:ietf:params:oauth:token-type:jwt", rebuilt.getActorTokenType()); + assertSame(testProvider, rebuilt.getIdentityPoolSubjectTokenSupplier()); + assertSame(testActorSupplier, rebuilt.getIdentityPoolActorTokenSupplier()); + } + + @Test + void builder_actorTokenWithoutMtls_throws() { + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setActorTokenSupplier(testActorSupplier) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.googleapis.com/v1/token") + .build()); + assertTrue( + e.getMessage().contains("Actor tokens are only supported for mTLS token exchanges.")); + } + + @Test + void builder_actorTokenWithNoArgMtlsFactory_throws() throws Exception { + // A no-arg MtlsHttpTransportFactory (e.g. from deserialization) has no KeyStore, + // so isMtlsConfigured() should return false and building should fail. + MtlsHttpTransportFactory noArgFactory = new MtlsHttpTransportFactory(); + assertFalse(noArgFactory.hasKeyStore()); + + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setActorTokenSupplier(testActorSupplier) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setHttpTransportFactory(noArgFactory) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build()); + assertTrue( + e.getMessage().contains("Actor tokens are only supported for mTLS token exchanges.")); + } + + @Test + void builder_actorTokenWithEmptyMtlsFactory_throws() throws Exception { + KeyStore emptyKeyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + emptyKeyStore.load(null, null); + MtlsHttpTransportFactory emptyFactory = new MtlsHttpTransportFactory(emptyKeyStore); + assertFalse(emptyFactory.hasKeyStore()); + + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setActorTokenSupplier(testActorSupplier) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setHttpTransportFactory(emptyFactory) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build()); + assertTrue( + e.getMessage().contains("Actor tokens are only supported for mTLS token exchanges.")); + } + + @Test + void mtlsHttpTransportFactory_hasKeyStore_withPopulatedKeyStore_returnsTrue() throws Exception { + KeyStore ks = createPopulatedKeyStore(); + MtlsHttpTransportFactory factory = new MtlsHttpTransportFactory(ks); + assertTrue(factory.hasKeyStore()); + } + + @Test + void mtlsHttpTransportFactory_hasKeyStore_withEmptyKeyStore_returnsFalse() throws Exception { + KeyStore emptyKeyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + emptyKeyStore.load(null, null); + MtlsHttpTransportFactory factory = new MtlsHttpTransportFactory(emptyKeyStore); + assertFalse(factory.hasKeyStore()); + } + + @Test + void mtlsHttpTransportFactory_hasKeyStore_noArg_returnsFalse() { + MtlsHttpTransportFactory factory = new MtlsHttpTransportFactory(); + assertFalse(factory.hasKeyStore()); + } + + // ================================================================================== + // Section A: Cert Pinning & Transport Factory Tests + // ================================================================================== + + @Test + void refreshAccessToken_pinsTransportForStsExchange() throws Exception { + // Verify that the STS exchange uses the pinned transport factory from the KeyStore snapshot + // within one refresh cycle. Threading the pinned transport to IAM impersonation is deferred + // to a follow-up PR. + KeyStore ks = createPopulatedKeyStore(); + + AtomicInteger getKeyStoreCallCount = new AtomicInteger(0); + X509Provider x509Provider = + new X509Provider() { + @Override + public KeyStore getKeyStore() { + getKeyStoreCallCount.incrementAndGet(); + return ks; + } + }; + + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); + + // Use TransportCapturingCredentials so we can capture the factory passed to exchange. + TransportCapturingCredentials credential = + new TransportCapturingCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(x509Provider) + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl(transportFactory.transport.getStsUrl()) + .setHttpTransportFactory(mtlsTransport)); + + AccessToken token = credential.refreshAccessToken(); + assertNotNull(token); + + // getKeyStore() should be called exactly once per refresh cycle for the snapshot. + assertEquals(1, getKeyStoreCallCount.get()); + // The exchange should have been called once, and a single transport factory was used. + assertEquals(1, credential.getCapturedFactories().size()); + assertTrue( + credential.getCapturedFactories().get(0) instanceof MtlsHttpTransportFactory, + "Exchange should use MtlsHttpTransportFactory from the cert snapshot"); + } + + @Test + void refreshAccessToken_certRotationBetweenCycles_usesNewCert() throws Exception { + // First refresh uses cert A, rotate the provider, second refresh uses cert B. + KeyStore ksA = createPopulatedKeyStore(); + KeyStore ksB = createPopulatedKeyStore(); + + AtomicInteger callCount = new AtomicInteger(0); + X509Provider rotatingProvider = + new X509Provider() { + @Override + public KeyStore getKeyStore() { + return callCount.getAndIncrement() == 0 ? ksA : ksB; + } + }; + + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ksA); + + TransportCapturingCredentials credential = + new TransportCapturingCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(rotatingProvider) + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl(transportFactory.transport.getStsUrl()) + .setHttpTransportFactory(mtlsTransport)); + + // First refresh — uses ksA + AccessToken token1 = credential.refreshAccessToken(); + assertNotNull(token1); + assertEquals(1, callCount.get()); + + // Second refresh — uses ksB (rotated) + AccessToken token2 = credential.refreshAccessToken(); + assertNotNull(token2); + assertEquals(2, callCount.get()); + + // Each cycle should have created a distinct MtlsHttpTransportFactory + assertEquals(2, credential.getCapturedFactories().size()); + assertNotSame( + credential.getCapturedFactories().get(0), + credential.getCapturedFactories().get(1), + "Each cycle should use a distinct transport factory"); + } + + @Test + void refreshAccessToken_401Retry_reReadsFromDisk() throws Exception { + // On 401, the code should re-read from X509Provider to get fresh certs and retry. + KeyStore ksA = createPopulatedKeyStore(); + KeyStore ksB = createPopulatedKeyStore(); + + AtomicInteger callCount = new AtomicInteger(0); + X509Provider rotatingProvider = + new X509Provider() { + @Override + public KeyStore getKeyStore() { + // First call: ksA (for initial snapshot) + // Second call: ksB (for retry after 401) + return callCount.getAndIncrement() == 0 ? ksA : ksB; + } + }; + + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ksA); + + // Testable credential: throws 401 on first exchange, succeeds on retry. + TestableIdentityPoolCredentials credential = + new TestableIdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(rotatingProvider) + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl(transportFactory.transport.getStsUrl()) + .setHttpTransportFactory(mtlsTransport), + /* failOnFirstExchange= */ true); + + AccessToken token = credential.refreshAccessToken(); + assertNotNull(token); + // Verify the provider was called twice: once for initial snapshot, once for retry + assertEquals(2, callCount.get()); + assertEquals(2, credential.getExchangeCallCount()); + } + + @Test + void refreshAccessToken_401Retry_nonMtls_bubblesUp() throws Exception { + // When x509Provider is null (non-mTLS), a 401 should bubble up, not retry. + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + TestableIdentityPoolCredentials credential = + new TestableIdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl(transportFactory.transport.getStsUrl()) + .setHttpTransportFactory(transportFactory), + /* failOnFirstExchange= */ true); + + // Should throw the 401 error without retry since there's no x509Provider. + OAuthException e = assertThrows(OAuthException.class, credential::refreshAccessToken); + assertEquals(401, e.getHttpStatusCode()); + assertEquals(1, credential.getExchangeCallCount()); + } + + @Test + void refreshAccessToken_401Retry_secondAttemptFails_throws() throws Exception { + // 401 → retry → retry also fails → exception propagates. + KeyStore ks = createPopulatedKeyStore(); + + X509Provider provider = + new X509Provider() { + @Override + public KeyStore getKeyStore() { + return ks; + } + }; + + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); + + // Testable credential that always throws 401 (both first and retry). + TestableIdentityPoolCredentials credential = + new TestableIdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(provider) + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl(transportFactory.transport.getStsUrl()) + .setHttpTransportFactory(mtlsTransport), + /* failOnFirstExchange= */ true, + /* failOnAllExchanges= */ true); + + OAuthException e = assertThrows(OAuthException.class, credential::refreshAccessToken); + assertEquals(401, e.getHttpStatusCode()); + // First attempt + one retry = 2 + assertEquals(2, credential.getExchangeCallCount()); + } + + @Test + void refreshAccessToken_401Retry_certLoadFailure_preservesOriginalError() throws Exception { + // When a 401 triggers retry but X509Provider.getKeyStore() throws on the retry, + // the IOException from cert loading should be thrown with the original OAuthException + // as a suppressed exception. + KeyStore ks = createPopulatedKeyStore(); + + AtomicInteger providerCallCount = new AtomicInteger(0); + X509Provider failingOnRetryProvider = + new X509Provider() { + @Override + public KeyStore getKeyStore() throws IOException { + int call = providerCallCount.getAndIncrement(); + if (call == 0) { + // First call: return valid KeyStore for initial snapshot + return ks; + } + // Second call: fail during retry (simulates cert file rotation/corruption) + throw new IOException("Certificate file not found during retry"); + } + }; + + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); + + // Testable credential: throws 401 on first exchange to trigger retry path. + TestableIdentityPoolCredentials credential = + new TestableIdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(failingOnRetryProvider) + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl(transportFactory.transport.getStsUrl()) + .setHttpTransportFactory(mtlsTransport), + /* failOnFirstExchange= */ true); + + IOException thrown = assertThrows(IOException.class, credential::refreshAccessToken); + assertEquals("Certificate file not found during retry", thrown.getMessage()); + + // Verify the original OAuthException is preserved as a suppressed exception + Throwable[] suppressed = thrown.getSuppressed(); + assertTrue(suppressed.length > 0, "Should have suppressed exceptions"); + assertTrue(suppressed[0] instanceof OAuthException); + assertEquals(401, ((OAuthException) suppressed[0]).getHttpStatusCode()); + } + + @Test + void refreshAccessToken_subjectAndActorFromSameFileParse() throws Exception { + // Verify when both subject and actor tokens come from the same file supplier, + // readTokens() is called (single file read) rather than separate getSubjectToken() + // + getActorToken() calls. + File file = File.createTempFile("ATOMIC_READ_TOKEN", /* suffix= */ null, /* directory= */ null); + file.deleteOnExit(); + + GenericJson tokenJson = new GenericJson(); + tokenJson.setFactory(JSON_FACTORY); + tokenJson.put("subject_token", "mySubjectToken"); + tokenJson.put("actor_token", "myActorToken"); + OAuth2Utils.writeInputStreamToFile( + new ByteArrayInputStream(tokenJson.toPrettyString().getBytes(StandardCharsets.UTF_8)), + file.getAbsolutePath()); + + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "subject_token"); + formatMap.put("actor_token_field_name", "actor_token"); + IdentityPoolCredentialSource credentialSource = + createFileCredentialSource(file.getAbsolutePath(), formatMap); + + MockExternalAccountCredentialsTransportFactory mockTransportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + KeyStore ks = createPopulatedKeyStore(); + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); + + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setCredentialSource(credentialSource) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl(mockTransportFactory.transport.getStsMtlsUrl()) + .setHttpTransportFactory(mtlsTransport)) { + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) + throws IOException { + return super.exchangeExternalCredentialForAccessToken( + stsTokenExchangeRequest, mockTransportFactory); + } + }; + + // The subject and actor suppliers should be the same instance (both FileIdentityPool...) + assertSame( + credential.getIdentityPoolSubjectTokenSupplier(), + credential.getIdentityPoolActorTokenSupplier(), + "Subject and actor suppliers should be the same instance for file-based sources"); + + // Refresh should succeed, reading both tokens from the single file + AccessToken token = credential.refreshAccessToken(); + assertNotNull(token); + + // Verify the STS request included the actor token from the file + Map query = + TestUtils.parseQuery(mockTransportFactory.transport.getLastRequest().getContentAsString()); + assertEquals("myActorToken", query.get("actor_token")); + assertEquals("urn:ietf:params:oauth:token-type:jwt", query.get("actor_token_type")); + } + + // ================================================================================== + // Section B: Concurrency Tests + // ================================================================================== + + @Test + void refreshAccessToken_concurrent_eachGetOwnSnapshot() throws Exception { + // Two threads refresh simultaneously. Each should get their own KeyStore snapshot. + AtomicInteger getKeyStoreCount = new AtomicInteger(0); + KeyStore ks1 = createPopulatedKeyStore(); + KeyStore ks2 = createPopulatedKeyStore(); + + X509Provider countingProvider = + new X509Provider() { + @Override + public KeyStore getKeyStore() { + int count = getKeyStoreCount.incrementAndGet(); + return count <= 1 ? ks1 : ks2; + } + }; + + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks1); + + TransportCapturingCredentials credential = + new TransportCapturingCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(countingProvider) + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl(transportFactory.transport.getStsUrl()) + .setHttpTransportFactory(mtlsTransport)); + + CyclicBarrier barrier = new CyclicBarrier(2); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future future1 = + executor.submit( + () -> { + barrier.await(5, TimeUnit.SECONDS); + return credential.refreshAccessToken(); + }); + Future future2 = + executor.submit( + () -> { + barrier.await(5, TimeUnit.SECONDS); + return credential.refreshAccessToken(); + }); + + AccessToken token1 = future1.get(10, TimeUnit.SECONDS); + AccessToken token2 = future2.get(10, TimeUnit.SECONDS); + + assertNotNull(token1); + assertNotNull(token2); + // Each thread should have called getKeyStore(), so we expect at least 2 calls. + assertTrue( + getKeyStoreCount.get() >= 2, + "Expected at least 2 getKeyStore calls, got " + getKeyStoreCount.get()); + // Each thread should get its own factory instance + assertEquals(2, credential.getCapturedFactories().size()); + } finally { + executor.shutdownNow(); + } + } + + @Test + void refreshAccessToken_concurrent_401OnOneThread_doesNotAffectOther() throws Exception { + // Thread A refreshes normally (succeeds on first exchange). + // Thread B gets a 401, causing a retry with a fresh cert from X509Provider. + // Verify that Thread B's retry (re-read from X509Provider) does not affect Thread A's + // transport — each thread has its own local cycleTransportFactory. + KeyStore ksInitial = createPopulatedKeyStore(); + KeyStore ksRetry = createPopulatedKeyStore(); + + AtomicInteger getKeyStoreCount = new AtomicInteger(0); + X509Provider provider = + new X509Provider() { + @Override + public KeyStore getKeyStore() { + int count = getKeyStoreCount.incrementAndGet(); + // First two calls are for the two threads' initial snapshots, + // third call is for Thread B's retry after 401. + return count <= 2 ? ksInitial : ksRetry; + } + }; + + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ksInitial); + + // Use a credential where one thread gets a 401 (first exchange fails) and the other + // succeeds. The AtomicInteger tracks per-thread exchange behavior. + AtomicInteger exchangeCallCount = new AtomicInteger(0); + CyclicBarrier barrier = new CyclicBarrier(2); + + // Subclass that alternates: first exchange call throws 401, all others succeed. + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(provider) + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl(transportFactory.transport.getStsUrl()) + .setHttpTransportFactory(mtlsTransport)) { + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) + throws IOException { + int count = exchangeCallCount.incrementAndGet(); + if (count == 1) { + // First exchange call (Thread B): throw 401 to trigger retry + throw new OAuthException("invalid_client", "Unauthorized", null, 401); + } + // All other calls succeed + return new AccessToken("token_" + count, null); + } + }; + + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future futureA = + executor.submit( + () -> { + barrier.await(5, TimeUnit.SECONDS); + return credential.refreshAccessToken(); + }); + + Future futureB = + executor.submit( + () -> { + barrier.await(5, TimeUnit.SECONDS); + return credential.refreshAccessToken(); + }); + + AccessToken tokenA = futureA.get(10, TimeUnit.SECONDS); + AccessToken tokenB = futureB.get(10, TimeUnit.SECONDS); + + assertNotNull(tokenA); + assertNotNull(tokenB); + + // Both threads did initial snapshots (2 calls), plus Thread B's retry (1 more) + assertTrue( + getKeyStoreCount.get() >= 3, + "Expected at least 3 getKeyStore calls (2 initial + 1 retry), got " + + getKeyStoreCount.get()); + // 3 exchange calls total: one 401 + one retry success + one normal success + assertEquals(3, exchangeCallCount.get()); + } finally { + executor.shutdownNow(); + } + } + + @Test + void refreshAccessToken_certRotationDuringRefresh_pinnedCertUsed() throws Exception { + // Cert rotates mid-refresh (during the exchange call). + // Verify the transport factory used in exchange is the one pinned at snapshot time, + // not the rotated cert. + KeyStore ksOriginal = createPopulatedKeyStore(); + KeyStore ksRotated = createPopulatedKeyStore(); + + AtomicReference currentKeyStore = new AtomicReference<>(ksOriginal); + AtomicInteger snapshotCount = new AtomicInteger(0); + + X509Provider provider = + new X509Provider() { + @Override + public KeyStore getKeyStore() { + snapshotCount.incrementAndGet(); + return currentKeyStore.get(); + } + }; + + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ksOriginal); + + // A credential that rotates the cert DURING the exchange call, then captures + // the transport factory to verify it's still the original pinned one. + AtomicReference capturedFactory = new AtomicReference<>(); + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(provider) + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl(transportFactory.transport.getStsUrl()) + .setHttpTransportFactory(mtlsTransport)) { + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) + throws IOException { + // Rotate the cert on the provider DURING the exchange. + // This simulates a cert rotation happening while STS/IAM is in-flight. + currentKeyStore.set(ksRotated); + // Capture the factory that was passed — it should be the original pinned one. + capturedFactory.set(cycleTransportFactory); + return new AccessToken("pinnedCertToken", null); + } + }; + + // Call refresh — this will snapshot ksOriginal, then during exchange, rotate to ksRotated. + AccessToken token = credential.refreshAccessToken(); + assertNotNull(token); + // Snapshot was taken exactly once (at the start of the cycle) + assertEquals(1, snapshotCount.get()); + + // The transport factory used in exchange should be an MtlsHttpTransportFactory + // built from the ORIGINAL snapshot, not the rotated cert. + assertNotNull(capturedFactory.get()); + assertTrue( + capturedFactory.get() instanceof MtlsHttpTransportFactory, + "Exchange should use MtlsHttpTransportFactory pinned to original cert"); + + // Verify that a SECOND refresh picks up the rotated cert (ksRotated). + AtomicReference secondCapturedFactory = new AtomicReference<>(); + IdentityPoolCredentials credential2 = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(provider) + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl(transportFactory.transport.getStsUrl()) + .setHttpTransportFactory(mtlsTransport)) { + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) + throws IOException { + secondCapturedFactory.set(cycleTransportFactory); + return new AccessToken("rotatedCertToken", null); + } + }; + + AccessToken token2 = credential2.refreshAccessToken(); + assertNotNull(token2); + // Second refresh should have taken a new snapshot + assertEquals(2, snapshotCount.get()); + + // The two factories should be different instances (different cert snapshots) + assertNotSame( + capturedFactory.get(), + secondCapturedFactory.get(), + "Each refresh cycle should create a distinct transport factory from its cert snapshot"); + } + + // ================================================================================== + // Section D: Serialization Tests + // ================================================================================== + + @Test + void serialize_deserialize_withActorTokenConfig_roundTrips() throws Exception { + KeyStore ks = createPopulatedKeyStore(); + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(mtlsTransport) + .setSubjectTokenSupplier(testProvider) + .setActorTokenSupplier(testActorSupplier) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .setQuotaProjectId("quotaProjectId") + .setClientId("clientId") + .setClientSecret("clientSecret") + .build(); + + IdentityPoolCredentials deserialized = serializeAndDeserialize(credentials); + assertEquals(credentials.getAudience(), deserialized.getAudience()); + assertEquals(credentials.getSubjectTokenType(), deserialized.getSubjectTokenType()); + assertEquals(credentials.getTokenUrl(), deserialized.getTokenUrl()); + assertEquals(credentials.getQuotaProjectId(), deserialized.getQuotaProjectId()); + assertEquals(credentials.getClientId(), deserialized.getClientId()); + assertEquals(credentials.getClientSecret(), deserialized.getClientSecret()); + assertEquals(credentials.getActorTokenType(), deserialized.getActorTokenType()); + } + + private static final String PRE_PR_SERIALIZED_BYTES_BASE64 = + "rO0ABXNyAC5jb20uZ29vZ2xlLmF1dGgub2F1dGgyLklkZW50aXR5UG9vbENyZWRlbnRpYWxzIkrrZ4jpHOkCAANMABJtZXRy" + + "aWNzSGVhZGVyVmFsdWV0ABJMamF2YS9sYW5nL1N0cmluZztMABRzdWJqZWN0VG9rZW5TdXBwbGllcnQAOUxjb20vZ29vZ2xl" + + "L2F1dGgvb2F1dGgyL0lkZW50aXR5UG9vbFN1YmplY3RUb2tlblN1cHBsaWVyO0wAD3N1cHBsaWVyQ29udGV4dHQAN0xjb20v" + + "Z29vZ2xlL2F1dGgvb2F1dGgyL0V4dGVybmFsQWNjb3VudFN1cHBsaWVyQ29udGV4dDt4cgAxY29tLmdvb2dsZS5hdXRoLm9h" + + "dXRoMi5FeHRlcm5hbEFjY291bnRDcmVkZW50aWFsc2+0PaCkD5P/AgAQTAAIYXVkaWVuY2VxAH4AAUwACGNsaWVudElkcQB+" + + "AAFMAAxjbGllbnRTZWNyZXRxAH4AAUwAEGNyZWRlbnRpYWxTb3VyY2V0AERMY29tL2dvb2dsZS9hdXRoL29hdXRoMi9FeHRl" + + "cm5hbEFjY291bnRDcmVkZW50aWFscyRDcmVkZW50aWFsU291cmNlO0wAE2Vudmlyb25tZW50UHJvdmlkZXJ0ACxMY29tL2dv" + + "b2dsZS9hdXRoL29hdXRoMi9FbnZpcm9ubWVudFByb3ZpZGVyO0wAF2ltcGVyc29uYXRlZENyZWRlbnRpYWxzdAAwTGNvbS9n" + + "b29nbGUvYXV0aC9vYXV0aDIvSW1wZXJzb25hdGVkQ3JlZGVudGlhbHM7TAAObWV0cmljc0hhbmRsZXJ0ADZMY29tL2dvb2ds" + + "ZS9hdXRoL29hdXRoMi9FeHRlcm5hbEFjY291bnRNZXRyaWNzSGFuZGxlcjtMABBwcm9wZXJ0eVByb3ZpZGVydAApTGNvbS9n" + + "b29nbGUvYXV0aC9vYXV0aDIvUHJvcGVydHlQcm92aWRlcjtMAAZzY29wZXN0ABZMamF2YS91dGlsL0NvbGxlY3Rpb247TAAi" + + "c2VydmljZUFjY291bnRJbXBlcnNvbmF0aW9uT3B0aW9uc3QAVkxjb20vZ29vZ2xlL2F1dGgvb2F1dGgyL0V4dGVybmFsQWNj" + + "b3VudENyZWRlbnRpYWxzJFNlcnZpY2VBY2NvdW50SW1wZXJzb25hdGlvbk9wdGlvbnM7TAAec2VydmljZUFjY291bnRJbXBl" + + "cnNvbmF0aW9uVXJscQB+AAFMABBzdWJqZWN0VG9rZW5UeXBlcQB+AAFMAAx0b2tlbkluZm9VcmxxAH4AAUwACHRva2VuVXJs" + + "cQB+AAFMABl0cmFuc3BvcnRGYWN0b3J5Q2xhc3NOYW1lcQB+AAFMABh3b3JrZm9yY2VQb29sVXNlclByb2plY3RxAH4AAXhy" + + "AChjb20uZ29vZ2xlLmF1dGgub2F1dGgyLkdvb2dsZUNyZWRlbnRpYWxz6t29xaLhXyUCAAVaABhpc0V4cGxpY2l0VW5pdmVy" + + "c2VEb21haW5MAARuYW1lcQB+AAFMAA5xdW90YVByb2plY3RJZHEAfgABTAAGc291cmNlcQB+AAFMAA51bml2ZXJzZURvbWFp" + + "bnEAfgABeHIAKGNvbS5nb29nbGUuYXV0aC5vYXV0aDIuT0F1dGgyQ3JlZGVudGlhbHM/PX166aVRVwIABEwAEGV4cGlyYXRp" + + "b25NYXJnaW50ABRMamF2YS90aW1lL0R1cmF0aW9uO0wABGxvY2t0ABJMamF2YS9sYW5nL09iamVjdDtMAA1yZWZyZXNoTWFy" + + "Z2lucQB+AA5MAAV2YWx1ZXQANUxjb20vZ29vZ2xlL2F1dGgvb2F1dGgyL09BdXRoMkNyZWRlbnRpYWxzJE9BdXRoVmFsdWU7" + + "eHIAG2NvbS5nb29nbGUuYXV0aC5DcmVkZW50aWFscws4oteMPZCBAgAAeHBzcgANamF2YS50aW1lLlNlcpVdhLobIkiyDAAA" + + "eHB3DQEAAAAAAAAAtAAAAAB4dXIAAltCrPMX+AYIVOACAAB4cAAAAABzcQB+ABN3DQEAAAAAAAAA4QAAAAB4cAB0ABxFeHRl" + + "cm5hbCBBY2NvdW50IENyZWRlbnRpYWxzdAAOcXVvdGFQcm9qZWN0SWRwdAAOZ29vZ2xlYXBpcy5jb210AGAvL2lhbS5nb29n" + + "bGVhcGlzLmNvbS9wcm9qZWN0cy8xMjMvbG9jYXRpb25zL2dsb2JhbC93b3JrbG9hZElkZW50aXR5UG9vbHMvcG9vbC9wcm92" + + "aWRlcnMvcHJvdmlkZXJ0AAhjbGllbnRJZHQADGNsaWVudFNlY3JldHNyADNjb20uZ29vZ2xlLmF1dGgub2F1dGgyLklkZW50" + + "aXR5UG9vbENyZWRlbnRpYWxTb3VyY2X1pjCawbfqwgIAB0wAE2FjdG9yVG9rZW5GaWVsZE5hbWVxAH4AAUwAEWNlcnRpZmlj" + + "YXRlQ29uZmlndABHTGNvbS9nb29nbGUvYXV0aC9vYXV0aDIvSWRlbnRpdHlQb29sQ3JlZGVudGlhbFNvdXJjZSRDZXJ0aWZp" + + "Y2F0ZUNvbmZpZztMABRjcmVkZW50aWFsRm9ybWF0VHlwZXQASkxjb20vZ29vZ2xlL2F1dGgvb2F1dGgyL0lkZW50aXR5UG9v" + + "bENyZWRlbnRpYWxTb3VyY2UkQ3JlZGVudGlhbEZvcm1hdFR5cGU7TAASY3JlZGVudGlhbExvY2F0aW9ucQB+AAFMABRjcmVk" + + "ZW50aWFsU291cmNlVHlwZXQAVkxjb20vZ29vZ2xlL2F1dGgvb2F1dGgyL0lkZW50aXR5UG9vbENyZWRlbnRpYWxTb3VyY2Uk" + + "SWRlbnRpdHlQb29sQ3JlZGVudGlhbFNvdXJjZVR5cGU7TAAHaGVhZGVyc3QAD0xqYXZhL3V0aWwvTWFwO0wAFXN1YmplY3RU" + + "b2tlbkZpZWxkTmFtZXEAfgABeHIAQmNvbS5nb29nbGUuYXV0aC5vYXV0aDIuRXh0ZXJuYWxBY2NvdW50Q3JlZGVudGlhbHMk" + + "Q3JlZGVudGlhbFNvdXJjZXHczM85z4jIAgAAeHBwcH5yAEhjb20uZ29vZ2xlLmF1dGgub2F1dGgyLklkZW50aXR5UG9vbENy" + + "ZWRlbnRpYWxTb3VyY2UkQ3JlZGVudGlhbEZvcm1hdFR5cGUAAAAAAAAAABIAAHhyAA5qYXZhLmxhbmcuRW51bQAAAAAAAAAA" + + "EgAAeHB0AARURVhUdAAEZmlsZX5yAFRjb20uZ29vZ2xlLmF1dGgub2F1dGgyLklkZW50aXR5UG9vbENyZWRlbnRpYWxTb3Vy" + + "Y2UkSWRlbnRpdHlQb29sQ3JlZGVudGlhbFNvdXJjZVR5cGUAAAAAAAAAABIAAHhxAH4AJnQABEZJTEVwcHNyADBjb20uZ29v" + + "Z2xlLmF1dGgub2F1dGgyLlN5c3RlbUVudmlyb25tZW50UHJvdmlkZXK+zMPWWDs8NAIAAHhwcHNyADRjb20uZ29vZ2xlLmF1" + + "dGgub2F1dGgyLkV4dGVybmFsQWNjb3VudE1ldHJpY3NIYW5kbGVyC4Qcubsxch4CAANaAA5jb25maWdMaWZldGltZVoAD3Nh" + + "SW1wZXJzb25hdGlvbkwAC2NyZWRlbnRpYWxzdAAzTGNvbS9nb29nbGUvYXV0aC9vYXV0aDIvRXh0ZXJuYWxBY2NvdW50Q3Jl" + + "ZGVudGlhbHM7eHAAAXEAfgASc3IALWNvbS5nb29nbGUuYXV0aC5vYXV0aDIuU3lzdGVtUHJvcGVydHlQcm92aWRlcgAAAAAA" + + "AAABAgAAeHBzcgAjamF2YS51dGlsLkNvbGxlY3Rpb25zJFNpbmdsZXRvbkxpc3Qq7ykQPKeblwIAAUwAB2VsZW1lbnRxAH4A" + + "D3hwdAAuaHR0cHM6Ly93d3cuZ29vZ2xlYXBpcy5jb20vYXV0aC9jbG91ZC1wbGF0Zm9ybXNyAFRjb20uZ29vZ2xlLmF1dGgu" + + "b2F1dGgyLkV4dGVybmFsQWNjb3VudENyZWRlbnRpYWxzJFNlcnZpY2VBY2NvdW50SW1wZXJzb25hdGlvbk9wdGlvbnM6/caK" + + "mTx8+QIAAloAHGN1c3RvbVRva2VuTGlmZXRpbWVSZXF1ZXN0ZWRJAAhsaWZldGltZXhwAAAADhB0AHpodHRwczovL2lhbWNy" + + "ZWRlbnRpYWxzLmdvb2dsZWFwaXMuY29tL3YxL3Byb2plY3RzLy0vc2VydmljZUFjY291bnRzL3Rlc3RuQHRlc3QuaWFtLmdz" + + "ZXJ2aWNlYWNjb3VudC5jb206Z2VuZXJhdGVBY2Nlc3NUb2tlbnQAEHN1YmplY3RUb2tlblR5cGV0AAx0b2tlbkluZm9Vcmx0" + + "ACNodHRwczovL3N0cy5nb29nbGVhcGlzLmNvbS92MS90b2tlbnQAPmNvbS5nb29nbGUuYXV0aC5vYXV0aDIuT0F1dGgyVXRp" + + "bHMkRGVmYXVsdEh0dHBUcmFuc3BvcnRGYWN0b3J5cHEAfgApc3IAO2NvbS5nb29nbGUuYXV0aC5vYXV0aDIuRmlsZUlkZW50" + + "aXR5UG9vbFN1YmplY3RUb2tlblN1cHBsaWVyY0G/6P4+lLYCAAFMABBjcmVkZW50aWFsU291cmNldAA1TGNvbS9nb29nbGUv" + + "YXV0aC9vYXV0aDIvSWRlbnRpdHlQb29sQ3JlZGVudGlhbFNvdXJjZTt4cHEAfgAkc3IANWNvbS5nb29nbGUuYXV0aC5vYXV0" + + "aDIuRXh0ZXJuYWxBY2NvdW50U3VwcGxpZXJDb250ZXh0kwegl1C5weoCAAJMAAhhdWRpZW5jZXEAfgABTAAQc3ViamVjdFRv" + + "a2VuVHlwZXEAfgABeHBxAH4AG3EAfgA6"; + + @Test + void serialize_deserialize_backwardCompatible() throws Exception { + byte[] fixtureBytes = Base64.getDecoder().decode(PRE_PR_SERIALIZED_BYTES_BASE64); + IdentityPoolCredentials deserialized; + try (ObjectInputStream input = + new ObjectInputStream(new ByteArrayInputStream(fixtureBytes)) { + @Override + protected ObjectStreamClass readClassDescriptor() + throws IOException, ClassNotFoundException { + ObjectStreamClass desc = super.readClassDescriptor(); + if ("com.google.auth.oauth2.ExternalAccountMetricsHandler".equals(desc.getName())) { + return ObjectStreamClass.lookup(ExternalAccountMetricsHandler.class); + } + return desc; + } + }) { + deserialized = (IdentityPoolCredentials) input.readObject(); + } + + assertNotNull(deserialized); + assertEquals( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider", + deserialized.getAudience()); + assertEquals("subjectTokenType", deserialized.getSubjectTokenType()); + assertEquals("https://sts.googleapis.com/v1/token", deserialized.getTokenUrl()); + assertEquals("quotaProjectId", deserialized.getQuotaProjectId()); + assertEquals("clientId", deserialized.getClientId()); + assertEquals("clientSecret", deserialized.getClientSecret()); + assertEquals( + SERVICE_ACCOUNT_IMPERSONATION_URL, deserialized.getServiceAccountImpersonationUrl()); + assertEquals(null, deserialized.getIdentityPoolActorTokenSupplier()); + assertEquals(null, deserialized.getActorTokenType()); + } + + @Test + void + serialize_deserialize_fileCredentialSource_withCertificateConfig_restoresX509ProviderAndTransport( + @TempDir Path tempDir) throws Exception { + Path tokenFile = tempDir.resolve("credential.txt"); + Files.write(tokenFile, "token_from_file".getBytes(StandardCharsets.UTF_8)); + + Map certMap = new HashMap<>(); + certMap.put("certificate_config_location", "testresources/mtls/certificate_config.json"); + + Map sourceMap = new HashMap<>(); + sourceMap.put("file", tokenFile.toString()); + sourceMap.put("certificate", certMap); + + IdentityPoolCredentialSource credentialSource = new IdentityPoolCredentialSource(sourceMap); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setCredentialSource(credentialSource) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build(); + + assertNotNull(credentials.getX509Provider()); + assertTrue(credentials.getTransportFactory() instanceof MtlsHttpTransportFactory); + assertTrue(((MtlsHttpTransportFactory) credentials.getTransportFactory()).hasKeyStore()); + + IdentityPoolCredentials deserialized = serializeAndDeserialize(credentials); + assertNotNull(deserialized); + assertNotNull(deserialized.getX509Provider()); + assertTrue(deserialized.getTransportFactory() instanceof MtlsHttpTransportFactory); + assertTrue(((MtlsHttpTransportFactory) deserialized.getTransportFactory()).hasKeyStore()); + + // createScoped() should succeed without throwing + IdentityPoolCredentials scoped = + deserialized.createScoped( + Collections.singletonList("https://www.googleapis.com/auth/cloud-platform")); + assertNotNull(scoped); + assertNotNull(scoped.getX509Provider()); + assertTrue(scoped.getTransportFactory() instanceof MtlsHttpTransportFactory); + assertTrue(((MtlsHttpTransportFactory) scoped.getTransportFactory()).hasKeyStore()); + + // refreshAccessToken() on deserialized credentials creates MtlsHttpTransportFactory from + // restored X509Provider + AtomicReference capturedFactory = new AtomicReference<>(); + IdentityPoolCredentials testable = + new IdentityPoolCredentials(deserialized.toBuilder()) { + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) { + capturedFactory.set(cycleTransportFactory); + return new AccessToken("deserializedToken", null); + } + }; + AccessToken token = testable.refreshAccessToken(); + assertEquals("deserializedToken", token.getTokenValue()); + assertNotNull(capturedFactory.get()); + assertTrue(capturedFactory.get() instanceof MtlsHttpTransportFactory); + assertTrue(((MtlsHttpTransportFactory) capturedFactory.get()).hasKeyStore()); + } + + @Test + void serialize_deserialize_certificateCredentialSource_restoresX509ProviderAndTransport() + throws Exception { + Map certMap = new HashMap<>(); + certMap.put("certificate_config_location", "testresources/mtls/certificate_config.json"); + + Map sourceMap = new HashMap<>(); + sourceMap.put("certificate", certMap); + + IdentityPoolCredentialSource credentialSource = new IdentityPoolCredentialSource(sourceMap); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setCredentialSource(credentialSource) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build(); + + assertNotNull(credentials.getX509Provider()); + assertTrue(credentials.getTransportFactory() instanceof MtlsHttpTransportFactory); + assertTrue(((MtlsHttpTransportFactory) credentials.getTransportFactory()).hasKeyStore()); + + IdentityPoolCredentials deserialized = serializeAndDeserialize(credentials); + assertNotNull(deserialized); + assertNotNull(deserialized.getX509Provider()); + assertTrue(deserialized.getTransportFactory() instanceof MtlsHttpTransportFactory); + assertTrue(((MtlsHttpTransportFactory) deserialized.getTransportFactory()).hasKeyStore()); + + // createScoped() should succeed without throwing + IdentityPoolCredentials scoped = + deserialized.createScoped( + Collections.singletonList("https://www.googleapis.com/auth/cloud-platform")); + assertNotNull(scoped); + assertNotNull(scoped.getX509Provider()); + assertTrue(scoped.getTransportFactory() instanceof MtlsHttpTransportFactory); + assertTrue(((MtlsHttpTransportFactory) scoped.getTransportFactory()).hasKeyStore()); + + // refreshAccessToken() on deserialized credentials creates MtlsHttpTransportFactory from + // restored X509Provider + AtomicReference capturedFactory = new AtomicReference<>(); + IdentityPoolCredentials testable = + new IdentityPoolCredentials(deserialized.toBuilder()) { + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) { + capturedFactory.set(cycleTransportFactory); + return new AccessToken("deserializedCertToken", null); + } + }; + AccessToken token = testable.refreshAccessToken(); + assertEquals("deserializedCertToken", token.getTokenValue()); + assertNotNull(capturedFactory.get()); + assertTrue(capturedFactory.get() instanceof MtlsHttpTransportFactory); + } + + @Test + void serialize_deserialize_programmaticFlow_andRefresh_succeeds() throws Exception { + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") + .setTokenUrl("https://sts.googleapis.com/v1/token") + .build(); + + IdentityPoolCredentials deserialized = serializeAndDeserialize(credentials); + assertNotNull(deserialized); + assertNotNull(deserialized.getTransportFactory()); + assertNull(deserialized.getX509Provider()); + assertNotNull(deserialized.getIdentityPoolSubjectTokenSupplier()); + assertNull(deserialized.getIdentityPoolActorTokenSupplier()); + assertNull(deserialized.getActorTokenType()); + + AtomicReference capturedRequest = new AtomicReference<>(); + IdentityPoolCredentials testable = + new IdentityPoolCredentials(deserialized.toBuilder()) { + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) { + capturedRequest.set(stsTokenExchangeRequest); + return new AccessToken("programmaticAccessToken", null); + } + }; + + AccessToken token = testable.refreshAccessToken(); + assertEquals("programmaticAccessToken", token.getTokenValue()); + assertNotNull(capturedRequest.get()); + assertEquals("testSubjectToken", capturedRequest.get().getSubjectToken()); + assertEquals( + "urn:ietf:params:oauth:token-type:jwt", capturedRequest.get().getSubjectTokenType()); + assertNull(capturedRequest.get().getActingParty()); + } + + @Test + void serialize_deserialize_programmaticFlow_withMtlsTransport_restoresFactoryWithoutKeyStore() + throws Exception { + KeyStore keyStore = createPopulatedKeyStore(); + HttpTransportFactory transportFactory = new MtlsHttpTransportFactory(keyStore); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(context -> "testSubjectToken") + .setActorTokenSupplier(context -> "testActorToken") + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") + .setTokenUrl(MockExternalAccountCredentialsTransport.STS_MTLS_URL) + .setHttpTransportFactory(transportFactory) + .build(); + + IdentityPoolCredentials deserialized = serializeAndDeserialize(credentials); + assertNotNull(deserialized); + // Programmatic flows restore default-constructed transportFactory from class name + assertNotNull(deserialized.getTransportFactory()); + assertTrue(deserialized.getTransportFactory() instanceof MtlsHttpTransportFactory); + assertFalse(((MtlsHttpTransportFactory) deserialized.getTransportFactory()).hasKeyStore()); + assertNull(deserialized.getX509Provider()); + assertNotNull(deserialized.getIdentityPoolSubjectTokenSupplier()); + assertNotNull(deserialized.getIdentityPoolActorTokenSupplier()); + } + + @Test + void builder_actorTokenWithPlainPublicTokenUrl_throwsIllegalArgumentException() throws Exception { + KeyStore keyStore = createPopulatedKeyStore(); + HttpTransportFactory transportFactory = new MtlsHttpTransportFactory(keyStore); + + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(context -> "testSubjectToken") + .setActorTokenSupplier(context -> "testActorToken") + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") + .setTokenUrl("https://sts.googleapis.com/v1/token") + .setHttpTransportFactory(transportFactory) + .build()); + assertTrue(e.getMessage().contains("tokenUrl")); + assertTrue(e.getMessage().contains("plain public Google API endpoint")); + } + + @Test + void builder_actorTokenWithPlainPublicImpersonationUrl_throwsIllegalArgumentException() + throws Exception { + KeyStore keyStore = createPopulatedKeyStore(); + HttpTransportFactory transportFactory = new MtlsHttpTransportFactory(keyStore); + + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(context -> "testSubjectToken") + .setActorTokenSupplier(context -> "testActorToken") + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") + .setTokenUrl(MockExternalAccountCredentialsTransport.STS_MTLS_URL) + .setServiceAccountImpersonationUrl( + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken") + .setHttpTransportFactory(transportFactory) + .build()); + assertTrue(e.getMessage().contains("serviceAccountImpersonationUrl")); + assertTrue(e.getMessage().contains("plain public Google API endpoint")); + } + + @Test + void builder_actorTokenWithMtlsTokenUrl_succeeds() throws Exception { + KeyStore keyStore = createPopulatedKeyStore(); + HttpTransportFactory transportFactory = new MtlsHttpTransportFactory(keyStore); + + IdentityPoolCredentials cred = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(context -> "testSubjectToken") + .setActorTokenSupplier(context -> "testActorToken") + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") + .setTokenUrl(MockExternalAccountCredentialsTransport.STS_MTLS_URL) + .setServiceAccountImpersonationUrl( + "https://iamcredentials.mtls.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken") + .setHttpTransportFactory(transportFactory) + .build(); + assertNotNull(cred); + } + + @Test + void builder_actorTokenWithPscUrls_succeeds() throws Exception { + KeyStore keyStore = createPopulatedKeyStore(); + HttpTransportFactory transportFactory = new MtlsHttpTransportFactory(keyStore); + + IdentityPoolCredentials cred = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(context -> "testSubjectToken") + .setActorTokenSupplier(context -> "testActorToken") + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") + .setTokenUrl("https://sts.p.googleapis.com/v1/token") + .setServiceAccountImpersonationUrl( + "https://iamcredentials.p.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken") + .setHttpTransportFactory(transportFactory) + .build(); + assertNotNull(cred); + } + + @Test + void builder_actorTokenWithCustomDomainUrls_succeeds() throws Exception { + KeyStore keyStore = createPopulatedKeyStore(); + HttpTransportFactory transportFactory = new MtlsHttpTransportFactory(keyStore); + + IdentityPoolCredentials cred = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(context -> "testSubjectToken") + .setActorTokenSupplier(context -> "testActorToken") + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience("//custom.domain.com/pool") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") + .setTokenUrl("https://auth.custom-domain.com/v1/token") + .setServiceAccountImpersonationUrl("https://iam.custom-domain.com/v1/generate") + .setHttpTransportFactory(transportFactory) + .build(); + assertNotNull(cred); + } + + @Test + void fromBuilder_withCustomTransportFactoryAndCertificateConfig_preservesCustomTransportFactory( + @TempDir Path tempDir) throws Exception { + Path tokenFile = tempDir.resolve("credential.txt"); + Files.write(tokenFile, "token_from_file".getBytes(StandardCharsets.UTF_8)); + + Map certMap = new HashMap<>(); + certMap.put("certificate_config_location", "testresources/mtls/certificate_config.json"); + + Map sourceMap = new HashMap<>(); + sourceMap.put("file", tokenFile.toString()); + sourceMap.put("certificate", certMap); + + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(sourceMap); + HttpTransportFactory customTransportFactory = + () -> new MockExternalAccountCredentialsTransport(); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") + .setTokenUrl("https://sts.googleapis.com/v1/token") + .setCredentialSource(source) + .setHttpTransportFactory(customTransportFactory) + .build(); + + assertSame(customTransportFactory, credentials.getTransportFactory()); + } + + // ================================================================================== + // Section E: Production Path (fromStream) Tests + // ================================================================================== + + @Test + void fromStream_fileCredentialSource_withCertificateConfig_andActorToken_refreshesSuccessfully( + @TempDir Path tempDir) throws Exception { + Path tokenFile = tempDir.resolve("credential.json"); + GenericJson tokenJson = new GenericJson(); + tokenJson.setFactory(JSON_FACTORY); + tokenJson.put("subject_token", "testSubjectTokenFromStream"); + tokenJson.put("actor_token", "testActorTokenFromStream"); + OAuth2Utils.writeInputStreamToFile( + new ByteArrayInputStream(tokenJson.toPrettyString().getBytes(StandardCharsets.UTF_8)), + tokenFile.toString()); + + String configJson = + "{\n" + + " \"type\": \"external_account\",\n" + + " \"audience\":" + + " \"//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider\",\n" + + " \"subject_token_type\": \"urn:ietf:params:oauth:token-type:jwt\",\n" + + " \"actor_token_type\": \"urn:ietf:params:oauth:token-type:jwt\",\n" + + " \"token_url\": \"https://sts.mtls.googleapis.com/v1/token\",\n" + + " \"credential_source\": {\n" + + " \"file\": \"" + + tokenFile.toString() + + "\",\n" + + " \"format\": {\n" + + " \"type\": \"json\",\n" + + " \"subject_token_field_name\": \"subject_token\",\n" + + " \"actor_token_field_name\": \"actor_token\"\n" + + " },\n" + + " \"certificate\": {\n" + + " \"certificate_config_location\": \"testresources/mtls/certificate_config.json\"\n" + + " }\n" + + " }\n" + + "}"; + + ExternalAccountCredentials credentials = + ExternalAccountCredentials.fromStream( + new ByteArrayInputStream(configJson.getBytes(StandardCharsets.UTF_8))); + + assertTrue(credentials instanceof IdentityPoolCredentials); + IdentityPoolCredentials idp = (IdentityPoolCredentials) credentials; + assertNotNull(idp.getX509Provider()); + assertEquals("urn:ietf:params:oauth:token-type:jwt", idp.getActorTokenType()); + assertTrue(idp.getTransportFactory() instanceof MtlsHttpTransportFactory); + assertTrue(((MtlsHttpTransportFactory) idp.getTransportFactory()).hasKeyStore()); + assertSame(idp.getIdentityPoolSubjectTokenSupplier(), idp.getIdentityPoolActorTokenSupplier()); + + // Execute refreshAccessToken() on testable credentials constructed from idp.toBuilder() + AtomicReference capturedRequest = new AtomicReference<>(); + AtomicReference capturedFactory = new AtomicReference<>(); + IdentityPoolCredentials testable = + new IdentityPoolCredentials(idp.toBuilder()) { + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) { + capturedRequest.set(stsTokenExchangeRequest); + capturedFactory.set(cycleTransportFactory); + return new AccessToken("prodAccessToken", null); + } + }; + + AccessToken token = testable.refreshAccessToken(); + assertEquals("prodAccessToken", token.getTokenValue()); + assertNotNull(capturedRequest.get()); + assertEquals("testSubjectTokenFromStream", capturedRequest.get().getSubjectToken()); + assertEquals( + "urn:ietf:params:oauth:token-type:jwt", capturedRequest.get().getSubjectTokenType()); + assertNotNull(capturedRequest.get().getActingParty()); + assertEquals( + "testActorTokenFromStream", capturedRequest.get().getActingParty().getActorToken()); + assertEquals( + "urn:ietf:params:oauth:token-type:jwt", + capturedRequest.get().getActingParty().getActorTokenType()); + assertNotNull(capturedFactory.get()); + assertTrue(capturedFactory.get() instanceof MtlsHttpTransportFactory); + assertTrue(((MtlsHttpTransportFactory) capturedFactory.get()).hasKeyStore()); + } + + @Test + void fromStream_fileCredentialSource_certRotation_401Retry_succeeds(@TempDir Path tempDir) + throws Exception { + Path tokenFile = tempDir.resolve("credential.json"); + GenericJson tokenJson = new GenericJson(); + tokenJson.setFactory(JSON_FACTORY); + tokenJson.put("subject_token", "testSubjectToken401"); + OAuth2Utils.writeInputStreamToFile( + new ByteArrayInputStream(tokenJson.toPrettyString().getBytes(StandardCharsets.UTF_8)), + tokenFile.toString()); + + String configJson = + "{\n" + + " \"type\": \"external_account\",\n" + + " \"audience\":" + + " \"//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider\",\n" + + " \"subject_token_type\": \"urn:ietf:params:oauth:token-type:jwt\",\n" + + " \"token_url\": \"https://sts.googleapis.com/v1/token\",\n" + + " \"credential_source\": {\n" + + " \"file\": \"" + + tokenFile.toString() + + "\",\n" + + " \"format\": {\n" + + " \"type\": \"json\",\n" + + " \"subject_token_field_name\": \"subject_token\"\n" + + " },\n" + + " \"certificate\": {\n" + + " \"certificate_config_location\": \"testresources/mtls/certificate_config.json\"\n" + + " }\n" + + " }\n" + + "}"; + + ExternalAccountCredentials credentials = + ExternalAccountCredentials.fromStream( + new ByteArrayInputStream(configJson.getBytes(StandardCharsets.UTF_8))); + + assertTrue(credentials instanceof IdentityPoolCredentials); + IdentityPoolCredentials idp = (IdentityPoolCredentials) credentials; + assertNotNull(idp.getX509Provider()); + + AtomicInteger exchangeCount = new AtomicInteger(0); + IdentityPoolCredentials testable = + new IdentityPoolCredentials(idp.toBuilder()) { + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) + throws IOException { + if (exchangeCount.incrementAndGet() == 1) { + throw new OAuthException("invalid_client", "Unauthorized", null, 401); + } + return new AccessToken("rotatedRetryToken", null); + } + }; + + AccessToken token = testable.refreshAccessToken(); + assertEquals("rotatedRetryToken", token.getTokenValue()); + assertEquals(2, exchangeCount.get()); + } + + // ================================================================================== + // Helper: TestableIdentityPoolCredentials — overrides exchange for 401 testing + // ================================================================================== + + /** + * A test subclass that overrides exchangeExternalCredentialForAccessToken to throw + * OAuthException(401) on configurable calls, simulating the cert rotation retry path. This is + * necessary because the real STS handler wraps HttpResponseException into OAuthException, which + * is what the catch(OAuthException) in refreshAccessToken expects via normal STS flow. + */ + private static class TestableIdentityPoolCredentials extends IdentityPoolCredentials { + private final AtomicInteger exchangeCallCount = new AtomicInteger(0); + private final boolean failOnFirstExchange; + private final boolean failOnAllExchanges; + + TestableIdentityPoolCredentials( + IdentityPoolCredentials.Builder builder, boolean failOnFirstExchange) { + this(builder, failOnFirstExchange, false); + } + + TestableIdentityPoolCredentials( + IdentityPoolCredentials.Builder builder, + boolean failOnFirstExchange, + boolean failOnAllExchanges) { + super(builder); + this.failOnFirstExchange = failOnFirstExchange; + this.failOnAllExchanges = failOnAllExchanges; + } + + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, HttpTransportFactory cycleTransportFactory) + throws IOException { + int count = exchangeCallCount.incrementAndGet(); + if (failOnAllExchanges || (failOnFirstExchange && count == 1)) { + throw new OAuthException("invalid_client", "Unauthorized", null, 401); + } + // Return a dummy access token for the retry path + return new AccessToken("retryAccessToken", null); + } + + int getExchangeCallCount() { + return exchangeCallCount.get(); + } + } + + // ================================================================================== + // Helper: TransportCapturingCredentials — captures transport factory for cert tests + // ================================================================================== + + /** + * A test subclass that captures the HttpTransportFactory passed to + * exchangeExternalCredentialForAccessToken, allowing tests to verify cert pinning behavior + * without making real HTTP calls. + */ + private static class TransportCapturingCredentials extends IdentityPoolCredentials { + private final java.util.List capturedFactories = + java.util.Collections.synchronizedList(new java.util.ArrayList<>()); + + TransportCapturingCredentials(IdentityPoolCredentials.Builder builder) { + super(builder); + } + + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, HttpTransportFactory cycleTransportFactory) + throws IOException { + capturedFactories.add(cycleTransportFactory); + // Return a dummy access token + return new AccessToken("capturedAccessToken", null); + } + + java.util.List getCapturedFactories() { + return capturedFactories; + } + } } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java index 7719b08d2e7b..85dff97bc270 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java @@ -68,6 +68,7 @@ public class MockExternalAccountCredentialsTransport extends MockHttpTransport { private static final String AWS_IMDSV2_SESSION_TOKEN_URL = "https://169.254.169.254/imdsv2"; private static final String METADATA_SERVER_URL = "https://www.metadata.google.com"; private static final String STS_URL = "https://sts.googleapis.com/v1/token"; + static final String STS_MTLS_URL = "https://sts.mtls.googleapis.com/v1/token"; private static final String SUBJECT_TOKEN = "subjectToken"; private static final String TOKEN_TYPE = "Bearer"; @@ -167,7 +168,7 @@ public LowLevelHttpResponse execute() throws IOException { .setContentType("text/html") .setContent(SUBJECT_TOKEN); } - if (STS_URL.equals(url)) { + if (STS_URL.equals(url) || STS_MTLS_URL.equals(url)) { Map query = TestUtils.parseQuery(getContentAsString()); // Store STS content as multiple calls are made using this transport. @@ -288,6 +289,10 @@ public String getStsUrl() { return STS_URL; } + public String getStsMtlsUrl() { + return STS_MTLS_URL; + } + public String getServiceAccountImpersonationUrl() { return SERVICE_ACCOUNT_IMPERSONATION_URL; } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/Slf4jUtilsLogbackTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/Slf4jUtilsLogbackTest.java index 340b6d199176..56fe3ed76fe5 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/Slf4jUtilsLogbackTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/Slf4jUtilsLogbackTest.java @@ -32,6 +32,8 @@ package com.google.auth.oauth2; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -43,6 +45,7 @@ import com.google.api.client.http.HttpRequestFactory; import com.google.api.client.http.UrlEncodedContent; import com.google.api.client.util.GenericData; +import com.google.gson.JsonObject; import com.google.gson.JsonParser; import com.google.gson.JsonSyntaxException; import java.io.IOException; @@ -198,6 +201,49 @@ void testLogRequest() throws IOException { testAppender.stop(); } + @Test + void testLogRequest_masksActorTokenAndSubjectToken() throws IOException { + testEnvironmentProvider.setEnv(LoggingUtils.GOOGLE_SDK_JAVA_LOGGING, "true"); + LoggingUtils.setEnvironmentProvider(testEnvironmentProvider); + + TestAppender testAppender = setupTestLogger(); + + GenericData tokenRequest = new GenericData(); + tokenRequest.set("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange"); + tokenRequest.set("subject_token", "raw_secret_subject_token"); + tokenRequest.set("actor_token", "raw_secret_actor_token"); + UrlEncodedContent content = new UrlEncodedContent(tokenRequest); + + MockHttpTransportFactory mockHttpTransportFactory = new MockHttpTransportFactory(); + HttpRequestFactory requestFactory = mockHttpTransportFactory.create().createRequestFactory(); + HttpRequest request = + requestFactory.buildPostRequest(new GenericUrl(OAuth2Utils.TOKEN_SERVER_URI), content); + + LoggerProvider loggerProvider = mock(LoggerProvider.class, withSettings().withoutAnnotations()); + when(loggerProvider.getLogger()).thenReturn(LOGGER); + LoggingUtils.logRequest(request, loggerProvider, "STS Token Exchange"); + + assertEquals(1, testAppender.events.size()); + String payloadJson = null; + for (KeyValuePair kvp : testAppender.events.get(0).getKeyValuePairs()) { + if ("request.payload".equals(kvp.key)) { + payloadJson = (String) kvp.value; + } + } + assertNotNull(payloadJson); + JsonObject payload = JsonParser.parseString(payloadJson).getAsJsonObject(); + + // Verify that raw secrets are never logged in plaintext + assertNotEquals("raw_secret_subject_token", payload.get("subject_token").getAsString()); + assertNotEquals("raw_secret_actor_token", payload.get("actor_token").getAsString()); + + // Verify that masked values are 64-char SHA-256 hex hashes + assertEquals(64, payload.get("subject_token").getAsString().length()); + assertEquals(64, payload.get("actor_token").getAsString().length()); + + testAppender.stop(); + } + boolean isValidJson(String jsonString) { try { JsonParser.parseString(jsonString);