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..4224ff061371 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 @@ -31,6 +31,7 @@ package com.google.auth.mtls; +import com.google.api.client.http.HttpTransport; import com.google.api.client.http.javanet.NetHttpTransport; import com.google.api.core.InternalApi; import com.google.auth.http.HttpTransportFactory; @@ -38,6 +39,7 @@ import java.security.KeyStore; 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 +52,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,8 +75,17 @@ 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) will return {@code false}. + */ + public boolean hasKeyStore() { + return this.mtlsKeyStore != null; + } + @Override - public NetHttpTransport create() { + public HttpTransport create() { try { // Build the mTLS transport using the provided KeyStore. return new NetHttpTransport.Builder().trustCertificates(null, mtlsKeyStore, "").build(); 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..2c3b2ed6e47d 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,161 @@ 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 = 2475549052347431992L; 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); + } + return value.toString(); + } + + /** 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); + } + return value.toString(); } + } - 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..156bda41f2d9 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 @@ -50,6 +50,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 +64,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. After deserialization, per-cycle cert pinning and 401 retry + // are disabled; the credential falls back to the class-level transportFactory for mTLS. + @Nullable private final transient X509Provider x509Provider; private final ExternalAccountSupplierContext supplierContext; private final String metricsHeaderValue; @@ -77,18 +86,34 @@ 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); + KeyStore mtlsKeyStore = x509Provider.getKeyStore(); + 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 +129,115 @@ 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."); + } + } + + /** + * 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 +255,21 @@ IdentityPoolSubjectTokenSupplier getIdentityPoolSubjectTokenSupplier() { return this.subjectTokenSupplier; } + @VisibleForTesting + @Nullable IdentityPoolActorTokenSupplier getIdentityPoolActorTokenSupplier() { + return this.actorTokenSupplier; + } + + @VisibleForTesting + String getActorTokenType() { + return this.actorTokenType; + } + + @VisibleForTesting + HttpTransportFactory getTransportFactory() { + return this.transportFactory; + } + /** Clones the IdentityPoolCredentials with the specified scopes. */ @Override public IdentityPoolCredentials createScoped(Collection newScopes) { @@ -205,6 +332,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 +342,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 +380,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/oauth2/ExternalAccountCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java index 1338c0d68fe9..c0b6bd6aa6e6 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 @@ -200,6 +200,33 @@ void fromJson_identityPoolCredentialsWorkload() { assertEquals(GOOGLE_DEFAULT_UNIVERSE, credential.getUniverseDomain()); } + @Test + void fromJson_identityPoolCredentials_withActorTokenType() throws Exception { + GenericJson json = buildJsonIdentityPoolCredential(); + 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 = + java.security.KeyStore.getInstance(java.security.KeyStore.getDefaultType()); + ks.load(null, null); + 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..33117092bb73 --- /dev/null +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplierTest.java @@ -0,0 +1,420 @@ +/* + * 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_convertsToString(@TempDir Path tempDir) + throws IOException { + Path credentialFile = tempDir.resolve("credential.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); + + assertEquals("12345", supplier.getSubjectToken(null)); + } + + @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 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..397747c93574 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,9 @@ 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.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -46,12 +48,16 @@ import com.google.api.client.util.Clock; 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.ByteArrayOutputStream; import java.io.File; import java.io.IOException; import java.io.InputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; import java.nio.charset.StandardCharsets; import java.security.KeyStore; import java.security.KeyStoreException; @@ -61,6 +67,13 @@ 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; @@ -75,6 +88,9 @@ class IdentityPoolCredentialsTest extends BaseSerializationTest { private static final IdentityPoolSubjectTokenSupplier testProvider = (ExternalAccountSupplierContext context) -> "testSubjectToken"; + private static final IdentityPoolActorTokenSupplier testActorSupplier = + (ExternalAccountSupplierContext context) -> "testActorToken"; + @Test void createdScoped_clonedCredentialWithAddedScopes() { IdentityPoolCredentials credentials = @@ -254,6 +270,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 +731,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 +871,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 +1312,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 +1354,1199 @@ 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 = KeyStore.getInstance(KeyStore.getDefaultType()); + ks.load(null, null); + 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 = KeyStore.getInstance(KeyStore.getDefaultType()); + ks.load(null, null); + 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 = KeyStore.getInstance(KeyStore.getDefaultType()); + ks.load(null, null); + 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 transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); + ks.load(null, null); + MtlsHttpTransportFactory mtlsTransport = + new MtlsHttpTransportFactory(ks) { + @Override + public com.google.api.client.http.HttpTransport create() { + return transportFactory.create(); + } + }; + + IdentityPoolCredentials credential = + 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(transportFactory.transport.getStsUrl()) + .setHttpTransportFactory(mtlsTransport) + .build(); + + AccessToken token = credential.refreshAccessToken(); + assertEquals(transportFactory.transport.getAccessToken(), token.getTokenValue()); + + Map query = + TestUtils.parseQuery(transportFactory.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 = KeyStore.getInstance(KeyStore.getDefaultType()); + ks.load(null, null); + 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 = KeyStore.getInstance(KeyStore.getDefaultType()); + ks.load(null, null); + 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 = KeyStore.getInstance(KeyStore.getDefaultType()); + ks.load(null, null); + 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 = KeyStore.getInstance(KeyStore.getDefaultType()); + ks.load(null, null); + 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 = KeyStore.getInstance(KeyStore.getDefaultType()); + ks.load(null, null); + 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 mtlsHttpTransportFactory_hasKeyStore_withKeyStore_returnsTrue() throws Exception { + KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); + ks.load(null, null); + MtlsHttpTransportFactory factory = new MtlsHttpTransportFactory(ks); + assertTrue(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_useSameCertForStsAndIam() throws Exception { + // Verify that both STS and IAM use the same transport factory (from the same KeyStore + // snapshot) within one refresh cycle. + KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); + ks.load(null, null); + + 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) { + @Override + public HttpTransport create() { + return transportFactory.create(); + } + }; + + // 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 = KeyStore.getInstance(KeyStore.getDefaultType()); + ksA.load(null, null); + KeyStore ksB = KeyStore.getInstance(KeyStore.getDefaultType()); + ksB.load(null, null); + + AtomicInteger callCount = new AtomicInteger(0); + X509Provider rotatingProvider = + new X509Provider() { + @Override + public KeyStore getKeyStore() { + return callCount.getAndIncrement() == 0 ? ksA : ksB; + } + }; + + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + MtlsHttpTransportFactory mtlsTransport = + new MtlsHttpTransportFactory(ksA) { + @Override + public HttpTransport create() { + return transportFactory.create(); + } + }; + + 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 = KeyStore.getInstance(KeyStore.getDefaultType()); + ksA.load(null, null); + KeyStore ksB = KeyStore.getInstance(KeyStore.getDefaultType()); + ksB.load(null, null); + + AtomicInteger callCount = new AtomicInteger(0); + X509Provider rotatingProvider = + new X509Provider() { + @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) { + @Override + public HttpTransport create() { + return transportFactory.create(); + } + }; + + // 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 = KeyStore.getInstance(KeyStore.getDefaultType()); + ks.load(null, null); + + X509Provider provider = + new X509Provider() { + @Override + public KeyStore getKeyStore() { + return ks; + } + }; + + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + MtlsHttpTransportFactory mtlsTransport = + new MtlsHttpTransportFactory(ks) { + @Override + public HttpTransport create() { + return transportFactory.create(); + } + }; + + // 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 = KeyStore.getInstance(KeyStore.getDefaultType()); + ks.load(null, null); + + 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) { + @Override + public HttpTransport create() { + return transportFactory.create(); + } + }; + + // 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 transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); + ks.load(null, null); + MtlsHttpTransportFactory mtlsTransport = + new MtlsHttpTransportFactory(ks) { + @Override + public HttpTransport create() { + return transportFactory.create(); + } + }; + + IdentityPoolCredentials credential = + 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(transportFactory.transport.getStsUrl()) + .setHttpTransportFactory(mtlsTransport) + .build(); + + // 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(transportFactory.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 = KeyStore.getInstance(KeyStore.getDefaultType()); + ks1.load(null, null); + KeyStore ks2 = KeyStore.getInstance(KeyStore.getDefaultType()); + ks2.load(null, null); + + 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) { + @Override + public HttpTransport create() { + return transportFactory.create(); + } + }; + + 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 = KeyStore.getInstance(KeyStore.getDefaultType()); + ksInitial.load(null, null); + KeyStore ksRetry = KeyStore.getInstance(KeyStore.getDefaultType()); + ksRetry.load(null, null); + + 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) { + @Override + public HttpTransport create() { + return transportFactory.create(); + } + }; + + // 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 = KeyStore.getInstance(KeyStore.getDefaultType()); + ksOriginal.load(null, null); + KeyStore ksRotated = KeyStore.getInstance(KeyStore.getDefaultType()); + ksRotated.load(null, null); + + 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) { + @Override + public HttpTransport create() { + return transportFactory.create(); + } + }; + + // 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 = KeyStore.getInstance(KeyStore.getDefaultType()); + ks.load(null, null); + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(mtlsTransport) + .setSubjectTokenSupplier(testProvider) + .setActorTokenSupplier(testActorSupplier) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .setQuotaProjectId("quotaProjectId") + .setClientId("clientId") + .setClientSecret("clientSecret") + .build(); + + IdentityPoolCredentials deserialized = serializeAndDeserialize(credentials); + assertEquals(credentials.getAudience(), deserialized.getAudience()); + assertEquals(credentials.getSubjectTokenType(), deserialized.getSubjectTokenType()); + assertEquals(credentials.getTokenUrl(), deserialized.getTokenUrl()); + assertEquals(credentials.getQuotaProjectId(), deserialized.getQuotaProjectId()); + assertEquals(credentials.getClientId(), deserialized.getClientId()); + assertEquals(credentials.getClientSecret(), deserialized.getClientSecret()); + assertEquals(credentials.getActorTokenType(), deserialized.getActorTokenType()); + } + + @Test + void serialize_deserialize_backwardCompatible() throws Exception { + // Verify that credentials serialized WITHOUT actor token config + // can still be deserialized. This simulates loading pre-actor-token bytes. + IdentityPoolCredentials original = + IdentityPoolCredentials.newBuilder(createBaseFileSourcedCredentials()) + .setServiceAccountImpersonationUrl(SERVICE_ACCOUNT_IMPERSONATION_URL) + .setQuotaProjectId("quotaProjectId") + .setClientId("clientId") + .setClientSecret("clientSecret") + .build(); + + // Serialize (simulates old format without actor token fields) + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ObjectOutputStream output = new ObjectOutputStream(bytes)) { + output.writeObject(original); + } + + // Deserialize the bytes — should succeed even if internal layout changes + IdentityPoolCredentials deserialized; + try (ObjectInputStream input = + new ObjectInputStream(new ByteArrayInputStream(bytes.toByteArray()))) { + deserialized = (IdentityPoolCredentials) input.readObject(); + } + + // Core fields should survive serialization round-trip + assertEquals(original.getAudience(), deserialized.getAudience()); + assertEquals(original.getSubjectTokenType(), deserialized.getSubjectTokenType()); + assertEquals(original.getTokenUrl(), deserialized.getTokenUrl()); + assertEquals(original.getQuotaProjectId(), deserialized.getQuotaProjectId()); + assertEquals(original.getClientId(), deserialized.getClientId()); + assertEquals(original.getClientSecret(), deserialized.getClientSecret()); + assertEquals( + original.getServiceAccountImpersonationUrl(), + deserialized.getServiceAccountImpersonationUrl()); + // Actor token fields should be null in pre-actor-token credentials + assertEquals(null, deserialized.getActorTokenType()); + } + + // ================================================================================== + // 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/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);