From a4813a2fc9a277462ff5e0d86dee7d73ce90e176 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Thu, 30 Jul 2026 01:59:16 +0000 Subject: [PATCH 01/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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 0e9aa1bd001d112b01506e699d78057ce01da862 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Mon, 3 Aug 2026 21:19:20 +0000 Subject: [PATCH 11/13] feat(oauth2): support mTLS dynamic certificate rotation and 401 retry for STS token exchange Implementation of Phase 4 of the Cert-Bound OAuth2 Design Document (go/java-auth-cert-bound-oauth2): 1. Extend MtlsHttpTransportFactory to accept MtlsProvider (e.g. X509Provider) and implement rebuildContext() for dynamic KeyStore reloading when certificates are rotated on disk. 2. Implement DelegatingSSLSocketFactory in MtlsHttpTransportFactory so existing NetHttpTransport instances automatically delegate to the reloaded SSLSocketFactory. 3. Update IdentityPoolCredentials to initialize MtlsHttpTransportFactory with X509Provider instead of a static KeyStore. 4. Attach an HttpUnsuccessfulResponseHandler retry interceptor in ExternalAccountCredentials to detect 401 Unauthorized responses during mTLS STS token exchange, rebuild the SSL context, and retry once per refresh cycle. 5. Add unit tests in MtlsHttpTransportFactoryTest and ExternalAccountCredentialsTest. --- .../auth/mtls/MtlsHttpTransportFactory.java | 129 +++++++++++++++++- .../oauth2/ExternalAccountCredentials.java | 27 ++++ .../auth/oauth2/IdentityPoolCredentials.java | 6 +- .../google/auth/oauth2/StsRequestHandler.java | 14 ++ .../mtls/MtlsHttpTransportFactoryTest.java | 110 +++++++++++++++ .../ExternalAccountCredentialsTest.java | 40 ++++++ ...ckExternalAccountCredentialsTransport.java | 15 ++ 7 files changed, 330 insertions(+), 11 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 3e658322347a..e0f7acd119c3 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,20 @@ 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 com.google.common.annotations.VisibleForTesting; +import java.io.IOException; +import java.net.InetAddress; +import java.net.Socket; +import java.net.UnknownHostException; import java.security.GeneralSecurityException; import java.security.KeyStore; import java.util.Objects; +import javax.net.ssl.SSLSocketFactory; +import org.jspecify.annotations.Nullable; import org.jspecify.annotations.NullMarked; /** @@ -50,7 +58,9 @@ @NullMarked @InternalApi public class MtlsHttpTransportFactory implements HttpTransportFactory { - private final KeyStore mtlsKeyStore; + @Nullable private final MtlsProvider mtlsProvider; + private volatile KeyStore mtlsKeyStore; + private final DelegatingSSLSocketFactory sslSocketFactory; /** * Constructs a factory for mTLS transports. @@ -61,17 +71,122 @@ public class MtlsHttpTransportFactory implements HttpTransportFactory { */ public MtlsHttpTransportFactory(KeyStore mtlsKeyStore) { this.mtlsKeyStore = Objects.requireNonNull(mtlsKeyStore, "mtlsKeyStore cannot be null"); + this.mtlsProvider = null; + try { + this.sslSocketFactory = new DelegatingSSLSocketFactory(buildSslSocketFactory(mtlsKeyStore)); + } catch (GeneralSecurityException e) { + throw new RuntimeException("Failed to initialize mTLS transport.", e); + } } - @Override - public NetHttpTransport create() { + /** + * Constructs a factory for mTLS transports using an {@link MtlsProvider}. + * + * @param mtlsProvider The {@link MtlsProvider} providing the client's KeyStore. + * @throws CertificateSourceUnavailableException if the certificate source is unavailable + * @throws IOException if a general I/O error occurs while creating the KeyStore + */ + public MtlsHttpTransportFactory(MtlsProvider mtlsProvider) + throws CertificateSourceUnavailableException, IOException { + this.mtlsProvider = Objects.requireNonNull(mtlsProvider, "mtlsProvider cannot be null"); + this.mtlsKeyStore = mtlsProvider.getKeyStore(); try { - // Build the mTLS transport using the provided KeyStore. - return new NetHttpTransport.Builder().trustCertificates(null, mtlsKeyStore, "").build(); + this.sslSocketFactory = + new DelegatingSSLSocketFactory(buildSslSocketFactory(this.mtlsKeyStore)); } catch (GeneralSecurityException e) { - // Wrap the checked exception in a RuntimeException because the HttpTransportFactory - // interface's create() method doesn't allow throwing checked exceptions. throw new RuntimeException("Failed to initialize mTLS transport.", e); } } + + private static SSLSocketFactory buildSslSocketFactory(KeyStore keyStore) + throws GeneralSecurityException { + return new NetHttpTransport.Builder() + .trustCertificates(null, keyStore, "") + .getSslSocketFactory(); + } + + /** + * Reloads the KeyStore from the underlying MtlsProvider if configured and rebuilds the SSL socket + * factory. + * + * @throws IOException if an I/O error occurs while reloading the KeyStore + */ + public synchronized void rebuildContext() throws IOException { + if (this.mtlsProvider != null) { + try { + this.mtlsKeyStore = this.mtlsProvider.getKeyStore(); + this.sslSocketFactory.setDelegate(buildSslSocketFactory(this.mtlsKeyStore)); + } catch (CertificateSourceUnavailableException e) { + throw new IOException("Failed to reload KeyStore from MtlsProvider.", e); + } catch (GeneralSecurityException e) { + throw new IOException("Failed to rebuild SSLSocketFactory.", e); + } + } + } + + @VisibleForTesting + KeyStore getKeyStore() { + return mtlsKeyStore; + } + + @Override + public HttpTransport create() { + return new NetHttpTransport.Builder().setSslSocketFactory(sslSocketFactory).build(); + } + + private static class DelegatingSSLSocketFactory extends SSLSocketFactory { + private volatile SSLSocketFactory delegate; + + DelegatingSSLSocketFactory(SSLSocketFactory initialDelegate) { + this.delegate = initialDelegate; + } + + void setDelegate(SSLSocketFactory newDelegate) { + this.delegate = newDelegate; + } + + @Override + public String[] getDefaultCipherSuites() { + return delegate.getDefaultCipherSuites(); + } + + @Override + public String[] getSupportedCipherSuites() { + return delegate.getSupportedCipherSuites(); + } + + @Override + public Socket createSocket(Socket s, String host, int port, boolean autoClose) + throws IOException { + return delegate.createSocket(s, host, port, autoClose); + } + + @Override + public Socket createSocket(String host, int port) throws IOException, UnknownHostException { + return delegate.createSocket(host, port); + } + + @Override + public Socket createSocket(String host, int port, InetAddress localHost, int localPort) + throws IOException, UnknownHostException { + return delegate.createSocket(host, port, localHost, localPort); + } + + @Override + public Socket createSocket(InetAddress host, int port) throws IOException { + return delegate.createSocket(host, port); + } + + @Override + public Socket createSocket( + InetAddress address, int port, InetAddress localAddress, int localPort) + throws IOException { + return delegate.createSocket(address, port, localAddress, localPort); + } + + @Override + public Socket createSocket() throws IOException { + return delegate.createSocket(); + } + } } 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..b97a18943990 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 @@ -34,10 +34,14 @@ import static com.google.common.base.Preconditions.checkNotNull; import com.google.api.client.http.HttpHeaders; +import com.google.api.client.http.HttpRequest; +import com.google.api.client.http.HttpResponse; +import com.google.api.client.http.HttpUnsuccessfulResponseHandler; import com.google.api.client.json.GenericJson; import com.google.api.client.util.Data; import com.google.auth.RequestMetadataCallback; import com.google.auth.http.HttpTransportFactory; +import com.google.auth.mtls.MtlsHttpTransportFactory; import com.google.common.base.MoreObjects; import com.google.common.base.Preconditions; import com.google.errorprone.annotations.CanIgnoreReturnValue; @@ -566,6 +570,29 @@ protected AccessToken exchangeExternalCredentialForAccessToken( requestHandler.setInternalOptions(stsTokenExchangeRequest.getInternalOptions()); } + requestHandler.setUnsuccessfulResponseHandler( + new HttpUnsuccessfulResponseHandler() { + boolean retried = false; + + @Override + public boolean handleResponse( + HttpRequest request, HttpResponse response, boolean supportsRetry) + throws IOException { + if (response.getStatusCode() != 401) { + return false; + } + if (!(transportFactory instanceof MtlsHttpTransportFactory)) { + return false; + } + if (retried) { + return false; + } + ((MtlsHttpTransportFactory) transportFactory).rebuildContext(); + retried = true; + return true; + } + }); + StsTokenExchangeResponse response = requestHandler.build().exchangeToken(); return response.getAccessToken(); } 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..0eaa82f7a72b 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 @@ -100,8 +100,7 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { if (credentialSource.getCertificateConfig() != null) { try { X509Provider x509Provider = getX509Provider(builder, credentialSource); - KeyStore mtlsKeyStore = x509Provider.getKeyStore(); - this.transportFactory = new MtlsHttpTransportFactory(mtlsKeyStore); + this.transportFactory = new MtlsHttpTransportFactory(x509Provider); } catch (Exception e) { throw new RuntimeException( "Failed to initialize mTLS transport for file credential source due to certificate error.", @@ -227,8 +226,7 @@ private IdentityPoolSubjectTokenSupplier createCertificateSubjectTokenSupplier( Builder builder, IdentityPoolCredentialSource credentialSource) throws IOException { // Configure the mTLS transport with the x509 keystore. X509Provider x509Provider = getX509Provider(builder, credentialSource); - KeyStore mtlsKeyStore = x509Provider.getKeyStore(); - this.transportFactory = new MtlsHttpTransportFactory(mtlsKeyStore); + this.transportFactory = new MtlsHttpTransportFactory(x509Provider); // Initialize the subject token supplier with the certificate path. String explicitCertConfigPath = getExplicitCertConfigPath(credentialSource); diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/StsRequestHandler.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/StsRequestHandler.java index b1db8b682b9c..6cb27c136958 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/StsRequestHandler.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/StsRequestHandler.java @@ -37,6 +37,7 @@ import com.google.api.client.http.HttpRequestFactory; import com.google.api.client.http.HttpResponse; import com.google.api.client.http.HttpResponseException; +import com.google.api.client.http.HttpUnsuccessfulResponseHandler; import com.google.api.client.http.UrlEncodedContent; import com.google.api.client.json.GenericJson; import com.google.api.client.json.JsonObjectParser; @@ -77,6 +78,7 @@ public final class StsRequestHandler { @Nullable private final HttpHeaders headers; @Nullable private final String internalOptions; + @Nullable private final HttpUnsuccessfulResponseHandler unsuccessfulResponseHandler; private StsRequestHandler(Builder builder) { this.tokenExchangeEndpoint = builder.tokenExchangeEndpoint; @@ -84,6 +86,7 @@ private StsRequestHandler(Builder builder) { this.httpRequestFactory = builder.httpRequestFactory; this.headers = builder.headers; this.internalOptions = builder.internalOptions; + this.unsuccessfulResponseHandler = builder.unsuccessfulResponseHandler; } /** @@ -116,6 +119,9 @@ public StsTokenExchangeResponse exchangeToken() throws IOException { if (headers != null) { httpRequest.setHeaders(headers); } + if (unsuccessfulResponseHandler != null) { + httpRequest.setUnsuccessfulResponseHandler(unsuccessfulResponseHandler); + } try { LoggingUtils.logRequest(httpRequest, LOGGER_PROVIDER, "Sending request for token exchange"); @@ -213,6 +219,7 @@ public static class Builder { @Nullable private HttpHeaders headers; @Nullable private String internalOptions; + @Nullable private HttpUnsuccessfulResponseHandler unsuccessfulResponseHandler; private Builder( String tokenExchangeEndpoint, @@ -235,6 +242,13 @@ public StsRequestHandler.Builder setInternalOptions(String internalOptions) { return this; } + @CanIgnoreReturnValue + public StsRequestHandler.Builder setUnsuccessfulResponseHandler( + HttpUnsuccessfulResponseHandler unsuccessfulResponseHandler) { + this.unsuccessfulResponseHandler = unsuccessfulResponseHandler; + return this; + } + public StsRequestHandler build() { return new StsRequestHandler(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 new file mode 100644 index 000000000000..280f2ff35206 --- /dev/null +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsHttpTransportFactoryTest.java @@ -0,0 +1,110 @@ +/* + * Copyright 2026, Google Inc. All rights reserved. + * + * 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 Inc. 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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.google.api.client.http.HttpTransport; +import java.io.IOException; +import java.security.KeyStore; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class MtlsHttpTransportFactoryTest { + + @Test + void constructor_nullKeyStore_throwsNullPointerException() { + assertThrows( + NullPointerException.class, + () -> new MtlsHttpTransportFactory((KeyStore) null)); + } + + @Test + void constructor_nullMtlsProvider_throwsNullPointerException() { + assertThrows( + NullPointerException.class, + () -> new MtlsHttpTransportFactory((MtlsProvider) null)); + } + + @Test + void constructor_withKeyStore_createsTransport() throws Exception { + KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); + ks.load(null, null); + + MtlsHttpTransportFactory factory = new MtlsHttpTransportFactory(ks); + assertEquals(ks, factory.getKeyStore()); + + HttpTransport transport = factory.create(); + assertNotNull(transport); + } + + @Test + void constructor_withMtlsProvider_createsTransportAndRebuildsContext() + throws Exception { + AtomicInteger callCount = new AtomicInteger(0); + KeyStore ks1 = KeyStore.getInstance(KeyStore.getDefaultType()); + ks1.load(null, null); + KeyStore ks2 = KeyStore.getInstance(KeyStore.getDefaultType()); + ks2.load(null, null); + + MtlsProvider provider = + new MtlsProvider() { + @Override + public KeyStore getKeyStore() + throws CertificateSourceUnavailableException, IOException { + int count = callCount.incrementAndGet(); + return count == 1 ? ks1 : ks2; + } + + @Override + public boolean isAvailable() throws IOException { + return true; + } + }; + + MtlsHttpTransportFactory factory = new MtlsHttpTransportFactory(provider); + assertEquals(1, callCount.get()); + assertEquals(ks1, factory.getKeyStore()); + + HttpTransport transport1 = factory.create(); + assertNotNull(transport1); + + factory.rebuildContext(); + assertEquals(2, callCount.get()); + assertEquals(ks2, factory.getKeyStore()); + + HttpTransport transport2 = factory.create(); + assertNotNull(transport2); + } +} 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..20fe3da621f7 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 @@ -47,6 +47,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.oauth2.ExternalAccountCredentials.SubjectTokenTypes; import com.google.auth.oauth2.ExternalAccountCredentialsTest.TestExternalAccountCredentials.TestCredentialSource; import java.io.ByteArrayInputStream; @@ -59,6 +60,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -922,6 +924,44 @@ void exchangeExternalCredentialForAccessToken() throws IOException { validateMetricsHeader(headers, "file", false, false); } + @Test + void exchangeExternalCredentialForAccessToken_withMtls401_retriesAndRebuildsContext() + throws Exception { + java.security.KeyStore ks = + java.security.KeyStore.getInstance(java.security.KeyStore.getDefaultType()); + ks.load(null, null); + + AtomicInteger rebuildCount = new AtomicInteger(0); + MockExternalAccountCredentialsTransport mockTransport = + new MockExternalAccountCredentialsTransport(); + mockTransport.addResponseStatusCodeSequence(401, 200); + + MtlsHttpTransportFactory mtlsFactory = + new MtlsHttpTransportFactory(ks) { + @Override + public synchronized void rebuildContext() throws IOException { + rebuildCount.incrementAndGet(); + super.rebuildContext(); + } + + @Override + public HttpTransport create() { + return mockTransport; + } + }; + + ExternalAccountCredentials credential = + ExternalAccountCredentials.fromJson(buildJsonIdentityPoolCredential(), mtlsFactory); + StsTokenExchangeRequest stsRequest = + StsTokenExchangeRequest.newBuilder("credential", "subjectTokenType").build(); + + AccessToken accessToken = + credential.exchangeExternalCredentialForAccessToken(stsRequest); + + assertEquals(1, rebuildCount.get()); + assertEquals(mockTransport.getAccessToken(), accessToken.getTokenValue()); + } + @Test void exchangeExternalCredentialForAccessToken_withInternalOptions() throws IOException { ExternalAccountCredentials credential = 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..514c7a7c8073 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 @@ -88,11 +88,16 @@ public class MockExternalAccountCredentialsTransport extends MockHttpTransport { private final Queue responseErrorSequence = new ArrayDeque<>(); private final Queue refreshTokenSequence = new ArrayDeque<>(); private final Queue> scopeSequence = new ArrayDeque<>(); + private final Queue responseStatusCodeSequence = new ArrayDeque<>(); private final List requests = new ArrayList<>(); private String expireTime; private String metadataServerContentType; private String stsContent; + public void addResponseStatusCodeSequence(Integer... statusCodes) { + Collections.addAll(responseStatusCodeSequence, statusCodes); + } + public void addResponseErrorSequence(IOException... errors) { Collections.addAll(responseErrorSequence, errors); } @@ -168,6 +173,16 @@ public LowLevelHttpResponse execute() throws IOException { .setContent(SUBJECT_TOKEN); } if (STS_URL.equals(url)) { + if (!responseStatusCodeSequence.isEmpty()) { + int code = responseStatusCodeSequence.poll(); + if (code != 200) { + return new MockLowLevelHttpResponse() + .setStatusCode(code) + .setContentType(Json.MEDIA_TYPE) + .setContent("{\"error\":\"unauthorized\"}"); + } + } + Map query = TestUtils.parseQuery(getContentAsString()); // Store STS content as multiple calls are made using this transport. From f42179936a0d4d8170383f4e29cfd128aa9983ff Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Tue, 4 Aug 2026 01:36:15 +0000 Subject: [PATCH 12/13] fix(oauth2): introduce ContextRebuildableTransportFactory to restore NetHttpTransport return type on MtlsHttpTransportFactory.create() Restores 100% binary bytecode compatibility for downstream Google Cloud client libraries (e.g. java-bigtable) while preserving dynamic SSL context rebuilding and STS 401 retry capabilities. --- .../ContextRebuildableTransportFactory.java | 50 +++++++++++++++++++ .../auth/mtls/MtlsHttpTransportFactory.java | 5 +- .../oauth2/ExternalAccountCredentials.java | 6 +-- .../ExternalAccountCredentialsTest.java | 9 ++-- 4 files changed, 61 insertions(+), 9 deletions(-) create mode 100644 google-auth-library-java/oauth2_http/java/com/google/auth/http/ContextRebuildableTransportFactory.java diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/http/ContextRebuildableTransportFactory.java b/google-auth-library-java/oauth2_http/java/com/google/auth/http/ContextRebuildableTransportFactory.java new file mode 100644 index 000000000000..8323a7dd33ed --- /dev/null +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/http/ContextRebuildableTransportFactory.java @@ -0,0 +1,50 @@ +/* + * Copyright 2026, Google Inc. All rights reserved. + * + * 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 Inc. 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.http; + +import java.io.IOException; +import org.jspecify.annotations.NullMarked; + +/** + * An interface for {@link HttpTransportFactory} implementations whose underlying context can be + * rebuilt dynamically (e.g. reloading mTLS certificates from disk). + */ +@NullMarked +public interface ContextRebuildableTransportFactory extends HttpTransportFactory { + + /** + * Rebuilds the underlying transport context (such as reloading a KeyStore or SSLSocketFactory). + * + * @throws IOException if rebuilding the context fails + */ + void rebuildContext() throws IOException; +} 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 e0f7acd119c3..8e0db81d8e68 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsHttpTransportFactory.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsHttpTransportFactory.java @@ -34,6 +34,7 @@ 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.ContextRebuildableTransportFactory; import com.google.auth.http.HttpTransportFactory; import com.google.common.annotations.VisibleForTesting; import java.io.IOException; @@ -57,7 +58,7 @@ */ @NullMarked @InternalApi -public class MtlsHttpTransportFactory implements HttpTransportFactory { +public class MtlsHttpTransportFactory implements ContextRebuildableTransportFactory { @Nullable private final MtlsProvider mtlsProvider; private volatile KeyStore mtlsKeyStore; private final DelegatingSSLSocketFactory sslSocketFactory; @@ -130,7 +131,7 @@ KeyStore getKeyStore() { } @Override - public HttpTransport create() { + public NetHttpTransport create() { return new NetHttpTransport.Builder().setSslSocketFactory(sslSocketFactory).build(); } diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java index b97a18943990..a02f475050aa 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 @@ -40,8 +40,8 @@ import com.google.api.client.json.GenericJson; import com.google.api.client.util.Data; import com.google.auth.RequestMetadataCallback; +import com.google.auth.http.ContextRebuildableTransportFactory; import com.google.auth.http.HttpTransportFactory; -import com.google.auth.mtls.MtlsHttpTransportFactory; import com.google.common.base.MoreObjects; import com.google.common.base.Preconditions; import com.google.errorprone.annotations.CanIgnoreReturnValue; @@ -581,13 +581,13 @@ public boolean handleResponse( if (response.getStatusCode() != 401) { return false; } - if (!(transportFactory instanceof MtlsHttpTransportFactory)) { + if (!(transportFactory instanceof ContextRebuildableTransportFactory)) { return false; } if (retried) { return false; } - ((MtlsHttpTransportFactory) transportFactory).rebuildContext(); + ((ContextRebuildableTransportFactory) transportFactory).rebuildContext(); retried = true; return true; } 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 20fe3da621f7..9fc5001eb122 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 @@ -46,6 +46,7 @@ import com.google.api.client.json.JsonParser; import com.google.api.client.util.Clock; import com.google.auth.TestUtils; +import com.google.auth.http.ContextRebuildableTransportFactory; import com.google.auth.http.HttpTransportFactory; import com.google.auth.mtls.MtlsHttpTransportFactory; import com.google.auth.oauth2.ExternalAccountCredentials.SubjectTokenTypes; @@ -936,12 +937,11 @@ void exchangeExternalCredentialForAccessToken_withMtls401_retriesAndRebuildsCont new MockExternalAccountCredentialsTransport(); mockTransport.addResponseStatusCodeSequence(401, 200); - MtlsHttpTransportFactory mtlsFactory = - new MtlsHttpTransportFactory(ks) { + ContextRebuildableTransportFactory rebuildableFactory = + new ContextRebuildableTransportFactory() { @Override public synchronized void rebuildContext() throws IOException { rebuildCount.incrementAndGet(); - super.rebuildContext(); } @Override @@ -951,7 +951,8 @@ public HttpTransport create() { }; ExternalAccountCredentials credential = - ExternalAccountCredentials.fromJson(buildJsonIdentityPoolCredential(), mtlsFactory); + ExternalAccountCredentials.fromJson( + buildJsonIdentityPoolCredential(), rebuildableFactory); StsTokenExchangeRequest stsRequest = StsTokenExchangeRequest.newBuilder("credential", "subjectTokenType").build(); From 6a9fc02912e9affc7a3d3feab7c93f2b3aad0611 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Tue, 4 Aug 2026 01:44:45 +0000 Subject: [PATCH 13/13] style(oauth2): format modified files with google-java-format 1.25.2 to satisfy fmt-maven-plugin check --- .../google/auth/mtls/MtlsHttpTransportFactory.java | 7 ++----- .../google/auth/oauth2/IdentityPoolCredentials.java | 1 - .../auth/mtls/MtlsHttpTransportFactoryTest.java | 13 ++++--------- .../auth/oauth2/ExternalAccountCredentialsTest.java | 7 ++----- 4 files changed, 8 insertions(+), 20 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 8e0db81d8e68..27c2232aad91 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,11 +31,9 @@ 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.ContextRebuildableTransportFactory; -import com.google.auth.http.HttpTransportFactory; import com.google.common.annotations.VisibleForTesting; import java.io.IOException; import java.net.InetAddress; @@ -45,8 +43,8 @@ import java.security.KeyStore; import java.util.Objects; import javax.net.ssl.SSLSocketFactory; -import org.jspecify.annotations.Nullable; import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; /** * An HttpTransportFactory that creates {@link NetHttpTransport} instances configured for mTLS @@ -180,8 +178,7 @@ public Socket createSocket(InetAddress host, int port) throws IOException { @Override public Socket createSocket( - InetAddress address, int port, InetAddress localAddress, int localPort) - throws IOException { + InetAddress address, int port, InetAddress localAddress, int localPort) throws IOException { return delegate.createSocket(address, port, localAddress, localPort); } 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 0eaa82f7a72b..2b147569f043 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,7 +39,6 @@ import com.google.common.annotations.VisibleForTesting; import com.google.errorprone.annotations.CanIgnoreReturnValue; import java.io.IOException; -import java.security.KeyStore; import java.util.ArrayList; import java.util.Collection; import java.util.Map; 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 280f2ff35206..19cc05c53e1d 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 @@ -45,16 +45,13 @@ class MtlsHttpTransportFactoryTest { @Test void constructor_nullKeyStore_throwsNullPointerException() { - assertThrows( - NullPointerException.class, - () -> new MtlsHttpTransportFactory((KeyStore) null)); + assertThrows(NullPointerException.class, () -> new MtlsHttpTransportFactory((KeyStore) null)); } @Test void constructor_nullMtlsProvider_throwsNullPointerException() { assertThrows( - NullPointerException.class, - () -> new MtlsHttpTransportFactory((MtlsProvider) null)); + NullPointerException.class, () -> new MtlsHttpTransportFactory((MtlsProvider) null)); } @Test @@ -70,8 +67,7 @@ void constructor_withKeyStore_createsTransport() throws Exception { } @Test - void constructor_withMtlsProvider_createsTransportAndRebuildsContext() - throws Exception { + void constructor_withMtlsProvider_createsTransportAndRebuildsContext() throws Exception { AtomicInteger callCount = new AtomicInteger(0); KeyStore ks1 = KeyStore.getInstance(KeyStore.getDefaultType()); ks1.load(null, null); @@ -81,8 +77,7 @@ void constructor_withMtlsProvider_createsTransportAndRebuildsContext() MtlsProvider provider = new MtlsProvider() { @Override - public KeyStore getKeyStore() - throws CertificateSourceUnavailableException, IOException { + public KeyStore getKeyStore() throws CertificateSourceUnavailableException, IOException { int count = callCount.incrementAndGet(); return count == 1 ? ks1 : ks2; } 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 9fc5001eb122..905624688f4f 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 @@ -48,7 +48,6 @@ import com.google.auth.TestUtils; import com.google.auth.http.ContextRebuildableTransportFactory; import com.google.auth.http.HttpTransportFactory; -import com.google.auth.mtls.MtlsHttpTransportFactory; import com.google.auth.oauth2.ExternalAccountCredentials.SubjectTokenTypes; import com.google.auth.oauth2.ExternalAccountCredentialsTest.TestExternalAccountCredentials.TestCredentialSource; import java.io.ByteArrayInputStream; @@ -951,13 +950,11 @@ public HttpTransport create() { }; ExternalAccountCredentials credential = - ExternalAccountCredentials.fromJson( - buildJsonIdentityPoolCredential(), rebuildableFactory); + ExternalAccountCredentials.fromJson(buildJsonIdentityPoolCredential(), rebuildableFactory); StsTokenExchangeRequest stsRequest = StsTokenExchangeRequest.newBuilder("credential", "subjectTokenType").build(); - AccessToken accessToken = - credential.exchangeExternalCredentialForAccessToken(stsRequest); + AccessToken accessToken = credential.exchangeExternalCredentialForAccessToken(stsRequest); assertEquals(1, rebuildCount.get()); assertEquals(mockTransport.getAccessToken(), accessToken.getTokenValue());