From a4813a2fc9a277462ff5e0d86dee7d73ce90e176 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Thu, 30 Jul 2026 01:59:16 +0000 Subject: [PATCH 01/25] feat(oauth2): Extract actor tokens for cert-bound OAuth2 STS exchange Implementation of Phase 1-3 of the Cert-Bound Oauth2 Design Document: 1. Extend IdentityPoolCredentialSource to parse actorTokenFieldName. 2. Relax mutual exclusivity to allow BOTH file and certificate configurations. 3. Parse actor_token_type in ExternalAccountCredentials. 4. Refactor FileIdentityPoolTokenSupplier and track file timestamp via volatile CachedFile for the parsed JSON payload. 5. Inject actor_token and actor_token_type into StsTokenExchangeRequest using ActingParty. 6. Enforce that actor token extraction requires an mTLS STS configuration. --- .../oauth2/ExternalAccountCredentials.java | 2 + .../FileIdentityPoolSubjectTokenSupplier.java | 103 ----------- .../oauth2/FileIdentityPoolTokenSupplier.java | 173 ++++++++++++++++++ .../IdentityPoolActorTokenSupplier.java | 48 +++++ .../oauth2/IdentityPoolCredentialSource.java | 11 +- .../auth/oauth2/IdentityPoolCredentials.java | 46 ++++- .../UrlIdentityPoolSubjectTokenSupplier.java | 7 +- .../ExternalAccountCredentialsTest.java | 14 ++ .../FileIdentityPoolTokenSupplierTest.java | 140 ++++++++++++++ .../IdentityPoolCredentialsSourceTest.java | 34 ++++ 10 files changed, 468 insertions(+), 110 deletions(-) delete mode 100644 google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplier.java create mode 100644 google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolTokenSupplier.java create mode 100644 google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolActorTokenSupplier.java create mode 100644 google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolTokenSupplierTest.java 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..4dec0b552270 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 @@ -431,6 +431,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 +488,7 @@ static ExternalAccountCredentials fromJson( .setHttpTransportFactory(transportFactory) .setAudience(audience) .setSubjectTokenType(subjectTokenType) + .setActorTokenType(actorTokenType) .setTokenUrl(tokenUrl) .setTokenInfoUrl(tokenInfoUrl) .setCredentialSource(new IdentityPoolCredentialSource(credentialSourceMap)) 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 deleted file mode 100644 index 02654578a418..000000000000 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplier.java +++ /dev/null @@ -1,103 +0,0 @@ -/* - * Copyright 2024 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 com.google.api.client.json.GenericJson; -import com.google.api.client.json.JsonObjectParser; -import com.google.auth.oauth2.IdentityPoolCredentialSource.CredentialFormatType; -import com.google.common.io.CharStreams; -import java.io.BufferedReader; -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.LinkOption; -import java.nio.file.Paths; -import org.jspecify.annotations.NullMarked; - -/** - * Internal provider for retrieving the subject tokens for {@link IdentityPoolCredentials} to - * exchange for GCP access tokens via a local file. - */ -@NullMarked -class FileIdentityPoolSubjectTokenSupplier implements IdentityPoolSubjectTokenSupplier { - - private final long serialVersionUID = 2475549052347431992L; - - private final IdentityPoolCredentialSource credentialSource; - - /** - * Constructor for FileIdentitySubjectTokenProvider - * - * @param credentialSource the credential source to use. - */ - FileIdentityPoolSubjectTokenSupplier(IdentityPoolCredentialSource credentialSource) { - this.credentialSource = credentialSource; - } - - @Override - public String getSubjectToken(ExternalAccountSupplierContext context) throws IOException { - String credentialFilePath = this.credentialSource.getCredentialLocation(); - if (!Files.exists(Paths.get(credentialFilePath), LinkOption.NOFOLLOW_LINKS)) { - throw new IOException( - String.format( - "Invalid credential location. The file at %s does not exist.", credentialFilePath)); - } - try { - return parseToken( - Files.newInputStream(new File(credentialFilePath).toPath()), this.credentialSource); - } catch (IOException e) { - throw new IOException( - "Error when attempting to read the subject token from the credential file.", e); - } - } - - static String parseToken(InputStream inputStream, IdentityPoolCredentialSource credentialSource) - throws IOException { - if (credentialSource.credentialFormatType == CredentialFormatType.TEXT) { - BufferedReader reader = - new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8)); - return CharStreams.toString(reader); - } - - JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY); - GenericJson fileContents = - parser.parseAndClose(inputStream, StandardCharsets.UTF_8, GenericJson.class); - - if (!fileContents.containsKey(credentialSource.subjectTokenFieldName)) { - throw new IOException("Invalid subject token field name. No subject token was found."); - } - return (String) fileContents.get(credentialSource.subjectTokenFieldName); - } -} diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolTokenSupplier.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolTokenSupplier.java new file mode 100644 index 000000000000..345fed014c3f --- /dev/null +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolTokenSupplier.java @@ -0,0 +1,173 @@ +/* + * Copyright 2024 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 com.google.common.base.Preconditions.checkNotNull; + +import com.google.api.client.json.GenericJson; +import com.google.api.client.json.JsonObjectParser; +import com.google.auth.oauth2.IdentityPoolCredentialSource.CredentialFormatType; + +import com.google.common.io.CharStreams; +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Paths; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Internal provider for retrieving the subject and actor tokens for {@link IdentityPoolCredentials} + * to exchange for GCP access tokens via a local file. + */ +@NullMarked +class FileIdentityPoolTokenSupplier + implements IdentityPoolSubjectTokenSupplier, IdentityPoolActorTokenSupplier { + + private final long serialVersionUID = 2475549052347431993L; + + private final IdentityPoolCredentialSource credentialSource; + @Nullable private final String targetFieldName; + + private static class CachedFile { + final long lastModified; + final GenericJson parsedJson; + + CachedFile(long lastModified, GenericJson parsedJson) { + this.lastModified = lastModified; + this.parsedJson = parsedJson; + } + } + + private volatile CachedFile cachedFile; + + /** Constructor that defaults to using the subjectTokenFieldName. */ + FileIdentityPoolTokenSupplier(IdentityPoolCredentialSource credentialSource) { + this(credentialSource, credentialSource.subjectTokenFieldName); + } + + /** Overloaded constructor allowing targeting of any specific field in the JSON. */ + FileIdentityPoolTokenSupplier( + IdentityPoolCredentialSource credentialSource, @Nullable String targetFieldName) { + this.credentialSource = checkNotNull(credentialSource, "credentialSource cannot be null"); + this.targetFieldName = targetFieldName; + } + + @Override + public String getSubjectToken(ExternalAccountSupplierContext context) throws IOException { + return getToken(); + } + + @Override + public String getActorToken(ExternalAccountSupplierContext context) throws IOException { + return getToken(); + } + + private String getToken() 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."); + } + File file = new File(credentialFilePath); + long lastModified = file.lastModified(); + + CachedFile cached = this.cachedFile; + + if (cached == null || cached.lastModified < lastModified) { + try (InputStream inputStream = Files.newInputStream(file.toPath())) { + JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY); + GenericJson parsedJson = + parser.parseAndClose(inputStream, StandardCharsets.UTF_8, GenericJson.class); + cached = new CachedFile(lastModified, parsedJson); + this.cachedFile = cached; + } catch (Exception e) { + throw new IOException( + "Error when attempting to read the token from the credential file.", e); + } + } + + Object value = cached.parsedJson.get(targetFieldName); + if (value == null) { + throw new IOException( + "Invalid token field name. No token was found for field: " + targetFieldName); + } + return value.toString(); + } + + 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); + } + } + + /** Used primarily for UrlIdentityPoolSubjectTokenSupplier */ + static String parseToken( + InputStream inputStream, + IdentityPoolCredentialSource credentialSource, + @Nullable String targetFieldName) + throws IOException { + if (credentialSource.credentialFormatType == CredentialFormatType.TEXT) { + BufferedReader reader = + new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8)); + return CharStreams.toString(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(inputStream, StandardCharsets.UTF_8, GenericJson.class); + + if (!fileContents.containsKey(targetFieldName)) { + throw new IOException( + "Invalid token field name. No token was found for field: " + targetFieldName); + } + return (String) fileContents.get(targetFieldName); + } +} 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..041b7e16c118 --- /dev/null +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolActorTokenSupplier.java @@ -0,0 +1,48 @@ +/* + * 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; + +/** Functional interface for supplying an actor token for IdentityPool credentials. */ +@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..350b32f5c63e 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; @@ -261,16 +262,17 @@ public IdentityPoolCredentialSource(Map credentialSourceMap) { boolean urlPresent = credentialSourceMap.containsKey("url"); boolean certificatePresent = credentialSourceMap.containsKey("certificate"); - if ((filePresent && urlPresent) - || (filePresent && certificatePresent) - || (urlPresent && certificatePresent)) { + if ((filePresent && urlPresent) || (urlPresent && certificatePresent)) { throw new IllegalArgumentException( - "Only one credential source type can be set: 'file', 'url', or 'certificate'."); + "A credential source type of URL can not be used with other credential source types."); } if (filePresent) { credentialLocation = (String) credentialSourceMap.get("file"); credentialSourceType = IdentityPoolCredentialSourceType.FILE; + if (certificatePresent) { + this.certificateConfig = certificateConfigFromSourceMap(credentialSourceMap); + } } else if (urlPresent) { credentialLocation = (String) credentialSourceMap.get("url"); credentialSourceType = IdentityPoolCredentialSourceType.URL; @@ -303,6 +305,7 @@ public IdentityPoolCredentialSource(Map credentialSourceMap) { } credentialFormatType = CredentialFormatType.JSON; subjectTokenFieldName = formatMap.get("subject_token_field_name"); + actorTokenFieldName = formatMap.get("actor_token_field_name"); } else if (type != null && "text".equals(type.toLowerCase(Locale.US))) { credentialFormatType = CredentialFormatType.TEXT; } else { 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..2ea9444b4498 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 @@ -60,6 +60,8 @@ 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; private final ExternalAccountSupplierContext supplierContext; private final String metricsHeaderValue; @@ -89,7 +91,7 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { this.subjectTokenSupplier = builder.subjectTokenSupplier; this.metricsHeaderValue = PROGRAMMATIC_METRICS_HEADER_VALUE; } else if (credentialSource.credentialSourceType == IdentityPoolCredentialSourceType.FILE) { - this.subjectTokenSupplier = new FileIdentityPoolSubjectTokenSupplier(credentialSource); + this.subjectTokenSupplier = new FileIdentityPoolTokenSupplier(credentialSource); this.metricsHeaderValue = FILE_METRICS_HEADER_VALUE; } else if (credentialSource.credentialSourceType == IdentityPoolCredentialSourceType.URL) { this.subjectTokenSupplier = @@ -111,6 +113,22 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { } else { throw new IllegalArgumentException("Source type not supported."); } + + this.actorTokenType = builder.actorTokenType; + if (builder.actorTokenSupplier != null) { + this.actorTokenSupplier = builder.actorTokenSupplier; + } else if (credentialSource != null && credentialSource.actorTokenFieldName != null) { + this.actorTokenSupplier = + new FileIdentityPoolTokenSupplier(credentialSource, credentialSource.actorTokenFieldName); + } else { + this.actorTokenSupplier = null; + } + + if (this.actorTokenSupplier != null + && (getTokenUrl() == null || !getTokenUrl().contains("mtls.googleapis.com"))) { + throw new IllegalArgumentException( + "Actor tokens are only supported for mTLS token URLs."); + } } @Override @@ -120,6 +138,11 @@ public AccessToken refreshAccessToken() throws IOException { StsTokenExchangeRequest.newBuilder(credential, getSubjectTokenType()) .setAudience(getAudience()); + if (this.actorTokenSupplier != null && this.actorTokenType != null) { + String actorToken = this.actorTokenSupplier.getActorToken(supplierContext); + stsTokenExchangeRequest.setActingParty(new ActingParty(actorToken, this.actorTokenType)); + } + Collection scopes = getScopes(); if (scopes != null && !scopes.isEmpty()) { stsTokenExchangeRequest.setScopes(new ArrayList<>(scopes)); @@ -143,6 +166,11 @@ IdentityPoolSubjectTokenSupplier getIdentityPoolSubjectTokenSupplier() { return this.subjectTokenSupplier; } + @VisibleForTesting + String getActorTokenType() { + return this.actorTokenType; + } + /** Clones the IdentityPoolCredentials with the specified scopes. */ @Override public IdentityPoolCredentials createScoped(Collection newScopes) { @@ -205,6 +233,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 +243,9 @@ public static class Builder extends ExternalAccountCredentials.Builder { super(credentials); if (this.credentialSource == null) { this.subjectTokenSupplier = credentials.subjectTokenSupplier; + this.actorTokenSupplier = credentials.actorTokenSupplier; } + this.actorTokenType = credentials.actorTokenType; } /** @@ -244,6 +276,18 @@ public Builder setSubjectTokenSupplier(IdentityPoolSubjectTokenSupplier subjectT return this; } + @CanIgnoreReturnValue + public Builder setActorTokenSupplier(IdentityPoolActorTokenSupplier actorTokenSupplier) { + this.actorTokenSupplier = actorTokenSupplier; + return this; + } + + @CanIgnoreReturnValue + public 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/UrlIdentityPoolSubjectTokenSupplier.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/UrlIdentityPoolSubjectTokenSupplier.java index 9a95701371b3..31749059c1a8 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 @@ -31,7 +31,7 @@ package com.google.auth.oauth2; -import static com.google.auth.oauth2.FileIdentityPoolSubjectTokenSupplier.parseToken; +import static com.google.auth.oauth2.FileIdentityPoolTokenSupplier.parseToken; import com.google.api.client.http.GenericUrl; import com.google.api.client.http.HttpHeaders; @@ -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..c4632cedd9ef 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,20 @@ void fromJson_identityPoolCredentialsWorkload() { assertEquals(GOOGLE_DEFAULT_UNIVERSE, credential.getUniverseDomain()); } + @Test + void fromJson_identityPoolCredentials_withActorTokenType() { + GenericJson json = buildJsonIdentityPoolCredential(); + json.put("actor_token_type", "actorTokenType"); + + ExternalAccountCredentials credential = + ExternalAccountCredentials.fromJson(json, OAuth2Utils.HTTP_TRANSPORT_FACTORY); + + 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/FileIdentityPoolTokenSupplierTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolTokenSupplierTest.java new file mode 100644 index 000000000000..cc255a2a3c7b --- /dev/null +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolTokenSupplierTest.java @@ -0,0 +1,140 @@ +/* + * Copyright 2024 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.assertThrows; + +import java.io.IOException; +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 FileIdentityPoolTokenSupplierTest { + + @Test + void getToken_textFormat(@TempDir Path tempDir) throws IOException { + Path credentialFile = tempDir.resolve("credential.txt"); + Files.write(credentialFile, "plain_token".getBytes()); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", credentialFile.toString()); + + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); + FileIdentityPoolTokenSupplier supplier = + new FileIdentityPoolTokenSupplier(source, null); // TEXT doesn't need targetFieldName + + assertEquals("plain_token", supplier.getSubjectToken(null)); + assertEquals("plain_token", supplier.getActorToken(null)); + } + + @Test + void getToken_jsonFormat_cachingLogic(@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()); + + 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); + FileIdentityPoolTokenSupplier subSupplier = + new FileIdentityPoolTokenSupplier(source, source.subjectTokenFieldName); + FileIdentityPoolTokenSupplier actSupplier = + new FileIdentityPoolTokenSupplier(source, source.actorTokenFieldName); + + // Initial read + assertEquals("my_sub_token", subSupplier.getSubjectToken(null)); + assertEquals("my_act_token", actSupplier.getActorToken(null)); + + // Wait 10ms for mtime to definitely advance for the reload logic + Thread.sleep(10); + + // Modify file + Files.write( + credentialFile, "{\"sub_token\": \"new_sub\", \"act_token\": \"new_act\"}".getBytes()); + + // Validate we read the new token after file modification + assertEquals("new_sub", subSupplier.getSubjectToken(null)); + assertEquals("new_act", actSupplier.getActorToken(null)); + } + + @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()); + + 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); + FileIdentityPoolTokenSupplier actSupplier = + new FileIdentityPoolTokenSupplier(source, source.actorTokenFieldName); + + 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 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); + FileIdentityPoolTokenSupplier supplier = new FileIdentityPoolTokenSupplier(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()); + } +} 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..7885b9d00e3f 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,38 @@ 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); + } } From c739b375fe9afd6ae3e4aed3ee9c58c7e0374c68 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Thu, 30 Jul 2026 02:28:58 +0000 Subject: [PATCH 02/25] fix(oauth2): share parsed JSON cache between subject and actor tokens --- .../oauth2/FileIdentityPoolTokenSupplier.java | 16 ++++------------ .../auth/oauth2/IdentityPoolCredentials.java | 7 +++++-- .../FileIdentityPoolTokenSupplierTest.java | 8 ++++---- 3 files changed, 13 insertions(+), 18 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolTokenSupplier.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolTokenSupplier.java index 345fed014c3f..6e83931dda3c 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolTokenSupplier.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolTokenSupplier.java @@ -61,7 +61,6 @@ class FileIdentityPoolTokenSupplier private final long serialVersionUID = 2475549052347431993L; private final IdentityPoolCredentialSource credentialSource; - @Nullable private final String targetFieldName; private static class CachedFile { final long lastModified; @@ -75,29 +74,21 @@ private static class CachedFile { private volatile CachedFile cachedFile; - /** Constructor that defaults to using the subjectTokenFieldName. */ FileIdentityPoolTokenSupplier(IdentityPoolCredentialSource credentialSource) { - this(credentialSource, credentialSource.subjectTokenFieldName); - } - - /** Overloaded constructor allowing targeting of any specific field in the JSON. */ - FileIdentityPoolTokenSupplier( - IdentityPoolCredentialSource credentialSource, @Nullable String targetFieldName) { this.credentialSource = checkNotNull(credentialSource, "credentialSource cannot be null"); - this.targetFieldName = targetFieldName; } @Override public String getSubjectToken(ExternalAccountSupplierContext context) throws IOException { - return getToken(); + return getToken(credentialSource.subjectTokenFieldName); } @Override public String getActorToken(ExternalAccountSupplierContext context) throws IOException { - return getToken(); + return getToken(credentialSource.actorTokenFieldName); } - private String getToken() throws IOException { + private String getToken(@Nullable String targetFieldName) throws IOException { String credentialFilePath = credentialSource.getCredentialLocation(); if (!Files.exists(Paths.get(credentialFilePath), LinkOption.NOFOLLOW_LINKS)) { throw new IOException( @@ -171,3 +162,4 @@ static String parseToken( return (String) fileContents.get(targetFieldName); } } + diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java index 2ea9444b4498..bb4acd87e562 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 @@ -118,8 +118,11 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { if (builder.actorTokenSupplier != null) { this.actorTokenSupplier = builder.actorTokenSupplier; } else if (credentialSource != null && credentialSource.actorTokenFieldName != null) { - this.actorTokenSupplier = - new FileIdentityPoolTokenSupplier(credentialSource, credentialSource.actorTokenFieldName); + if (this.subjectTokenSupplier instanceof FileIdentityPoolTokenSupplier) { + this.actorTokenSupplier = (FileIdentityPoolTokenSupplier) this.subjectTokenSupplier; + } else { + this.actorTokenSupplier = new FileIdentityPoolTokenSupplier(credentialSource); + } } else { this.actorTokenSupplier = null; } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolTokenSupplierTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolTokenSupplierTest.java index cc255a2a3c7b..d29b8650efb0 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolTokenSupplierTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolTokenSupplierTest.java @@ -54,7 +54,7 @@ void getToken_textFormat(@TempDir Path tempDir) throws IOException { IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); FileIdentityPoolTokenSupplier supplier = - new FileIdentityPoolTokenSupplier(source, null); // TEXT doesn't need targetFieldName + new FileIdentityPoolTokenSupplier(source); // TEXT doesn't need targetFieldName assertEquals("plain_token", supplier.getSubjectToken(null)); assertEquals("plain_token", supplier.getActorToken(null)); @@ -78,9 +78,9 @@ void getToken_jsonFormat_cachingLogic(@TempDir Path tempDir) IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); FileIdentityPoolTokenSupplier subSupplier = - new FileIdentityPoolTokenSupplier(source, source.subjectTokenFieldName); + new FileIdentityPoolTokenSupplier(source); FileIdentityPoolTokenSupplier actSupplier = - new FileIdentityPoolTokenSupplier(source, source.actorTokenFieldName); + new FileIdentityPoolTokenSupplier(source); // Initial read assertEquals("my_sub_token", subSupplier.getSubjectToken(null)); @@ -113,7 +113,7 @@ void getToken_jsonFormat_invalidField(@TempDir Path tempDir) throws IOException IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); FileIdentityPoolTokenSupplier actSupplier = - new FileIdentityPoolTokenSupplier(source, source.actorTokenFieldName); + new FileIdentityPoolTokenSupplier(source); IOException exception = assertThrows(IOException.class, () -> actSupplier.getActorToken(null)); assertEquals( From 1a32e6f1f654f7da7f85cb3fd592ba951801a877 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Thu, 30 Jul 2026 02:34:40 +0000 Subject: [PATCH 03/25] feat(oauth2): fail loudly if actor token requested against non-file source --- .../java/com/google/auth/oauth2/IdentityPoolCredentials.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 bb4acd87e562..435098bf4a79 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 @@ -121,7 +121,8 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { if (this.subjectTokenSupplier instanceof FileIdentityPoolTokenSupplier) { this.actorTokenSupplier = (FileIdentityPoolTokenSupplier) this.subjectTokenSupplier; } else { - this.actorTokenSupplier = new FileIdentityPoolTokenSupplier(credentialSource); + throw new IllegalArgumentException( + "Actor tokens are currently only supported for file-based credential sources."); } } else { this.actorTokenSupplier = null; From c42bd537a8349fa8efa099ad7ca8d1f89e19ac03 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Thu, 30 Jul 2026 02:42:49 +0000 Subject: [PATCH 04/25] feat(oauth2): relax actor token mTLS URL validation to .mtls. for PSC / universes --- .../java/com/google/auth/oauth2/IdentityPoolCredentials.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 435098bf4a79..02d1e18e4080 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 @@ -129,7 +129,7 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { } if (this.actorTokenSupplier != null - && (getTokenUrl() == null || !getTokenUrl().contains("mtls.googleapis.com"))) { + && (getTokenUrl() == null || !getTokenUrl().contains(".mtls."))) { throw new IllegalArgumentException( "Actor tokens are only supported for mTLS token URLs."); } From 803dd53b8071c8ad96aafc8ea53d420b743b87ca Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Thu, 30 Jul 2026 03:04:37 +0000 Subject: [PATCH 05/25] test(oauth2): add tests for strict actor token exceptions --- .../oauth2/FileIdentityPoolTokenSupplier.java | 2 - .../auth/oauth2/IdentityPoolCredentials.java | 3 +- .../FileIdentityPoolTokenSupplierTest.java | 9 +-- .../oauth2/IdentityPoolCredentialsTest.java | 57 +++++++++++++++++++ 4 files changed, 61 insertions(+), 10 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolTokenSupplier.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolTokenSupplier.java index 6e83931dda3c..6782691a385b 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolTokenSupplier.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolTokenSupplier.java @@ -36,7 +36,6 @@ import com.google.api.client.json.GenericJson; import com.google.api.client.json.JsonObjectParser; import com.google.auth.oauth2.IdentityPoolCredentialSource.CredentialFormatType; - import com.google.common.io.CharStreams; import java.io.BufferedReader; import java.io.File; @@ -162,4 +161,3 @@ static String parseToken( return (String) fileContents.get(targetFieldName); } } - diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java index 02d1e18e4080..4409a585a3e8 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 @@ -130,8 +130,7 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { if (this.actorTokenSupplier != null && (getTokenUrl() == null || !getTokenUrl().contains(".mtls."))) { - throw new IllegalArgumentException( - "Actor tokens are only supported for mTLS token URLs."); + throw new IllegalArgumentException("Actor tokens are only supported for mTLS token URLs."); } } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolTokenSupplierTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolTokenSupplierTest.java index d29b8650efb0..4747e0cfb463 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolTokenSupplierTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolTokenSupplierTest.java @@ -77,10 +77,8 @@ void getToken_jsonFormat_cachingLogic(@TempDir Path tempDir) credentialSourceMap.put("format", formatMap); IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); - FileIdentityPoolTokenSupplier subSupplier = - new FileIdentityPoolTokenSupplier(source); - FileIdentityPoolTokenSupplier actSupplier = - new FileIdentityPoolTokenSupplier(source); + FileIdentityPoolTokenSupplier subSupplier = new FileIdentityPoolTokenSupplier(source); + FileIdentityPoolTokenSupplier actSupplier = new FileIdentityPoolTokenSupplier(source); // Initial read assertEquals("my_sub_token", subSupplier.getSubjectToken(null)); @@ -112,8 +110,7 @@ void getToken_jsonFormat_invalidField(@TempDir Path tempDir) throws IOException credentialSourceMap.put("format", formatMap); IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); - FileIdentityPoolTokenSupplier actSupplier = - new FileIdentityPoolTokenSupplier(source); + FileIdentityPoolTokenSupplier actSupplier = new FileIdentityPoolTokenSupplier(source); IOException exception = assertThrows(IOException.class, () -> actSupplier.getActorToken(null)); assertEquals( 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..30e64f8def84 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 @@ -1299,4 +1299,61 @@ void setShouldThrowOnGetKeyStore(boolean shouldThrow) { this.shouldThrowOnGetKeyStore = shouldThrow; } } + + @Test + void builder_actorTokenWithInvalidUrl_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/") // Does not contain .mtls. + .setCredentialSource(credentialSource) + .setActorTokenSupplier( + new IdentityPoolActorTokenSupplier() { + @Override + public String getActorToken(ExternalAccountSupplierContext context) { + return "token"; + } + }) + .build()); + + assertEquals("Actor tokens are only supported for mTLS token URLs.", 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()); + } } From cbcce28c1339f4cb04f2aa65380c6954a8d77543 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Thu, 30 Jul 2026 03:09:00 +0000 Subject: [PATCH 06/25] chore(oauth2): update copyright year to 2026 for new files --- .../com/google/auth/oauth2/FileIdentityPoolTokenSupplier.java | 2 +- .../google/auth/oauth2/FileIdentityPoolTokenSupplierTest.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolTokenSupplier.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolTokenSupplier.java index 6782691a385b..7c4ab9a619ec 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolTokenSupplier.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolTokenSupplier.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 diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolTokenSupplierTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolTokenSupplierTest.java index 4747e0cfb463..5afea1385741 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolTokenSupplierTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolTokenSupplierTest.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 From 745d26b1dc243460a109da8a4d726316ed31082f Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Thu, 30 Jul 2026 20:44:55 +0000 Subject: [PATCH 07/25] Address architectural review feedback from paste 5381957298028544 Fixes test failures and thread synchronization bugs regarding actor token credentials from https://paste.googleplex.com/5381957298028544 --- .../oauth2/FileIdentityPoolTokenSupplier.java | 58 ++++++++++-------- .../auth/oauth2/IdentityPoolCredentials.java | 35 ++++++++++- .../ExternalAccountCredentialsTest.java | 12 +++- .../FileIdentityPoolTokenSupplierTest.java | 55 ++++++++++++++++- .../oauth2/IdentityPoolCredentialsTest.java | 59 ++++++++++++++++++- 5 files changed, 190 insertions(+), 29 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolTokenSupplier.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolTokenSupplier.java index 7c4ab9a619ec..97beb38deffd 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolTokenSupplier.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolTokenSupplier.java @@ -84,6 +84,10 @@ public String getSubjectToken(ExternalAccountSupplierContext context) throws IOE @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); } @@ -105,15 +109,20 @@ private String getToken(@Nullable String targetFieldName) throws IOException { CachedFile cached = this.cachedFile; if (cached == null || cached.lastModified < lastModified) { - try (InputStream inputStream = Files.newInputStream(file.toPath())) { - JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY); - GenericJson parsedJson = - parser.parseAndClose(inputStream, StandardCharsets.UTF_8, GenericJson.class); - cached = new CachedFile(lastModified, parsedJson); - this.cachedFile = cached; - } catch (Exception e) { - throw new IOException( - "Error when attempting to read the token from the credential file.", e); + synchronized (this) { + cached = this.cachedFile; + if (cached == null || cached.lastModified < lastModified) { + try (InputStream inputStream = Files.newInputStream(file.toPath())) { + JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY); + GenericJson parsedJson = + parser.parseAndClose(inputStream, StandardCharsets.UTF_8, GenericJson.class); + cached = new CachedFile(lastModified, parsedJson); + this.cachedFile = cached; + } catch (Exception e) { + throw new IOException( + "Error when attempting to read the token from the credential file.", e); + } + } } } @@ -140,24 +149,25 @@ static String parseToken( IdentityPoolCredentialSource credentialSource, @Nullable String targetFieldName) throws IOException { - if (credentialSource.credentialFormatType == CredentialFormatType.TEXT) { - BufferedReader reader = - new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8)); - return CharStreams.toString(reader); - } + 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."); - } + 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(inputStream, StandardCharsets.UTF_8, GenericJson.class); + JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY); + GenericJson fileContents = + parser.parseAndClose(in, StandardCharsets.UTF_8, GenericJson.class); - if (!fileContents.containsKey(targetFieldName)) { - throw new IOException( - "Invalid token field name. No token was found for field: " + targetFieldName); + if (!fileContents.containsKey(targetFieldName)) { + throw new IOException( + "Invalid token field name. No token was found for field: " + targetFieldName); + } + return (String) fileContents.get(targetFieldName); } - return (String) fileContents.get(targetFieldName); } } diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java index 4409a585a3e8..155d53b75270 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,11 @@ * 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 for GAX channel + * providers, ensure you configure {@code + * InstantiatingGrpcChannelProvider.newBuilder().setMtlsProvider(...)} in tandem. */ @NullMarked public class IdentityPoolCredentials extends ExternalAccountCredentials { @@ -62,6 +67,7 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { private final IdentityPoolSubjectTokenSupplier subjectTokenSupplier; @Nullable private final IdentityPoolActorTokenSupplier actorTokenSupplier; @Nullable private final String actorTokenType; + @Nullable private final X509Provider x509Provider; private final ExternalAccountSupplierContext supplierContext; private final String metricsHeaderValue; @@ -91,6 +97,17 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { 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 FileIdentityPoolTokenSupplier(credentialSource); this.metricsHeaderValue = FILE_METRICS_HEADER_VALUE; } else if (credentialSource.credentialSourceType == IdentityPoolCredentialSourceType.URL) { @@ -129,9 +146,22 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { } if (this.actorTokenSupplier != null - && (getTokenUrl() == null || !getTokenUrl().contains(".mtls."))) { - throw new IllegalArgumentException("Actor tokens are only supported for mTLS token URLs."); + && (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 + && !(this.transportFactory instanceof MtlsHttpTransportFactory)) { + throw new IllegalArgumentException( + "Actor tokens are only supported for mTLS token exchanges. Please configure a certificate source or MtlsHttpTransportFactory."); } + + this.x509Provider = builder.x509Provider; } @Override @@ -249,6 +279,7 @@ public static class Builder extends ExternalAccountCredentials.Builder { this.actorTokenSupplier = credentials.actorTokenSupplier; } this.actorTokenType = credentials.actorTokenType; + this.x509Provider = credentials.x509Provider; } /** 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 c4632cedd9ef..d81aa8c210b0 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 @@ -205,8 +205,18 @@ void fromJson_identityPoolCredentials_withActorTokenType() { 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); + + com.google.auth.mtls.MtlsHttpTransportFactory mockTransportFactory = + org.mockito.Mockito.mock(com.google.auth.mtls.MtlsHttpTransportFactory.class); + ExternalAccountCredentials credential = - ExternalAccountCredentials.fromJson(json, OAuth2Utils.HTTP_TRANSPORT_FACTORY); + ExternalAccountCredentials.fromJson(json, mockTransportFactory); assertInstanceOf(IdentityPoolCredentials.class, credential); IdentityPoolCredentials idpCreds = (IdentityPoolCredentials) credential; diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolTokenSupplierTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolTokenSupplierTest.java index 5afea1385741..4253f979405a 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolTokenSupplierTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolTokenSupplierTest.java @@ -57,7 +57,12 @@ void getToken_textFormat(@TempDir Path tempDir) throws IOException { new FileIdentityPoolTokenSupplier(source); // TEXT doesn't need targetFieldName assertEquals("plain_token", supplier.getSubjectToken(null)); - assertEquals("plain_token", supplier.getActorToken(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 @@ -96,6 +101,54 @@ void getToken_jsonFormat_cachingLogic(@TempDir Path tempDir) assertEquals("new_act", actSupplier.getActorToken(null)); } + @Test + void getToken_jsonFormat_cachingLogic_multithreaded(@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()); + + 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); + FileIdentityPoolTokenSupplier supplier = new FileIdentityPoolTokenSupplier(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"); 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 30e64f8def84..8b7843ec7a21 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 @@ -254,6 +254,32 @@ 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 = @@ -1314,6 +1340,7 @@ void builder_actorTokenWithInvalidUrl_throws() { .setSubjectTokenType("subjectTokenType") .setTokenUrl("https://invalid.googleapis.com/") // Does not contain .mtls. .setCredentialSource(credentialSource) + .setActorTokenType("actorTokenType") .setActorTokenSupplier( new IdentityPoolActorTokenSupplier() { @Override @@ -1323,7 +1350,37 @@ public String getActorToken(ExternalAccountSupplierContext context) { }) .build()); - assertEquals("Actor tokens are only supported for mTLS token URLs.", e.getMessage()); + 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 From 48c29d2ea8f0459232e5ec77adf413f278ae9407 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Thu, 30 Jul 2026 21:04:09 +0000 Subject: [PATCH 08/25] Fix trailing whitespace and formatting in IdentityPoolCredentialsTest --- .../google/auth/oauth2/IdentityPoolCredentialsTest.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 8b7843ec7a21..ebceb5cb042e 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 @@ -271,9 +271,12 @@ void retrieveSubjectToken_urlSourcedWithJsonFormat_withActorTokenField() throws UrlIdentityPoolSubjectTokenSupplier supplier = new UrlIdentityPoolSubjectTokenSupplier(credentialSource, transportFactory); - + ExternalAccountSupplierContext dummyContext = - ExternalAccountSupplierContext.newBuilder().setAudience("aud").setSubjectTokenType("urn").build(); + ExternalAccountSupplierContext.newBuilder() + .setAudience("aud") + .setSubjectTokenType("urn") + .build(); String subjectToken = supplier.getSubjectToken(dummyContext); From 178d71d8e9a55a90e215008001e2dcd99b883b41 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Fri, 31 Jul 2026 02:00:46 +0000 Subject: [PATCH 09/25] test: use real MtlsHttpTransportFactory instead of mock to fix Java 8 Mockito --- .../google/auth/oauth2/ExternalAccountCredentialsTest.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 d81aa8c210b0..4b16f00a6d91 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 @@ -201,7 +201,7 @@ void fromJson_identityPoolCredentialsWorkload() { } @Test - void fromJson_identityPoolCredentials_withActorTokenType() { + void fromJson_identityPoolCredentials_withActorTokenType() throws Exception { GenericJson json = buildJsonIdentityPoolCredential(); json.put("actor_token_type", "actorTokenType"); @@ -212,8 +212,10 @@ void fromJson_identityPoolCredentials_withActorTokenType() { 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 = - org.mockito.Mockito.mock(com.google.auth.mtls.MtlsHttpTransportFactory.class); + new com.google.auth.mtls.MtlsHttpTransportFactory(ks); ExternalAccountCredentials credential = ExternalAccountCredentials.fromJson(json, mockTransportFactory); From c22c5213b110f80e20559662ea703e3bd0875f13 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Fri, 31 Jul 2026 02:07:38 +0000 Subject: [PATCH 10/25] Fix line width formatting in ExternalAccountCredentialsTest --- .../com/google/auth/oauth2/ExternalAccountCredentialsTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 4b16f00a6d91..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 @@ -212,7 +212,8 @@ void fromJson_identityPoolCredentials_withActorTokenType() throws Exception { formatMap.put("subject_token_field_name", "subject_token"); credentialSource.put("format", formatMap); - java.security.KeyStore ks = java.security.KeyStore.getInstance(java.security.KeyStore.getDefaultType()); + 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); From 1aa5036b8bc47119dbbe019dd1cb8e52a36a396b Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Thu, 6 Aug 2026 21:14:09 +0000 Subject: [PATCH 11/25] fix(oauth2): Address review findings from paste 5644036370202624 - Mark CachedFile and X509Provider transient to ensure clean serialization. - Add static modifier to FileIdentityPoolTokenSupplier serialVersionUID. - Make IdentityPoolActorTokenSupplier public with @NullMarked annotation. - Preserve actorTokenSupplier in IdentityPoolCredentials Builder copy constructor. - Mask actor_token in Slf4jLoggingHelpers sensitive keys. - Add no-arg constructor to MtlsHttpTransportFactory for serialization support. - Handle Data.isNull in FileIdentityPoolTokenSupplier JSON parsing. - Add comprehensive test coverage for supplier caching, builder, serialization, and log masking. --- .../auth/mtls/MtlsHttpTransportFactory.java | 11 +- .../oauth2/FileIdentityPoolTokenSupplier.java | 12 +- .../IdentityPoolActorTokenSupplier.java | 4 +- .../auth/oauth2/IdentityPoolCredentials.java | 4 +- .../auth/oauth2/Slf4jLoggingHelpers.java | 1 + .../FileIdentityPoolTokenSupplierTest.java | 117 +++++++++++++++--- .../oauth2/IdentityPoolCredentialsTest.java | 109 +++++++++++++++- .../auth/oauth2/Slf4jUtilsLogbackTest.java | 46 +++++++ 8 files changed, 275 insertions(+), 29 deletions(-) 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..b1a45aaa803d 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,12 @@ @NullMarked @InternalApi public class MtlsHttpTransportFactory implements HttpTransportFactory { - private final KeyStore mtlsKeyStore; + @Nullable private final KeyStore mtlsKeyStore; + + /** Constructs a default factory for mTLS transports without a custom KeyStore. */ + public MtlsHttpTransportFactory() { + this.mtlsKeyStore = null; + } /** * Constructs a factory for mTLS transports. @@ -64,7 +71,7 @@ public MtlsHttpTransportFactory(KeyStore mtlsKeyStore) { } @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/FileIdentityPoolTokenSupplier.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolTokenSupplier.java index 97beb38deffd..2115e3617f9e 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolTokenSupplier.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolTokenSupplier.java @@ -35,6 +35,7 @@ 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; @@ -57,7 +58,7 @@ class FileIdentityPoolTokenSupplier implements IdentityPoolSubjectTokenSupplier, IdentityPoolActorTokenSupplier { - private final long serialVersionUID = 2475549052347431993L; + private static final long serialVersionUID = 2475549052347431993L; private final IdentityPoolCredentialSource credentialSource; @@ -71,7 +72,7 @@ private static class CachedFile { } } - private volatile CachedFile cachedFile; + private transient volatile CachedFile cachedFile; FileIdentityPoolTokenSupplier(IdentityPoolCredentialSource credentialSource) { this.credentialSource = checkNotNull(credentialSource, "credentialSource cannot be null"); @@ -127,7 +128,7 @@ private String getToken(@Nullable String targetFieldName) throws IOException { } Object value = cached.parsedJson.get(targetFieldName); - if (value == null) { + if (value == null || Data.isNull(value)) { throw new IOException( "Invalid token field name. No token was found for field: " + targetFieldName); } @@ -163,11 +164,12 @@ static String parseToken( GenericJson fileContents = parser.parseAndClose(in, StandardCharsets.UTF_8, GenericJson.class); - if (!fileContents.containsKey(targetFieldName)) { + 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 (String) fileContents.get(targetFieldName); + return value.toString(); } } } 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 index 041b7e16c118..e46c8c3bda1c 100644 --- 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 @@ -32,10 +32,12 @@ 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 { +public interface IdentityPoolActorTokenSupplier extends java.io.Serializable { /** * Returns a valid actor token as a string. 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 155d53b75270..95e95bec12b0 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 @@ -67,7 +67,7 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { private final IdentityPoolSubjectTokenSupplier subjectTokenSupplier; @Nullable private final IdentityPoolActorTokenSupplier actorTokenSupplier; @Nullable private final String actorTokenType; - @Nullable private final X509Provider x509Provider; + @Nullable private final transient X509Provider x509Provider; private final ExternalAccountSupplierContext supplierContext; private final String metricsHeaderValue; @@ -276,8 +276,8 @@ public static class Builder extends ExternalAccountCredentials.Builder { super(credentials); if (this.credentialSource == null) { this.subjectTokenSupplier = credentials.subjectTokenSupplier; - this.actorTokenSupplier = credentials.actorTokenSupplier; } + this.actorTokenSupplier = credentials.actorTokenSupplier; this.actorTokenType = credentials.actorTokenType; this.x509Provider = credentials.x509Provider; } 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/javatests/com/google/auth/oauth2/FileIdentityPoolTokenSupplierTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolTokenSupplierTest.java index 4253f979405a..e2b411eefed7 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolTokenSupplierTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolTokenSupplierTest.java @@ -32,11 +32,19 @@ 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.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.nio.file.attribute.FileTime; import java.util.HashMap; import java.util.Map; import org.junit.jupiter.api.Test; @@ -47,7 +55,7 @@ class FileIdentityPoolTokenSupplierTest { @Test void getToken_textFormat(@TempDir Path tempDir) throws IOException { Path credentialFile = tempDir.resolve("credential.txt"); - Files.write(credentialFile, "plain_token".getBytes()); + Files.write(credentialFile, "plain_token".getBytes(StandardCharsets.UTF_8)); Map credentialSourceMap = new HashMap<>(); credentialSourceMap.put("file", credentialFile.toString()); @@ -66,12 +74,13 @@ void getToken_textFormat(@TempDir Path tempDir) throws IOException { } @Test - void getToken_jsonFormat_cachingLogic(@TempDir Path tempDir) - throws IOException, InterruptedException { + void getToken_jsonFormat_cachingLogic(@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()); + "{\"sub_token\": \"my_sub_token\", \"act_token\": \"my_act_token\"}" + .getBytes(StandardCharsets.UTF_8)); + Files.setLastModifiedTime(credentialFile, FileTime.fromMillis(10000)); Map credentialSourceMap = new HashMap<>(); credentialSourceMap.put("file", credentialFile.toString()); @@ -82,23 +91,22 @@ void getToken_jsonFormat_cachingLogic(@TempDir Path tempDir) credentialSourceMap.put("format", formatMap); IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); - FileIdentityPoolTokenSupplier subSupplier = new FileIdentityPoolTokenSupplier(source); - FileIdentityPoolTokenSupplier actSupplier = new FileIdentityPoolTokenSupplier(source); + FileIdentityPoolTokenSupplier supplier = new FileIdentityPoolTokenSupplier(source); // Initial read - assertEquals("my_sub_token", subSupplier.getSubjectToken(null)); - assertEquals("my_act_token", actSupplier.getActorToken(null)); - - // Wait 10ms for mtime to definitely advance for the reload logic - Thread.sleep(10); + assertEquals("my_sub_token", supplier.getSubjectToken(null)); + assertEquals("my_act_token", supplier.getActorToken(null)); - // Modify file + // Modify file with advance in modification time Files.write( - credentialFile, "{\"sub_token\": \"new_sub\", \"act_token\": \"new_act\"}".getBytes()); + credentialFile, + "{\"sub_token\": \"new_sub\", \"act_token\": \"new_act\"}" + .getBytes(StandardCharsets.UTF_8)); + Files.setLastModifiedTime(credentialFile, FileTime.fromMillis(20000)); // Validate we read the new token after file modification - assertEquals("new_sub", subSupplier.getSubjectToken(null)); - assertEquals("new_act", actSupplier.getActorToken(null)); + assertEquals("new_sub", supplier.getSubjectToken(null)); + assertEquals("new_act", supplier.getActorToken(null)); } @Test @@ -107,7 +115,8 @@ void getToken_jsonFormat_cachingLogic_multithreaded(@TempDir Path tempDir) Path credentialFile = tempDir.resolve("credential.json"); Files.write( credentialFile, - "{\"sub_token\": \"my_sub_token\", \"act_token\": \"my_act_token\"}".getBytes()); + "{\"sub_token\": \"my_sub_token\", \"act_token\": \"my_act_token\"}" + .getBytes(StandardCharsets.UTF_8)); Map credentialSourceMap = new HashMap<>(); credentialSourceMap.put("file", credentialFile.toString()); @@ -152,7 +161,8 @@ void getToken_jsonFormat_cachingLogic_multithreaded(@TempDir Path tempDir) @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()); + Files.write( + credentialFile, "{\"sub_token\": \"my_sub_token\"}".getBytes(StandardCharsets.UTF_8)); Map credentialSourceMap = new HashMap<>(); credentialSourceMap.put("file", credentialFile.toString()); @@ -171,6 +181,79 @@ void getToken_jsonFormat_invalidField(@TempDir Path tempDir) throws IOException 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); + FileIdentityPoolTokenSupplier supplier = new FileIdentityPoolTokenSupplier(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); + FileIdentityPoolTokenSupplier supplier = new FileIdentityPoolTokenSupplier(source); + + assertEquals("12345", supplier.getSubjectToken(null)); + } + + @Test + void serialization_postCachePopulation_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); + FileIdentityPoolTokenSupplier supplier = new FileIdentityPoolTokenSupplier(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()))) { + FileIdentityPoolTokenSupplier deserialized = (FileIdentityPoolTokenSupplier) 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"); 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 ebceb5cb042e..a03ff88e424d 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 @@ -46,6 +46,7 @@ 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; @@ -75,6 +76,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 = @@ -1330,7 +1334,7 @@ void setShouldThrowOnGetKeyStore(boolean shouldThrow) { } @Test - void builder_actorTokenWithInvalidUrl_throws() { + void builder_actorTokenWithNonMtlsTransportFactory_throws() { IdentityPoolCredentialSource credentialSource = createFileCredentialSource(); IllegalArgumentException e = @@ -1341,7 +1345,7 @@ void builder_actorTokenWithInvalidUrl_throws() { .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY) .setAudience("audience") .setSubjectTokenType("subjectTokenType") - .setTokenUrl("https://invalid.googleapis.com/") // Does not contain .mtls. + .setTokenUrl("https://invalid.googleapis.com/") .setCredentialSource(credentialSource) .setActorTokenType("actorTokenType") .setActorTokenSupplier( @@ -1416,4 +1420,105 @@ void builder_actorTokenWithInvalidCredentialSource_throws() { "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_preservesActorTokenConfiguration() 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()); + } + + @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); + } } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/Slf4jUtilsLogbackTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/Slf4jUtilsLogbackTest.java index 340b6d199176..56fe3ed76fe5 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/Slf4jUtilsLogbackTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/Slf4jUtilsLogbackTest.java @@ -32,6 +32,8 @@ package com.google.auth.oauth2; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -43,6 +45,7 @@ import com.google.api.client.http.HttpRequestFactory; import com.google.api.client.http.UrlEncodedContent; import com.google.api.client.util.GenericData; +import com.google.gson.JsonObject; import com.google.gson.JsonParser; import com.google.gson.JsonSyntaxException; import java.io.IOException; @@ -198,6 +201,49 @@ void testLogRequest() throws IOException { testAppender.stop(); } + @Test + void testLogRequest_masksActorTokenAndSubjectToken() throws IOException { + testEnvironmentProvider.setEnv(LoggingUtils.GOOGLE_SDK_JAVA_LOGGING, "true"); + LoggingUtils.setEnvironmentProvider(testEnvironmentProvider); + + TestAppender testAppender = setupTestLogger(); + + GenericData tokenRequest = new GenericData(); + tokenRequest.set("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange"); + tokenRequest.set("subject_token", "raw_secret_subject_token"); + tokenRequest.set("actor_token", "raw_secret_actor_token"); + UrlEncodedContent content = new UrlEncodedContent(tokenRequest); + + MockHttpTransportFactory mockHttpTransportFactory = new MockHttpTransportFactory(); + HttpRequestFactory requestFactory = mockHttpTransportFactory.create().createRequestFactory(); + HttpRequest request = + requestFactory.buildPostRequest(new GenericUrl(OAuth2Utils.TOKEN_SERVER_URI), content); + + LoggerProvider loggerProvider = mock(LoggerProvider.class, withSettings().withoutAnnotations()); + when(loggerProvider.getLogger()).thenReturn(LOGGER); + LoggingUtils.logRequest(request, loggerProvider, "STS Token Exchange"); + + assertEquals(1, testAppender.events.size()); + String payloadJson = null; + for (KeyValuePair kvp : testAppender.events.get(0).getKeyValuePairs()) { + if ("request.payload".equals(kvp.key)) { + payloadJson = (String) kvp.value; + } + } + assertNotNull(payloadJson); + JsonObject payload = JsonParser.parseString(payloadJson).getAsJsonObject(); + + // Verify that raw secrets are never logged in plaintext + assertNotEquals("raw_secret_subject_token", payload.get("subject_token").getAsString()); + assertNotEquals("raw_secret_actor_token", payload.get("actor_token").getAsString()); + + // Verify that masked values are 64-char SHA-256 hex hashes + assertEquals(64, payload.get("subject_token").getAsString().length()); + assertEquals(64, payload.get("actor_token").getAsString().length()); + + testAppender.stop(); + } + boolean isValidJson(String jsonString) { try { JsonParser.parseString(jsonString); From 474f0d7f13e2e98c8c116543f8b6c787b02c578a Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Thu, 6 Aug 2026 21:37:35 +0000 Subject: [PATCH 12/25] fix(oauth2): preserve shared FileIdentityPoolTokenSupplier cache in Builder copy constructor - Guard actorTokenSupplier assignment with if (this.credentialSource == null) in Builder copy constructor. - Add getIdentityPoolActorTokenSupplier getter for test assertions. - Add createScoped tests for both file-sourced and supplier-sourced credentials with actor tokens. --- .../auth/oauth2/IdentityPoolCredentials.java | 8 ++- .../oauth2/IdentityPoolCredentialsTest.java | 54 ++++++++++++++++++- 2 files changed, 60 insertions(+), 2 deletions(-) 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 95e95bec12b0..f43214d6789b 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 @@ -199,6 +199,12 @@ IdentityPoolSubjectTokenSupplier getIdentityPoolSubjectTokenSupplier() { return this.subjectTokenSupplier; } + @VisibleForTesting + @Nullable + IdentityPoolActorTokenSupplier getIdentityPoolActorTokenSupplier() { + return this.actorTokenSupplier; + } + @VisibleForTesting String getActorTokenType() { return this.actorTokenType; @@ -276,8 +282,8 @@ public static class Builder extends ExternalAccountCredentials.Builder { super(credentials); if (this.credentialSource == null) { this.subjectTokenSupplier = credentials.subjectTokenSupplier; + this.actorTokenSupplier = credentials.actorTokenSupplier; } - this.actorTokenSupplier = credentials.actorTokenSupplier; this.actorTokenType = credentials.actorTokenType; this.x509Provider = credentials.x509Provider; } 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 a03ff88e424d..1988effd40c7 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 @@ -1298,6 +1298,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 = @@ -1443,7 +1451,7 @@ void builder_supplierSourcedActorToken() throws Exception { } @Test - void createScoped_preservesActorTokenConfiguration() throws Exception { + void createScoped_supplierSourcedWithActorToken_preservesCustomSuppliers() throws Exception { KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); ks.load(null, null); MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); @@ -1465,6 +1473,50 @@ void createScoped_preservesActorTokenConfiguration() throws Exception { 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 From 62b8bd5be865621a40476a5302ffe7b5cbdc99ae Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Fri, 7 Aug 2026 02:35:23 +0000 Subject: [PATCH 13/25] test(oauth2): expand unit test coverage across IdentityPoolCredentials and FileIdentityPoolTokenSupplier - Add builder_actorTokenTypeWithoutSupplier_throws testing missing supplier validation. - Add builder_fileWithCertificateConfig_initializesMtlsTransport testing mTLS initialization for composite file + cert sources. - Add toBuilder_preservesConfiguration testing builder reconstruction. - Add parseToken_textFormat_succeeds and parseToken_jsonFormat_missingFieldName_throws testing static token parsing methods. --- .../auth/oauth2/IdentityPoolCredentials.java | 5 ++ .../FileIdentityPoolTokenSupplierTest.java | 32 ++++++++ .../oauth2/IdentityPoolCredentialsTest.java | 73 +++++++++++++++++++ 3 files changed, 110 insertions(+) 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 f43214d6789b..8840a53ab199 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 @@ -210,6 +210,11 @@ String getActorTokenType() { return this.actorTokenType; } + @VisibleForTesting + HttpTransportFactory getTransportFactory() { + return this.transportFactory; + } + /** Clones the IdentityPoolCredentials with the specified scopes. */ @Override public IdentityPoolCredentials createScoped(Collection newScopes) { diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolTokenSupplierTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolTokenSupplierTest.java index e2b411eefed7..b60e25f6277c 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolTokenSupplierTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolTokenSupplierTest.java @@ -270,4 +270,36 @@ void getToken_missingFile_throws(@TempDir Path tempDir) { "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 = FileIdentityPoolTokenSupplier.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, + () -> FileIdentityPoolTokenSupplier.parseToken(stream, source, null)); + assertEquals( + "Target field name must be specified for JSON credentials.", 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 1988effd40c7..eb6445763050 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 @@ -1573,4 +1573,77 @@ void serialization_withX509Provider_succeeds() throws Exception { 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()); + } } From 32883505157193909b615dd9dbc3babacf4a7fe5 Mon Sep 17 00:00:00 2001 From: mcastelaz Date: Fri, 21 Aug 2026 04:39:33 +0000 Subject: [PATCH 14/25] Address PR #13955 review comments Summary of changes: - Comment 1: Fix Javadoc referencing package-private API - Comment 2: Replace instanceof with isMtlsConfigured() check - Comment 3: Per-cycle cert pinning with KeyStore snapshot, 401 retry - Comment 4: Atomic subject+actor token read via readTokens() - Comment 5: Make setActorTokenSupplier/Type package-private - Comment 6: Add Javadocs to builder setter methods - Comment 7: Fix Builder copy constructor (always copy actorTokenType) - Comment 8: Annotate no-arg MtlsHttpTransportFactory with @InternalApi - Comment 9: Revert class rename to FileIdentityPoolSubjectTokenSupplier - Comment 10: Remove CachedFile/volatile caching mechanism - Comment 11: Add actorTokenFieldName validation - Comment 12: Integration tests noted for follow-up PR Added overload exchangeExternalCredentialForAccessToken(request, factory) for per-cycle transport factory threading. Added 12 new unit tests covering readTokens(), validation, and mTLS. All 982 existing + new tests pass. --- .../auth/mtls/MtlsHttpTransportFactory.java | 6 +- .../oauth2/ExternalAccountCredentials.java | 20 +- ...FileIdentityPoolSubjectTokenSupplier.java} | 119 ++- .../IdentityPoolActorTokenSupplier.java | 2 +- .../oauth2/IdentityPoolCredentialSource.java | 15 + .../auth/oauth2/IdentityPoolCredentials.java | 103 ++- .../google/auth/oauth2/OAuthException.java | 16 +- .../UrlIdentityPoolSubjectTokenSupplier.java | 2 +- ...IdentityPoolSubjectTokenSupplierTest.java} | 154 +++- .../IdentityPoolCredentialsSourceTest.java | 74 ++ .../oauth2/IdentityPoolCredentialsTest.java | 872 ++++++++++++++++++ 11 files changed, 1297 insertions(+), 86 deletions(-) rename google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/{FileIdentityPoolTokenSupplier.java => FileIdentityPoolSubjectTokenSupplier.java} (64%) rename google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/{FileIdentityPoolTokenSupplierTest.java => FileIdentityPoolSubjectTokenSupplierTest.java} (64%) 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 b1a45aaa803d..c4959aa6df11 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 @@ -54,7 +54,11 @@ public class MtlsHttpTransportFactory implements HttpTransportFactory { @Nullable private final KeyStore mtlsKeyStore; - /** Constructs a default factory for mTLS transports without a custom KeyStore. */ + /** + * No-arg constructor required for deserialization via reflection. Not intended for direct use; + * callers should use {@link #MtlsHttpTransportFactory(KeyStore)}. + */ + @InternalApi public MtlsHttpTransportFactory() { this.mtlsKeyStore = null; } 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 4dec0b552270..039f102f6d0a 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 @@ -533,6 +533,22 @@ private boolean shouldBuildImpersonatedCredential() { */ protected AccessToken exchangeExternalCredentialForAccessToken( StsTokenExchangeRequest stsTokenExchangeRequest) throws IOException { + return exchangeExternalCredentialForAccessToken(stsTokenExchangeRequest, this.transportFactory); + } + + /** + * Exchanges the external credential for a Google Cloud access token using the specified + * transport factory. This overload allows callers to provide a per-cycle transport factory, + * for example one pinned to a specific mTLS certificate. + * + * @param stsTokenExchangeRequest the Security Token Service token exchange request + * @param cycleTransportFactory the HTTP transport factory to use for this exchange + * @return the access token returned by the Security Token Service + * @throws OAuthException if the call to the Security Token Service fails + */ + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, HttpTransportFactory cycleTransportFactory) + throws IOException { // Handle service account impersonation if necessary. if (this.shouldBuildImpersonatedCredential()) { this.impersonatedCredentials = this.buildImpersonatedCredentials(); @@ -543,7 +559,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/FileIdentityPoolTokenSupplier.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplier.java similarity index 64% rename from google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolTokenSupplier.java rename to google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplier.java index 2115e3617f9e..9de9fae640ae 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolTokenSupplier.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplier.java @@ -39,7 +39,6 @@ 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; @@ -55,26 +54,14 @@ * to exchange for GCP access tokens via a local file. */ @NullMarked -class FileIdentityPoolTokenSupplier +class FileIdentityPoolSubjectTokenSupplier implements IdentityPoolSubjectTokenSupplier, IdentityPoolActorTokenSupplier { - private static final long serialVersionUID = 2475549052347431993L; + private static final long serialVersionUID = 2475549052347431992L; private final IdentityPoolCredentialSource credentialSource; - private static class CachedFile { - final long lastModified; - final GenericJson parsedJson; - - CachedFile(long lastModified, GenericJson parsedJson) { - this.lastModified = lastModified; - this.parsedJson = parsedJson; - } - } - - private transient volatile CachedFile cachedFile; - - FileIdentityPoolTokenSupplier(IdentityPoolCredentialSource credentialSource) { + FileIdentityPoolSubjectTokenSupplier(IdentityPoolCredentialSource credentialSource) { this.credentialSource = checkNotNull(credentialSource, "credentialSource cannot be null"); } @@ -92,6 +79,45 @@ public String getActorToken(ExternalAccountSupplierContext context) throws IOExc 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)); + } + + if (credentialSource.credentialFormatType != CredentialFormatType.JSON) { + throw new IOException( + "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); + } + private String getToken(@Nullable String targetFieldName) throws IOException { String credentialFilePath = credentialSource.getCredentialLocation(); if (!Files.exists(Paths.get(credentialFilePath), LinkOption.NOFOLLOW_LINKS)) { @@ -104,35 +130,8 @@ private String getToken(@Nullable String targetFieldName) throws IOException { if (targetFieldName == null) { throw new IOException("Target field name must be specified for JSON credentials."); } - File file = new File(credentialFilePath); - long lastModified = file.lastModified(); - - CachedFile cached = this.cachedFile; - - if (cached == null || cached.lastModified < lastModified) { - synchronized (this) { - cached = this.cachedFile; - if (cached == null || cached.lastModified < lastModified) { - try (InputStream inputStream = Files.newInputStream(file.toPath())) { - JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY); - GenericJson parsedJson = - parser.parseAndClose(inputStream, StandardCharsets.UTF_8, GenericJson.class); - cached = new CachedFile(lastModified, parsedJson); - this.cachedFile = cached; - } catch (Exception e) { - throw new IOException( - "Error when attempting to read the token from the credential file.", e); - } - } - } - } - - Object value = cached.parsedJson.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(); + GenericJson parsedJson = readAndParseJsonFile(credentialFilePath); + return extractField(parsedJson, targetFieldName); } try (InputStream inputStream = Files.newInputStream(Paths.get(credentialFilePath))) { @@ -144,6 +143,25 @@ private String getToken(@Nullable String targetFieldName) throws IOException { } } + 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, @@ -172,4 +190,15 @@ static String parseToken( return value.toString(); } } + + /** Holds a pair of subject and actor tokens read atomically from the same file. */ + static class TokenPair { + final String subject; + @Nullable final String actor; + + TokenPair(String subject, @Nullable String actor) { + this.subject = subject; + this.actor = actor; + } + } } 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 index e46c8c3bda1c..9e19a0eba575 100644 --- 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 @@ -37,7 +37,7 @@ /** Functional interface for supplying an actor token for IdentityPool credentials. */ @NullMarked @FunctionalInterface -public interface IdentityPoolActorTokenSupplier extends java.io.Serializable { +interface IdentityPoolActorTokenSupplier extends java.io.Serializable { /** * Returns a valid actor token as a string. 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 350b32f5c63e..acb0d449a507 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 @@ -306,8 +306,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 8840a53ab199..90cefb3a1430 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 @@ -52,9 +52,8 @@ *

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 for GAX channel - * providers, ensure you configure {@code - * InstantiatingGrpcChannelProvider.newBuilder().setMtlsProvider(...)} in tandem. + * 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 { @@ -67,6 +66,8 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { 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; @@ -92,6 +93,9 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { "A subjectTokenSupplier or a credentialSource must be provided."); } + // Store the x509Provider for per-cycle cert pinning. + this.x509Provider = builder.x509Provider; + // Initialize based on the source type if (builder.subjectTokenSupplier != null) { this.subjectTokenSupplier = builder.subjectTokenSupplier; @@ -108,7 +112,7 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { e); } } - this.subjectTokenSupplier = new FileIdentityPoolTokenSupplier(credentialSource); + this.subjectTokenSupplier = new FileIdentityPoolSubjectTokenSupplier(credentialSource); this.metricsHeaderValue = FILE_METRICS_HEADER_VALUE; } else if (credentialSource.credentialSourceType == IdentityPoolCredentialSourceType.URL) { this.subjectTokenSupplier = @@ -135,8 +139,8 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { if (builder.actorTokenSupplier != null) { this.actorTokenSupplier = builder.actorTokenSupplier; } else if (credentialSource != null && credentialSource.actorTokenFieldName != null) { - if (this.subjectTokenSupplier instanceof FileIdentityPoolTokenSupplier) { - this.actorTokenSupplier = (FileIdentityPoolTokenSupplier) this.subjectTokenSupplier; + 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."); @@ -155,24 +159,52 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { "An actorTokenSupplier must be specified when an actorTokenType is configured."); } - if (this.actorTokenSupplier != null - && !(this.transportFactory instanceof MtlsHttpTransportFactory)) { + if (this.actorTokenSupplier != null && !isMtlsConfigured()) { throw new IllegalArgumentException( "Actor tokens are only supported for mTLS token exchanges. Please configure a certificate source or MtlsHttpTransportFactory."); } + } - this.x509Provider = builder.x509Provider; + /** + * Checks whether mTLS is properly configured by verifying that an X509Provider is set or the + * transport factory is an MtlsHttpTransportFactory. This avoids relying solely on instanceof + * checks which could pass for a misconfigured factory. + */ + private boolean isMtlsConfigured() { + return this.x509Provider != null || this.transportFactory instanceof MtlsHttpTransportFactory; } @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 (this.actorTokenSupplier != null && this.actorTokenType != null) { - String actorToken = this.actorTokenSupplier.getActorToken(supplierContext); + if (actorToken != null && this.actorTokenType != null) { stsTokenExchangeRequest.setActingParty(new ActingParty(actorToken, this.actorTokenType)); } @@ -181,7 +213,24 @@ public AccessToken refreshAccessToken() throws IOException { 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 @@ -321,14 +370,36 @@ 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 - public Builder setActorTokenSupplier(IdentityPoolActorTokenSupplier actorTokenSupplier) { + 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 - public Builder setActorTokenType(String actorTokenType) { + Builder setActorTokenType(String actorTokenType) { this.actorTokenType = actorTokenType; return this; } 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/UrlIdentityPoolSubjectTokenSupplier.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/UrlIdentityPoolSubjectTokenSupplier.java index 31749059c1a8..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 @@ -31,7 +31,7 @@ package com.google.auth.oauth2; -import static com.google.auth.oauth2.FileIdentityPoolTokenSupplier.parseToken; +import static com.google.auth.oauth2.FileIdentityPoolSubjectTokenSupplier.parseToken; import com.google.api.client.http.GenericUrl; import com.google.api.client.http.HttpHeaders; diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolTokenSupplierTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplierTest.java similarity index 64% rename from google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolTokenSupplierTest.java rename to google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplierTest.java index b60e25f6277c..3216052e8b67 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolTokenSupplierTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplierTest.java @@ -34,6 +34,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.ByteArrayInputStream; @@ -44,13 +45,13 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.attribute.FileTime; + import java.util.HashMap; import java.util.Map; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -class FileIdentityPoolTokenSupplierTest { +class FileIdentityPoolSubjectTokenSupplierTest { @Test void getToken_textFormat(@TempDir Path tempDir) throws IOException { @@ -61,8 +62,8 @@ void getToken_textFormat(@TempDir Path tempDir) throws IOException { credentialSourceMap.put("file", credentialFile.toString()); IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); - FileIdentityPoolTokenSupplier supplier = - new FileIdentityPoolTokenSupplier(source); // TEXT doesn't need targetFieldName + FileIdentityPoolSubjectTokenSupplier supplier = + new FileIdentityPoolSubjectTokenSupplier(source); // TEXT doesn't need targetFieldName assertEquals("plain_token", supplier.getSubjectToken(null)); @@ -74,13 +75,12 @@ void getToken_textFormat(@TempDir Path tempDir) throws IOException { } @Test - void getToken_jsonFormat_cachingLogic(@TempDir Path tempDir) throws IOException { + 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)); - Files.setLastModifiedTime(credentialFile, FileTime.fromMillis(10000)); Map credentialSourceMap = new HashMap<>(); credentialSourceMap.put("file", credentialFile.toString()); @@ -91,18 +91,17 @@ void getToken_jsonFormat_cachingLogic(@TempDir Path tempDir) throws IOException credentialSourceMap.put("format", formatMap); IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); - FileIdentityPoolTokenSupplier supplier = new FileIdentityPoolTokenSupplier(source); + FileIdentityPoolSubjectTokenSupplier supplier = new FileIdentityPoolSubjectTokenSupplier(source); // Initial read assertEquals("my_sub_token", supplier.getSubjectToken(null)); assertEquals("my_act_token", supplier.getActorToken(null)); - // Modify file with advance in modification time + // Modify file contents Files.write( credentialFile, "{\"sub_token\": \"new_sub\", \"act_token\": \"new_act\"}" .getBytes(StandardCharsets.UTF_8)); - Files.setLastModifiedTime(credentialFile, FileTime.fromMillis(20000)); // Validate we read the new token after file modification assertEquals("new_sub", supplier.getSubjectToken(null)); @@ -110,7 +109,7 @@ void getToken_jsonFormat_cachingLogic(@TempDir Path tempDir) throws IOException } @Test - void getToken_jsonFormat_cachingLogic_multithreaded(@TempDir Path tempDir) + void getToken_jsonFormat_concurrentReads(@TempDir Path tempDir) throws IOException, InterruptedException { Path credentialFile = tempDir.resolve("credential.json"); Files.write( @@ -127,7 +126,7 @@ void getToken_jsonFormat_cachingLogic_multithreaded(@TempDir Path tempDir) credentialSourceMap.put("format", formatMap); IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); - FileIdentityPoolTokenSupplier supplier = new FileIdentityPoolTokenSupplier(source); + FileIdentityPoolSubjectTokenSupplier supplier = new FileIdentityPoolSubjectTokenSupplier(source); int numThreads = 10; java.util.concurrent.ExecutorService executor = @@ -173,7 +172,7 @@ void getToken_jsonFormat_invalidField(@TempDir Path tempDir) throws IOException credentialSourceMap.put("format", formatMap); IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); - FileIdentityPoolTokenSupplier actSupplier = new FileIdentityPoolTokenSupplier(source); + FileIdentityPoolSubjectTokenSupplier actSupplier = new FileIdentityPoolSubjectTokenSupplier(source); IOException exception = assertThrows(IOException.class, () -> actSupplier.getActorToken(null)); assertEquals( @@ -194,7 +193,7 @@ void parseToken_jsonFormat_nullField_throws(@TempDir Path tempDir) throws IOExce credentialSourceMap.put("format", formatMap); IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); - FileIdentityPoolTokenSupplier supplier = new FileIdentityPoolTokenSupplier(source); + 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")); @@ -214,13 +213,13 @@ void parseToken_jsonFormat_nonStringField_convertsToString(@TempDir Path tempDir credentialSourceMap.put("format", formatMap); IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); - FileIdentityPoolTokenSupplier supplier = new FileIdentityPoolTokenSupplier(source); + FileIdentityPoolSubjectTokenSupplier supplier = new FileIdentityPoolSubjectTokenSupplier(source); assertEquals("12345", supplier.getSubjectToken(null)); } @Test - void serialization_postCachePopulation_succeeds(@TempDir Path tempDir) throws Exception { + void serialization_roundTrip_succeeds(@TempDir Path tempDir) throws Exception { Path credentialFile = tempDir.resolve("credential.json"); Files.write( credentialFile, @@ -236,7 +235,7 @@ void serialization_postCachePopulation_succeeds(@TempDir Path tempDir) throws Ex credentialSourceMap.put("format", formatMap); IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); - FileIdentityPoolTokenSupplier supplier = new FileIdentityPoolTokenSupplier(source); + FileIdentityPoolSubjectTokenSupplier supplier = new FileIdentityPoolSubjectTokenSupplier(source); // Populate cache assertEquals("my_sub_token", supplier.getSubjectToken(null)); @@ -248,7 +247,7 @@ void serialization_postCachePopulation_succeeds(@TempDir Path tempDir) throws Ex } try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(baos.toByteArray()))) { - FileIdentityPoolTokenSupplier deserialized = (FileIdentityPoolTokenSupplier) ois.readObject(); + FileIdentityPoolSubjectTokenSupplier deserialized = (FileIdentityPoolSubjectTokenSupplier) ois.readObject(); assertNotNull(deserialized); assertEquals("my_sub_token", deserialized.getSubjectToken(null)); } @@ -262,7 +261,7 @@ void getToken_missingFile_throws(@TempDir Path tempDir) { credentialSourceMap.put("file", credentialFile.toString()); IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); - FileIdentityPoolTokenSupplier supplier = new FileIdentityPoolTokenSupplier(source); + FileIdentityPoolSubjectTokenSupplier supplier = new FileIdentityPoolSubjectTokenSupplier(source); IOException exception = assertThrows(IOException.class, () -> supplier.getSubjectToken(null)); assertEquals( @@ -279,7 +278,7 @@ void parseToken_textFormat_succeeds() throws IOException { ByteArrayInputStream stream = new ByteArrayInputStream("plain_text_token".getBytes(StandardCharsets.UTF_8)); - String parsed = FileIdentityPoolTokenSupplier.parseToken(stream, source, null); + String parsed = FileIdentityPoolSubjectTokenSupplier.parseToken(stream, source, null); assertEquals("plain_text_token", parsed); } @@ -298,8 +297,123 @@ void parseToken_jsonFormat_missingFieldName_throws() { IOException exception = assertThrows( IOException.class, - () -> FileIdentityPoolTokenSupplier.parseToken(stream, source, null)); + () -> 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 7885b9d00e3f..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 @@ -186,4 +186,78 @@ void constructor_jsonFormat_withActorTokenFieldName() { 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 eb6445763050..5f4130ac3468 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 @@ -50,9 +50,12 @@ 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; @@ -62,11 +65,22 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.junit.jupiter.MockitoExtension; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.fail; + /** Tests for {@link IdentityPoolCredentials}. */ @ExtendWith(MockitoExtension.class) class IdentityPoolCredentialsTest extends BaseSerializationTest { @@ -1646,4 +1660,862 @@ void toBuilder_preservesConfiguration() throws Exception { 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.")); + } + + // ================================================================================== + // 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; + } + } } + From 8dbaa6f1a5f15606ea40794186ac1fd11583fc0b Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Fri, 21 Aug 2026 20:39:41 +0000 Subject: [PATCH 15/25] Review session improvements - Fix copyright year (2024 -> 2026) in FileIdentityPoolSubjectTokenSupplier - Add Javadoc explaining class name retained for serialization compatibility - Add hasKeyStore() to MtlsHttpTransportFactory for watertight mTLS validation - Update isMtlsConfigured() to verify KeyStore is non-null via hasKeyStore() - Update no-arg constructor Javadoc to explain serialization requirement - Add comment to Builder copy constructor explaining supplier reconstruction - Add 3 unit tests for hasKeyStore() and no-arg factory validation --- .../auth/mtls/MtlsHttpTransportFactory.java | 17 ++++++-- .../FileIdentityPoolSubjectTokenSupplier.java | 4 ++ .../auth/oauth2/IdentityPoolCredentials.java | 13 ++++-- .../oauth2/IdentityPoolCredentialsTest.java | 40 +++++++++++++++++++ 4 files changed, 68 insertions(+), 6 deletions(-) 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 c4959aa6df11..e20307d9dcc5 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 @@ -55,10 +55,12 @@ public class MtlsHttpTransportFactory implements HttpTransportFactory { @Nullable private final KeyStore mtlsKeyStore; /** - * No-arg constructor required for deserialization via reflection. Not intended for direct use; - * callers should use {@link #MtlsHttpTransportFactory(KeyStore)}. + * 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)}. */ - @InternalApi public MtlsHttpTransportFactory() { this.mtlsKeyStore = null; } @@ -74,6 +76,15 @@ 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 HttpTransport create() { try { 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 9de9fae640ae..1639329f2f55 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 @@ -52,6 +52,10 @@ /** * 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 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 90cefb3a1430..54476b37f1cc 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 @@ -167,11 +167,14 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { /** * Checks whether mTLS is properly configured by verifying that an X509Provider is set or the - * transport factory is an MtlsHttpTransportFactory. This avoids relying solely on instanceof - * checks which could pass for a misconfigured factory. + * 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; + return this.x509Provider != null + || (this.transportFactory instanceof MtlsHttpTransportFactory + && ((MtlsHttpTransportFactory) this.transportFactory).hasKeyStore()); } @Override @@ -338,6 +341,10 @@ public static class Builder extends ExternalAccountCredentials.Builder { 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; } 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 5f4130ac3468..13b0410fa8e7 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 @@ -1730,6 +1730,46 @@ void builder_actorTokenWithoutMtls_throws() { "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 // ================================================================================== From 48ee7951dd3a08244af2e5e12e84dddd0f98bef6 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Sat, 22 Aug 2026 01:42:42 +0000 Subject: [PATCH 16/25] chore: fix google-java-format compliance --- .../auth/mtls/MtlsHttpTransportFactory.java | 9 ++-- .../oauth2/ExternalAccountCredentials.java | 9 ++-- .../FileIdentityPoolSubjectTokenSupplier.java | 9 ++-- .../oauth2/IdentityPoolCredentialSource.java | 12 +++-- .../auth/oauth2/IdentityPoolCredentials.java | 19 ++++---- ...eIdentityPoolSubjectTokenSupplierTest.java | 45 +++++++++--------- .../oauth2/IdentityPoolCredentialsTest.java | 47 ++++++++----------- 7 files changed, 73 insertions(+), 77 deletions(-) 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 e20307d9dcc5..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 @@ -57,9 +57,8 @@ public class MtlsHttpTransportFactory implements HttpTransportFactory { /** * 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)}. + * 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; @@ -77,8 +76,8 @@ public MtlsHttpTransportFactory(KeyStore mtlsKeyStore) { } /** - * Returns whether this factory was constructed with a non-null {@link KeyStore} containing - * client certificates for mTLS. A factory created via the no-arg constructor (e.g. during + * 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() { 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 039f102f6d0a..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); @@ -537,9 +538,9 @@ protected AccessToken exchangeExternalCredentialForAccessToken( } /** - * 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. + * 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 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 1639329f2f55..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 @@ -78,7 +78,8 @@ public String getSubjectToken(ExternalAccountSupplierContext context) throws IOE 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."); + "Actor tokens are only supported for JSON-formatted credential files with distinct field" + + " names."); } return getToken(credentialSource.actorTokenFieldName); } @@ -152,16 +153,14 @@ private static GenericJson readAndParseJsonFile(String credentialFilePath) throw 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); + 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); + throw new IOException("Invalid token field name. No token was found for field: " + fieldName); } return value.toString(); } 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 acb0d449a507..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 @@ -209,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; @@ -281,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"); @@ -308,8 +311,7 @@ public IdentityPoolCredentialSource(Map credentialSourceMap) { 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."); + throw new IllegalArgumentException("The actor_token_field_name must not be empty."); } if (actorTokenFieldName.equals(subjectTokenFieldName)) { throw new IllegalArgumentException( 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 54476b37f1cc..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 @@ -86,7 +86,8 @@ 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( @@ -108,7 +109,8 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { this.transportFactory = new MtlsHttpTransportFactory(mtlsKeyStore); } catch (Exception e) { throw new RuntimeException( - "Failed to initialize mTLS transport for file credential source due to certificate error.", + "Failed to initialize mTLS transport for file credential source due to certificate" + + " error.", e); } } @@ -127,7 +129,8 @@ 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; @@ -161,15 +164,16 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { if (this.actorTokenSupplier != null && !isMtlsConfigured()) { throw new IllegalArgumentException( - "Actor tokens are only supported for mTLS token exchanges. Please configure a certificate source or MtlsHttpTransportFactory."); + "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. + * positives from a no-arg-constructed MtlsHttpTransportFactory (e.g. after deserialization) that + * has no actual certificates. */ private boolean isMtlsConfigured() { return this.x509Provider != null @@ -252,8 +256,7 @@ IdentityPoolSubjectTokenSupplier getIdentityPoolSubjectTokenSupplier() { } @VisibleForTesting - @Nullable - IdentityPoolActorTokenSupplier getIdentityPoolActorTokenSupplier() { + @Nullable IdentityPoolActorTokenSupplier getIdentityPoolActorTokenSupplier() { return this.actorTokenSupplier; } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplierTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplierTest.java index 3216052e8b67..33117092bb73 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplierTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplierTest.java @@ -33,8 +33,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; 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; @@ -45,7 +45,6 @@ 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; @@ -70,7 +69,8 @@ void getToken_textFormat(@TempDir Path tempDir) throws IOException { IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> supplier.getActorToken(null)); assertEquals( - "Actor tokens are only supported for JSON-formatted credential files with distinct field names.", + "Actor tokens are only supported for JSON-formatted credential files with distinct field" + + " names.", exception.getMessage()); } @@ -91,7 +91,8 @@ void getToken_jsonFormat_reReadsFileOnEachCall(@TempDir Path tempDir) throws IOE credentialSourceMap.put("format", formatMap); IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); - FileIdentityPoolSubjectTokenSupplier supplier = new FileIdentityPoolSubjectTokenSupplier(source); + FileIdentityPoolSubjectTokenSupplier supplier = + new FileIdentityPoolSubjectTokenSupplier(source); // Initial read assertEquals("my_sub_token", supplier.getSubjectToken(null)); @@ -126,7 +127,8 @@ void getToken_jsonFormat_concurrentReads(@TempDir Path tempDir) credentialSourceMap.put("format", formatMap); IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); - FileIdentityPoolSubjectTokenSupplier supplier = new FileIdentityPoolSubjectTokenSupplier(source); + FileIdentityPoolSubjectTokenSupplier supplier = + new FileIdentityPoolSubjectTokenSupplier(source); int numThreads = 10; java.util.concurrent.ExecutorService executor = @@ -172,7 +174,8 @@ void getToken_jsonFormat_invalidField(@TempDir Path tempDir) throws IOException credentialSourceMap.put("format", formatMap); IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); - FileIdentityPoolSubjectTokenSupplier actSupplier = new FileIdentityPoolSubjectTokenSupplier(source); + FileIdentityPoolSubjectTokenSupplier actSupplier = + new FileIdentityPoolSubjectTokenSupplier(source); IOException exception = assertThrows(IOException.class, () -> actSupplier.getActorToken(null)); assertEquals( @@ -193,7 +196,8 @@ void parseToken_jsonFormat_nullField_throws(@TempDir Path tempDir) throws IOExce credentialSourceMap.put("format", formatMap); IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); - FileIdentityPoolSubjectTokenSupplier supplier = new FileIdentityPoolSubjectTokenSupplier(source); + 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")); @@ -213,7 +217,8 @@ void parseToken_jsonFormat_nonStringField_convertsToString(@TempDir Path tempDir credentialSourceMap.put("format", formatMap); IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); - FileIdentityPoolSubjectTokenSupplier supplier = new FileIdentityPoolSubjectTokenSupplier(source); + FileIdentityPoolSubjectTokenSupplier supplier = + new FileIdentityPoolSubjectTokenSupplier(source); assertEquals("12345", supplier.getSubjectToken(null)); } @@ -235,7 +240,8 @@ void serialization_roundTrip_succeeds(@TempDir Path tempDir) throws Exception { credentialSourceMap.put("format", formatMap); IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); - FileIdentityPoolSubjectTokenSupplier supplier = new FileIdentityPoolSubjectTokenSupplier(source); + FileIdentityPoolSubjectTokenSupplier supplier = + new FileIdentityPoolSubjectTokenSupplier(source); // Populate cache assertEquals("my_sub_token", supplier.getSubjectToken(null)); @@ -247,7 +253,8 @@ void serialization_roundTrip_succeeds(@TempDir Path tempDir) throws Exception { } try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(baos.toByteArray()))) { - FileIdentityPoolSubjectTokenSupplier deserialized = (FileIdentityPoolSubjectTokenSupplier) ois.readObject(); + FileIdentityPoolSubjectTokenSupplier deserialized = + (FileIdentityPoolSubjectTokenSupplier) ois.readObject(); assertNotNull(deserialized); assertEquals("my_sub_token", deserialized.getSubjectToken(null)); } @@ -261,7 +268,8 @@ void getToken_missingFile_throws(@TempDir Path tempDir) { credentialSourceMap.put("file", credentialFile.toString()); IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); - FileIdentityPoolSubjectTokenSupplier supplier = new FileIdentityPoolSubjectTokenSupplier(source); + FileIdentityPoolSubjectTokenSupplier supplier = + new FileIdentityPoolSubjectTokenSupplier(source); IOException exception = assertThrows(IOException.class, () -> supplier.getSubjectToken(null)); assertEquals( @@ -331,9 +339,7 @@ void readTokens_extractsBothFields(@TempDir Path tempDir) throws IOException { 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)); + credentialFile, "{\"sub_token\": \"my_sub_token\"}".getBytes(StandardCharsets.UTF_8)); Map credentialSourceMap = new HashMap<>(); credentialSourceMap.put("file", credentialFile.toString()); @@ -355,9 +361,7 @@ void readTokens_missingActorField_throwsIOException(@TempDir Path tempDir) throw 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)); + credentialFile, "{\"act_token\": \"my_act_token\"}".getBytes(StandardCharsets.UTF_8)); Map credentialSourceMap = new HashMap<>(); credentialSourceMap.put("file", credentialFile.toString()); @@ -380,9 +384,7 @@ 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)); + credentialFile, "{\"sub_token\": \"my_sub_token\"}".getBytes(StandardCharsets.UTF_8)); Map credentialSourceMap = new HashMap<>(); credentialSourceMap.put("file", credentialFile.toString()); @@ -413,7 +415,6 @@ void readTokens_textFormat_throwsIOException(@TempDir Path tempDir) throws IOExc new FileIdentityPoolSubjectTokenSupplier(source); IOException exception = assertThrows(IOException.class, () -> supplier.readTokens(null)); - assertTrue( - exception.getMessage().contains("only supported for JSON-formatted")); + assertTrue(exception.getMessage().contains("only supported for JSON-formatted")); } } 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 13b0410fa8e7..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; @@ -77,10 +79,6 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.junit.jupiter.MockitoExtension; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotSame; -import static org.junit.jupiter.api.Assertions.fail; - /** Tests for {@link IdentityPoolCredentials}. */ @ExtendWith(MockitoExtension.class) class IdentityPoolCredentialsTest extends BaseSerializationTest { @@ -733,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()); } @@ -872,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()); } } @@ -1380,7 +1380,8 @@ public String getActorToken(ExternalAccountSupplierContext context) { .build()); assertEquals( - "Actor tokens are only supported for mTLS token exchanges. Please configure a certificate source or MtlsHttpTransportFactory.", + "Actor tokens are only supported for mTLS token exchanges. Please configure a certificate" + + " source or MtlsHttpTransportFactory.", e.getMessage()); } @@ -1529,8 +1530,7 @@ void createScoped_fileSourcedWithActorToken_preservesSharedSupplierInstance() th assertEquals(newScopes, scoped.getScopes()); // Verify scoped clone maintains a single shared supplier instance for its own cache assertSame( - scoped.getIdentityPoolSubjectTokenSupplier(), - scoped.getIdentityPoolActorTokenSupplier()); + scoped.getIdentityPoolSubjectTokenSupplier(), scoped.getIdentityPoolActorTokenSupplier()); } @Test @@ -1725,9 +1725,7 @@ void builder_actorTokenWithoutMtls_throws() { .setTokenUrl("https://sts.googleapis.com/v1/token") .build()); assertTrue( - e.getMessage() - .contains( - "Actor tokens are only supported for mTLS token exchanges.")); + e.getMessage().contains("Actor tokens are only supported for mTLS token exchanges.")); } @Test @@ -1751,9 +1749,7 @@ void builder_actorTokenWithNoArgMtlsFactory_throws() throws Exception { .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") .build()); assertTrue( - e.getMessage() - .contains( - "Actor tokens are only supported for mTLS token exchanges.")); + e.getMessage().contains("Actor tokens are only supported for mTLS token exchanges.")); } @Test @@ -1951,8 +1947,7 @@ void refreshAccessToken_401Retry_nonMtls_bubblesUp() throws Exception { /* failOnFirstExchange= */ true); // Should throw the 401 error without retry since there's no x509Provider. - OAuthException e = - assertThrows(OAuthException.class, credential::refreshAccessToken); + OAuthException e = assertThrows(OAuthException.class, credential::refreshAccessToken); assertEquals(401, e.getHttpStatusCode()); assertEquals(1, credential.getExchangeCallCount()); } @@ -1996,8 +1991,7 @@ public HttpTransport create() { /* failOnFirstExchange= */ true, /* failOnAllExchanges= */ true); - OAuthException e = - assertThrows(OAuthException.class, credential::refreshAccessToken); + OAuthException e = assertThrows(OAuthException.class, credential::refreshAccessToken); assertEquals(401, e.getHttpStatusCode()); // First attempt + one retry = 2 assertEquals(2, credential.getExchangeCallCount()); @@ -2065,8 +2059,7 @@ 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 file = File.createTempFile("ATOMIC_READ_TOKEN", /* suffix= */ null, /* directory= */ null); file.deleteOnExit(); GenericJson tokenJson = new GenericJson(); @@ -2486,10 +2479,9 @@ void serialize_deserialize_backwardCompatible() throws Exception { /** * 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. + * 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); @@ -2533,8 +2525,8 @@ int getExchangeCallCount() { /** * A test subclass that captures the HttpTransportFactory passed to - * exchangeExternalCredentialForAccessToken, allowing tests to verify cert pinning - * behavior without making real HTTP calls. + * 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 = @@ -2558,4 +2550,3 @@ java.util.List getCapturedFactories() { } } } - From 728d65403ed11db34117474fc292ed812fee0cd0 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Mon, 24 Aug 2026 19:45:07 +0000 Subject: [PATCH 17/25] Address review comments for cert-bound OAuth Part 2 - Set explicit static serialVersionUID in FileIdentityPoolSubjectTokenSupplier matching pre-PR synthetic SUID (7152208690659890358L). - Revert MtlsHttpTransportFactory.create() return type to NetHttpTransport for binary compatibility. - Update MtlsHttpTransportFactory.hasKeyStore() to verify mtlsKeyStore is non-null and size > 0. - Make IdentityPoolCredentials.x509Provider non-final transient and implement custom readObject to restore x509Provider and transportFactory on deserialization. - Add comprehensive unit tests for MtlsHttpTransportFactory, backward-compatible deserialization, readObject restoration, and fromStream production paths. --- .../auth/mtls/MtlsHttpTransportFactory.java | 15 +- .../FileIdentityPoolSubjectTokenSupplier.java | 2 +- .../auth/oauth2/IdentityPoolCredentials.java | 35 +- .../mtls/MtlsHttpTransportFactoryTest.java | 106 +++ .../ExternalAccountCredentialsTest.java | 25 +- ...eIdentityPoolSubjectTokenSupplierTest.java | 8 + .../oauth2/IdentityPoolCredentialsTest.java | 647 +++++++++++++----- 7 files changed, 653 insertions(+), 185 deletions(-) create mode 100644 google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsHttpTransportFactoryTest.java diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsHttpTransportFactory.java b/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsHttpTransportFactory.java index 4224ff061371..bfb8831f8272 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,12 +31,12 @@ 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; import java.security.GeneralSecurityException; import java.security.KeyStore; +import java.security.KeyStoreException; import java.util.Objects; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -78,14 +78,21 @@ public MtlsHttpTransportFactory(KeyStore mtlsKeyStore) { /** * Returns whether this factory was constructed with a non-null {@link KeyStore} containing client * certificates for mTLS. A factory created via the no-arg constructor (e.g. during - * deserialization) will return {@code false}. + * deserialization) or with an empty KeyStore will return {@code false}. */ public boolean hasKeyStore() { - return this.mtlsKeyStore != null; + if (this.mtlsKeyStore == null) { + return false; + } + try { + return this.mtlsKeyStore.size() > 0; + } catch (KeyStoreException e) { + return false; + } } @Override - public HttpTransport create() { + public NetHttpTransport 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/FileIdentityPoolSubjectTokenSupplier.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplier.java index 2c3b2ed6e47d..9499918659d7 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 @@ -61,7 +61,7 @@ class FileIdentityPoolSubjectTokenSupplier implements IdentityPoolSubjectTokenSupplier, IdentityPoolActorTokenSupplier { - private static final long serialVersionUID = 2475549052347431992L; + private static final long serialVersionUID = 7152208690659890358L; private final IdentityPoolCredentialSource credentialSource; 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 156bda41f2d9..8a1b5dbf572b 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java @@ -39,6 +39,7 @@ import com.google.common.annotations.VisibleForTesting; import com.google.errorprone.annotations.CanIgnoreReturnValue; import java.io.IOException; +import java.io.ObjectInputStream; import java.security.KeyStore; import java.util.ArrayList; import java.util.Collection; @@ -66,9 +67,9 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { 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; + // Transient: not serialized directly. Reconstructed in readObject() from the credentialSource + // certificate config so deserialized credentials remain usable for mTLS and refresh. + @Nullable private transient X509Provider x509Provider; private final ExternalAccountSupplierContext supplierContext; private final String metricsHeaderValue; @@ -105,6 +106,7 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { if (credentialSource.getCertificateConfig() != null) { try { X509Provider x509Provider = getX509Provider(builder, credentialSource); + this.x509Provider = x509Provider; KeyStore mtlsKeyStore = x509Provider.getKeyStore(); this.transportFactory = new MtlsHttpTransportFactory(mtlsKeyStore); } catch (Exception e) { @@ -270,6 +272,11 @@ HttpTransportFactory getTransportFactory() { return this.transportFactory; } + @VisibleForTesting + @Nullable X509Provider getX509Provider() { + return this.x509Provider; + } + /** Clones the IdentityPoolCredentials with the specified scopes. */ @Override public IdentityPoolCredentials createScoped(Collection newScopes) { @@ -293,6 +300,7 @@ private IdentityPoolSubjectTokenSupplier createCertificateSubjectTokenSupplier( Builder builder, IdentityPoolCredentialSource credentialSource) throws IOException { // Configure the mTLS transport with the x509 keystore. X509Provider x509Provider = getX509Provider(builder, credentialSource); + this.x509Provider = x509Provider; KeyStore mtlsKeyStore = x509Provider.getKeyStore(); this.transportFactory = new MtlsHttpTransportFactory(mtlsKeyStore); @@ -304,6 +312,27 @@ private IdentityPoolSubjectTokenSupplier createCertificateSubjectTokenSupplier( return new CertificateIdentityPoolSubjectTokenSupplier(credentialSource); } + @SuppressWarnings("unused") + private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException { + input.defaultReadObject(); + IdentityPoolCredentialSource credentialSource = + (IdentityPoolCredentialSource) getCredentialSource(); + if (credentialSource != null + && (credentialSource.getCertificateConfig() != null + || credentialSource.credentialSourceType + == IdentityPoolCredentialSourceType.CERTIFICATE)) { + String explicitCertConfigPath = getExplicitCertConfigPath(credentialSource); + this.x509Provider = + new X509Provider(getEnvironmentProvider(), getPropertyProvider(), explicitCertConfigPath); + try { + KeyStore mtlsKeyStore = this.x509Provider.getKeyStore(); + this.transportFactory = new MtlsHttpTransportFactory(mtlsKeyStore); + } catch (Exception e) { + // Cert loading failure will be handled on refreshAccessToken() + } + } + } + private X509Provider getX509Provider( Builder builder, IdentityPoolCredentialSource credentialSource) { // Use the provided X509Provider if available, otherwise initialize a default one. diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsHttpTransportFactoryTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsHttpTransportFactoryTest.java new file mode 100644 index 000000000000..06b581391269 --- /dev/null +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsHttpTransportFactoryTest.java @@ -0,0 +1,106 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.mtls; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.api.client.http.javanet.NetHttpTransport; +import java.io.File; +import java.io.FileInputStream; +import java.security.KeyStore; +import java.security.cert.Certificate; +import java.security.cert.CertificateFactory; +import org.junit.jupiter.api.Test; + +class MtlsHttpTransportFactoryTest { + + private static final String TEST_CERT_PATH = "testresources/mtls/test_cert.pem"; + + @Test + void hasKeyStore_noArgConstructor_returnsFalse() { + MtlsHttpTransportFactory factory = new MtlsHttpTransportFactory(); + assertFalse(factory.hasKeyStore()); + } + + @Test + void hasKeyStore_emptyKeyStore_returnsFalse() throws Exception { + KeyStore emptyKeyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + emptyKeyStore.load(null, null); + assertEquals(0, emptyKeyStore.size()); + + MtlsHttpTransportFactory factory = new MtlsHttpTransportFactory(emptyKeyStore); + assertFalse(factory.hasKeyStore()); + } + + @Test + void hasKeyStore_populatedKeyStore_returnsTrue() throws Exception { + KeyStore populatedKeyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + populatedKeyStore.load(null, null); + + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + try (FileInputStream fis = new FileInputStream(new File(TEST_CERT_PATH))) { + Certificate cert = cf.generateCertificate(fis); + populatedKeyStore.setCertificateEntry("test-alias", cert); + } + assertEquals(1, populatedKeyStore.size()); + + MtlsHttpTransportFactory factory = new MtlsHttpTransportFactory(populatedKeyStore); + assertTrue(factory.hasKeyStore()); + } + + @Test + void hasKeyStore_uninitializedKeyStore_returnsFalse() throws Exception { + KeyStore uninitializedKeyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + // KeyStore.size() on uninitialized KeyStore throws KeyStoreException + MtlsHttpTransportFactory factory = new MtlsHttpTransportFactory(uninitializedKeyStore); + assertFalse(factory.hasKeyStore()); + } + + @Test + void constructor_nullKeyStore_throwsNullPointerException() { + assertThrows(NullPointerException.class, () -> new MtlsHttpTransportFactory(null)); + } + + @Test + void create_returnsNetHttpTransport() throws Exception { + KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + keyStore.load(null, null); + + MtlsHttpTransportFactory factory = new MtlsHttpTransportFactory(keyStore); + NetHttpTransport transport = factory.create(); + assertNotNull(transport); + } +} diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java index c0b6bd6aa6e6..848d5e3696e5 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 @@ -50,9 +50,14 @@ import com.google.auth.oauth2.ExternalAccountCredentials.SubjectTokenTypes; import com.google.auth.oauth2.ExternalAccountCredentialsTest.TestExternalAccountCredentials.TestCredentialSource; import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileInputStream; import java.io.IOException; import java.math.BigDecimal; import java.net.URI; +import java.security.KeyStore; +import java.security.cert.Certificate; +import java.security.cert.CertificateFactory; import java.util.Arrays; import java.util.Date; import java.util.HashMap; @@ -68,6 +73,22 @@ class ExternalAccountCredentialsTest extends BaseSerializationTest { private static final String STS_URL = "https://sts.googleapis.com/v1/token"; private static final String GOOGLE_DEFAULT_UNIVERSE = "googleapis.com"; + private static KeyStore createPopulatedKeyStore() { + try { + KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); + ks.load(null, null); + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + try (FileInputStream fis = + new FileInputStream(new File("testresources/mtls/test_cert.pem"))) { + Certificate cert = cf.generateCertificate(fis); + ks.setCertificateEntry("test-alias", cert); + } + return ks; + } catch (Exception e) { + throw new RuntimeException("Failed to create test KeyStore", e); + } + } + private static final Map FILE_CREDENTIAL_SOURCE_MAP = new HashMap() { { @@ -212,9 +233,7 @@ void fromJson_identityPoolCredentials_withActorTokenType() throws Exception { 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); + java.security.KeyStore ks = createPopulatedKeyStore(); com.google.auth.mtls.MtlsHttpTransportFactory mockTransportFactory = new com.google.auth.mtls.MtlsHttpTransportFactory(ks); diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplierTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplierTest.java index 33117092bb73..47ae094f9002 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplierTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplierTest.java @@ -260,6 +260,14 @@ void serialization_roundTrip_succeeds(@TempDir Path tempDir) throws Exception { } } + @Test + void serialVersionUID_matchesPrePrSyntheticSuid() { + assertEquals( + 7152208690659890358L, + java.io.ObjectStreamClass.lookup(FileIdentityPoolSubjectTokenSupplier.class) + .getSerialVersionUID()); + } + @Test void getToken_missingFile_throws(@TempDir Path tempDir) { Path credentialFile = tempDir.resolve("missing_file.txt"); 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 397747c93574..d6cced7292f7 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 @@ -52,18 +52,23 @@ 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.FileInputStream; import java.io.IOException; import java.io.InputStream; 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.security.KeyStore; import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; +import java.security.cert.Certificate; import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; import java.util.Arrays; +import java.util.Base64; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -77,6 +82,7 @@ import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; import org.mockito.junit.jupiter.MockitoExtension; /** Tests for {@link IdentityPoolCredentials}. */ @@ -91,6 +97,22 @@ class IdentityPoolCredentialsTest extends BaseSerializationTest { private static final IdentityPoolActorTokenSupplier testActorSupplier = (ExternalAccountSupplierContext context) -> "testActorToken"; + private static KeyStore createPopulatedKeyStore() { + try { + KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); + ks.load(null, null); + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + try (FileInputStream fis = + new FileInputStream(new File("testresources/mtls/test_cert.pem"))) { + Certificate cert = cf.generateCertificate(fis); + ks.setCertificateEntry("test-alias", cert); + } + return ks; + } catch (Exception e) { + throw new RuntimeException("Failed to create test KeyStore", e); + } + } + @Test void createdScoped_clonedCredentialWithAddedScopes() { IdentityPoolCredentials credentials = @@ -1446,8 +1468,7 @@ void builder_actorTokenWithInvalidCredentialSource_throws() { @Test void builder_supplierSourcedActorToken() throws Exception { - KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); - ks.load(null, null); + KeyStore ks = createPopulatedKeyStore(); MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); IdentityPoolCredentials credentials = @@ -1467,8 +1488,7 @@ void builder_supplierSourcedActorToken() throws Exception { @Test void createScoped_supplierSourcedWithActorToken_preservesCustomSuppliers() throws Exception { - KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); - ks.load(null, null); + KeyStore ks = createPopulatedKeyStore(); MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); IdentityPoolCredentials credentials = @@ -1502,8 +1522,7 @@ void createScoped_fileSourcedWithActorToken_preservesSharedSupplierInstance() th IdentityPoolCredentialSource credentialSource = createFileCredentialSource("credential.json", formatMap); - KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); - ks.load(null, null); + KeyStore ks = createPopulatedKeyStore(); MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); IdentityPoolCredentials credentials = @@ -1535,43 +1554,44 @@ void createScoped_fileSourcedWithActorToken_preservesSharedSupplierInstance() th @Test void refreshAccessToken_withActorToken_injectsActingPartyIntoStsRequest() throws Exception { - MockExternalAccountCredentialsTransportFactory transportFactory = + MockExternalAccountCredentialsTransportFactory mockTransportFactory = new MockExternalAccountCredentialsTransportFactory(); - KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); - ks.load(null, null); - MtlsHttpTransportFactory mtlsTransport = - new MtlsHttpTransportFactory(ks) { + KeyStore ks = createPopulatedKeyStore(); + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); + + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setActorTokenSupplier(testActorSupplier) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl(mockTransportFactory.transport.getStsUrl()) + .setHttpTransportFactory(mtlsTransport)) { @Override - public com.google.api.client.http.HttpTransport create() { - return transportFactory.create(); + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) + throws IOException { + return super.exchangeExternalCredentialForAccessToken( + stsTokenExchangeRequest, mockTransportFactory); } }; - 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()); + assertEquals(mockTransportFactory.transport.getAccessToken(), token.getTokenValue()); Map query = - TestUtils.parseQuery(transportFactory.transport.getLastRequest().getContentAsString()); + TestUtils.parseQuery(mockTransportFactory.transport.getLastRequest().getContentAsString()); assertEquals("testActorToken", query.get("actor_token")); assertEquals("urn:ietf:params:oauth:token-type:jwt", query.get("actor_token_type")); } @Test void serialization_withX509Provider_succeeds() throws Exception { - KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); - ks.load(null, null); + KeyStore ks = createPopulatedKeyStore(); MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); X509Provider x509Provider = new TestX509Provider(ks, "certificate_config_location"); @@ -1618,8 +1638,7 @@ void builder_fileWithCertificateConfig_initializesMtlsTransport() throws Excepti IdentityPoolCredentialSource credentialSource = new IdentityPoolCredentialSource(sourceMap); - KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); - ks.load(null, null); + KeyStore ks = createPopulatedKeyStore(); X509Provider x509Provider = new TestX509Provider(ks, "certificate_config_location"); IdentityPoolCredentials credentials = @@ -1638,8 +1657,7 @@ void builder_fileWithCertificateConfig_initializesMtlsTransport() throws Excepti @Test void toBuilder_preservesConfiguration() throws Exception { - KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); - ks.load(null, null); + KeyStore ks = createPopulatedKeyStore(); MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); IdentityPoolCredentials credentials = @@ -1663,8 +1681,7 @@ void toBuilder_preservesConfiguration() throws Exception { @Test void builder_actorTokenWithX509Provider_succeeds() throws Exception { - KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); - ks.load(null, null); + KeyStore ks = createPopulatedKeyStore(); X509Provider x509Provider = new TestX509Provider(ks, "certificate_config_location"); MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); @@ -1686,8 +1703,7 @@ void builder_actorTokenWithX509Provider_succeeds() throws Exception { @Test void toBuilder_preservesActorTokenType() throws Exception { - KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); - ks.load(null, null); + KeyStore ks = createPopulatedKeyStore(); MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); IdentityPoolCredentials credentials = @@ -1753,13 +1769,44 @@ void builder_actorTokenWithNoArgMtlsFactory_throws() throws Exception { } @Test - void mtlsHttpTransportFactory_hasKeyStore_withKeyStore_returnsTrue() throws Exception { - KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); - ks.load(null, null); + void builder_actorTokenWithEmptyMtlsFactory_throws() throws Exception { + KeyStore emptyKeyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + emptyKeyStore.load(null, null); + MtlsHttpTransportFactory emptyFactory = new MtlsHttpTransportFactory(emptyKeyStore); + assertFalse(emptyFactory.hasKeyStore()); + + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setActorTokenSupplier(testActorSupplier) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setHttpTransportFactory(emptyFactory) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build()); + assertTrue( + e.getMessage().contains("Actor tokens are only supported for mTLS token exchanges.")); + } + + @Test + void mtlsHttpTransportFactory_hasKeyStore_withPopulatedKeyStore_returnsTrue() throws Exception { + KeyStore ks = createPopulatedKeyStore(); MtlsHttpTransportFactory factory = new MtlsHttpTransportFactory(ks); assertTrue(factory.hasKeyStore()); } + @Test + void mtlsHttpTransportFactory_hasKeyStore_withEmptyKeyStore_returnsFalse() throws Exception { + KeyStore emptyKeyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + emptyKeyStore.load(null, null); + MtlsHttpTransportFactory factory = new MtlsHttpTransportFactory(emptyKeyStore); + assertFalse(factory.hasKeyStore()); + } + @Test void mtlsHttpTransportFactory_hasKeyStore_noArg_returnsFalse() { MtlsHttpTransportFactory factory = new MtlsHttpTransportFactory(); @@ -1774,8 +1821,7 @@ void mtlsHttpTransportFactory_hasKeyStore_noArg_returnsFalse() { 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); + KeyStore ks = createPopulatedKeyStore(); AtomicInteger getKeyStoreCallCount = new AtomicInteger(0); X509Provider x509Provider = @@ -1790,13 +1836,7 @@ public KeyStore getKeyStore() { MockExternalAccountCredentialsTransportFactory transportFactory = new MockExternalAccountCredentialsTransportFactory(); - MtlsHttpTransportFactory mtlsTransport = - new MtlsHttpTransportFactory(ks) { - @Override - public HttpTransport create() { - return transportFactory.create(); - } - }; + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); // Use TransportCapturingCredentials so we can capture the factory passed to exchange. TransportCapturingCredentials credential = @@ -1825,10 +1865,8 @@ public HttpTransport create() { @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); + KeyStore ksA = createPopulatedKeyStore(); + KeyStore ksB = createPopulatedKeyStore(); AtomicInteger callCount = new AtomicInteger(0); X509Provider rotatingProvider = @@ -1842,13 +1880,7 @@ public KeyStore getKeyStore() { MockExternalAccountCredentialsTransportFactory transportFactory = new MockExternalAccountCredentialsTransportFactory(); - MtlsHttpTransportFactory mtlsTransport = - new MtlsHttpTransportFactory(ksA) { - @Override - public HttpTransport create() { - return transportFactory.create(); - } - }; + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ksA); TransportCapturingCredentials credential = new TransportCapturingCredentials( @@ -1882,10 +1914,8 @@ public HttpTransport create() { @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); + KeyStore ksA = createPopulatedKeyStore(); + KeyStore ksB = createPopulatedKeyStore(); AtomicInteger callCount = new AtomicInteger(0); X509Provider rotatingProvider = @@ -1901,13 +1931,7 @@ public KeyStore getKeyStore() { MockExternalAccountCredentialsTransportFactory transportFactory = new MockExternalAccountCredentialsTransportFactory(); - MtlsHttpTransportFactory mtlsTransport = - new MtlsHttpTransportFactory(ksA) { - @Override - public HttpTransport create() { - return transportFactory.create(); - } - }; + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ksA); // Testable credential: throws 401 on first exchange, succeeds on retry. TestableIdentityPoolCredentials credential = @@ -1955,8 +1979,7 @@ void refreshAccessToken_401Retry_nonMtls_bubblesUp() throws Exception { @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); + KeyStore ks = createPopulatedKeyStore(); X509Provider provider = new X509Provider() { @@ -1969,13 +1992,7 @@ public KeyStore getKeyStore() { MockExternalAccountCredentialsTransportFactory transportFactory = new MockExternalAccountCredentialsTransportFactory(); - MtlsHttpTransportFactory mtlsTransport = - new MtlsHttpTransportFactory(ks) { - @Override - public HttpTransport create() { - return transportFactory.create(); - } - }; + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); // Testable credential that always throws 401 (both first and retry). TestableIdentityPoolCredentials credential = @@ -2002,8 +2019,7 @@ void refreshAccessToken_401Retry_certLoadFailure_preservesOriginalError() throws // 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); + KeyStore ks = createPopulatedKeyStore(); AtomicInteger providerCallCount = new AtomicInteger(0); X509Provider failingOnRetryProvider = @@ -2023,13 +2039,7 @@ public KeyStore getKeyStore() throws IOException { MockExternalAccountCredentialsTransportFactory transportFactory = new MockExternalAccountCredentialsTransportFactory(); - MtlsHttpTransportFactory mtlsTransport = - new MtlsHttpTransportFactory(ks) { - @Override - public HttpTransport create() { - return transportFactory.create(); - } - }; + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); // Testable credential: throws 401 on first exchange to trigger retry path. TestableIdentityPoolCredentials credential = @@ -2077,30 +2087,32 @@ void refreshAccessToken_subjectAndActorFromSameFileParse() throws Exception { IdentityPoolCredentialSource credentialSource = createFileCredentialSource(file.getAbsolutePath(), formatMap); - MockExternalAccountCredentialsTransportFactory transportFactory = + MockExternalAccountCredentialsTransportFactory mockTransportFactory = new MockExternalAccountCredentialsTransportFactory(); - KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); - ks.load(null, null); - MtlsHttpTransportFactory mtlsTransport = - new MtlsHttpTransportFactory(ks) { + KeyStore ks = createPopulatedKeyStore(); + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); + + IdentityPoolCredentials credential = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setCredentialSource(credentialSource) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl(mockTransportFactory.transport.getStsUrl()) + .setHttpTransportFactory(mtlsTransport)) { @Override - public HttpTransport create() { - return transportFactory.create(); + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) + throws IOException { + return super.exchangeExternalCredentialForAccessToken( + stsTokenExchangeRequest, mockTransportFactory); } }; - 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(), @@ -2113,7 +2125,7 @@ public HttpTransport create() { // Verify the STS request included the actor token from the file Map query = - TestUtils.parseQuery(transportFactory.transport.getLastRequest().getContentAsString()); + TestUtils.parseQuery(mockTransportFactory.transport.getLastRequest().getContentAsString()); assertEquals("myActorToken", query.get("actor_token")); assertEquals("urn:ietf:params:oauth:token-type:jwt", query.get("actor_token_type")); } @@ -2126,10 +2138,8 @@ public HttpTransport create() { 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); + KeyStore ks1 = createPopulatedKeyStore(); + KeyStore ks2 = createPopulatedKeyStore(); X509Provider countingProvider = new X509Provider() { @@ -2143,13 +2153,7 @@ public KeyStore getKeyStore() { MockExternalAccountCredentialsTransportFactory transportFactory = new MockExternalAccountCredentialsTransportFactory(); - MtlsHttpTransportFactory mtlsTransport = - new MtlsHttpTransportFactory(ks1) { - @Override - public HttpTransport create() { - return transportFactory.create(); - } - }; + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks1); TransportCapturingCredentials credential = new TransportCapturingCredentials( @@ -2200,10 +2204,8 @@ void refreshAccessToken_concurrent_401OnOneThread_doesNotAffectOther() throws Ex // 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); + KeyStore ksInitial = createPopulatedKeyStore(); + KeyStore ksRetry = createPopulatedKeyStore(); AtomicInteger getKeyStoreCount = new AtomicInteger(0); X509Provider provider = @@ -2220,13 +2222,7 @@ public KeyStore getKeyStore() { MockExternalAccountCredentialsTransportFactory transportFactory = new MockExternalAccountCredentialsTransportFactory(); - MtlsHttpTransportFactory mtlsTransport = - new MtlsHttpTransportFactory(ksInitial) { - @Override - public HttpTransport create() { - return transportFactory.create(); - } - }; + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ksInitial); // Use a credential where one thread gets a 401 (first exchange fails) and the other // succeeds. The AtomicInteger tracks per-thread exchange behavior. @@ -2298,10 +2294,8 @@ void refreshAccessToken_certRotationDuringRefresh_pinnedCertUsed() throws Except // 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); + KeyStore ksOriginal = createPopulatedKeyStore(); + KeyStore ksRotated = createPopulatedKeyStore(); AtomicReference currentKeyStore = new AtomicReference<>(ksOriginal); AtomicInteger snapshotCount = new AtomicInteger(0); @@ -2318,13 +2312,7 @@ public KeyStore getKeyStore() { MockExternalAccountCredentialsTransportFactory transportFactory = new MockExternalAccountCredentialsTransportFactory(); - MtlsHttpTransportFactory mtlsTransport = - new MtlsHttpTransportFactory(ksOriginal) { - @Override - public HttpTransport create() { - return transportFactory.create(); - } - }; + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ksOriginal); // A credential that rotates the cert DURING the exchange call, then captures // the transport factory to verify it's still the original pinned one. @@ -2406,8 +2394,7 @@ protected AccessToken exchangeExternalCredentialForAccessToken( @Test void serialize_deserialize_withActorTokenConfig_roundTrips() throws Exception { - KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); - ks.load(null, null); + KeyStore ks = createPopulatedKeyStore(); MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); IdentityPoolCredentials credentials = @@ -2434,45 +2421,357 @@ void serialize_deserialize_withActorTokenConfig_roundTrips() throws Exception { assertEquals(credentials.getActorTokenType(), deserialized.getActorTokenType()); } + private static final String PRE_PR_SERIALIZED_BYTES_BASE64 = + "rO0ABXNyAC5jb20uZ29vZ2xlLmF1dGgub2F1dGgyLklkZW50aXR5UG9vbENyZWRlbnRpYWxzIkrrZ4jpHOkCAAVMABJh" + + "Y3RvclRva2VuU3VwcGxpZXJ0ADdMY29tL2dvb2dsZS9hdXRoL29hdXRoMi9JZGVudGl0eVBvb2xBY3RvclRva2Vu" + + "U3VwcGxpZXI7TAAOYWN0b3JUb2tlblR5cGV0ABJMamF2YS9sYW5nL1N0cmluZztMABJtZXRyaWNzSGVhZGVyVmFs" + + "dWVxAH4AAkwAFHN1YmplY3RUb2tlblN1cHBsaWVydAA5TGNvbS9nb29nbGUvYXV0aC9vYXV0aDIvSWRlbnRpdHlQ" + + "b29sU3ViamVjdFRva2VuU3VwcGxpZXI7TAAPc3VwcGxpZXJDb250ZXh0dAA3TGNvbS9nb29nbGUvYXV0aC9vYXV0" + + "aDIvRXh0ZXJuYWxBY2NvdW50U3VwcGxpZXJDb250ZXh0O3hyADFjb20uZ29vZ2xlLmF1dGgub2F1dGgyLkV4dGVy" + + "bmFsQWNjb3VudENyZWRlbnRpYWxzb7Q9oKQPk/8CABBMAAhhdWRpZW5jZXEAfgACTAAIY2xpZW50SWRxAH4AAkwA" + + "DGNsaWVudFNlY3JldHEAfgACTAAQY3JlZGVudGlhbFNvdXJjZXQARExjb20vZ29vZ2xlL2F1dGgvb2F1dGgyL0V4" + + "dGVybmFsQWNjb3VudENyZWRlbnRpYWxzJENyZWRlbnRpYWxTb3VyY2U7TAATZW52aXJvbm1lbnRQcm92aWRlcnQA" + + "LExjb20vZ29vZ2xlL2F1dGgvb2F1dGgyL0Vudmlyb25tZW50UHJvdmlkZXI7TAAXaW1wZXJzb25hdGVkQ3JlZGVu" + + "dGlhbHN0ADBMY29tL2dvb2dsZS9hdXRoL29hdXRoMi9JbXBlcnNvbmF0ZWRDcmVkZW50aWFscztMAA5tZXRyaWNz" + + "SGFuZGxlcnQANkxjb20vZ29vZ2xlL2F1dGgvb2F1dGgyL0V4dGVybmFsQWNjb3VudE1ldHJpY3NIYW5kbGVyO0wA" + + "EHByb3BlcnR5UHJvdmlkZXJ0AClMY29tL2dvb2dsZS9hdXRoL29hdXRoMi9Qcm9wZXJ0eVByb3ZpZGVyO0wABnNj" + + "b3Blc3QAFkxqYXZhL3V0aWwvQ29sbGVjdGlvbjtMACJzZXJ2aWNlQWNjb3VudEltcGVyc29uYXRpb25PcHRpb25z" + + "dABWTGNvbS9nb29nbGUvYXV0aC9vYXV0aDIvRXh0ZXJuYWxBY2NvdW50Q3JlZGVudGlhbHMkU2VydmljZUFjY291" + + "bnRJbXBlcnNvbmF0aW9uT3B0aW9ucztMAB5zZXJ2aWNlQWNjb3VudEltcGVyc29uYXRpb25VcmxxAH4AAkwAEHN1" + + "YmplY3RUb2tlblR5cGVxAH4AAkwADHRva2VuSW5mb1VybHEAfgACTAAIdG9rZW5VcmxxAH4AAkwAGXRyYW5zcG9y" + + "dEZhY3RvcnlDbGFzc05hbWVxAH4AAkwAGHdvcmtmb3JjZVBvb2xVc2VyUHJvamVjdHEAfgACeHIAKGNvbS5nb29n" + + "bGUuYXV0aC5vYXV0aDIuR29vZ2xlQ3JlZGVudGlhbHPq3b3FouFfJQIABVoAGGlzRXhwbGljaXRVbml2ZXJzZURv" + + "bWFpbkwABG5hbWVxAH4AAkwADnF1b3RhUHJvamVjdElkcQB+AAJMAAZzb3VyY2VxAH4AAkwADnVuaXZlcnNlRG9t" + + "YWlucQB+AAJ4cgAoY29tLmdvb2dsZS5hdXRoLm9hdXRoMi5PQXV0aDJDcmVkZW50aWFscz89fXrppVFXAgAETAAQ" + + "ZXhwaXJhdGlvbk1hcmdpbnQAFExqYXZhL3RpbWUvRHVyYXRpb247TAAEbG9ja3QAEkxqYXZhL2xhbmcvT2JqZWN0" + + "O0wADXJlZnJlc2hNYXJnaW5xAH4AD0wABXZhbHVldAA1TGNvbS9nb29nbGUvYXV0aC9vYXV0aDIvT0F1dGgyQ3Jl" + + "ZGVudGlhbHMkT0F1dGhWYWx1ZTt4cgAbY29tLmdvb2dsZS5hdXRoLkNyZWRlbnRpYWxzCzii14w9kIECAAB4cHNy" + + "AA1qYXZhLnRpbWUuU2VylV2EuhsiSLIMAAB4cHcNAQAAAAAAAAC0AAAAAHh1cgACW0Ks8xf4BghU4AIAAHhwAAAA" + + "AHNxAH4AFHcNAQAAAAAAAADhAAAAAHhwAHQAHEV4dGVybmFsIEFjY291bnQgQ3JlZGVudGlhbHN0AA5xdW90YVBy" + + "b2plY3RJZHB0AA5nb29nbGVhcGlzLmNvbXQAYC8vaWFtLmdvb2dsZWFwaXMuY29tL3Byb2plY3RzLzEyMy9sb2Nh" + + "dGlvbnMvZ2xvYmFsL3dvcmtsb2FkSWRlbnRpdHlQb29scy9wb29sL3Byb3ZpZGVycy9wcm92aWRlcnQACGNsaWVu" + + "dElkdAAMY2xpZW50U2VjcmV0c3IAM2NvbS5nb29nbGUuYXV0aC5vYXV0aDIuSWRlbnRpdHlQb29sQ3JlZGVudGlh" + + "bFNvdXJjZfWmMJrBt+rCAgAHTAATYWN0b3JUb2tlbkZpZWxkTmFtZXEAfgACTAARY2VydGlmaWNhdGVDb25maWd0" + + "AEdMY29tL2dvb2dsZS9hdXRoL29hdXRoMi9JZGVudGl0eVBvb2xDcmVkZW50aWFsU291cmNlJENlcnRpZmljYXRl" + + "Q29uZmlnO0wAFGNyZWRlbnRpYWxGb3JtYXRUeXBldABKTGNvbS9nb29nbGUvYXV0aC9vYXV0aDIvSWRlbnRpdHlQ" + + "b29sQ3JlZGVudGlhbFNvdXJjZSRDcmVkZW50aWFsRm9ybWF0VHlwZTtMABJjcmVkZW50aWFsTG9jYXRpb25xAH4A" + + "AkwAFGNyZWRlbnRpYWxTb3VyY2VUeXBldABWTGNvbS9nb29nbGUvYXV0aC9vYXV0aDIvSWRlbnRpdHlQb29sQ3Jl" + + "ZGVudGlhbFNvdXJjZSRJZGVudGl0eVBvb2xDcmVkZW50aWFsU291cmNlVHlwZTtMAAdoZWFkZXJzdAAPTGphdmEv" + + "dXRpbC9NYXA7TAAVc3ViamVjdFRva2VuRmllbGROYW1lcQB+AAJ4cgBCY29tLmdvb2dsZS5hdXRoLm9hdXRoMi5F" + + "eHRlcm5hbEFjY291bnRDcmVkZW50aWFscyRDcmVkZW50aWFsU291cmNlcdzMzznPiMgCAAB4cHBwfnIASGNvbS5n" + + "b29nbGUuYXV0aC5vYXV0aDIuSWRlbnRpdHlQb29sQ3JlZGVudGlhbFNvdXJjZSRDcmVkZW50aWFsRm9ybWF0VHlw" + + "ZQAAAAAAAAAAEgAAeHIADmphdmEubGFuZy5FbnVtAAAAAAAAAAASAAB4cHQABFRFWFR0AARmaWxlfnIAVGNvbS5n" + + "b29nbGUuYXV0aC5vYXV0aDIuSWRlbnRpdHlQb29sQ3JlZGVudGlhbFNvdXJjZSRJZGVudGl0eVBvb2xDcmVkZW50" + + "aWFsU291cmNlVHlwZQAAAAAAAAAAEgAAeHEAfgAndAAERklMRXBwc3IAMGNvbS5nb29nbGUuYXV0aC5vYXV0aDIu" + + "U3lzdGVtRW52aXJvbm1lbnRQcm92aWRlcr7Mw9ZYOzw0AgAAeHBwc3IANGNvbS5nb29nbGUuYXV0aC5vYXV0aDIu" + + "RXh0ZXJuYWxBY2NvdW50TWV0cmljc0hhbmRsZXILhDC5uzFxHgIAA1oADmNvbmZpZ0xpZmV0aW1lWgAPc2FJbXBl" + + "cnNvbmF0aW9uTAALY3JlZGVudGlhbHN0ADNMY29tL2dvb2dsZS9hdXRoL29hdXRoMi9FeHRlcm5hbEFjY291bnRD" + + "cmVkZW50aWFsczt4cAAAc3EAfgAAcQB+ABV1cQB+ABYAAAAAcQB+ABhwAHEAfgAZcHBxAH4AG3EAfgAccHBxAH4A" + + "JXEAfgAvcHEAfgAyc3IALWNvbS5nb29nbGUuYXV0aC5vYXV0aDIuU3lzdGVtUHJvcGVydHlQcm92aWRlcgAAAAAA" + + "AAABAgAAeHBzcgAjamF2YS51dGlsLkNvbGxlY3Rpb25zJFNpbmdsZXRvbkxpc3Qq7ykQPKeblwIAAUwAB2VsZW1l" + + "bnRxAH4AEHhwdAAuaHR0cHM6Ly93d3cuZ29vZ2xlYXBpcy5jb20vYXV0aC9jbG91ZC1wbGF0Zm9ybXNyAFRjb20u" + + "Z29vZ2xlLmF1dGgub2F1dGgyLkV4dGVybmFsQWNjb3VudENyZWRlbnRpYWxzJFNlcnZpY2VBY2NvdW50SW1wZXJz" + + "b25hdGlvbk9wdGlvbnM6/caKmTx8+QIAAloAHGN1c3RvbVRva2VuTGlmZXRpbWVSZXF1ZXN0ZWRJAAhsaWZldGlt" + + "ZXhwAAAADhBwdAAQc3ViamVjdFRva2VuVHlwZXQADHRva2VuSW5mb1VybHQAI2h0dHBzOi8vc3RzLmdvb2dsZWFw" + + "aXMuY29tL3YxL3Rva2VudAA+Y29tLmdvb2dsZS5hdXRoLm9hdXRoMi5PQXV0aDJVdGlscyREZWZhdWx0SHR0cFRy" + + "YW5zcG9ydEZhY3RvcnlwcHBxAH4AKnNyADtjb20uZ29vZ2xlLmF1dGgub2F1dGgyLkZpbGVJZGVudGl0eVBvb2xT" + + "dWJqZWN0VG9rZW5TdXBwbGllcmNBv+j+PpS2AgABTAAQY3JlZGVudGlhbFNvdXJjZXQANUxjb20vZ29vZ2xlL2F1" + + "dGgvb2F1dGgyL0lkZW50aXR5UG9vbENyZWRlbnRpYWxTb3VyY2U7eHBxAH4AJXNyADVjb20uZ29vZ2xlLmF1dGgu" + + "b2F1dGgyLkV4dGVybmFsQWNjb3VudFN1cHBsaWVyQ29udGV4dJMHoJdQucHqAgACTAAIYXVkaWVuY2VxAH4AAkwA" + + "EHN1YmplY3RUb2tlblR5cGVxAH4AAnhwcQB+ABxxAH4APHEAfgA2cQB+ADhxAH4AO3QAemh0dHBzOi8vaWFtY3Jl" + + "ZGVudGlhbHMuZ29vZ2xlYXBpcy5jb20vdjEvcHJvamVjdHMvLS9zZXJ2aWNlQWNjb3VudHMvdGVzdG5AdGVzdC5p" + + "YW0uZ3NlcnZpY2VhY2NvdW50LmNvbTpnZW5lcmF0ZUFjY2Vzc1Rva2VucQB+ADxxAH4APXEAfgA+cQB+AD9wcHBx" + + "AH4AKnNxAH4AQHEAfgAlc3EAfgBDcQB+ABxxAH4APA=="; + @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 + // Verify that credentials serialized BEFORE this PR (hardcoded byte fixture using synthetic + // SUID + // 7152208690659890358L for FileIdentityPoolSubjectTokenSupplier) deserialize successfully. + byte[] fixtureBytes = Base64.getDecoder().decode(PRE_PR_SERIALIZED_BYTES_BASE64); IdentityPoolCredentials deserialized; - try (ObjectInputStream input = - new ObjectInputStream(new ByteArrayInputStream(bytes.toByteArray()))) { + try (ObjectInputStream input = new ObjectInputStream(new ByteArrayInputStream(fixtureBytes))) { 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()); + assertNotNull(deserialized); assertEquals( - original.getServiceAccountImpersonationUrl(), - deserialized.getServiceAccountImpersonationUrl()); - // Actor token fields should be null in pre-actor-token credentials + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider", + deserialized.getAudience()); + assertEquals("subjectTokenType", deserialized.getSubjectTokenType()); + assertEquals("https://sts.googleapis.com/v1/token", deserialized.getTokenUrl()); + assertEquals("quotaProjectId", deserialized.getQuotaProjectId()); + assertEquals("clientId", deserialized.getClientId()); + assertEquals("clientSecret", deserialized.getClientSecret()); + assertEquals( + SERVICE_ACCOUNT_IMPERSONATION_URL, deserialized.getServiceAccountImpersonationUrl()); assertEquals(null, deserialized.getActorTokenType()); } + @Test + void + serialize_deserialize_fileCredentialSource_withCertificateConfig_restoresX509ProviderAndTransport( + @TempDir Path tempDir) throws Exception { + Path tokenFile = tempDir.resolve("credential.txt"); + Files.write(tokenFile, "token_from_file".getBytes(StandardCharsets.UTF_8)); + + Map certMap = new HashMap<>(); + certMap.put("certificate_config_location", "testresources/mtls/certificate_config.json"); + + Map sourceMap = new HashMap<>(); + sourceMap.put("file", tokenFile.toString()); + sourceMap.put("certificate", certMap); + + IdentityPoolCredentialSource credentialSource = new IdentityPoolCredentialSource(sourceMap); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setCredentialSource(credentialSource) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build(); + + assertNotNull(credentials.getX509Provider()); + assertTrue(credentials.getTransportFactory() instanceof MtlsHttpTransportFactory); + assertTrue(((MtlsHttpTransportFactory) credentials.getTransportFactory()).hasKeyStore()); + + IdentityPoolCredentials deserialized = serializeAndDeserialize(credentials); + assertNotNull(deserialized); + assertNotNull(deserialized.getX509Provider()); + assertTrue(deserialized.getTransportFactory() instanceof MtlsHttpTransportFactory); + assertTrue(((MtlsHttpTransportFactory) deserialized.getTransportFactory()).hasKeyStore()); + + // createScoped() should succeed without throwing + IdentityPoolCredentials scoped = + deserialized.createScoped( + Collections.singletonList("https://www.googleapis.com/auth/cloud-platform")); + assertNotNull(scoped); + assertNotNull(scoped.getX509Provider()); + assertTrue(scoped.getTransportFactory() instanceof MtlsHttpTransportFactory); + assertTrue(((MtlsHttpTransportFactory) scoped.getTransportFactory()).hasKeyStore()); + + // refreshAccessToken() on deserialized credentials creates MtlsHttpTransportFactory from + // restored X509Provider + AtomicReference capturedFactory = new AtomicReference<>(); + IdentityPoolCredentials testable = + new IdentityPoolCredentials(deserialized.toBuilder()) { + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) { + capturedFactory.set(cycleTransportFactory); + return new AccessToken("deserializedToken", null); + } + }; + AccessToken token = testable.refreshAccessToken(); + assertEquals("deserializedToken", token.getTokenValue()); + assertNotNull(capturedFactory.get()); + assertTrue(capturedFactory.get() instanceof MtlsHttpTransportFactory); + assertTrue(((MtlsHttpTransportFactory) capturedFactory.get()).hasKeyStore()); + } + + @Test + void serialize_deserialize_certificateCredentialSource_restoresX509ProviderAndTransport() + throws Exception { + Map certMap = new HashMap<>(); + certMap.put("certificate_config_location", "testresources/mtls/certificate_config.json"); + + Map sourceMap = new HashMap<>(); + sourceMap.put("certificate", certMap); + + IdentityPoolCredentialSource credentialSource = new IdentityPoolCredentialSource(sourceMap); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setCredentialSource(credentialSource) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build(); + + assertNotNull(credentials.getX509Provider()); + assertTrue(credentials.getTransportFactory() instanceof MtlsHttpTransportFactory); + assertTrue(((MtlsHttpTransportFactory) credentials.getTransportFactory()).hasKeyStore()); + + IdentityPoolCredentials deserialized = serializeAndDeserialize(credentials); + assertNotNull(deserialized); + assertNotNull(deserialized.getX509Provider()); + assertTrue(deserialized.getTransportFactory() instanceof MtlsHttpTransportFactory); + assertTrue(((MtlsHttpTransportFactory) deserialized.getTransportFactory()).hasKeyStore()); + + // createScoped() should succeed without throwing + IdentityPoolCredentials scoped = + deserialized.createScoped( + Collections.singletonList("https://www.googleapis.com/auth/cloud-platform")); + assertNotNull(scoped); + assertNotNull(scoped.getX509Provider()); + assertTrue(scoped.getTransportFactory() instanceof MtlsHttpTransportFactory); + assertTrue(((MtlsHttpTransportFactory) scoped.getTransportFactory()).hasKeyStore()); + + // refreshAccessToken() on deserialized credentials creates MtlsHttpTransportFactory from + // restored X509Provider + AtomicReference capturedFactory = new AtomicReference<>(); + IdentityPoolCredentials testable = + new IdentityPoolCredentials(deserialized.toBuilder()) { + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) { + capturedFactory.set(cycleTransportFactory); + return new AccessToken("deserializedCertToken", null); + } + }; + AccessToken token = testable.refreshAccessToken(); + assertEquals("deserializedCertToken", token.getTokenValue()); + assertNotNull(capturedFactory.get()); + assertTrue(capturedFactory.get() instanceof MtlsHttpTransportFactory); + assertTrue(((MtlsHttpTransportFactory) capturedFactory.get()).hasKeyStore()); + } + + // ================================================================================== + // Section E: Production Path (fromStream) Tests + // ================================================================================== + + @Test + void fromStream_fileCredentialSource_withCertificateConfig_andActorToken_refreshesSuccessfully( + @TempDir Path tempDir) throws Exception { + Path tokenFile = tempDir.resolve("credential.json"); + GenericJson tokenJson = new GenericJson(); + tokenJson.setFactory(JSON_FACTORY); + tokenJson.put("subject_token", "testSubjectTokenFromStream"); + tokenJson.put("actor_token", "testActorTokenFromStream"); + OAuth2Utils.writeInputStreamToFile( + new ByteArrayInputStream(tokenJson.toPrettyString().getBytes(StandardCharsets.UTF_8)), + tokenFile.toString()); + + String configJson = + "{\n" + + " \"type\": \"external_account\",\n" + + " \"audience\":" + + " \"//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider\",\n" + + " \"subject_token_type\": \"urn:ietf:params:oauth:token-type:jwt\",\n" + + " \"actor_token_type\": \"urn:ietf:params:oauth:token-type:jwt\",\n" + + " \"token_url\": \"https://sts.googleapis.com/v1/token\",\n" + + " \"credential_source\": {\n" + + " \"file\": \"" + + tokenFile.toString() + + "\",\n" + + " \"format\": {\n" + + " \"type\": \"json\",\n" + + " \"subject_token_field_name\": \"subject_token\",\n" + + " \"actor_token_field_name\": \"actor_token\"\n" + + " },\n" + + " \"certificate\": {\n" + + " \"certificate_config_location\": \"testresources/mtls/certificate_config.json\"\n" + + " }\n" + + " }\n" + + "}"; + + ExternalAccountCredentials credentials = + ExternalAccountCredentials.fromStream( + new ByteArrayInputStream(configJson.getBytes(StandardCharsets.UTF_8))); + + assertTrue(credentials instanceof IdentityPoolCredentials); + IdentityPoolCredentials idp = (IdentityPoolCredentials) credentials; + assertNotNull(idp.getX509Provider()); + assertEquals("urn:ietf:params:oauth:token-type:jwt", idp.getActorTokenType()); + assertTrue(idp.getTransportFactory() instanceof MtlsHttpTransportFactory); + assertTrue(((MtlsHttpTransportFactory) idp.getTransportFactory()).hasKeyStore()); + assertSame(idp.getIdentityPoolSubjectTokenSupplier(), idp.getIdentityPoolActorTokenSupplier()); + + // Execute refreshAccessToken() on testable credentials constructed from idp.toBuilder() + AtomicReference capturedRequest = new AtomicReference<>(); + AtomicReference capturedFactory = new AtomicReference<>(); + IdentityPoolCredentials testable = + new IdentityPoolCredentials(idp.toBuilder()) { + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) { + capturedRequest.set(stsTokenExchangeRequest); + capturedFactory.set(cycleTransportFactory); + return new AccessToken("prodAccessToken", null); + } + }; + + AccessToken token = testable.refreshAccessToken(); + assertEquals("prodAccessToken", token.getTokenValue()); + assertNotNull(capturedRequest.get()); + assertEquals("testSubjectTokenFromStream", capturedRequest.get().getSubjectToken()); + assertEquals( + "urn:ietf:params:oauth:token-type:jwt", capturedRequest.get().getSubjectTokenType()); + assertNotNull(capturedRequest.get().getActingParty()); + assertEquals( + "testActorTokenFromStream", capturedRequest.get().getActingParty().getActorToken()); + assertEquals( + "urn:ietf:params:oauth:token-type:jwt", + capturedRequest.get().getActingParty().getActorTokenType()); + assertNotNull(capturedFactory.get()); + assertTrue(capturedFactory.get() instanceof MtlsHttpTransportFactory); + assertTrue(((MtlsHttpTransportFactory) capturedFactory.get()).hasKeyStore()); + } + + @Test + void fromStream_fileCredentialSource_certRotation_401Retry_succeeds(@TempDir Path tempDir) + throws Exception { + Path tokenFile = tempDir.resolve("credential.json"); + GenericJson tokenJson = new GenericJson(); + tokenJson.setFactory(JSON_FACTORY); + tokenJson.put("subject_token", "testSubjectToken401"); + OAuth2Utils.writeInputStreamToFile( + new ByteArrayInputStream(tokenJson.toPrettyString().getBytes(StandardCharsets.UTF_8)), + tokenFile.toString()); + + String configJson = + "{\n" + + " \"type\": \"external_account\",\n" + + " \"audience\":" + + " \"//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider\",\n" + + " \"subject_token_type\": \"urn:ietf:params:oauth:token-type:jwt\",\n" + + " \"token_url\": \"https://sts.googleapis.com/v1/token\",\n" + + " \"credential_source\": {\n" + + " \"file\": \"" + + tokenFile.toString() + + "\",\n" + + " \"format\": {\n" + + " \"type\": \"json\",\n" + + " \"subject_token_field_name\": \"subject_token\"\n" + + " },\n" + + " \"certificate\": {\n" + + " \"certificate_config_location\": \"testresources/mtls/certificate_config.json\"\n" + + " }\n" + + " }\n" + + "}"; + + ExternalAccountCredentials credentials = + ExternalAccountCredentials.fromStream( + new ByteArrayInputStream(configJson.getBytes(StandardCharsets.UTF_8))); + + assertTrue(credentials instanceof IdentityPoolCredentials); + IdentityPoolCredentials idp = (IdentityPoolCredentials) credentials; + assertNotNull(idp.getX509Provider()); + + AtomicInteger exchangeCount = new AtomicInteger(0); + IdentityPoolCredentials testable = + new IdentityPoolCredentials(idp.toBuilder()) { + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) + throws IOException { + if (exchangeCount.incrementAndGet() == 1) { + throw new OAuthException("invalid_client", "Unauthorized", null, 401); + } + return new AccessToken("rotatedRetryToken", null); + } + }; + + AccessToken token = testable.refreshAccessToken(); + assertEquals("rotatedRetryToken", token.getTokenValue()); + assertEquals(2, exchangeCount.get()); + } + // ================================================================================== // Helper: TestableIdentityPoolCredentials — overrides exchange for 401 testing // ================================================================================== From 483cfe03fdf184d64651a7c538582f7365950c5f Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Tue, 25 Aug 2026 03:04:21 +0000 Subject: [PATCH 18/25] test(oauth2): rename refreshAccessToken_useSameCertForStsAndIam to refreshAccessToken_pinsTransportForStsExchange --- .../google/auth/oauth2/IdentityPoolCredentialsTest.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 d6cced7292f7..fca5ef1a1811 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 @@ -1818,9 +1818,10 @@ void mtlsHttpTransportFactory_hasKeyStore_noArg_returnsFalse() { // ================================================================================== @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. + void refreshAccessToken_pinsTransportForStsExchange() throws Exception { + // Verify that the STS exchange uses the pinned transport factory from the KeyStore snapshot + // within one refresh cycle. Threading the pinned transport to IAM impersonation is deferred + // to a follow-up PR. KeyStore ks = createPopulatedKeyStore(); AtomicInteger getKeyStoreCallCount = new AtomicInteger(0); From d9b26e45c6505e0bc69d5290822361a2ddc2908e Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Fri, 28 Aug 2026 21:02:58 +0000 Subject: [PATCH 19/25] fix(oauth2): address review comments on PR #13955 --- .../auth/mtls/MtlsHttpTransportFactory.java | 20 +- .../FileIdentityPoolSubjectTokenSupplier.java | 18 +- .../auth/oauth2/IdentityPoolCredentials.java | 63 +++- .../mtls/MtlsHttpTransportFactoryTest.java | 29 +- .../ExternalAccountCredentialsTest.java | 22 +- ...eIdentityPoolSubjectTokenSupplierTest.java | 68 +++- .../oauth2/IdentityPoolCredentialsTest.java | 324 +++++++++++++----- ...ckExternalAccountCredentialsTransport.java | 7 +- 8 files changed, 442 insertions(+), 109 deletions(-) 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 bfb8831f8272..5f8b7e233bf0 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsHttpTransportFactory.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsHttpTransportFactory.java @@ -37,6 +37,8 @@ import java.security.GeneralSecurityException; import java.security.KeyStore; import java.security.KeyStoreException; +import java.security.cert.Certificate; +import java.util.Enumeration; import java.util.Objects; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -78,14 +80,28 @@ public MtlsHttpTransportFactory(KeyStore mtlsKeyStore) { /** * Returns whether this factory was constructed with a non-null {@link KeyStore} containing client * certificates for mTLS. A factory created via the no-arg constructor (e.g. during - * deserialization) or with an empty KeyStore will return {@code false}. + * deserialization), with an empty KeyStore, or with a KeyStore containing only trusted CA + * certificates (without a private key entry and certificate chain) will return {@code false}. */ public boolean hasKeyStore() { if (this.mtlsKeyStore == null) { return false; } try { - return this.mtlsKeyStore.size() > 0; + Enumeration aliases = this.mtlsKeyStore.aliases(); + if (aliases == null) { + return false; + } + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + if (this.mtlsKeyStore.isKeyEntry(alias)) { + Certificate[] chain = this.mtlsKeyStore.getCertificateChain(alias); + if (chain != null && chain.length > 0) { + return true; + } + } + } + return false; } catch (KeyStoreException e) { return false; } 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 9499918659d7..8da013451af8 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplier.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplier.java @@ -162,7 +162,14 @@ private static String extractField(GenericJson json, String fieldName) throws IO if (value == null || Data.isNull(value)) { throw new IOException("Invalid token field name. No token was found for field: " + fieldName); } - return value.toString(); + if (!(value instanceof String)) { + throw new IOException( + "Token field value for " + + fieldName + + " must be a String but was: " + + value.getClass().getName()); + } + return (String) value; } /** Used primarily for UrlIdentityPoolSubjectTokenSupplier */ @@ -190,7 +197,14 @@ static String parseToken( throw new IOException( "Invalid token field name. No token was found for field: " + targetFieldName); } - return value.toString(); + if (!(value instanceof String)) { + throw new IOException( + "Token field value for " + + targetFieldName + + " must be a String but was: " + + value.getClass().getName()); + } + return (String) value; } } 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 8a1b5dbf572b..304463f79daa 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 @@ -40,6 +40,7 @@ import com.google.errorprone.annotations.CanIgnoreReturnValue; import java.io.IOException; import java.io.ObjectInputStream; +import java.net.URI; import java.security.KeyStore; import java.util.ArrayList; import java.util.Collection; @@ -108,7 +109,11 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { X509Provider x509Provider = getX509Provider(builder, credentialSource); this.x509Provider = x509Provider; KeyStore mtlsKeyStore = x509Provider.getKeyStore(); - this.transportFactory = new MtlsHttpTransportFactory(mtlsKeyStore); + if (builder.transportFactory == null + || builder.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY + || builder.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory) { + this.transportFactory = new MtlsHttpTransportFactory(mtlsKeyStore); + } } catch (Exception e) { throw new RuntimeException( "Failed to initialize mTLS transport for file credential source due to certificate" @@ -169,6 +174,44 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { "Actor tokens are only supported for mTLS token exchanges. Please configure a certificate" + " source or MtlsHttpTransportFactory."); } + + if (this.actorTokenSupplier != null) { + validateMtlsEndpoint(getTokenUrl(), "tokenUrl"); + if (getServiceAccountImpersonationUrl() != null) { + validateMtlsEndpoint(getServiceAccountImpersonationUrl(), "serviceAccountImpersonationUrl"); + } + } + } + + private static void validateMtlsEndpoint(@Nullable String url, String fieldName) { + if (url == null) { + return; + } + try { + URI uri = URI.create(url); + String host = uri.getHost(); + if (host != null && host.endsWith("googleapis.com") && !host.contains(".mtls.")) { + throw new IllegalArgumentException( + "The " + + fieldName + + " endpoint (" + + url + + ") must be an mTLS endpoint (e.g. contain '.mtls.') when an actor token is" + + " configured."); + } + } catch (IllegalArgumentException e) { + throw e; + } catch (Exception e) { + if (url.contains("googleapis.com") && !url.contains(".mtls.")) { + throw new IllegalArgumentException( + "The " + + fieldName + + " endpoint (" + + url + + ") must be an mTLS endpoint (e.g. contain '.mtls.') when an actor token is" + + " configured."); + } + } } /** @@ -298,11 +341,15 @@ public Builder toBuilder() { private IdentityPoolSubjectTokenSupplier createCertificateSubjectTokenSupplier( Builder builder, IdentityPoolCredentialSource credentialSource) throws IOException { - // Configure the mTLS transport with the x509 keystore. + // Configure the mTLS transport with the x509 keystore if custom transport was not provided. X509Provider x509Provider = getX509Provider(builder, credentialSource); this.x509Provider = x509Provider; KeyStore mtlsKeyStore = x509Provider.getKeyStore(); - this.transportFactory = new MtlsHttpTransportFactory(mtlsKeyStore); + if (builder.transportFactory == null + || builder.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY + || builder.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory) { + this.transportFactory = new MtlsHttpTransportFactory(mtlsKeyStore); + } // Initialize the subject token supplier with the certificate path. String explicitCertConfigPath = getExplicitCertConfigPath(credentialSource); @@ -312,6 +359,16 @@ private IdentityPoolSubjectTokenSupplier createCertificateSubjectTokenSupplier( return new CertificateIdentityPoolSubjectTokenSupplier(credentialSource); } + /** + * Reconstitutes the {@link IdentityPoolCredentials} instance from a stream. + * + *

For credential-source based credentials (file or certificate), this method reconstructs the + * transient {@link X509Provider} and mTLS {@link HttpTransportFactory} if a certificate + * configuration is present. For programmatic suppliers (where {@code subjectTokenSupplier != + * null} and {@code credentialSource == null}), the suppliers and standard transport are restored + * directly from the serialized stream, while in-memory {@link X509Provider} instances are + * non-persistent. + */ @SuppressWarnings("unused") private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException { input.defaultReadObject(); diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsHttpTransportFactoryTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsHttpTransportFactoryTest.java index 06b581391269..f917af477bee 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsHttpTransportFactoryTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsHttpTransportFactoryTest.java @@ -38,8 +38,11 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.api.client.util.SecurityUtils; import java.io.File; import java.io.FileInputStream; +import java.io.InputStream; +import java.io.SequenceInputStream; import java.security.KeyStore; import java.security.cert.Certificate; import java.security.cert.CertificateFactory; @@ -48,6 +51,7 @@ class MtlsHttpTransportFactoryTest { private static final String TEST_CERT_PATH = "testresources/mtls/test_cert.pem"; + private static final String TEST_KEY_PATH = "testresources/mtls/test_key.pem"; @Test void hasKeyStore_noArgConstructor_returnsFalse() { @@ -66,18 +70,31 @@ void hasKeyStore_emptyKeyStore_returnsFalse() throws Exception { } @Test - void hasKeyStore_populatedKeyStore_returnsTrue() throws Exception { - KeyStore populatedKeyStore = KeyStore.getInstance(KeyStore.getDefaultType()); - populatedKeyStore.load(null, null); + void hasKeyStore_keyStoreWithOnlyCaCertificates_returnsFalse() throws Exception { + KeyStore caKeyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + caKeyStore.load(null, null); CertificateFactory cf = CertificateFactory.getInstance("X.509"); try (FileInputStream fis = new FileInputStream(new File(TEST_CERT_PATH))) { Certificate cert = cf.generateCertificate(fis); - populatedKeyStore.setCertificateEntry("test-alias", cert); + caKeyStore.setCertificateEntry("ca-alias", cert); } - assertEquals(1, populatedKeyStore.size()); + assertEquals(1, caKeyStore.size()); - MtlsHttpTransportFactory factory = new MtlsHttpTransportFactory(populatedKeyStore); + MtlsHttpTransportFactory factory = new MtlsHttpTransportFactory(caKeyStore); + assertFalse(factory.hasKeyStore()); + } + + @Test + void hasKeyStore_keyStoreWithPrivateKeyAndCertChain_returnsTrue() throws Exception { + KeyStore keyStore; + try (InputStream certStream = new FileInputStream(new File(TEST_CERT_PATH)); + InputStream keyStream = new FileInputStream(new File(TEST_KEY_PATH)); + InputStream combined = new SequenceInputStream(certStream, keyStream)) { + keyStore = SecurityUtils.createMtlsKeyStore(combined); + } + + MtlsHttpTransportFactory factory = new MtlsHttpTransportFactory(keyStore); assertTrue(factory.hasKeyStore()); } 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 848d5e3696e5..2cf24e5491ba 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java @@ -45,6 +45,7 @@ import com.google.api.client.json.GenericJson; import com.google.api.client.json.JsonParser; import com.google.api.client.util.Clock; +import com.google.api.client.util.SecurityUtils; import com.google.auth.TestUtils; import com.google.auth.http.HttpTransportFactory; import com.google.auth.oauth2.ExternalAccountCredentials.SubjectTokenTypes; @@ -53,11 +54,11 @@ import java.io.File; import java.io.FileInputStream; import java.io.IOException; +import java.io.InputStream; +import java.io.SequenceInputStream; import java.math.BigDecimal; import java.net.URI; import java.security.KeyStore; -import java.security.cert.Certificate; -import java.security.cert.CertificateFactory; import java.util.Arrays; import java.util.Date; import java.util.HashMap; @@ -71,19 +72,15 @@ class ExternalAccountCredentialsTest extends BaseSerializationTest { private static final String STS_URL = "https://sts.googleapis.com/v1/token"; + private static final String STS_MTLS_URL = "https://sts.mtls.googleapis.com/v1/token"; private static final String GOOGLE_DEFAULT_UNIVERSE = "googleapis.com"; private static KeyStore createPopulatedKeyStore() { - try { - KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); - ks.load(null, null); - CertificateFactory cf = CertificateFactory.getInstance("X.509"); - try (FileInputStream fis = - new FileInputStream(new File("testresources/mtls/test_cert.pem"))) { - Certificate cert = cf.generateCertificate(fis); - ks.setCertificateEntry("test-alias", cert); - } - return ks; + try (InputStream certStream = + new FileInputStream(new File("testresources/mtls/test_cert.pem")); + InputStream keyStream = new FileInputStream(new File("testresources/mtls/test_key.pem")); + InputStream combined = new SequenceInputStream(certStream, keyStream)) { + return SecurityUtils.createMtlsKeyStore(combined); } catch (Exception e) { throw new RuntimeException("Failed to create test KeyStore", e); } @@ -224,6 +221,7 @@ void fromJson_identityPoolCredentialsWorkload() { @Test void fromJson_identityPoolCredentials_withActorTokenType() throws Exception { GenericJson json = buildJsonIdentityPoolCredential(); + json.put("token_url", STS_MTLS_URL); json.put("actor_token_type", "actorTokenType"); Map credentialSource = (Map) json.get("credential_source"); diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplierTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplierTest.java index 47ae094f9002..8c239a87f84a 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplierTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplierTest.java @@ -204,9 +204,10 @@ void parseToken_jsonFormat_nullField_throws(@TempDir Path tempDir) throws IOExce } @Test - void parseToken_jsonFormat_nonStringField_convertsToString(@TempDir Path tempDir) + void parseToken_jsonFormat_nonStringField_throwsIOException(@TempDir Path tempDir) throws IOException { - Path credentialFile = tempDir.resolve("credential.json"); + // Numeric value + Path credentialFile = tempDir.resolve("credential_numeric.json"); Files.write(credentialFile, "{\"sub_token\": 12345}".getBytes(StandardCharsets.UTF_8)); Map credentialSourceMap = new HashMap<>(); @@ -220,7 +221,68 @@ void parseToken_jsonFormat_nonStringField_convertsToString(@TempDir Path tempDir FileIdentityPoolSubjectTokenSupplier supplier = new FileIdentityPoolSubjectTokenSupplier(source); - assertEquals("12345", supplier.getSubjectToken(null)); + IOException numException = + assertThrows(IOException.class, () -> supplier.getSubjectToken(null)); + assertTrue( + numException + .getMessage() + .contains("Token field value for sub_token must be a String but was:")); + + // Nested object value + Path objCredentialFile = tempDir.resolve("credential_object.json"); + Files.write( + objCredentialFile, + "{\"sub_token\": {\"nested\": \"val\"}}".getBytes(StandardCharsets.UTF_8)); + credentialSourceMap.put("file", objCredentialFile.toString()); + IdentityPoolCredentialSource objSource = new IdentityPoolCredentialSource(credentialSourceMap); + FileIdentityPoolSubjectTokenSupplier objSupplier = + new FileIdentityPoolSubjectTokenSupplier(objSource); + + IOException objException = + assertThrows(IOException.class, () -> objSupplier.getSubjectToken(null)); + assertTrue( + objException + .getMessage() + .contains("Token field value for sub_token must be a String but was:")); + + // Boolean value + Path boolCredentialFile = tempDir.resolve("credential_bool.json"); + Files.write(boolCredentialFile, "{\"sub_token\": true}".getBytes(StandardCharsets.UTF_8)); + credentialSourceMap.put("file", boolCredentialFile.toString()); + IdentityPoolCredentialSource boolSource = new IdentityPoolCredentialSource(credentialSourceMap); + FileIdentityPoolSubjectTokenSupplier boolSupplier = + new FileIdentityPoolSubjectTokenSupplier(boolSource); + + IOException boolException = + assertThrows(IOException.class, () -> boolSupplier.getSubjectToken(null)); + assertTrue( + boolException + .getMessage() + .contains("Token field value for sub_token must be a String but was:")); + + // Static parseToken with numeric and object inputs + ByteArrayInputStream numStream = + new ByteArrayInputStream("{\"sub_token\": 12345}".getBytes(StandardCharsets.UTF_8)); + IOException parseNumException = + assertThrows( + IOException.class, + () -> FileIdentityPoolSubjectTokenSupplier.parseToken(numStream, source, "sub_token")); + assertTrue( + parseNumException + .getMessage() + .contains("Token field value for sub_token must be a String but was:")); + + ByteArrayInputStream objStream = + new ByteArrayInputStream( + "{\"sub_token\": {\"nested\": 42}}".getBytes(StandardCharsets.UTF_8)); + IOException parseObjException = + assertThrows( + IOException.class, + () -> FileIdentityPoolSubjectTokenSupplier.parseToken(objStream, source, "sub_token")); + assertTrue( + parseObjException + .getMessage() + .contains("Token field value for sub_token must be a String but was:")); } @Test 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 fca5ef1a1811..eea759f5e14b 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 @@ -39,6 +39,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -46,6 +47,7 @@ import com.google.api.client.http.HttpTransport; import com.google.api.client.json.GenericJson; import com.google.api.client.util.Clock; +import com.google.api.client.util.SecurityUtils; import com.google.auth.TestUtils; import com.google.auth.http.HttpTransportFactory; import com.google.auth.mtls.MtlsHttpTransportFactory; @@ -57,15 +59,15 @@ import java.io.IOException; import java.io.InputStream; import java.io.ObjectInputStream; +import java.io.ObjectStreamClass; +import java.io.SequenceInputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.security.KeyStore; import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; -import java.security.cert.Certificate; import java.security.cert.CertificateException; -import java.security.cert.CertificateFactory; import java.util.Arrays; import java.util.Base64; import java.util.Collections; @@ -98,16 +100,11 @@ class IdentityPoolCredentialsTest extends BaseSerializationTest { (ExternalAccountSupplierContext context) -> "testActorToken"; private static KeyStore createPopulatedKeyStore() { - try { - KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); - ks.load(null, null); - CertificateFactory cf = CertificateFactory.getInstance("X.509"); - try (FileInputStream fis = - new FileInputStream(new File("testresources/mtls/test_cert.pem"))) { - Certificate cert = cf.generateCertificate(fis); - ks.setCertificateEntry("test-alias", cert); - } - return ks; + try (InputStream certStream = + new FileInputStream(new File("testresources/mtls/test_cert.pem")); + InputStream keyStream = new FileInputStream(new File("testresources/mtls/test_key.pem")); + InputStream combined = new SequenceInputStream(certStream, keyStream)) { + return SecurityUtils.createMtlsKeyStore(combined); } catch (Exception e) { throw new RuntimeException("Failed to create test KeyStore", e); } @@ -1568,7 +1565,7 @@ void refreshAccessToken_withActorToken_injectsActingPartyIntoStsRequest() throws .setAudience( "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") - .setTokenUrl(mockTransportFactory.transport.getStsUrl()) + .setTokenUrl(mockTransportFactory.transport.getStsMtlsUrl()) .setHttpTransportFactory(mtlsTransport)) { @Override protected AccessToken exchangeExternalCredentialForAccessToken( @@ -2102,7 +2099,7 @@ void refreshAccessToken_subjectAndActorFromSameFileParse() throws Exception { .setAudience( "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") - .setTokenUrl(mockTransportFactory.transport.getStsUrl()) + .setTokenUrl(mockTransportFactory.transport.getStsMtlsUrl()) .setHttpTransportFactory(mtlsTransport)) { @Override protected AccessToken exchangeExternalCredentialForAccessToken( @@ -2423,75 +2420,75 @@ void serialize_deserialize_withActorTokenConfig_roundTrips() throws Exception { } private static final String PRE_PR_SERIALIZED_BYTES_BASE64 = - "rO0ABXNyAC5jb20uZ29vZ2xlLmF1dGgub2F1dGgyLklkZW50aXR5UG9vbENyZWRlbnRpYWxzIkrrZ4jpHOkCAAVMABJh" - + "Y3RvclRva2VuU3VwcGxpZXJ0ADdMY29tL2dvb2dsZS9hdXRoL29hdXRoMi9JZGVudGl0eVBvb2xBY3RvclRva2Vu" - + "U3VwcGxpZXI7TAAOYWN0b3JUb2tlblR5cGV0ABJMamF2YS9sYW5nL1N0cmluZztMABJtZXRyaWNzSGVhZGVyVmFs" - + "dWVxAH4AAkwAFHN1YmplY3RUb2tlblN1cHBsaWVydAA5TGNvbS9nb29nbGUvYXV0aC9vYXV0aDIvSWRlbnRpdHlQ" - + "b29sU3ViamVjdFRva2VuU3VwcGxpZXI7TAAPc3VwcGxpZXJDb250ZXh0dAA3TGNvbS9nb29nbGUvYXV0aC9vYXV0" - + "aDIvRXh0ZXJuYWxBY2NvdW50U3VwcGxpZXJDb250ZXh0O3hyADFjb20uZ29vZ2xlLmF1dGgub2F1dGgyLkV4dGVy" - + "bmFsQWNjb3VudENyZWRlbnRpYWxzb7Q9oKQPk/8CABBMAAhhdWRpZW5jZXEAfgACTAAIY2xpZW50SWRxAH4AAkwA" - + "DGNsaWVudFNlY3JldHEAfgACTAAQY3JlZGVudGlhbFNvdXJjZXQARExjb20vZ29vZ2xlL2F1dGgvb2F1dGgyL0V4" - + "dGVybmFsQWNjb3VudENyZWRlbnRpYWxzJENyZWRlbnRpYWxTb3VyY2U7TAATZW52aXJvbm1lbnRQcm92aWRlcnQA" - + "LExjb20vZ29vZ2xlL2F1dGgvb2F1dGgyL0Vudmlyb25tZW50UHJvdmlkZXI7TAAXaW1wZXJzb25hdGVkQ3JlZGVu" - + "dGlhbHN0ADBMY29tL2dvb2dsZS9hdXRoL29hdXRoMi9JbXBlcnNvbmF0ZWRDcmVkZW50aWFscztMAA5tZXRyaWNz" - + "SGFuZGxlcnQANkxjb20vZ29vZ2xlL2F1dGgvb2F1dGgyL0V4dGVybmFsQWNjb3VudE1ldHJpY3NIYW5kbGVyO0wA" - + "EHByb3BlcnR5UHJvdmlkZXJ0AClMY29tL2dvb2dsZS9hdXRoL29hdXRoMi9Qcm9wZXJ0eVByb3ZpZGVyO0wABnNj" - + "b3Blc3QAFkxqYXZhL3V0aWwvQ29sbGVjdGlvbjtMACJzZXJ2aWNlQWNjb3VudEltcGVyc29uYXRpb25PcHRpb25z" - + "dABWTGNvbS9nb29nbGUvYXV0aC9vYXV0aDIvRXh0ZXJuYWxBY2NvdW50Q3JlZGVudGlhbHMkU2VydmljZUFjY291" - + "bnRJbXBlcnNvbmF0aW9uT3B0aW9ucztMAB5zZXJ2aWNlQWNjb3VudEltcGVyc29uYXRpb25VcmxxAH4AAkwAEHN1" - + "YmplY3RUb2tlblR5cGVxAH4AAkwADHRva2VuSW5mb1VybHEAfgACTAAIdG9rZW5VcmxxAH4AAkwAGXRyYW5zcG9y" - + "dEZhY3RvcnlDbGFzc05hbWVxAH4AAkwAGHdvcmtmb3JjZVBvb2xVc2VyUHJvamVjdHEAfgACeHIAKGNvbS5nb29n" - + "bGUuYXV0aC5vYXV0aDIuR29vZ2xlQ3JlZGVudGlhbHPq3b3FouFfJQIABVoAGGlzRXhwbGljaXRVbml2ZXJzZURv" - + "bWFpbkwABG5hbWVxAH4AAkwADnF1b3RhUHJvamVjdElkcQB+AAJMAAZzb3VyY2VxAH4AAkwADnVuaXZlcnNlRG9t" - + "YWlucQB+AAJ4cgAoY29tLmdvb2dsZS5hdXRoLm9hdXRoMi5PQXV0aDJDcmVkZW50aWFscz89fXrppVFXAgAETAAQ" - + "ZXhwaXJhdGlvbk1hcmdpbnQAFExqYXZhL3RpbWUvRHVyYXRpb247TAAEbG9ja3QAEkxqYXZhL2xhbmcvT2JqZWN0" - + "O0wADXJlZnJlc2hNYXJnaW5xAH4AD0wABXZhbHVldAA1TGNvbS9nb29nbGUvYXV0aC9vYXV0aDIvT0F1dGgyQ3Jl" - + "ZGVudGlhbHMkT0F1dGhWYWx1ZTt4cgAbY29tLmdvb2dsZS5hdXRoLkNyZWRlbnRpYWxzCzii14w9kIECAAB4cHNy" - + "AA1qYXZhLnRpbWUuU2VylV2EuhsiSLIMAAB4cHcNAQAAAAAAAAC0AAAAAHh1cgACW0Ks8xf4BghU4AIAAHhwAAAA" - + "AHNxAH4AFHcNAQAAAAAAAADhAAAAAHhwAHQAHEV4dGVybmFsIEFjY291bnQgQ3JlZGVudGlhbHN0AA5xdW90YVBy" - + "b2plY3RJZHB0AA5nb29nbGVhcGlzLmNvbXQAYC8vaWFtLmdvb2dsZWFwaXMuY29tL3Byb2plY3RzLzEyMy9sb2Nh" - + "dGlvbnMvZ2xvYmFsL3dvcmtsb2FkSWRlbnRpdHlQb29scy9wb29sL3Byb3ZpZGVycy9wcm92aWRlcnQACGNsaWVu" - + "dElkdAAMY2xpZW50U2VjcmV0c3IAM2NvbS5nb29nbGUuYXV0aC5vYXV0aDIuSWRlbnRpdHlQb29sQ3JlZGVudGlh" - + "bFNvdXJjZfWmMJrBt+rCAgAHTAATYWN0b3JUb2tlbkZpZWxkTmFtZXEAfgACTAARY2VydGlmaWNhdGVDb25maWd0" - + "AEdMY29tL2dvb2dsZS9hdXRoL29hdXRoMi9JZGVudGl0eVBvb2xDcmVkZW50aWFsU291cmNlJENlcnRpZmljYXRl" - + "Q29uZmlnO0wAFGNyZWRlbnRpYWxGb3JtYXRUeXBldABKTGNvbS9nb29nbGUvYXV0aC9vYXV0aDIvSWRlbnRpdHlQ" - + "b29sQ3JlZGVudGlhbFNvdXJjZSRDcmVkZW50aWFsRm9ybWF0VHlwZTtMABJjcmVkZW50aWFsTG9jYXRpb25xAH4A" - + "AkwAFGNyZWRlbnRpYWxTb3VyY2VUeXBldABWTGNvbS9nb29nbGUvYXV0aC9vYXV0aDIvSWRlbnRpdHlQb29sQ3Jl" - + "ZGVudGlhbFNvdXJjZSRJZGVudGl0eVBvb2xDcmVkZW50aWFsU291cmNlVHlwZTtMAAdoZWFkZXJzdAAPTGphdmEv" - + "dXRpbC9NYXA7TAAVc3ViamVjdFRva2VuRmllbGROYW1lcQB+AAJ4cgBCY29tLmdvb2dsZS5hdXRoLm9hdXRoMi5F" - + "eHRlcm5hbEFjY291bnRDcmVkZW50aWFscyRDcmVkZW50aWFsU291cmNlcdzMzznPiMgCAAB4cHBwfnIASGNvbS5n" - + "b29nbGUuYXV0aC5vYXV0aDIuSWRlbnRpdHlQb29sQ3JlZGVudGlhbFNvdXJjZSRDcmVkZW50aWFsRm9ybWF0VHlw" - + "ZQAAAAAAAAAAEgAAeHIADmphdmEubGFuZy5FbnVtAAAAAAAAAAASAAB4cHQABFRFWFR0AARmaWxlfnIAVGNvbS5n" - + "b29nbGUuYXV0aC5vYXV0aDIuSWRlbnRpdHlQb29sQ3JlZGVudGlhbFNvdXJjZSRJZGVudGl0eVBvb2xDcmVkZW50" - + "aWFsU291cmNlVHlwZQAAAAAAAAAAEgAAeHEAfgAndAAERklMRXBwc3IAMGNvbS5nb29nbGUuYXV0aC5vYXV0aDIu" - + "U3lzdGVtRW52aXJvbm1lbnRQcm92aWRlcr7Mw9ZYOzw0AgAAeHBwc3IANGNvbS5nb29nbGUuYXV0aC5vYXV0aDIu" - + "RXh0ZXJuYWxBY2NvdW50TWV0cmljc0hhbmRsZXILhDC5uzFxHgIAA1oADmNvbmZpZ0xpZmV0aW1lWgAPc2FJbXBl" - + "cnNvbmF0aW9uTAALY3JlZGVudGlhbHN0ADNMY29tL2dvb2dsZS9hdXRoL29hdXRoMi9FeHRlcm5hbEFjY291bnRD" - + "cmVkZW50aWFsczt4cAAAc3EAfgAAcQB+ABV1cQB+ABYAAAAAcQB+ABhwAHEAfgAZcHBxAH4AG3EAfgAccHBxAH4A" - + "JXEAfgAvcHEAfgAyc3IALWNvbS5nb29nbGUuYXV0aC5vYXV0aDIuU3lzdGVtUHJvcGVydHlQcm92aWRlcgAAAAAA" - + "AAABAgAAeHBzcgAjamF2YS51dGlsLkNvbGxlY3Rpb25zJFNpbmdsZXRvbkxpc3Qq7ykQPKeblwIAAUwAB2VsZW1l" - + "bnRxAH4AEHhwdAAuaHR0cHM6Ly93d3cuZ29vZ2xlYXBpcy5jb20vYXV0aC9jbG91ZC1wbGF0Zm9ybXNyAFRjb20u" - + "Z29vZ2xlLmF1dGgub2F1dGgyLkV4dGVybmFsQWNjb3VudENyZWRlbnRpYWxzJFNlcnZpY2VBY2NvdW50SW1wZXJz" - + "b25hdGlvbk9wdGlvbnM6/caKmTx8+QIAAloAHGN1c3RvbVRva2VuTGlmZXRpbWVSZXF1ZXN0ZWRJAAhsaWZldGlt" - + "ZXhwAAAADhBwdAAQc3ViamVjdFRva2VuVHlwZXQADHRva2VuSW5mb1VybHQAI2h0dHBzOi8vc3RzLmdvb2dsZWFw" - + "aXMuY29tL3YxL3Rva2VudAA+Y29tLmdvb2dsZS5hdXRoLm9hdXRoMi5PQXV0aDJVdGlscyREZWZhdWx0SHR0cFRy" - + "YW5zcG9ydEZhY3RvcnlwcHBxAH4AKnNyADtjb20uZ29vZ2xlLmF1dGgub2F1dGgyLkZpbGVJZGVudGl0eVBvb2xT" - + "dWJqZWN0VG9rZW5TdXBwbGllcmNBv+j+PpS2AgABTAAQY3JlZGVudGlhbFNvdXJjZXQANUxjb20vZ29vZ2xlL2F1" - + "dGgvb2F1dGgyL0lkZW50aXR5UG9vbENyZWRlbnRpYWxTb3VyY2U7eHBxAH4AJXNyADVjb20uZ29vZ2xlLmF1dGgu" - + "b2F1dGgyLkV4dGVybmFsQWNjb3VudFN1cHBsaWVyQ29udGV4dJMHoJdQucHqAgACTAAIYXVkaWVuY2VxAH4AAkwA" - + "EHN1YmplY3RUb2tlblR5cGVxAH4AAnhwcQB+ABxxAH4APHEAfgA2cQB+ADhxAH4AO3QAemh0dHBzOi8vaWFtY3Jl" - + "ZGVudGlhbHMuZ29vZ2xlYXBpcy5jb20vdjEvcHJvamVjdHMvLS9zZXJ2aWNlQWNjb3VudHMvdGVzdG5AdGVzdC5p" - + "YW0uZ3NlcnZpY2VhY2NvdW50LmNvbTpnZW5lcmF0ZUFjY2Vzc1Rva2VucQB+ADxxAH4APXEAfgA+cQB+AD9wcHBx" - + "AH4AKnNxAH4AQHEAfgAlc3EAfgBDcQB+ABxxAH4APA=="; + "rO0ABXNyAC5jb20uZ29vZ2xlLmF1dGgub2F1dGgyLklkZW50aXR5UG9vbENyZWRlbnRpYWxzIkrrZ4jpHOkCAANMABJtZXRy" + + "aWNzSGVhZGVyVmFsdWV0ABJMamF2YS9sYW5nL1N0cmluZztMABRzdWJqZWN0VG9rZW5TdXBwbGllcnQAOUxjb20vZ29vZ2xl" + + "L2F1dGgvb2F1dGgyL0lkZW50aXR5UG9vbFN1YmplY3RUb2tlblN1cHBsaWVyO0wAD3N1cHBsaWVyQ29udGV4dHQAN0xjb20v" + + "Z29vZ2xlL2F1dGgvb2F1dGgyL0V4dGVybmFsQWNjb3VudFN1cHBsaWVyQ29udGV4dDt4cgAxY29tLmdvb2dsZS5hdXRoLm9h" + + "dXRoMi5FeHRlcm5hbEFjY291bnRDcmVkZW50aWFsc2+0PaCkD5P/AgAQTAAIYXVkaWVuY2VxAH4AAUwACGNsaWVudElkcQB+" + + "AAFMAAxjbGllbnRTZWNyZXRxAH4AAUwAEGNyZWRlbnRpYWxTb3VyY2V0AERMY29tL2dvb2dsZS9hdXRoL29hdXRoMi9FeHRl" + + "cm5hbEFjY291bnRDcmVkZW50aWFscyRDcmVkZW50aWFsU291cmNlO0wAE2Vudmlyb25tZW50UHJvdmlkZXJ0ACxMY29tL2dv" + + "b2dsZS9hdXRoL29hdXRoMi9FbnZpcm9ubWVudFByb3ZpZGVyO0wAF2ltcGVyc29uYXRlZENyZWRlbnRpYWxzdAAwTGNvbS9n" + + "b29nbGUvYXV0aC9vYXV0aDIvSW1wZXJzb25hdGVkQ3JlZGVudGlhbHM7TAAObWV0cmljc0hhbmRsZXJ0ADZMY29tL2dvb2ds" + + "ZS9hdXRoL29hdXRoMi9FeHRlcm5hbEFjY291bnRNZXRyaWNzSGFuZGxlcjtMABBwcm9wZXJ0eVByb3ZpZGVydAApTGNvbS9n" + + "b29nbGUvYXV0aC9vYXV0aDIvUHJvcGVydHlQcm92aWRlcjtMAAZzY29wZXN0ABZMamF2YS91dGlsL0NvbGxlY3Rpb247TAAi" + + "c2VydmljZUFjY291bnRJbXBlcnNvbmF0aW9uT3B0aW9uc3QAVkxjb20vZ29vZ2xlL2F1dGgvb2F1dGgyL0V4dGVybmFsQWNj" + + "b3VudENyZWRlbnRpYWxzJFNlcnZpY2VBY2NvdW50SW1wZXJzb25hdGlvbk9wdGlvbnM7TAAec2VydmljZUFjY291bnRJbXBl" + + "cnNvbmF0aW9uVXJscQB+AAFMABBzdWJqZWN0VG9rZW5UeXBlcQB+AAFMAAx0b2tlbkluZm9VcmxxAH4AAUwACHRva2VuVXJs" + + "cQB+AAFMABl0cmFuc3BvcnRGYWN0b3J5Q2xhc3NOYW1lcQB+AAFMABh3b3JrZm9yY2VQb29sVXNlclByb2plY3RxAH4AAXhy" + + "AChjb20uZ29vZ2xlLmF1dGgub2F1dGgyLkdvb2dsZUNyZWRlbnRpYWxz6t29xaLhXyUCAAVaABhpc0V4cGxpY2l0VW5pdmVy" + + "c2VEb21haW5MAARuYW1lcQB+AAFMAA5xdW90YVByb2plY3RJZHEAfgABTAAGc291cmNlcQB+AAFMAA51bml2ZXJzZURvbWFp" + + "bnEAfgABeHIAKGNvbS5nb29nbGUuYXV0aC5vYXV0aDIuT0F1dGgyQ3JlZGVudGlhbHM/PX166aVRVwIABEwAEGV4cGlyYXRp" + + "b25NYXJnaW50ABRMamF2YS90aW1lL0R1cmF0aW9uO0wABGxvY2t0ABJMamF2YS9sYW5nL09iamVjdDtMAA1yZWZyZXNoTWFy" + + "Z2lucQB+AA5MAAV2YWx1ZXQANUxjb20vZ29vZ2xlL2F1dGgvb2F1dGgyL09BdXRoMkNyZWRlbnRpYWxzJE9BdXRoVmFsdWU7" + + "eHIAG2NvbS5nb29nbGUuYXV0aC5DcmVkZW50aWFscws4oteMPZCBAgAAeHBzcgANamF2YS50aW1lLlNlcpVdhLobIkiyDAAA" + + "eHB3DQEAAAAAAAAAtAAAAAB4dXIAAltCrPMX+AYIVOACAAB4cAAAAABzcQB+ABN3DQEAAAAAAAAA4QAAAAB4cAB0ABxFeHRl" + + "cm5hbCBBY2NvdW50IENyZWRlbnRpYWxzdAAOcXVvdGFQcm9qZWN0SWRwdAAOZ29vZ2xlYXBpcy5jb210AGAvL2lhbS5nb29n" + + "bGVhcGlzLmNvbS9wcm9qZWN0cy8xMjMvbG9jYXRpb25zL2dsb2JhbC93b3JrbG9hZElkZW50aXR5UG9vbHMvcG9vbC9wcm92" + + "aWRlcnMvcHJvdmlkZXJ0AAhjbGllbnRJZHQADGNsaWVudFNlY3JldHNyADNjb20uZ29vZ2xlLmF1dGgub2F1dGgyLklkZW50" + + "aXR5UG9vbENyZWRlbnRpYWxTb3VyY2X1pjCawbfqwgIAB0wAE2FjdG9yVG9rZW5GaWVsZE5hbWVxAH4AAUwAEWNlcnRpZmlj" + + "YXRlQ29uZmlndABHTGNvbS9nb29nbGUvYXV0aC9vYXV0aDIvSWRlbnRpdHlQb29sQ3JlZGVudGlhbFNvdXJjZSRDZXJ0aWZp" + + "Y2F0ZUNvbmZpZztMABRjcmVkZW50aWFsRm9ybWF0VHlwZXQASkxjb20vZ29vZ2xlL2F1dGgvb2F1dGgyL0lkZW50aXR5UG9v" + + "bENyZWRlbnRpYWxTb3VyY2UkQ3JlZGVudGlhbEZvcm1hdFR5cGU7TAASY3JlZGVudGlhbExvY2F0aW9ucQB+AAFMABRjcmVk" + + "ZW50aWFsU291cmNlVHlwZXQAVkxjb20vZ29vZ2xlL2F1dGgvb2F1dGgyL0lkZW50aXR5UG9vbENyZWRlbnRpYWxTb3VyY2Uk" + + "SWRlbnRpdHlQb29sQ3JlZGVudGlhbFNvdXJjZVR5cGU7TAAHaGVhZGVyc3QAD0xqYXZhL3V0aWwvTWFwO0wAFXN1YmplY3RU" + + "b2tlbkZpZWxkTmFtZXEAfgABeHIAQmNvbS5nb29nbGUuYXV0aC5vYXV0aDIuRXh0ZXJuYWxBY2NvdW50Q3JlZGVudGlhbHMk" + + "Q3JlZGVudGlhbFNvdXJjZXHczM85z4jIAgAAeHBwcH5yAEhjb20uZ29vZ2xlLmF1dGgub2F1dGgyLklkZW50aXR5UG9vbENy" + + "ZWRlbnRpYWxTb3VyY2UkQ3JlZGVudGlhbEZvcm1hdFR5cGUAAAAAAAAAABIAAHhyAA5qYXZhLmxhbmcuRW51bQAAAAAAAAAA" + + "EgAAeHB0AARURVhUdAAEZmlsZX5yAFRjb20uZ29vZ2xlLmF1dGgub2F1dGgyLklkZW50aXR5UG9vbENyZWRlbnRpYWxTb3Vy" + + "Y2UkSWRlbnRpdHlQb29sQ3JlZGVudGlhbFNvdXJjZVR5cGUAAAAAAAAAABIAAHhxAH4AJnQABEZJTEVwcHNyADBjb20uZ29v" + + "Z2xlLmF1dGgub2F1dGgyLlN5c3RlbUVudmlyb25tZW50UHJvdmlkZXK+zMPWWDs8NAIAAHhwcHNyADRjb20uZ29vZ2xlLmF1" + + "dGgub2F1dGgyLkV4dGVybmFsQWNjb3VudE1ldHJpY3NIYW5kbGVyC4Qcubsxch4CAANaAA5jb25maWdMaWZldGltZVoAD3Nh" + + "SW1wZXJzb25hdGlvbkwAC2NyZWRlbnRpYWxzdAAzTGNvbS9nb29nbGUvYXV0aC9vYXV0aDIvRXh0ZXJuYWxBY2NvdW50Q3Jl" + + "ZGVudGlhbHM7eHAAAXEAfgASc3IALWNvbS5nb29nbGUuYXV0aC5vYXV0aDIuU3lzdGVtUHJvcGVydHlQcm92aWRlcgAAAAAA" + + "AAABAgAAeHBzcgAjamF2YS51dGlsLkNvbGxlY3Rpb25zJFNpbmdsZXRvbkxpc3Qq7ykQPKeblwIAAUwAB2VsZW1lbnRxAH4A" + + "D3hwdAAuaHR0cHM6Ly93d3cuZ29vZ2xlYXBpcy5jb20vYXV0aC9jbG91ZC1wbGF0Zm9ybXNyAFRjb20uZ29vZ2xlLmF1dGgu" + + "b2F1dGgyLkV4dGVybmFsQWNjb3VudENyZWRlbnRpYWxzJFNlcnZpY2VBY2NvdW50SW1wZXJzb25hdGlvbk9wdGlvbnM6/caK" + + "mTx8+QIAAloAHGN1c3RvbVRva2VuTGlmZXRpbWVSZXF1ZXN0ZWRJAAhsaWZldGltZXhwAAAADhB0AHpodHRwczovL2lhbWNy" + + "ZWRlbnRpYWxzLmdvb2dsZWFwaXMuY29tL3YxL3Byb2plY3RzLy0vc2VydmljZUFjY291bnRzL3Rlc3RuQHRlc3QuaWFtLmdz" + + "ZXJ2aWNlYWNjb3VudC5jb206Z2VuZXJhdGVBY2Nlc3NUb2tlbnQAEHN1YmplY3RUb2tlblR5cGV0AAx0b2tlbkluZm9Vcmx0" + + "ACNodHRwczovL3N0cy5nb29nbGVhcGlzLmNvbS92MS90b2tlbnQAPmNvbS5nb29nbGUuYXV0aC5vYXV0aDIuT0F1dGgyVXRp" + + "bHMkRGVmYXVsdEh0dHBUcmFuc3BvcnRGYWN0b3J5cHEAfgApc3IAO2NvbS5nb29nbGUuYXV0aC5vYXV0aDIuRmlsZUlkZW50" + + "aXR5UG9vbFN1YmplY3RUb2tlblN1cHBsaWVyY0G/6P4+lLYCAAFMABBjcmVkZW50aWFsU291cmNldAA1TGNvbS9nb29nbGUv" + + "YXV0aC9vYXV0aDIvSWRlbnRpdHlQb29sQ3JlZGVudGlhbFNvdXJjZTt4cHEAfgAkc3IANWNvbS5nb29nbGUuYXV0aC5vYXV0" + + "aDIuRXh0ZXJuYWxBY2NvdW50U3VwcGxpZXJDb250ZXh0kwegl1C5weoCAAJMAAhhdWRpZW5jZXEAfgABTAAQc3ViamVjdFRv" + + "a2VuVHlwZXEAfgABeHBxAH4AG3EAfgA6"; @Test void serialize_deserialize_backwardCompatible() throws Exception { - // Verify that credentials serialized BEFORE this PR (hardcoded byte fixture using synthetic - // SUID - // 7152208690659890358L for FileIdentityPoolSubjectTokenSupplier) deserialize successfully. byte[] fixtureBytes = Base64.getDecoder().decode(PRE_PR_SERIALIZED_BYTES_BASE64); IdentityPoolCredentials deserialized; - try (ObjectInputStream input = new ObjectInputStream(new ByteArrayInputStream(fixtureBytes))) { + try (ObjectInputStream input = + new ObjectInputStream(new ByteArrayInputStream(fixtureBytes)) { + @Override + protected ObjectStreamClass readClassDescriptor() + throws IOException, ClassNotFoundException { + ObjectStreamClass desc = super.readClassDescriptor(); + if ("com.google.auth.oauth2.ExternalAccountMetricsHandler".equals(desc.getName())) { + return ObjectStreamClass.lookup(ExternalAccountMetricsHandler.class); + } + return desc; + } + }) { deserialized = (IdentityPoolCredentials) input.readObject(); } @@ -2506,6 +2503,7 @@ void serialize_deserialize_backwardCompatible() throws Exception { assertEquals("clientSecret", deserialized.getClientSecret()); assertEquals( SERVICE_ACCOUNT_IMPERSONATION_URL, deserialized.getServiceAccountImpersonationUrl()); + assertEquals(null, deserialized.getIdentityPoolActorTokenSupplier()); assertEquals(null, deserialized.getActorTokenType()); } @@ -2627,7 +2625,173 @@ protected AccessToken exchangeExternalCredentialForAccessToken( assertEquals("deserializedCertToken", token.getTokenValue()); assertNotNull(capturedFactory.get()); assertTrue(capturedFactory.get() instanceof MtlsHttpTransportFactory); - assertTrue(((MtlsHttpTransportFactory) capturedFactory.get()).hasKeyStore()); + } + + @Test + void serialize_deserialize_programmaticFlow_andRefresh_succeeds() throws Exception { + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") + .setTokenUrl("https://sts.googleapis.com/v1/token") + .build(); + + IdentityPoolCredentials deserialized = serializeAndDeserialize(credentials); + assertNotNull(deserialized); + assertNotNull(deserialized.getTransportFactory()); + assertNull(deserialized.getX509Provider()); + assertNotNull(deserialized.getIdentityPoolSubjectTokenSupplier()); + assertNull(deserialized.getIdentityPoolActorTokenSupplier()); + assertNull(deserialized.getActorTokenType()); + + AtomicReference capturedRequest = new AtomicReference<>(); + IdentityPoolCredentials testable = + new IdentityPoolCredentials(deserialized.toBuilder()) { + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) { + capturedRequest.set(stsTokenExchangeRequest); + return new AccessToken("programmaticAccessToken", null); + } + }; + + AccessToken token = testable.refreshAccessToken(); + assertEquals("programmaticAccessToken", token.getTokenValue()); + assertNotNull(capturedRequest.get()); + assertEquals("testSubjectToken", capturedRequest.get().getSubjectToken()); + assertEquals( + "urn:ietf:params:oauth:token-type:jwt", capturedRequest.get().getSubjectTokenType()); + assertNull(capturedRequest.get().getActingParty()); + } + + @Test + void serialize_deserialize_programmaticFlow_withMtlsTransport_restoresFactoryWithoutKeyStore() + throws Exception { + KeyStore keyStore = createPopulatedKeyStore(); + HttpTransportFactory transportFactory = new MtlsHttpTransportFactory(keyStore); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(context -> "testSubjectToken") + .setActorTokenSupplier(context -> "testActorToken") + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") + .setTokenUrl(MockExternalAccountCredentialsTransport.STS_MTLS_URL) + .setHttpTransportFactory(transportFactory) + .build(); + + IdentityPoolCredentials deserialized = serializeAndDeserialize(credentials); + assertNotNull(deserialized); + // Programmatic flows restore default-constructed transportFactory from class name + assertNotNull(deserialized.getTransportFactory()); + assertTrue(deserialized.getTransportFactory() instanceof MtlsHttpTransportFactory); + assertFalse(((MtlsHttpTransportFactory) deserialized.getTransportFactory()).hasKeyStore()); + assertNull(deserialized.getX509Provider()); + assertNotNull(deserialized.getIdentityPoolSubjectTokenSupplier()); + assertNotNull(deserialized.getIdentityPoolActorTokenSupplier()); + } + + @Test + void builder_actorTokenWithNonMtlsTokenUrl_throwsIllegalArgumentException() throws Exception { + KeyStore keyStore = createPopulatedKeyStore(); + HttpTransportFactory transportFactory = new MtlsHttpTransportFactory(keyStore); + + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(context -> "testSubjectToken") + .setActorTokenSupplier(context -> "testActorToken") + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") + .setTokenUrl("https://sts.googleapis.com/v1/token") + .setHttpTransportFactory(transportFactory) + .build()); + assertTrue(e.getMessage().contains("tokenUrl")); + assertTrue(e.getMessage().contains(".mtls.")); + } + + @Test + void builder_actorTokenWithNonMtlsImpersonationUrl_throwsIllegalArgumentException() + throws Exception { + KeyStore keyStore = createPopulatedKeyStore(); + HttpTransportFactory transportFactory = new MtlsHttpTransportFactory(keyStore); + + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(context -> "testSubjectToken") + .setActorTokenSupplier(context -> "testActorToken") + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") + .setTokenUrl(MockExternalAccountCredentialsTransport.STS_MTLS_URL) + .setServiceAccountImpersonationUrl( + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken") + .setHttpTransportFactory(transportFactory) + .build()); + assertTrue(e.getMessage().contains("serviceAccountImpersonationUrl")); + assertTrue(e.getMessage().contains(".mtls.")); + } + + @Test + void builder_actorTokenWithCustomDomainMtlsUrl_succeeds() throws Exception { + KeyStore keyStore = createPopulatedKeyStore(); + HttpTransportFactory transportFactory = new MtlsHttpTransportFactory(keyStore); + + IdentityPoolCredentials cred = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(context -> "testSubjectToken") + .setActorTokenSupplier(context -> "testActorToken") + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience("//custom.domain.com/pool") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") + .setTokenUrl("https://auth.custom-domain.com/v1/token") + .setServiceAccountImpersonationUrl("https://iam.custom-domain.com/v1/generate") + .setHttpTransportFactory(transportFactory) + .build(); + assertNotNull(cred); + } + + @Test + void fromBuilder_withCustomTransportFactoryAndCertificateConfig_preservesCustomTransportFactory( + @TempDir Path tempDir) throws Exception { + Path tokenFile = tempDir.resolve("credential.txt"); + Files.write(tokenFile, "token_from_file".getBytes(StandardCharsets.UTF_8)); + + Map certMap = new HashMap<>(); + certMap.put("certificate_config_location", "testresources/mtls/certificate_config.json"); + + Map sourceMap = new HashMap<>(); + sourceMap.put("file", tokenFile.toString()); + sourceMap.put("certificate", certMap); + + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(sourceMap); + HttpTransportFactory customTransportFactory = + () -> new MockExternalAccountCredentialsTransport(); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") + .setTokenUrl("https://sts.googleapis.com/v1/token") + .setCredentialSource(source) + .setHttpTransportFactory(customTransportFactory) + .build(); + + assertSame(customTransportFactory, credentials.getTransportFactory()); } // ================================================================================== @@ -2653,7 +2817,7 @@ void fromStream_fileCredentialSource_withCertificateConfig_andActorToken_refresh + " \"//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider\",\n" + " \"subject_token_type\": \"urn:ietf:params:oauth:token-type:jwt\",\n" + " \"actor_token_type\": \"urn:ietf:params:oauth:token-type:jwt\",\n" - + " \"token_url\": \"https://sts.googleapis.com/v1/token\",\n" + + " \"token_url\": \"https://sts.mtls.googleapis.com/v1/token\",\n" + " \"credential_source\": {\n" + " \"file\": \"" + tokenFile.toString() diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java index 7719b08d2e7b..85dff97bc270 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java @@ -68,6 +68,7 @@ public class MockExternalAccountCredentialsTransport extends MockHttpTransport { private static final String AWS_IMDSV2_SESSION_TOKEN_URL = "https://169.254.169.254/imdsv2"; private static final String METADATA_SERVER_URL = "https://www.metadata.google.com"; private static final String STS_URL = "https://sts.googleapis.com/v1/token"; + static final String STS_MTLS_URL = "https://sts.mtls.googleapis.com/v1/token"; private static final String SUBJECT_TOKEN = "subjectToken"; private static final String TOKEN_TYPE = "Bearer"; @@ -167,7 +168,7 @@ public LowLevelHttpResponse execute() throws IOException { .setContentType("text/html") .setContent(SUBJECT_TOKEN); } - if (STS_URL.equals(url)) { + if (STS_URL.equals(url) || STS_MTLS_URL.equals(url)) { Map query = TestUtils.parseQuery(getContentAsString()); // Store STS content as multiple calls are made using this transport. @@ -288,6 +289,10 @@ public String getStsUrl() { return STS_URL; } + public String getStsMtlsUrl() { + return STS_MTLS_URL; + } + public String getServiceAccountImpersonationUrl() { return SERVICE_ACCOUNT_IMPERSONATION_URL; } From 2b8a0e725a3de317c7c06a34cec5289917a6adfe Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Fri, 28 Aug 2026 21:18:40 +0000 Subject: [PATCH 20/25] fix(oauth2): rely on transport mTLS validation rather than URL string checking --- .../auth/oauth2/IdentityPoolCredentials.java | 39 ----------- .../oauth2/IdentityPoolCredentialsTest.java | 68 ------------------- 2 files changed, 107 deletions(-) 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 304463f79daa..ff8d51688d3f 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 @@ -40,7 +40,6 @@ import com.google.errorprone.annotations.CanIgnoreReturnValue; import java.io.IOException; import java.io.ObjectInputStream; -import java.net.URI; import java.security.KeyStore; import java.util.ArrayList; import java.util.Collection; @@ -174,44 +173,6 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { "Actor tokens are only supported for mTLS token exchanges. Please configure a certificate" + " source or MtlsHttpTransportFactory."); } - - if (this.actorTokenSupplier != null) { - validateMtlsEndpoint(getTokenUrl(), "tokenUrl"); - if (getServiceAccountImpersonationUrl() != null) { - validateMtlsEndpoint(getServiceAccountImpersonationUrl(), "serviceAccountImpersonationUrl"); - } - } - } - - private static void validateMtlsEndpoint(@Nullable String url, String fieldName) { - if (url == null) { - return; - } - try { - URI uri = URI.create(url); - String host = uri.getHost(); - if (host != null && host.endsWith("googleapis.com") && !host.contains(".mtls.")) { - throw new IllegalArgumentException( - "The " - + fieldName - + " endpoint (" - + url - + ") must be an mTLS endpoint (e.g. contain '.mtls.') when an actor token is" - + " configured."); - } - } catch (IllegalArgumentException e) { - throw e; - } catch (Exception e) { - if (url.contains("googleapis.com") && !url.contains(".mtls.")) { - throw new IllegalArgumentException( - "The " - + fieldName - + " endpoint (" - + url - + ") must be an mTLS endpoint (e.g. contain '.mtls.') when an actor token is" - + " configured."); - } - } } /** 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 eea759f5e14b..af85ef11267a 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 @@ -2696,74 +2696,6 @@ void serialize_deserialize_programmaticFlow_withMtlsTransport_restoresFactoryWit assertNotNull(deserialized.getIdentityPoolActorTokenSupplier()); } - @Test - void builder_actorTokenWithNonMtlsTokenUrl_throwsIllegalArgumentException() throws Exception { - KeyStore keyStore = createPopulatedKeyStore(); - HttpTransportFactory transportFactory = new MtlsHttpTransportFactory(keyStore); - - IllegalArgumentException e = - assertThrows( - IllegalArgumentException.class, - () -> - IdentityPoolCredentials.newBuilder() - .setSubjectTokenSupplier(context -> "testSubjectToken") - .setActorTokenSupplier(context -> "testActorToken") - .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") - .setAudience( - "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") - .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") - .setTokenUrl("https://sts.googleapis.com/v1/token") - .setHttpTransportFactory(transportFactory) - .build()); - assertTrue(e.getMessage().contains("tokenUrl")); - assertTrue(e.getMessage().contains(".mtls.")); - } - - @Test - void builder_actorTokenWithNonMtlsImpersonationUrl_throwsIllegalArgumentException() - throws Exception { - KeyStore keyStore = createPopulatedKeyStore(); - HttpTransportFactory transportFactory = new MtlsHttpTransportFactory(keyStore); - - IllegalArgumentException e = - assertThrows( - IllegalArgumentException.class, - () -> - IdentityPoolCredentials.newBuilder() - .setSubjectTokenSupplier(context -> "testSubjectToken") - .setActorTokenSupplier(context -> "testActorToken") - .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") - .setAudience( - "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") - .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") - .setTokenUrl(MockExternalAccountCredentialsTransport.STS_MTLS_URL) - .setServiceAccountImpersonationUrl( - "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken") - .setHttpTransportFactory(transportFactory) - .build()); - assertTrue(e.getMessage().contains("serviceAccountImpersonationUrl")); - assertTrue(e.getMessage().contains(".mtls.")); - } - - @Test - void builder_actorTokenWithCustomDomainMtlsUrl_succeeds() throws Exception { - KeyStore keyStore = createPopulatedKeyStore(); - HttpTransportFactory transportFactory = new MtlsHttpTransportFactory(keyStore); - - IdentityPoolCredentials cred = - IdentityPoolCredentials.newBuilder() - .setSubjectTokenSupplier(context -> "testSubjectToken") - .setActorTokenSupplier(context -> "testActorToken") - .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") - .setAudience("//custom.domain.com/pool") - .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") - .setTokenUrl("https://auth.custom-domain.com/v1/token") - .setServiceAccountImpersonationUrl("https://iam.custom-domain.com/v1/generate") - .setHttpTransportFactory(transportFactory) - .build(); - assertNotNull(cred); - } - @Test void fromBuilder_withCustomTransportFactoryAndCertificateConfig_preservesCustomTransportFactory( @TempDir Path tempDir) throws Exception { From e6a4797f8606ca510b25963b0e4c05c1f9798b15 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Sat, 29 Aug 2026 01:48:58 +0000 Subject: [PATCH 21/25] fix(oauth2): validate plain public endpoints when actor tokens are configured - Add validation in IdentityPoolCredentials constructor to reject plain public googleapis.com endpoints when actor tokens are configured - Allow mTLS (.mtls.), Private Service Connect (.p.), and custom domain endpoints - Add comprehensive unit tests covering plain public, mTLS, PSC, and custom domain endpoints --- .../auth/oauth2/IdentityPoolCredentials.java | 35 ++++++ .../oauth2/IdentityPoolCredentialsTest.java | 110 ++++++++++++++++++ 2 files changed, 145 insertions(+) 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 ff8d51688d3f..4b4569304a47 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java @@ -40,6 +40,7 @@ import com.google.errorprone.annotations.CanIgnoreReturnValue; import java.io.IOException; import java.io.ObjectInputStream; +import java.net.URI; import java.security.KeyStore; import java.util.ArrayList; import java.util.Collection; @@ -173,6 +174,40 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { "Actor tokens are only supported for mTLS token exchanges. Please configure a certificate" + " source or MtlsHttpTransportFactory."); } + + if (this.actorTokenSupplier != null) { + validateMtlsEndpoint(getTokenUrl(), "tokenUrl"); + if (getServiceAccountImpersonationUrl() != null) { + validateMtlsEndpoint(getServiceAccountImpersonationUrl(), "serviceAccountImpersonationUrl"); + } + } + } + + private static void validateMtlsEndpoint(@Nullable String url, String fieldName) { + if (url == null) { + return; + } + try { + URI uri = URI.create(url); + String host = uri.getHost(); + if (host != null + && host.endsWith("googleapis.com") + && !host.contains(".mtls.") + && !host.contains(".p.")) { + throw new IllegalArgumentException( + "The " + + fieldName + + " endpoint (" + + url + + ") cannot be used with actor tokens because it is a plain public Google API" + + " endpoint. Please use an mTLS endpoint (e.g. containing '.mtls.') or Private" + + " Service Connect (containing '.p.')."); + } + } catch (IllegalArgumentException e) { + throw e; + } catch (Exception ignored) { + // Ignored: non-parseable URIs will fail downstream on HTTP execute. + } } /** 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 af85ef11267a..364194f7cb53 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java @@ -2696,6 +2696,116 @@ void serialize_deserialize_programmaticFlow_withMtlsTransport_restoresFactoryWit assertNotNull(deserialized.getIdentityPoolActorTokenSupplier()); } + @Test + void builder_actorTokenWithPlainPublicTokenUrl_throwsIllegalArgumentException() throws Exception { + KeyStore keyStore = createPopulatedKeyStore(); + HttpTransportFactory transportFactory = new MtlsHttpTransportFactory(keyStore); + + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(context -> "testSubjectToken") + .setActorTokenSupplier(context -> "testActorToken") + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") + .setTokenUrl("https://sts.googleapis.com/v1/token") + .setHttpTransportFactory(transportFactory) + .build()); + assertTrue(e.getMessage().contains("tokenUrl")); + assertTrue(e.getMessage().contains("plain public Google API endpoint")); + } + + @Test + void builder_actorTokenWithPlainPublicImpersonationUrl_throwsIllegalArgumentException() + throws Exception { + KeyStore keyStore = createPopulatedKeyStore(); + HttpTransportFactory transportFactory = new MtlsHttpTransportFactory(keyStore); + + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(context -> "testSubjectToken") + .setActorTokenSupplier(context -> "testActorToken") + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") + .setTokenUrl(MockExternalAccountCredentialsTransport.STS_MTLS_URL) + .setServiceAccountImpersonationUrl( + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken") + .setHttpTransportFactory(transportFactory) + .build()); + assertTrue(e.getMessage().contains("serviceAccountImpersonationUrl")); + assertTrue(e.getMessage().contains("plain public Google API endpoint")); + } + + @Test + void builder_actorTokenWithMtlsTokenUrl_succeeds() throws Exception { + KeyStore keyStore = createPopulatedKeyStore(); + HttpTransportFactory transportFactory = new MtlsHttpTransportFactory(keyStore); + + IdentityPoolCredentials cred = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(context -> "testSubjectToken") + .setActorTokenSupplier(context -> "testActorToken") + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") + .setTokenUrl(MockExternalAccountCredentialsTransport.STS_MTLS_URL) + .setServiceAccountImpersonationUrl( + "https://iamcredentials.mtls.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken") + .setHttpTransportFactory(transportFactory) + .build(); + assertNotNull(cred); + } + + @Test + void builder_actorTokenWithPscUrls_succeeds() throws Exception { + KeyStore keyStore = createPopulatedKeyStore(); + HttpTransportFactory transportFactory = new MtlsHttpTransportFactory(keyStore); + + IdentityPoolCredentials cred = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(context -> "testSubjectToken") + .setActorTokenSupplier(context -> "testActorToken") + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") + .setTokenUrl("https://sts.p.googleapis.com/v1/token") + .setServiceAccountImpersonationUrl( + "https://iamcredentials.p.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken") + .setHttpTransportFactory(transportFactory) + .build(); + assertNotNull(cred); + } + + @Test + void builder_actorTokenWithCustomDomainUrls_succeeds() throws Exception { + KeyStore keyStore = createPopulatedKeyStore(); + HttpTransportFactory transportFactory = new MtlsHttpTransportFactory(keyStore); + + IdentityPoolCredentials cred = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(context -> "testSubjectToken") + .setActorTokenSupplier(context -> "testActorToken") + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience("//custom.domain.com/pool") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") + .setTokenUrl("https://auth.custom-domain.com/v1/token") + .setServiceAccountImpersonationUrl("https://iam.custom-domain.com/v1/generate") + .setHttpTransportFactory(transportFactory) + .build(); + assertNotNull(cred); + } + @Test void fromBuilder_withCustomTransportFactoryAndCertificateConfig_preservesCustomTransportFactory( @TempDir Path tempDir) throws Exception { From 3f95ff586a340d52dbbd4b30339f5c9b54c215f9 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Tue, 1 Sep 2026 02:07:51 +0000 Subject: [PATCH 22/25] fix(oauth2): implement Serializable in MtlsHttpTransportFactory --- .../auth/mtls/MtlsHttpTransportFactory.java | 5 +-- .../mtls/MtlsHttpTransportFactoryTest.java | 31 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) 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 5f8b7e233bf0..4cbfd70d7993 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 @@ -53,8 +53,9 @@ */ @NullMarked @InternalApi -public class MtlsHttpTransportFactory implements HttpTransportFactory { - @Nullable private final KeyStore mtlsKeyStore; +public class MtlsHttpTransportFactory implements HttpTransportFactory, java.io.Serializable { + private static final long serialVersionUID = 1L; + @Nullable private final transient KeyStore mtlsKeyStore; /** * No-arg constructor required for Java serialization. {@link IdentityPoolCredentials} stores this diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsHttpTransportFactoryTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsHttpTransportFactoryTest.java index f917af477bee..fb3f564c0d54 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsHttpTransportFactoryTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsHttpTransportFactoryTest.java @@ -39,9 +39,13 @@ import com.google.api.client.http.javanet.NetHttpTransport; import com.google.api.client.util.SecurityUtils; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.InputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; import java.io.SequenceInputStream; import java.security.KeyStore; import java.security.cert.Certificate; @@ -120,4 +124,31 @@ void create_returnsNetHttpTransport() throws Exception { NetHttpTransport transport = factory.create(); assertNotNull(transport); } + + @Test + void serialization_roundTrip_hasKeyStoreReturnsFalse() throws Exception { + KeyStore keyStore; + try (InputStream certStream = new FileInputStream(new File(TEST_CERT_PATH)); + InputStream keyStream = new FileInputStream(new File(TEST_KEY_PATH)); + InputStream combined = new SequenceInputStream(certStream, keyStream)) { + keyStore = SecurityUtils.createMtlsKeyStore(combined); + } + + MtlsHttpTransportFactory factory = new MtlsHttpTransportFactory(keyStore); + assertTrue(factory.hasKeyStore()); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ObjectOutputStream oos = new ObjectOutputStream(baos)) { + oos.writeObject(factory); + } + + MtlsHttpTransportFactory deserialized; + try (ObjectInputStream ois = + new ObjectInputStream(new ByteArrayInputStream(baos.toByteArray()))) { + deserialized = (MtlsHttpTransportFactory) ois.readObject(); + } + + assertNotNull(deserialized); + assertFalse(deserialized.hasKeyStore()); + } } From ce341c7d73842fee22cb7d22303a1bb6633f56c7 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Sun, 30 Aug 2026 01:37:28 +0000 Subject: [PATCH 23/25] feat(oauth2): implement IAM impersonation mTLS transport pinning and 401 recovery --- .../google/auth/oauth2/AwsCredentials.java | 8 +- .../oauth2/ExternalAccountCredentials.java | 15 +- .../auth/oauth2/IdentityPoolCredentials.java | 45 ++- .../auth/oauth2/ImpersonatedCredentials.java | 60 ++- .../com/google/auth/oauth2/OAuth2Utils.java | 24 ++ .../auth/oauth2/PluggableAuthCredentials.java | 8 +- .../oauth2/IdentityPoolCredentialsTest.java | 349 ++++++++++++++++++ .../oauth2/ImpersonatedCredentialsTest.java | 46 +++ .../google/auth/oauth2/OAuth2UtilsTest.java | 56 +++ 9 files changed, 581 insertions(+), 30 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java index ce8ed886f608..45306fb1419d 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java @@ -119,6 +119,11 @@ public class AwsCredentials extends ExternalAccountCredentials { @Override public AccessToken refreshAccessToken() throws IOException { + return refreshAccessToken(this.transportFactory); + } + + @Override + public AccessToken refreshAccessToken(HttpTransportFactory transportFactory) throws IOException { StsTokenExchangeRequest.Builder stsTokenExchangeRequest = StsTokenExchangeRequest.newBuilder(retrieveSubjectToken(), getSubjectTokenType()) .setAudience(getAudience()); @@ -129,7 +134,8 @@ public AccessToken refreshAccessToken() throws IOException { stsTokenExchangeRequest.setScopes(new ArrayList<>(scopes)); } - return exchangeExternalCredentialForAccessToken(stsTokenExchangeRequest.build()); + return exchangeExternalCredentialForAccessToken( + stsTokenExchangeRequest.build(), transportFactory); } @Override diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java index 0cc8e3847594..4ad9979c14a7 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 @@ -525,6 +525,19 @@ private boolean shouldBuildImpersonatedCredential() { return this.serviceAccountImpersonationUrl != null && this.impersonatedCredentials == null; } + /** + * Refreshes the access token using the specified transport factory. Default implementation + * delegates to {@link #refreshAccessToken()}. Subclasses should override this method if they + * support transport pinning per refresh cycle. + * + * @param transportFactory the HTTP transport factory to use for this refresh cycle + * @return the refreshed access token + * @throws IOException if the token refresh fails + */ + public AccessToken refreshAccessToken(HttpTransportFactory transportFactory) throws IOException { + return refreshAccessToken(); + } + /** * Exchanges the external credential for a Google Cloud access token. * @@ -555,7 +568,7 @@ protected AccessToken exchangeExternalCredentialForAccessToken( this.impersonatedCredentials = this.buildImpersonatedCredentials(); } if (this.impersonatedCredentials != null) { - return this.impersonatedCredentials.refreshAccessToken(); + return this.impersonatedCredentials.refreshAccessToken(cycleTransportFactory); } StsRequestHandler.Builder requestHandler = 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 4b4569304a47..3e57ae3e341b 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 @@ -228,9 +228,24 @@ public AccessToken refreshAccessToken() throws IOException { HttpTransportFactory cycleTransportFactory = this.transportFactory; if (this.x509Provider != null) { KeyStore pinnedKeyStore = this.x509Provider.getKeyStore(); - cycleTransportFactory = new MtlsHttpTransportFactory(pinnedKeyStore); + if (this.transportFactory == null + || this.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY + || this.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory + || this.transportFactory instanceof MtlsHttpTransportFactory) { + cycleTransportFactory = new MtlsHttpTransportFactory(pinnedKeyStore); + } } + return refreshWithRetry(cycleTransportFactory, true); + } + + @Override + public AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) + throws IOException { + return refreshWithRetry(cycleTransportFactory, false); + } + private AccessToken refreshWithRetry( + HttpTransportFactory cycleTransportFactory, boolean allowRetry) throws IOException { // Read subject and actor tokens, atomically if from the same file supplier. String subjectToken; String actorToken = null; @@ -264,20 +279,36 @@ public AccessToken refreshAccessToken() throws IOException { try { return exchangeExternalCredentialForAccessToken( stsTokenExchangeRequest.build(), cycleTransportFactory); - } catch (OAuthException e) { - if (e.getHttpStatusCode() == 401 && this.x509Provider != null) { + } catch (Exception e) { + if (allowRetry && OAuth2Utils.isUnauthorizedException(e) && 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); + HttpTransportFactory retryTransportFactory = cycleTransportFactory; + if (this.transportFactory == null + || this.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY + || this.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory + || this.transportFactory instanceof MtlsHttpTransportFactory) { + retryTransportFactory = new MtlsHttpTransportFactory(freshKeyStore); + } + return refreshWithRetry(retryTransportFactory, false); } catch (IOException retryException) { retryException.addSuppressed(e); throw retryException; + } catch (Exception retryException) { + IOException ioException = + new IOException("Failed to reload certificate on retry", retryException); + ioException.addSuppressed(e); + throw ioException; } } - throw e; + if (e instanceof IOException) { + throw (IOException) e; + } + if (e instanceof RuntimeException) { + throw (RuntimeException) e; + } + throw new IOException(e); } } diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java index 3cd65471b986..bc2badde0ad9 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java @@ -46,6 +46,7 @@ import com.google.api.client.util.GenericData; import com.google.api.core.ObsoleteApi; import com.google.auth.CredentialTypeForMetrics; +import com.google.auth.Credentials; import com.google.auth.ServiceAccountSigner; import com.google.auth.http.HttpCredentialsAdapter; import com.google.auth.http.HttpTransportFactory; @@ -580,31 +581,47 @@ public String getUniverseDomain() throws IOException { @Override public AccessToken refreshAccessToken() throws IOException { - if (this.sourceCredentials.getAccessToken() == null) { - // Apply the `CLOUD_PLATFORM_SCOPE` to access the iamcredentials endpoint - this.sourceCredentials = - this.sourceCredentials.createScoped( - Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE)); - } - - // skip for SA with SSJ flow because it uses self-signed JWT - // and will get refreshed at initialize request step - // run for other source credential types or SA with GDU assert flow - if (!(this.sourceCredentials instanceof ServiceAccountCredentials) - || (isDefaultUniverseDomain() - && ((ServiceAccountCredentials) this.sourceCredentials) - .shouldUseAssertionFlowForGdu())) { - try { - this.sourceCredentials.refreshIfExpired(); - } catch (IOException e) { - throw new IOException("Unable to refresh sourceCredentials", e); + return refreshAccessToken(this.transportFactory); + } + + AccessToken refreshAccessToken(HttpTransportFactory transportFactory) throws IOException { + HttpCredentialsAdapter adapter; + if (this.sourceCredentials instanceof ExternalAccountCredentials) { + AccessToken intermediateAccessToken = + ((ExternalAccountCredentials) this.sourceCredentials) + .refreshAccessToken(transportFactory); + Credentials authCredentials = + intermediateAccessToken != null + ? OAuth2Credentials.create(intermediateAccessToken) + : this.sourceCredentials; + adapter = new HttpCredentialsAdapter(authCredentials); + } else { + if (this.sourceCredentials.getAccessToken() == null) { + // Apply the `CLOUD_PLATFORM_SCOPE` to access the iamcredentials endpoint + this.sourceCredentials = + this.sourceCredentials.createScoped( + Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE)); + } + + // skip for SA with SSJ flow because it uses self-signed JWT + // and will get refreshed at initialize request step + // run for other source credential types or SA with GDU assert flow + if (!(this.sourceCredentials instanceof ServiceAccountCredentials) + || (isDefaultUniverseDomain() + && ((ServiceAccountCredentials) this.sourceCredentials) + .shouldUseAssertionFlowForGdu())) { + try { + this.sourceCredentials.refreshIfExpired(); + } catch (IOException e) { + throw new IOException("Unable to refresh sourceCredentials", e); + } } + adapter = new HttpCredentialsAdapter(sourceCredentials); } - HttpTransport httpTransport = this.transportFactory.create(); + HttpTransport httpTransport = transportFactory.create(); JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY); - HttpCredentialsAdapter adapter = new HttpCredentialsAdapter(sourceCredentials); HttpRequestFactory requestFactory = httpTransport.createRequestFactory(); String endpointUrl = @@ -627,6 +644,9 @@ public AccessToken refreshAccessToken() throws IOException { // Client Library Debug Logging via LoggingUtils is used instead. request.setLoggingEnabled(false); adapter.initialize(request); + if (this.sourceCredentials instanceof ExternalAccountCredentials) { + request.setUnsuccessfulResponseHandler(null); + } request.setParser(parser); MetricsUtils.setMetricsHeader( request, diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java index f740dd980e73..61d58bc55316 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java @@ -32,6 +32,7 @@ package com.google.auth.oauth2; import com.google.api.client.http.HttpHeaders; +import com.google.api.client.http.HttpResponseException; import com.google.api.client.http.HttpTransport; import com.google.api.client.http.javanet.NetHttpTransport; import com.google.api.client.json.GenericJson; @@ -324,5 +325,28 @@ static String generateBasicAuthHeader(String username, String password) { return "Basic " + encodedCredentials; } + /** + * Returns whether the given throwable or any exception in its causal chain represents a 401 + * Unauthorized error (either an {@link OAuthException} or {@link HttpResponseException} with + * status code 401). + */ + static boolean isUnauthorizedException(@Nullable Throwable t) { + while (t != null) { + if (t instanceof OAuthException && ((OAuthException) t).getHttpStatusCode() == 401) { + return true; + } + if (t instanceof HttpResponseException + && ((HttpResponseException) t).getStatusCode() == 401) { + return true; + } + Throwable cause = t.getCause(); + if (cause == t) { + break; + } + t = cause; + } + return false; + } + private OAuth2Utils() {} } diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java index 7e02aee07cd9..602c4cc43413 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java @@ -121,6 +121,11 @@ public class PluggableAuthCredentials extends ExternalAccountCredentials { @Override public AccessToken refreshAccessToken() throws IOException { + return refreshAccessToken(this.transportFactory); + } + + @Override + public AccessToken refreshAccessToken(HttpTransportFactory transportFactory) throws IOException { String credential = retrieveSubjectToken(); StsTokenExchangeRequest.Builder stsTokenExchangeRequest = StsTokenExchangeRequest.newBuilder(credential, getSubjectTokenType()) @@ -130,7 +135,8 @@ public AccessToken refreshAccessToken() throws IOException { if (scopes != null && !scopes.isEmpty()) { stsTokenExchangeRequest.setScopes(new ArrayList<>(scopes)); } - return exchangeExternalCredentialForAccessToken(stsTokenExchangeRequest.build()); + return exchangeExternalCredentialForAccessToken( + stsTokenExchangeRequest.build(), transportFactory); } /** 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 364194f7cb53..10c35c4d2e36 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java @@ -45,7 +45,13 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import com.google.api.client.http.HttpTransport; +import com.google.api.client.http.LowLevelHttpRequest; +import com.google.api.client.http.LowLevelHttpResponse; import com.google.api.client.json.GenericJson; +import com.google.api.client.json.Json; +import com.google.api.client.testing.http.MockHttpTransport; +import com.google.api.client.testing.http.MockLowLevelHttpRequest; +import com.google.api.client.testing.http.MockLowLevelHttpResponse; import com.google.api.client.util.Clock; import com.google.api.client.util.SecurityUtils; import com.google.auth.TestUtils; @@ -68,6 +74,7 @@ import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; import java.security.cert.CertificateException; +import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; import java.util.Collections; @@ -3055,4 +3062,346 @@ java.util.List getCapturedFactories() { return capturedFactories; } } + + // ================================================================================== + // Section: IAM Impersonation mTLS Transport Pinning & Retry Tests + // ================================================================================== + + @Test + void refreshAccessToken_impersonation_pinsTransportForBothStsAndIam() throws Exception { + KeyStore ks = createPopulatedKeyStore(); + AtomicInteger getKeyStoreCallCount = new AtomicInteger(0); + X509Provider x509Provider = + new X509Provider() { + @Override + public KeyStore getKeyStore() { + getKeyStoreCallCount.incrementAndGet(); + return ks; + } + }; + + AtomicInteger stsCallCount = new AtomicInteger(0); + AtomicInteger iamCallCount = new AtomicInteger(0); + List iamAuthHeaders = Collections.synchronizedList(new ArrayList<>()); + + MockHttpTransport mockTransport = + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() { + if (url.contains("/v1/token")) { + int count = stsCallCount.incrementAndGet(); + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("access_token", "intermediate-sts-token-" + count); + response.put("token_type", "Bearer"); + response.put("expires_in", 3600); + response.put( + "issued_token_type", "urn:ietf:params:oauth:token-type:access_token"); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } else if (url.contains(":generateAccessToken")) { + int count = iamCallCount.incrementAndGet(); + iamAuthHeaders.add(getFirstHeaderValue("Authorization")); + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("accessToken", "final-iam-token-" + count); + response.put("expireTime", "2030-01-01T00:00:00Z"); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } + return new MockLowLevelHttpResponse().setStatusCode(404); + } + }; + } + }; + + HttpTransportFactory transportFactory = () -> mockTransport; + + IdentityPoolCredentials credential = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(x509Provider) + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .setServiceAccountImpersonationUrl( + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken") + .setHttpTransportFactory(transportFactory) + .build(); + + AccessToken token = credential.refreshAccessToken(); + assertNotNull(token); + assertEquals("final-iam-token-1", token.getTokenValue()); + + // getKeyStore() should be called exactly once per refresh cycle. + assertEquals(1, getKeyStoreCallCount.get()); + + // Both STS and IAM should have been called once on the transport. + assertEquals(1, stsCallCount.get()); + assertEquals(1, iamCallCount.get()); + + // Verify the IAM request received Authorization: Bearer . + assertEquals(1, iamAuthHeaders.size()); + assertEquals("Bearer intermediate-sts-token-1", iamAuthHeaders.get(0)); + } + + @Test + void refreshAccessToken_impersonation_401OnIam_retriesBothStsAndIamWithFreshCert() + throws Exception { + KeyStore ks1 = createPopulatedKeyStore(); + KeyStore ks2 = createPopulatedKeyStore(); + AtomicInteger getKeyStoreCallCount = new AtomicInteger(0); + X509Provider x509Provider = + new X509Provider() { + @Override + public KeyStore getKeyStore() { + int count = getKeyStoreCallCount.incrementAndGet(); + return count == 1 ? ks1 : ks2; + } + }; + + AtomicInteger stsCallCount = new AtomicInteger(0); + AtomicInteger iamCallCount = new AtomicInteger(0); + List iamAuthHeaders = Collections.synchronizedList(new ArrayList<>()); + + MockHttpTransport mockTransport = + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() { + if (url.contains("/v1/token")) { + int count = stsCallCount.incrementAndGet(); + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("access_token", "intermediate-sts-token-" + count); + response.put("token_type", "Bearer"); + response.put("expires_in", 3600); + response.put( + "issued_token_type", "urn:ietf:params:oauth:token-type:access_token"); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } else if (url.contains(":generateAccessToken")) { + int count = iamCallCount.incrementAndGet(); + iamAuthHeaders.add(getFirstHeaderValue("Authorization")); + if (count == 1) { + return new MockLowLevelHttpResponse() + .setStatusCode(401) + .setContentType(Json.MEDIA_TYPE) + .setContent("{\"error\": {\"code\": 401, \"message\": \"Unauthorized\"}}"); + } + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("accessToken", "final-iam-token-" + count); + response.put("expireTime", "2030-01-01T00:00:00Z"); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } + return new MockLowLevelHttpResponse().setStatusCode(404); + } + }; + } + }; + + HttpTransportFactory transportFactory = () -> mockTransport; + + IdentityPoolCredentials credential = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(x509Provider) + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .setServiceAccountImpersonationUrl( + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken") + .setHttpTransportFactory(transportFactory) + .build(); + + AccessToken token = credential.refreshAccessToken(); + assertNotNull(token); + assertEquals("final-iam-token-2", token.getTokenValue()); + + // 1st call for initial cycle + 2nd call on 401 retry. + assertEquals(2, getKeyStoreCallCount.get()); + + // STS called twice (once on original cycle, once on retry with fresh cert). + assertEquals(2, stsCallCount.get()); + + // IAM called twice (once failed with 401, once succeeded on retry). + assertEquals(2, iamCallCount.get()); + + // IAM retry should have used the new intermediate STS token. + assertEquals(2, iamAuthHeaders.size()); + assertEquals("Bearer intermediate-sts-token-1", iamAuthHeaders.get(0)); + assertEquals("Bearer intermediate-sts-token-2", iamAuthHeaders.get(1)); + } + + @Test + void refreshAccessToken_impersonation_401OnIam_certLoadFailure_preservesOriginalError() + throws Exception { + KeyStore ks = createPopulatedKeyStore(); + AtomicInteger getKeyStoreCallCount = new AtomicInteger(0); + X509Provider x509Provider = + new X509Provider() { + @Override + public KeyStore getKeyStore() throws IOException { + int count = getKeyStoreCallCount.incrementAndGet(); + if (count == 1) { + return ks; + } + throw new IOException("Cert rotation reload disk error"); + } + }; + + MockHttpTransport mockTransport = + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() { + if (url.contains("/v1/token")) { + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("access_token", "intermediate-sts-token-1"); + response.put("token_type", "Bearer"); + response.put("expires_in", 3600); + response.put( + "issued_token_type", "urn:ietf:params:oauth:token-type:access_token"); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } else if (url.contains(":generateAccessToken")) { + return new MockLowLevelHttpResponse() + .setStatusCode(401) + .setContentType(Json.MEDIA_TYPE) + .setContent("{\"error\": {\"code\": 401, \"message\": \"Unauthorized\"}}"); + } + return new MockLowLevelHttpResponse().setStatusCode(404); + } + }; + } + }; + + HttpTransportFactory transportFactory = () -> mockTransport; + + IdentityPoolCredentials credential = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(x509Provider) + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .setServiceAccountImpersonationUrl( + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken") + .setHttpTransportFactory(transportFactory) + .build(); + + IOException thrown = assertThrows(IOException.class, credential::refreshAccessToken); + assertEquals("Cert rotation reload disk error", thrown.getMessage()); + assertEquals(2, getKeyStoreCallCount.get()); + + Throwable[] suppressed = thrown.getSuppressed(); + assertTrue(suppressed.length > 0); + assertTrue(OAuth2Utils.isUnauthorizedException(suppressed[0])); + } + + @Test + void refreshAccessToken_impersonation_certRotationBetweenCycles_usesNewCert() throws Exception { + KeyStore ksA = createPopulatedKeyStore(); + KeyStore ksB = createPopulatedKeyStore(); + AtomicInteger getKeyStoreCallCount = new AtomicInteger(0); + X509Provider x509Provider = + new X509Provider() { + @Override + public KeyStore getKeyStore() { + int count = getKeyStoreCallCount.incrementAndGet(); + return count == 1 ? ksA : ksB; + } + }; + + AtomicInteger stsCallCount = new AtomicInteger(0); + AtomicInteger iamCallCount = new AtomicInteger(0); + List iamAuthHeaders = Collections.synchronizedList(new ArrayList<>()); + + MockHttpTransport mockTransport = + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() { + if (url.contains("/v1/token")) { + int count = stsCallCount.incrementAndGet(); + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("access_token", "intermediate-sts-token-" + count); + response.put("token_type", "Bearer"); + response.put("expires_in", 3600); + response.put( + "issued_token_type", "urn:ietf:params:oauth:token-type:access_token"); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } else if (url.contains(":generateAccessToken")) { + int count = iamCallCount.incrementAndGet(); + iamAuthHeaders.add(getFirstHeaderValue("Authorization")); + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("accessToken", "final-iam-token-" + count); + response.put("expireTime", "2030-01-01T00:00:00Z"); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } + return new MockLowLevelHttpResponse().setStatusCode(404); + } + }; + } + }; + + HttpTransportFactory transportFactory = () -> mockTransport; + + IdentityPoolCredentials credential = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(x509Provider) + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .setServiceAccountImpersonationUrl( + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken") + .setHttpTransportFactory(transportFactory) + .build(); + + // Refresh cycle 1 + AccessToken token1 = credential.refreshAccessToken(); + assertNotNull(token1); + assertEquals("final-iam-token-1", token1.getTokenValue()); + assertEquals(1, getKeyStoreCallCount.get()); + assertEquals(1, stsCallCount.get()); + assertEquals(1, iamCallCount.get()); + assertEquals("Bearer intermediate-sts-token-1", iamAuthHeaders.get(0)); + + // Refresh cycle 2 + AccessToken token2 = credential.refreshAccessToken(); + assertNotNull(token2); + assertEquals("final-iam-token-2", token2.getTokenValue()); + assertEquals(2, getKeyStoreCallCount.get()); + assertEquals(2, stsCallCount.get()); + assertEquals(2, iamCallCount.get()); + assertEquals("Bearer intermediate-sts-token-2", iamAuthHeaders.get(1)); + } } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java index cc95fbe5b575..758c05ed5c89 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java @@ -1373,4 +1373,50 @@ static InputStream writeImpersonationCredentialsStream( buildImpersonationCredentialsJson(impersonationUrl, delegates, quotaProjectId, scopes); return TestUtils.jsonToInputStream(json); } + + @Test + void refreshAccessToken_withExternalAccountSource_usesProvidedTransportFactory() + throws IOException { + MockIAMCredentialsServiceTransportFactory customTransportFactory = + new MockIAMCredentialsServiceTransportFactory(); + customTransportFactory.getTransport().setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL); + customTransportFactory.getTransport().setAccessToken("final-iam-token"); + customTransportFactory.getTransport().setExpireTime(getDefaultExpireTime()); + customTransportFactory + .getTransport() + .addStatusCodeAndMessage(HttpStatusCodes.STATUS_CODE_OK, ""); + + java.util.concurrent.atomic.AtomicReference capturedSourceTransport = + new java.util.concurrent.atomic.AtomicReference<>(); + ExternalAccountCredentials mockExternalAccountCredentials = + new IdentityPoolCredentials( + IdentityPoolCredentials.newBuilder() + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setSubjectTokenSupplier(context -> "token") + .setTokenUrl("https://sts.googleapis.com/v1/token")) { + @Override + public AccessToken refreshAccessToken(HttpTransportFactory transportFactory) { + capturedSourceTransport.set(transportFactory); + return new AccessToken("intermediate-sts-token-xyz", null); + } + }; + + ImpersonatedCredentials credentials = + ImpersonatedCredentials.newBuilder() + .setSourceCredentials(mockExternalAccountCredentials) + .setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL) + .setScopes(IMMUTABLE_SCOPES_LIST) + .setLifetime(VALID_LIFETIME) + .setHttpTransportFactory(mockTransportFactory) + .build(); + + AccessToken token = credentials.refreshAccessToken(customTransportFactory); + assertEquals("final-iam-token", token.getTokenValue()); + assertSame(customTransportFactory, capturedSourceTransport.get()); + assertEquals( + "Bearer intermediate-sts-token-xyz", + customTransportFactory.getTransport().getRequest().getFirstHeaderValue("Authorization")); + } } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuth2UtilsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuth2UtilsTest.java index f540ac41d2b9..e043b235c50c 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuth2UtilsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuth2UtilsTest.java @@ -98,4 +98,60 @@ void testNullPassword_throws() { generateBasicAuthHeader(username, password); }); } + + @Test + void isUnauthorizedException_null_returnsFalse() { + org.junit.jupiter.api.Assertions.assertFalse(OAuth2Utils.isUnauthorizedException(null)); + } + + @Test + void isUnauthorizedException_genericIOException_returnsFalse() { + org.junit.jupiter.api.Assertions.assertFalse( + OAuth2Utils.isUnauthorizedException(new java.io.IOException("Network error"))); + } + + @Test + void isUnauthorizedException_oauthException401_returnsTrue() { + OAuthException ex = new OAuthException("invalid_client", "Unauthorized", null, 401); + org.junit.jupiter.api.Assertions.assertTrue(OAuth2Utils.isUnauthorizedException(ex)); + } + + @Test + void isUnauthorizedException_oauthExceptionNon401_returnsFalse() { + OAuthException ex = new OAuthException("bad_request", "Bad Request", null, 400); + org.junit.jupiter.api.Assertions.assertFalse(OAuth2Utils.isUnauthorizedException(ex)); + } + + @Test + void isUnauthorizedException_httpResponseException401_returnsTrue() { + com.google.api.client.http.HttpResponseException ex = + new com.google.api.client.http.HttpResponseException.Builder( + 401, "Unauthorized", new com.google.api.client.http.HttpHeaders()) + .build(); + org.junit.jupiter.api.Assertions.assertTrue(OAuth2Utils.isUnauthorizedException(ex)); + } + + @Test + void isUnauthorizedException_httpResponseExceptionNon401_returnsFalse() { + com.google.api.client.http.HttpResponseException ex = + new com.google.api.client.http.HttpResponseException.Builder( + 403, "Forbidden", new com.google.api.client.http.HttpHeaders()) + .build(); + org.junit.jupiter.api.Assertions.assertFalse(OAuth2Utils.isUnauthorizedException(ex)); + } + + @Test + void isUnauthorizedException_wrappedInExceptionChain_returnsTrue() { + OAuthException oauthEx = new OAuthException("invalid_client", "Unauthorized", null, 401); + java.io.IOException wrapped = new java.io.IOException("Wrapped failure", oauthEx); + org.junit.jupiter.api.Assertions.assertTrue(OAuth2Utils.isUnauthorizedException(wrapped)); + + com.google.api.client.http.HttpResponseException httpEx = + new com.google.api.client.http.HttpResponseException.Builder( + 401, "Unauthorized", new com.google.api.client.http.HttpHeaders()) + .build(); + java.io.IOException wrappedHttp = + new java.io.IOException("Outer", new java.io.IOException("Inner", httpEx)); + org.junit.jupiter.api.Assertions.assertTrue(OAuth2Utils.isUnauthorizedException(wrappedHttp)); + } } From 2a6a58b162a23145759f525f2ed01c19ae583bd6 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Sat, 29 Aug 2026 03:01:32 +0000 Subject: [PATCH 24/25] test(oauth2): add mTLS in-process socket tests and live GCP WIF integration tests - Add MtlsPipelineLocalTest providing hermetic in-process socket tests over JDK HttpsServer with client certificate authentication (peer cert verification, 401 retry with cert rotation, concurrent refreshes, atomic token read, IAM impersonation mTLS transport pinning, and 401 retry with fresh cert). - Add ITWorkloadIdentityFederationTest extensions for certificate-bound workload + actor token JSON config and programmatic mTLS token suppliers covering both direct STS and Service Account Impersonation. - Fix OAuthException to safely handle null HTTP error response content. --- .../google/auth/oauth2/OAuthException.java | 36 +- .../ITWorkloadIdentityFederationTest.java | 228 ++++ .../auth/oauth2/MtlsPipelineLocalTest.java | 1049 +++++++++++++++++ .../auth/oauth2/OAuthExceptionTest.java | 66 ++ 4 files changed, 1367 insertions(+), 12 deletions(-) create mode 100644 google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MtlsPipelineLocalTest.java diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuthException.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/OAuthException.java index 76d7fc60aa3c..d7c01b0df54d 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 @@ -11,7 +11,6 @@ * 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. @@ -98,18 +97,31 @@ int getHttpStatusCode() { static OAuthException createFromHttpResponseException(HttpResponseException e) throws IOException { - JsonParser parser = OAuth2Utils.JSON_FACTORY.createJsonParser((e).getContent()); - GenericJson errorResponse = parser.parseAndClose(GenericJson.class); - - String errorCode = (String) errorResponse.get("error"); - String errorDescription = null; - String errorUri = null; - if (errorResponse.containsKey("error_description")) { - errorDescription = (String) errorResponse.get("error_description"); + String content = e.getContent(); + if (content == null || content.trim().isEmpty()) { + return new OAuthException( + "http_error_" + e.getStatusCode(), e.getStatusMessage(), null, e.getStatusCode()); } - if (errorResponse.containsKey("error_uri")) { - errorUri = (String) errorResponse.get("error_uri"); + try { + JsonParser parser = OAuth2Utils.JSON_FACTORY.createJsonParser(content); + GenericJson errorResponse = parser.parseAndClose(GenericJson.class); + + String errorCode = (String) errorResponse.get("error"); + if (errorCode == null) { + errorCode = "http_error_" + e.getStatusCode(); + } + String errorDescription = null; + String errorUri = null; + if (errorResponse.containsKey("error_description")) { + errorDescription = (String) errorResponse.get("error_description"); + } + if (errorResponse.containsKey("error_uri")) { + errorUri = (String) errorResponse.get("error_uri"); + } + return new OAuthException(errorCode, errorDescription, errorUri, e.getStatusCode()); + } catch (Exception parseException) { + return new OAuthException( + "http_error_" + e.getStatusCode(), e.getStatusMessage(), null, e.getStatusCode()); } - return new OAuthException(errorCode, errorDescription, errorUri, e.getStatusCode()); } } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ITWorkloadIdentityFederationTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ITWorkloadIdentityFederationTest.java index 0f1cdd3092f6..4e7f426dd164 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ITWorkloadIdentityFederationTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ITWorkloadIdentityFederationTest.java @@ -31,6 +31,7 @@ package com.google.auth.oauth2; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; @@ -45,13 +46,19 @@ import com.google.api.client.json.JsonObjectParser; import com.google.api.client.json.gson.GsonFactory; import com.google.api.client.util.GenericData; +import com.google.api.client.util.SecurityUtils; import com.google.auth.http.HttpCredentialsAdapter; +import com.google.auth.http.HttpTransportFactory; +import com.google.auth.mtls.MtlsHttpTransportFactory; import com.google.auth.oauth2.ExternalAccountCredentials.SubjectTokenTypes; import java.io.ByteArrayInputStream; import java.io.File; import java.io.FileInputStream; import java.io.IOException; +import java.io.InputStream; +import java.io.SequenceInputStream; import java.nio.charset.StandardCharsets; +import java.security.KeyStore; import java.time.Instant; import java.util.HashMap; import java.util.Map; @@ -281,6 +288,227 @@ void identityPoolCredentials_withProgrammaticAuth() throws IOException { callGcs(identityPoolCredentials); } + /** + * IdentityPoolCredentials (OIDC provider with certificate-bound workload and actor token): Uses + * the service account to generate Google ID tokens for subject and actor tokens. Writes both + * tokens to a temporary JSON file with subject_token and actor_token field names. Configures + * certificate_config_location pointing to the certificate config. Exchanges the tokens over the + * mTLS STS endpoint (https://sts.mtls.googleapis.com/v1/token) and calls GCS. + */ + @Test + void identityPoolCredentials_withCertificateBoundWorkloadAndActorToken() throws IOException { + String subjectToken = generateGoogleIdToken(OIDC_AUDIENCE); + String actorToken = generateGoogleIdToken(OIDC_AUDIENCE); + + File tokenFile = + File.createTempFile( + "ITWorkloadIdentityFederation_cert_actor", /* suffix= */ null, /* directory= */ null); + tokenFile.deleteOnExit(); + + GenericJson tokenJson = new GenericJson(); + tokenJson.setFactory(OAuth2Utils.JSON_FACTORY); + tokenJson.put("subject_token", subjectToken); + tokenJson.put("actor_token", actorToken); + + OAuth2Utils.writeInputStreamToFile( + new ByteArrayInputStream(tokenJson.toPrettyString().getBytes(StandardCharsets.UTF_8)), + tokenFile.getAbsolutePath()); + + GenericJson config = new GenericJson(); + config.put("type", "external_account"); + config.put("audience", OIDC_AUDIENCE); + config.put("subject_token_type", "urn:ietf:params:oauth:token-type:jwt"); + config.put("actor_token_type", "urn:ietf:params:oauth:token-type:jwt"); + config.put("token_url", "https://sts.mtls.googleapis.com/v1/token"); + config.put( + "service_account_impersonation_url", + String.format( + "https://iamcredentials.mtls.googleapis.com/v1/projects/-/serviceAccounts/%s:generateAccessToken", + clientEmail)); + + GenericJson credentialSource = new GenericJson(); + credentialSource.put("file", tokenFile.getAbsolutePath()); + + GenericJson format = new GenericJson(); + format.put("type", "json"); + format.put("subject_token_field_name", "subject_token"); + format.put("actor_token_field_name", "actor_token"); + credentialSource.put("format", format); + + GenericJson certificate = new GenericJson(); + certificate.put("certificate_config_location", "testresources/mtls/certificate_config.json"); + credentialSource.put("certificate", certificate); + + config.put("credential_source", credentialSource); + + IdentityPoolCredentials identityPoolCredentials = + (IdentityPoolCredentials) + ExternalAccountCredentials.fromJson(config, OAuth2Utils.HTTP_TRANSPORT_FACTORY); + + callGcs(identityPoolCredentials); + } + + /** + * IdentityPoolCredentials (OIDC provider with programmatic mTLS and actor token): Uses the + * service account to generate Google ID tokens for subject and actor tokens via suppliers. + * Configures mTLS transport using MtlsHttpTransportFactory with KeyStore loaded from test + * certificate resources. Exchanges the tokens over mTLS STS endpoint and calls GCS. + */ + @Test + void identityPoolCredentials_withProgrammaticMtlsAndActorToken() throws Exception { + IdentityPoolSubjectTokenSupplier tokenSupplier = + (ExternalAccountSupplierContext context) -> { + try { + return generateGoogleIdToken(OIDC_AUDIENCE); + } catch (IOException e) { + throw new RuntimeException(e); + } + }; + + IdentityPoolActorTokenSupplier actorSupplier = + (ExternalAccountSupplierContext context) -> { + try { + return generateGoogleIdToken(OIDC_AUDIENCE); + } catch (IOException e) { + throw new RuntimeException(e); + } + }; + + KeyStore keyStore; + try (InputStream certStream = + new FileInputStream(new File("testresources/mtls/test_cert.pem")); + InputStream keyStream = new FileInputStream(new File("testresources/mtls/test_key.pem")); + InputStream combined = new SequenceInputStream(certStream, keyStream)) { + keyStore = SecurityUtils.createMtlsKeyStore(combined); + } + HttpTransportFactory transportFactory = new MtlsHttpTransportFactory(keyStore); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(tokenSupplier) + .setActorTokenSupplier(actorSupplier) + .setActorTokenType(SubjectTokenTypes.JWT.value) + .setAudience(OIDC_AUDIENCE) + .setSubjectTokenType(SubjectTokenTypes.JWT) + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .setServiceAccountImpersonationUrl( + String.format( + "https://iamcredentials.mtls.googleapis.com/v1/projects/-/serviceAccounts/%s:generateAccessToken", + clientEmail)) + .setHttpTransportFactory(transportFactory) + .build(); + + callGcs(credentials); + } + + /** + * IdentityPoolCredentials (OIDC provider with certificate-bound workload and actor token, direct + * STS): Exchanges tokens directly over the mTLS STS endpoint + * (https://sts.mtls.googleapis.com/v1/token) without service account impersonation. + */ + @Test + void identityPoolCredentials_directSts_withCertificateBoundWorkloadAndActorToken() + throws IOException { + String subjectToken = generateGoogleIdToken(OIDC_AUDIENCE); + String actorToken = generateGoogleIdToken(OIDC_AUDIENCE); + + File tokenFile = + File.createTempFile( + "ITWorkloadIdentityFederation_direct_cert_actor", + /* suffix= */ null, + /* directory= */ null); + tokenFile.deleteOnExit(); + + GenericJson tokenJson = new GenericJson(); + tokenJson.setFactory(OAuth2Utils.JSON_FACTORY); + tokenJson.put("subject_token", subjectToken); + tokenJson.put("actor_token", actorToken); + + OAuth2Utils.writeInputStreamToFile( + new ByteArrayInputStream(tokenJson.toPrettyString().getBytes(StandardCharsets.UTF_8)), + tokenFile.getAbsolutePath()); + + GenericJson config = new GenericJson(); + config.put("type", "external_account"); + config.put("audience", OIDC_AUDIENCE); + config.put("subject_token_type", "urn:ietf:params:oauth:token-type:jwt"); + config.put("actor_token_type", "urn:ietf:params:oauth:token-type:jwt"); + config.put("token_url", "https://sts.mtls.googleapis.com/v1/token"); + + GenericJson credentialSource = new GenericJson(); + credentialSource.put("file", tokenFile.getAbsolutePath()); + + GenericJson format = new GenericJson(); + format.put("type", "json"); + format.put("subject_token_field_name", "subject_token"); + format.put("actor_token_field_name", "actor_token"); + credentialSource.put("format", format); + + GenericJson certificate = new GenericJson(); + certificate.put("certificate_config_location", "testresources/mtls/certificate_config.json"); + credentialSource.put("certificate", certificate); + + config.put("credential_source", credentialSource); + + IdentityPoolCredentials identityPoolCredentials = + (IdentityPoolCredentials) + ExternalAccountCredentials.fromJson(config, OAuth2Utils.HTTP_TRANSPORT_FACTORY); + + AccessToken accessToken = identityPoolCredentials.refreshAccessToken(); + assertNotNull(accessToken); + assertNotNull(accessToken.getTokenValue()); + } + + /** + * IdentityPoolCredentials (OIDC provider with programmatic mTLS and actor token, direct STS): + * Uses suppliers for subject and actor tokens, configuring MtlsHttpTransportFactory. Exchanges + * directly over mTLS STS endpoint without service account impersonation. + */ + @Test + void identityPoolCredentials_directSts_withProgrammaticMtlsAndActorToken() throws Exception { + IdentityPoolSubjectTokenSupplier tokenSupplier = + (ExternalAccountSupplierContext context) -> { + try { + return generateGoogleIdToken(OIDC_AUDIENCE); + } catch (IOException e) { + throw new RuntimeException(e); + } + }; + + IdentityPoolActorTokenSupplier actorSupplier = + (ExternalAccountSupplierContext context) -> { + try { + return generateGoogleIdToken(OIDC_AUDIENCE); + } catch (IOException e) { + throw new RuntimeException(e); + } + }; + + KeyStore keyStore; + try (InputStream certStream = + new FileInputStream(new File("testresources/mtls/test_cert.pem")); + InputStream keyStream = new FileInputStream(new File("testresources/mtls/test_key.pem")); + InputStream combined = new SequenceInputStream(certStream, keyStream)) { + keyStore = SecurityUtils.createMtlsKeyStore(combined); + } + HttpTransportFactory transportFactory = new MtlsHttpTransportFactory(keyStore); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(tokenSupplier) + .setActorTokenSupplier(actorSupplier) + .setActorTokenType(SubjectTokenTypes.JWT.value) + .setAudience(OIDC_AUDIENCE) + .setSubjectTokenType(SubjectTokenTypes.JWT) + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .setHttpTransportFactory(transportFactory) + .build(); + + AccessToken accessToken = credentials.refreshAccessToken(); + assertNotNull(accessToken); + assertNotNull(accessToken.getTokenValue()); + } + private GenericJson buildIdentityPoolCredentialConfig() throws IOException { String idToken = generateGoogleIdToken(OIDC_AUDIENCE); diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MtlsPipelineLocalTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MtlsPipelineLocalTest.java new file mode 100644 index 000000000000..90fe23df65e8 --- /dev/null +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MtlsPipelineLocalTest.java @@ -0,0 +1,1049 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.oauth2; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.api.client.json.GenericJson; +import com.google.api.client.util.SecurityUtils; +import com.google.auth.http.HttpTransportFactory; +import com.google.auth.mtls.MtlsHttpTransportFactory; +import com.google.auth.oauth2.ExternalAccountCredentials.SubjectTokenTypes; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpsConfigurator; +import com.sun.net.httpserver.HttpsExchange; +import com.sun.net.httpserver.HttpsParameters; +import com.sun.net.httpserver.HttpsServer; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.SequenceInputStream; +import java.net.InetSocketAddress; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.KeyStore; +import java.security.SecureRandom; +import java.security.cert.Certificate; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSession; +import javax.net.ssl.TrustManagerFactory; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Hermetic in-process socket test suite for the mTLS OAuth token exchange pipeline. + * + *

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

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

Verify that when server responds with 401 Unauthorized on initial exchange, + * IdentityPoolCredentials catches it, re-reads fresh KeyStore from X509Provider, and retries the + * exchange successfully. + */ + @Test + void testMtlsPipeline_401Retry_reReadsCertFromDisk(@TempDir Path tempDir) throws Exception { + AtomicInteger requestCount = new AtomicInteger(0); + List certsPerRequest = new ArrayList<>(); + + server.createContext( + "/v1/token", + new HttpHandler() { + @Override + public void handle(HttpExchange exchange) throws IOException { + try { + HttpsExchange httpsExchange = (HttpsExchange) exchange; + SSLSession session = httpsExchange.getSSLSession(); + synchronized (certsPerRequest) { + certsPerRequest.add(session.getPeerCertificates()); + } + + // Always read and drain the request body + String body = readRequestBody(exchange); + + int count = requestCount.incrementAndGet(); + if (count == 1) { + // Initial exchange responds with 401 Unauthorized + GenericJson error = new GenericJson(); + error.setFactory(OAuth2Utils.JSON_FACTORY); + error.put("error", "invalid_client"); + error.put("error_description", "Certificate rotation required"); + sendJsonResponse(exchange, 401, error.toPrettyString()); + } else { + // Second exchange succeeds with 200 OK + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("access_token", "retry_success_token_401_handled"); + response.put("token_type", "Bearer"); + response.put("expires_in", 3600); + response.put("issued_token_type", ACCESS_TOKEN_TYPE); + sendJsonResponse(exchange, 200, response.toPrettyString()); + } + } catch (Exception e) { + sendJsonResponse(exchange, 500, "{\"error\": \"" + e.getMessage() + "\"}"); + } + } + }); + server.start(); + serverPort = server.getAddress().getPort(); + + Path tokenFile = tempDir.resolve("credential.json"); + GenericJson tokenJson = new GenericJson(); + tokenJson.setFactory(OAuth2Utils.JSON_FACTORY); + tokenJson.put("subject_token", "testSubjectToken401"); + tokenJson.put("actor_token", "testActorToken401"); + OAuth2Utils.writeInputStreamToFile( + new ByteArrayInputStream(tokenJson.toPrettyString().getBytes(StandardCharsets.UTF_8)), + tokenFile.toString()); + + String configJson = + "{\n" + + " \"type\": \"external_account\",\n" + + " \"audience\": \"" + + AUDIENCE + + "\",\n" + + " \"subject_token_type\": \"urn:ietf:params:oauth:token-type:jwt\",\n" + + " \"actor_token_type\": \"urn:ietf:params:oauth:token-type:jwt\",\n" + + " \"token_url\": \"https://localhost:" + + serverPort + + "/v1/token\",\n" + + " \"credential_source\": {\n" + + " \"file\": \"" + + tokenFile.toString() + + "\",\n" + + " \"format\": {\n" + + " \"type\": \"json\",\n" + + " \"subject_token_field_name\": \"subject_token\",\n" + + " \"actor_token_field_name\": \"actor_token\"\n" + + " },\n" + + " \"certificate\": {\n" + + " \"certificate_config_location\": \"testresources/mtls/certificate_config.json\"\n" + + " }\n" + + " }\n" + + "}"; + + IdentityPoolCredentials credentials = + (IdentityPoolCredentials) + ExternalAccountCredentials.fromStream( + new ByteArrayInputStream(configJson.getBytes(StandardCharsets.UTF_8))); + + AccessToken accessToken = credentials.refreshAccessToken(); + assertEquals("retry_success_token_401_handled", accessToken.getTokenValue()); + assertEquals(2, requestCount.get()); + assertEquals(2, certsPerRequest.size()); + assertNotNull(certsPerRequest.get(0)); + assertNotNull(certsPerRequest.get(1)); + assertTrue(certsPerRequest.get(0).length > 0); + assertTrue(certsPerRequest.get(1).length > 0); + assertTrue(certsPerRequest.get(0)[0] instanceof X509Certificate); + assertTrue(certsPerRequest.get(1)[0] instanceof X509Certificate); + assertEquals( + ((X509Certificate) certsPerRequest.get(0)[0]).getSubjectX500Principal(), + ((X509Certificate) certsPerRequest.get(1)[0]).getSubjectX500Principal()); + } + + /** + * Scenario C: testMtlsPipeline_concurrentRefreshes + * + *

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

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

Sets up in-process HttpsServer handlers for both STS (/v1/token) and IAM + * (/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken), both + * requiring client certificates. Executes refreshAccessToken() on IdentityPoolCredentials + * configured with serviceAccountImpersonationUrl, asserting that both STS and IAM receive the + * client X509Certificate from SSLSession, IAM receives the Authorization header from STS, and the + * final target access token is returned. + */ + @Test + void testMtlsPipeline_withImpersonation_usesSameCertForStsAndIam(@TempDir Path tempDir) + throws Exception { + AtomicInteger stsCallCount = new AtomicInteger(0); + AtomicReference capturedStsCerts = new AtomicReference<>(); + AtomicReference> capturedStsParams = new AtomicReference<>(); + + server.createContext( + "/v1/token", + new HttpHandler() { + @Override + public void handle(HttpExchange exchange) throws IOException { + try { + HttpsExchange httpsExchange = (HttpsExchange) exchange; + SSLSession session = httpsExchange.getSSLSession(); + capturedStsCerts.set(session.getPeerCertificates()); + + String body = readRequestBody(exchange); + capturedStsParams.set(parseFormData(body)); + + stsCallCount.incrementAndGet(); + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("access_token", "intermediate_sts_token_123"); + response.put("token_type", "Bearer"); + response.put("expires_in", 3600); + response.put("issued_token_type", ACCESS_TOKEN_TYPE); + sendJsonResponse(exchange, 200, response.toPrettyString()); + } catch (Exception e) { + sendJsonResponse(exchange, 500, "{\"error\": \"" + e.getMessage() + "\"}"); + } + } + }); + + AtomicInteger iamCallCount = new AtomicInteger(0); + AtomicReference capturedIamCerts = new AtomicReference<>(); + AtomicReference capturedIamAuthHeader = new AtomicReference<>(); + + server.createContext( + "/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken", + new HttpHandler() { + @Override + public void handle(HttpExchange exchange) throws IOException { + try { + HttpsExchange httpsExchange = (HttpsExchange) exchange; + SSLSession session = httpsExchange.getSSLSession(); + capturedIamCerts.set(session.getPeerCertificates()); + + capturedIamAuthHeader.set(exchange.getRequestHeaders().getFirst("Authorization")); + readRequestBody(exchange); + + iamCallCount.incrementAndGet(); + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("accessToken", "final_target_sa_access_token_456"); + response.put("expireTime", "2030-01-01T00:00:00Z"); + sendJsonResponse(exchange, 200, response.toPrettyString()); + } catch (Exception e) { + sendJsonResponse(exchange, 500, "{\"error\": \"" + e.getMessage() + "\"}"); + } + } + }); + + server.start(); + serverPort = server.getAddress().getPort(); + + Path tokenFile = tempDir.resolve("credential.json"); + GenericJson tokenJson = new GenericJson(); + tokenJson.setFactory(OAuth2Utils.JSON_FACTORY); + tokenJson.put("subject_token", "testSubjectTokenImpersonation"); + tokenJson.put("actor_token", "testActorTokenImpersonation"); + OAuth2Utils.writeInputStreamToFile( + new ByteArrayInputStream(tokenJson.toPrettyString().getBytes(StandardCharsets.UTF_8)), + tokenFile.toString()); + + String iamUrl = + "https://localhost:" + + serverPort + + "/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken"; + + String configJson = + "{\n" + + " \"type\": \"external_account\",\n" + + " \"audience\": \"" + + AUDIENCE + + "\",\n" + + " \"subject_token_type\": \"urn:ietf:params:oauth:token-type:jwt\",\n" + + " \"actor_token_type\": \"urn:ietf:params:oauth:token-type:jwt\",\n" + + " \"token_url\": \"https://localhost:" + + serverPort + + "/v1/token\",\n" + + " \"service_account_impersonation_url\": \"" + + iamUrl + + "\",\n" + + " \"credential_source\": {\n" + + " \"file\": \"" + + tokenFile.toString() + + "\",\n" + + " \"format\": {\n" + + " \"type\": \"json\",\n" + + " \"subject_token_field_name\": \"subject_token\",\n" + + " \"actor_token_field_name\": \"actor_token\"\n" + + " },\n" + + " \"certificate\": {\n" + + " \"certificate_config_location\": \"testresources/mtls/certificate_config.json\"\n" + + " }\n" + + " }\n" + + "}"; + + IdentityPoolCredentials credentials = + (IdentityPoolCredentials) + ExternalAccountCredentials.fromStream( + new ByteArrayInputStream(configJson.getBytes(StandardCharsets.UTF_8))); + + AccessToken accessToken = credentials.refreshAccessToken(); + assertEquals("final_target_sa_access_token_456", accessToken.getTokenValue()); + assertEquals(1, stsCallCount.get()); + assertEquals(1, iamCallCount.get()); + + // Asserts both STS and IAM handlers receive the client's X509Certificate from SSLSession + Certificate[] stsCerts = capturedStsCerts.get(); + assertNotNull(stsCerts); + assertTrue(stsCerts.length > 0); + assertTrue(stsCerts[0] instanceof X509Certificate); + + Certificate[] iamCerts = capturedIamCerts.get(); + assertNotNull(iamCerts); + assertTrue(iamCerts.length > 0); + assertTrue(iamCerts[0] instanceof X509Certificate); + + // Verify both handlers received the exact same client certificate principal + assertEquals( + ((X509Certificate) stsCerts[0]).getSubjectX500Principal(), + ((X509Certificate) iamCerts[0]).getSubjectX500Principal()); + + // Asserts IAM handler receives Authorization: Bearer + assertEquals("Bearer intermediate_sts_token_123", capturedIamAuthHeader.get()); + + // Asserts STS received proper token exchange parameters + Map stsParams = capturedStsParams.get(); + assertNotNull(stsParams); + assertEquals("testSubjectTokenImpersonation", stsParams.get("subject_token")); + assertEquals("testActorTokenImpersonation", stsParams.get("actor_token")); + } + + /** + * Scenario F: testMtlsPipeline_withImpersonation_401OnIam_retriesWithFreshCert + * + *

Sets up STS and IAM handlers. On attempt 1, STS succeeds (returning intermediate token 1) + * and IAM returns HTTP 401 Unauthorized. On 401, test updates the cert file on disk (Cert A -> + * Cert B). Verifies IdentityPoolCredentials catches IAM 401, reloads the fresh cert, re-exchanges + * at STS for intermediate token 2 (bound to Cert B), and calls IAM with intermediate token 2 + + * Cert B, succeeding with HTTP 200. + */ + @Test + void testMtlsPipeline_withImpersonation_401OnIam_retriesWithFreshCert(@TempDir Path tempDir) + throws Exception { + Path dynamicCertFile = tempDir.resolve("dynamic_cert.pem"); + Path dynamicKeyFile = tempDir.resolve("dynamic_key.pem"); + Path certConfigFile = tempDir.resolve("dynamic_cert_config.json"); + + // Write initial cert and key (Cert A) to disk + Files.copy(Paths.get(TEST_CERT_PATH), dynamicCertFile); + Files.copy(Paths.get(TEST_KEY_PATH), dynamicKeyFile); + + String certConfigContent = + "{\n" + + " \"cert_configs\": {\n" + + " \"workload\": {\n" + + " \"cert_path\": \"" + + dynamicCertFile.toString() + + "\",\n" + + " \"key_path\": \"" + + dynamicKeyFile.toString() + + "\"\n" + + " }\n" + + " }\n" + + "}"; + Files.write(certConfigFile, certConfigContent.getBytes(StandardCharsets.UTF_8)); + + AtomicInteger stsRequestCount = new AtomicInteger(0); + List stsCertsList = Collections.synchronizedList(new ArrayList<>()); + + server.createContext( + "/v1/token", + new HttpHandler() { + @Override + public void handle(HttpExchange exchange) throws IOException { + try { + HttpsExchange httpsExchange = (HttpsExchange) exchange; + SSLSession session = httpsExchange.getSSLSession(); + stsCertsList.add(session.getPeerCertificates()); + + readRequestBody(exchange); + + int count = stsRequestCount.incrementAndGet(); + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("access_token", "intermediate_sts_token_" + count); + response.put("token_type", "Bearer"); + response.put("expires_in", 3600); + response.put("issued_token_type", ACCESS_TOKEN_TYPE); + sendJsonResponse(exchange, 200, response.toPrettyString()); + } catch (Exception e) { + sendJsonResponse(exchange, 500, "{\"error\": \"" + e.getMessage() + "\"}"); + } + } + }); + + AtomicInteger iamRequestCount = new AtomicInteger(0); + List iamCertsList = Collections.synchronizedList(new ArrayList<>()); + List iamAuthHeaders = Collections.synchronizedList(new ArrayList<>()); + + server.createContext( + "/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken", + new HttpHandler() { + @Override + public void handle(HttpExchange exchange) throws IOException { + try { + HttpsExchange httpsExchange = (HttpsExchange) exchange; + SSLSession session = httpsExchange.getSSLSession(); + iamCertsList.add(session.getPeerCertificates()); + + iamAuthHeaders.add(exchange.getRequestHeaders().getFirst("Authorization")); + readRequestBody(exchange); + + int count = iamRequestCount.incrementAndGet(); + if (count == 1) { + // Update the cert files on disk on 401 (Cert A -> Cert B) + Files.write(dynamicCertFile, Files.readAllBytes(Paths.get(TEST_CERT_PATH))); + Files.write(dynamicKeyFile, Files.readAllBytes(Paths.get(TEST_KEY_PATH))); + + GenericJson error = new GenericJson(); + error.setFactory(OAuth2Utils.JSON_FACTORY); + error.put("error", "invalid_client"); + error.put("error_description", "Certificate rotation required"); + sendJsonResponse(exchange, 401, error.toPrettyString()); + } else { + GenericJson response = new GenericJson(); + response.setFactory(OAuth2Utils.JSON_FACTORY); + response.put("accessToken", "retry_final_target_sa_token_success"); + response.put("expireTime", "2030-01-01T00:00:00Z"); + sendJsonResponse(exchange, 200, response.toPrettyString()); + } + } catch (Exception e) { + sendJsonResponse(exchange, 500, "{\"error\": \"" + e.getMessage() + "\"}"); + } + } + }); + + server.start(); + serverPort = server.getAddress().getPort(); + + Path tokenFile = tempDir.resolve("credential.json"); + GenericJson tokenJson = new GenericJson(); + tokenJson.setFactory(OAuth2Utils.JSON_FACTORY); + tokenJson.put("subject_token", "testSubjectToken401Iam"); + tokenJson.put("actor_token", "testActorToken401Iam"); + OAuth2Utils.writeInputStreamToFile( + new ByteArrayInputStream(tokenJson.toPrettyString().getBytes(StandardCharsets.UTF_8)), + tokenFile.toString()); + + String iamUrl = + "https://localhost:" + + serverPort + + "/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken"; + + String configJson = + "{\n" + + " \"type\": \"external_account\",\n" + + " \"audience\": \"" + + AUDIENCE + + "\",\n" + + " \"subject_token_type\": \"urn:ietf:params:oauth:token-type:jwt\",\n" + + " \"actor_token_type\": \"urn:ietf:params:oauth:token-type:jwt\",\n" + + " \"token_url\": \"https://localhost:" + + serverPort + + "/v1/token\",\n" + + " \"service_account_impersonation_url\": \"" + + iamUrl + + "\",\n" + + " \"credential_source\": {\n" + + " \"file\": \"" + + tokenFile.toString() + + "\",\n" + + " \"format\": {\n" + + " \"type\": \"json\",\n" + + " \"subject_token_field_name\": \"subject_token\",\n" + + " \"actor_token_field_name\": \"actor_token\"\n" + + " },\n" + + " \"certificate\": {\n" + + " \"certificate_config_location\": \"" + + certConfigFile.toString() + + "\"\n" + + " }\n" + + " }\n" + + "}"; + + IdentityPoolCredentials credentials = + (IdentityPoolCredentials) + ExternalAccountCredentials.fromStream( + new ByteArrayInputStream(configJson.getBytes(StandardCharsets.UTF_8))); + + AccessToken accessToken = credentials.refreshAccessToken(); + assertEquals("retry_final_target_sa_token_success", accessToken.getTokenValue()); + + // Verify 2 STS exchanges and 2 IAM calls occurred + assertEquals(2, stsRequestCount.get()); + assertEquals(2, iamRequestCount.get()); + + // Verify certs captured for both attempts + assertEquals(2, stsCertsList.size()); + assertEquals(2, iamCertsList.size()); + assertNotNull(stsCertsList.get(0)); + assertNotNull(stsCertsList.get(1)); + assertNotNull(iamCertsList.get(0)); + assertNotNull(iamCertsList.get(1)); + assertTrue(stsCertsList.get(0)[0] instanceof X509Certificate); + assertTrue(stsCertsList.get(1)[0] instanceof X509Certificate); + assertTrue(iamCertsList.get(0)[0] instanceof X509Certificate); + assertTrue(iamCertsList.get(1)[0] instanceof X509Certificate); + + // Verify in each attempt, STS and IAM received the same peer certificate + assertEquals( + ((X509Certificate) stsCertsList.get(0)[0]).getSubjectX500Principal(), + ((X509Certificate) iamCertsList.get(0)[0]).getSubjectX500Principal()); + assertEquals( + ((X509Certificate) stsCertsList.get(1)[0]).getSubjectX500Principal(), + ((X509Certificate) iamCertsList.get(1)[0]).getSubjectX500Principal()); + + // Verify IAM received intermediate tokens 1 and 2 respectively + assertEquals(2, iamAuthHeaders.size()); + assertEquals("Bearer intermediate_sts_token_1", iamAuthHeaders.get(0)); + assertEquals("Bearer intermediate_sts_token_2", iamAuthHeaders.get(1)); + } +} diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuthExceptionTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuthExceptionTest.java index e70ba6851574..bec00cc3ffdd 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuthExceptionTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/OAuthExceptionTest.java @@ -34,6 +34,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; +import com.google.api.client.http.HttpHeaders; +import com.google.api.client.http.HttpResponseException; import com.google.auth.TestUtils; import java.io.IOException; import org.junit.jupiter.api.Test; @@ -129,4 +131,68 @@ void createFromHttpResponseException_baseFormat() throws IOException { String expectedMessage = String.format(BASE_MESSAGE_FORMAT, "errorCode"); assertEquals(expectedMessage, e.getMessage()); } + + @Test + void createFromHttpResponseException_nullContent() throws IOException { + HttpResponseException httpException = + new HttpResponseException.Builder( + /* statusCode= */ 401, /* statusMessage= */ "Unauthorized", new HttpHeaders()) + .setContent(null) + .build(); + + OAuthException e = OAuthException.createFromHttpResponseException(httpException); + + assertEquals("http_error_401", e.getErrorCode()); + assertEquals("Unauthorized", e.getErrorDescription()); + assertNull(e.getErrorUri()); + assertEquals(401, e.getHttpStatusCode()); + } + + @Test + void createFromHttpResponseException_emptyContent() throws IOException { + HttpResponseException httpException = + new HttpResponseException.Builder( + /* statusCode= */ 401, /* statusMessage= */ "Unauthorized", new HttpHeaders()) + .setContent(" ") + .build(); + + OAuthException e = OAuthException.createFromHttpResponseException(httpException); + + assertEquals("http_error_401", e.getErrorCode()); + assertEquals("Unauthorized", e.getErrorDescription()); + assertNull(e.getErrorUri()); + assertEquals(401, e.getHttpStatusCode()); + } + + @Test + void createFromHttpResponseException_nonJsonContent() throws IOException { + HttpResponseException httpException = + new HttpResponseException.Builder( + /* statusCode= */ 502, /* statusMessage= */ "Bad Gateway", new HttpHeaders()) + .setContent("Bad Gateway") + .build(); + + OAuthException e = OAuthException.createFromHttpResponseException(httpException); + + assertEquals("http_error_502", e.getErrorCode()); + assertEquals("Bad Gateway", e.getErrorDescription()); + assertNull(e.getErrorUri()); + assertEquals(502, e.getHttpStatusCode()); + } + + @Test + void createFromHttpResponseException_missingErrorField() throws IOException { + HttpResponseException httpException = + new HttpResponseException.Builder( + /* statusCode= */ 400, /* statusMessage= */ "Bad Request", new HttpHeaders()) + .setContent("{\"error_description\": \"some description\"}") + .build(); + + OAuthException e = OAuthException.createFromHttpResponseException(httpException); + + assertEquals("http_error_400", e.getErrorCode()); + assertEquals("some description", e.getErrorDescription()); + assertNull(e.getErrorUri()); + assertEquals(400, e.getHttpStatusCode()); + } } From fe6d3ca122fadfb2088a674b076835f78007200f Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Tue, 1 Sep 2026 02:04:57 +0000 Subject: [PATCH 25/25] test(oauth2): escape Windows path backslashes in MtlsPipelineLocalTest JSON templates --- .../auth/oauth2/MtlsPipelineLocalTest.java | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MtlsPipelineLocalTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MtlsPipelineLocalTest.java index 90fe23df65e8..b742e36853b8 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MtlsPipelineLocalTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MtlsPipelineLocalTest.java @@ -347,7 +347,7 @@ public void handle(HttpExchange exchange) throws IOException { + "/v1/token\",\n" + " \"credential_source\": {\n" + " \"file\": \"" - + tokenFile.toString() + + tokenFile.toString().replace("\\", "\\\\") + "\",\n" + " \"format\": {\n" + " \"type\": \"json\",\n" @@ -355,7 +355,8 @@ public void handle(HttpExchange exchange) throws IOException { + " \"actor_token_field_name\": \"actor_token\"\n" + " },\n" + " \"certificate\": {\n" - + " \"certificate_config_location\": \"testresources/mtls/certificate_config.json\"\n" + + " \"certificate_config_location\":" + + " \"testresources/mtls/certificate_config.json\"\n" + " }\n" + " }\n" + "}"; @@ -468,7 +469,7 @@ public void handle(HttpExchange exchange) throws IOException { + "/v1/token\",\n" + " \"credential_source\": {\n" + " \"file\": \"" - + tokenFile.toString() + + tokenFile.toString().replace("\\", "\\\\") + "\",\n" + " \"format\": {\n" + " \"type\": \"json\",\n" @@ -476,7 +477,8 @@ public void handle(HttpExchange exchange) throws IOException { + " \"actor_token_field_name\": \"actor_token\"\n" + " },\n" + " \"certificate\": {\n" - + " \"certificate_config_location\": \"testresources/mtls/certificate_config.json\"\n" + + " \"certificate_config_location\":" + + " \"testresources/mtls/certificate_config.json\"\n" + " }\n" + " }\n" + "}"; @@ -566,7 +568,7 @@ public void handle(HttpExchange exchange) throws IOException { + "/v1/token\",\n" + " \"credential_source\": {\n" + " \"file\": \"" - + tokenFile.toString() + + tokenFile.toString().replace("\\", "\\\\") + "\",\n" + " \"format\": {\n" + " \"type\": \"json\",\n" @@ -574,7 +576,8 @@ public void handle(HttpExchange exchange) throws IOException { + " \"actor_token_field_name\": \"actor_token\"\n" + " },\n" + " \"certificate\": {\n" - + " \"certificate_config_location\": \"testresources/mtls/certificate_config.json\"\n" + + " \"certificate_config_location\":" + + " \"testresources/mtls/certificate_config.json\"\n" + " }\n" + " }\n" + "}"; @@ -807,7 +810,7 @@ public void handle(HttpExchange exchange) throws IOException { + "\",\n" + " \"credential_source\": {\n" + " \"file\": \"" - + tokenFile.toString() + + tokenFile.toString().replace("\\", "\\\\") + "\",\n" + " \"format\": {\n" + " \"type\": \"json\",\n" @@ -815,7 +818,8 @@ public void handle(HttpExchange exchange) throws IOException { + " \"actor_token_field_name\": \"actor_token\"\n" + " },\n" + " \"certificate\": {\n" - + " \"certificate_config_location\": \"testresources/mtls/certificate_config.json\"\n" + + " \"certificate_config_location\":" + + " \"testresources/mtls/certificate_config.json\"\n" + " }\n" + " }\n" + "}"; @@ -881,10 +885,10 @@ void testMtlsPipeline_withImpersonation_401OnIam_retriesWithFreshCert(@TempDir P + " \"cert_configs\": {\n" + " \"workload\": {\n" + " \"cert_path\": \"" - + dynamicCertFile.toString() + + dynamicCertFile.toString().replace("\\", "\\\\") + "\",\n" + " \"key_path\": \"" - + dynamicKeyFile.toString() + + dynamicKeyFile.toString().replace("\\", "\\\\") + "\"\n" + " }\n" + " }\n" @@ -994,7 +998,7 @@ public void handle(HttpExchange exchange) throws IOException { + "\",\n" + " \"credential_source\": {\n" + " \"file\": \"" - + tokenFile.toString() + + tokenFile.toString().replace("\\", "\\\\") + "\",\n" + " \"format\": {\n" + " \"type\": \"json\",\n" @@ -1003,7 +1007,7 @@ public void handle(HttpExchange exchange) throws IOException { + " },\n" + " \"certificate\": {\n" + " \"certificate_config_location\": \"" - + certConfigFile.toString() + + certConfigFile.toString().replace("\\", "\\\\") + "\"\n" + " }\n" + " }\n"