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 3e658322347a..ac8b0c22d434 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 @@ -33,11 +33,18 @@ import com.google.api.client.http.javanet.NetHttpTransport; import com.google.api.core.InternalApi; -import com.google.auth.http.HttpTransportFactory; +import com.google.auth.http.ContextRebuildableTransportFactory; +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.NullMarked; +import org.jspecify.annotations.Nullable; /** * An HttpTransportFactory that creates {@link NetHttpTransport} instances configured for mTLS @@ -49,8 +56,21 @@ */ @NullMarked @InternalApi -public class MtlsHttpTransportFactory implements HttpTransportFactory { - private final KeyStore mtlsKeyStore; +public class MtlsHttpTransportFactory implements ContextRebuildableTransportFactory { + @Nullable private final MtlsProvider mtlsProvider; + @Nullable private volatile KeyStore mtlsKeyStore; + private final DelegatingSSLSocketFactory sslSocketFactory; + + /** Constructs a default factory for mTLS transports without a custom KeyStore. */ + public MtlsHttpTransportFactory() { + this.mtlsKeyStore = null; + this.mtlsProvider = null; + try { + this.sslSocketFactory = new DelegatingSSLSocketFactory(buildSslSocketFactory(null)); + } catch (GeneralSecurityException e) { + throw new RuntimeException("Failed to initialize mTLS transport.", e); + } + } /** * Constructs a factory for mTLS transports. @@ -61,17 +81,123 @@ 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(@Nullable 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 { + KeyStore newKeyStore = this.mtlsProvider.getKeyStore(); + SSLSocketFactory newSslSocketFactory = buildSslSocketFactory(newKeyStore); + this.mtlsKeyStore = newKeyStore; + this.sslSocketFactory.setDelegate(newSslSocketFactory); + } 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 + @Nullable KeyStore getKeyStore() { + return mtlsKeyStore; + } + + @Override + public NetHttpTransport 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 917f01fe89e0..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 @@ -34,9 +34,13 @@ 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.ContextRebuildableTransportFactory; import com.google.auth.http.HttpTransportFactory; import com.google.common.base.MoreObjects; import com.google.common.base.Preconditions; @@ -431,6 +435,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 +492,7 @@ static ExternalAccountCredentials fromJson( .setHttpTransportFactory(transportFactory) .setAudience(audience) .setSubjectTokenType(subjectTokenType) + .setActorTokenType(actorTokenType) .setTokenUrl(tokenUrl) .setTokenInfoUrl(tokenInfoUrl) .setCredentialSource(new IdentityPoolCredentialSource(credentialSourceMap)) @@ -564,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 ContextRebuildableTransportFactory)) { + return false; + } + if (retried) { + return false; + } + ((ContextRebuildableTransportFactory) 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/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..2115e3617f9e --- /dev/null +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolTokenSupplier.java @@ -0,0 +1,175 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.oauth2; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.api.client.json.GenericJson; +import com.google.api.client.json.JsonObjectParser; +import com.google.api.client.util.Data; +import com.google.auth.oauth2.IdentityPoolCredentialSource.CredentialFormatType; +import com.google.common.io.CharStreams; +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.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 static final long serialVersionUID = 2475549052347431993L; + + private final IdentityPoolCredentialSource credentialSource; + + private static class CachedFile { + final long lastModified; + final GenericJson parsedJson; + + CachedFile(long lastModified, GenericJson parsedJson) { + this.lastModified = lastModified; + this.parsedJson = parsedJson; + } + } + + private transient volatile CachedFile cachedFile; + + FileIdentityPoolTokenSupplier(IdentityPoolCredentialSource credentialSource) { + this.credentialSource = checkNotNull(credentialSource, "credentialSource cannot be null"); + } + + @Override + public String getSubjectToken(ExternalAccountSupplierContext context) throws IOException { + return getToken(credentialSource.subjectTokenFieldName); + } + + @Override + public String getActorToken(ExternalAccountSupplierContext context) throws IOException { + if (credentialSource.credentialFormatType == CredentialFormatType.TEXT) { + throw new IllegalArgumentException( + "Actor tokens are only supported for JSON-formatted credential files with distinct field names."); + } + return getToken(credentialSource.actorTokenFieldName); + } + + private String getToken(@Nullable String targetFieldName) throws IOException { + String credentialFilePath = credentialSource.getCredentialLocation(); + if (!Files.exists(Paths.get(credentialFilePath), LinkOption.NOFOLLOW_LINKS)) { + throw new IOException( + String.format( + "Invalid credential location. The file at %s does not exist.", credentialFilePath)); + } + + if (credentialSource.credentialFormatType == CredentialFormatType.JSON) { + if (targetFieldName == null) { + throw new IOException("Target field name must be specified for JSON credentials."); + } + File file = new File(credentialFilePath); + long lastModified = file.lastModified(); + + CachedFile cached = this.cachedFile; + + if (cached == null || cached.lastModified < lastModified) { + synchronized (this) { + cached = this.cachedFile; + if (cached == null || cached.lastModified < lastModified) { + try (InputStream inputStream = Files.newInputStream(file.toPath())) { + JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY); + GenericJson parsedJson = + parser.parseAndClose(inputStream, StandardCharsets.UTF_8, GenericJson.class); + cached = new CachedFile(lastModified, parsedJson); + this.cachedFile = cached; + } catch (Exception e) { + throw new IOException( + "Error when attempting to read the token from the credential file.", e); + } + } + } + } + + Object value = cached.parsedJson.get(targetFieldName); + if (value == null || Data.isNull(value)) { + throw new IOException( + "Invalid token field name. No token was found for field: " + targetFieldName); + } + return value.toString(); + } + + 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 { + try (InputStream in = inputStream; + java.io.Reader reader = new InputStreamReader(in, StandardCharsets.UTF_8)) { + if (credentialSource.credentialFormatType == CredentialFormatType.TEXT) { + return CharStreams.toString(new BufferedReader(reader)); + } + + if (targetFieldName == null) { + throw new IOException("Target field name must be specified for JSON credentials."); + } + + JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY); + GenericJson fileContents = + parser.parseAndClose(in, StandardCharsets.UTF_8, GenericJson.class); + + Object value = fileContents.get(targetFieldName); + if (value == null || Data.isNull(value)) { + throw new IOException( + "Invalid token field name. No token was found for field: " + targetFieldName); + } + return value.toString(); + } + } +} 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..e46c8c3bda1c --- /dev/null +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolActorTokenSupplier.java @@ -0,0 +1,50 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.oauth2; + +import java.io.IOException; +import org.jspecify.annotations.NullMarked; + +/** Functional interface for supplying an actor token for IdentityPool credentials. */ +@NullMarked +@FunctionalInterface +public 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..b1382204309e 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java @@ -31,6 +31,7 @@ package com.google.auth.oauth2; +import com.google.auth.http.ContextRebuildableTransportFactory; import com.google.auth.http.HttpTransportFactory; import com.google.auth.mtls.MtlsHttpTransportFactory; import com.google.auth.mtls.MtlsUtils; @@ -39,7 +40,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; @@ -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 { @@ -60,6 +65,9 @@ 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; + @Nullable private final transient X509Provider x509Provider; private final ExternalAccountSupplierContext supplierContext; private final String metricsHeaderValue; @@ -89,7 +97,17 @@ 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); + if (credentialSource.getCertificateConfig() != null) { + try { + X509Provider x509Provider = getX509Provider(builder, credentialSource); + this.transportFactory = new MtlsHttpTransportFactory(x509Provider); + } 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) { this.subjectTokenSupplier = @@ -111,6 +129,38 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { } else { throw new IllegalArgumentException("Source type not supported."); } + + this.actorTokenType = builder.actorTokenType; + if (builder.actorTokenSupplier != null) { + this.actorTokenSupplier = builder.actorTokenSupplier; + } else if (credentialSource != null && credentialSource.actorTokenFieldName != null) { + if (this.subjectTokenSupplier instanceof FileIdentityPoolTokenSupplier) { + this.actorTokenSupplier = (FileIdentityPoolTokenSupplier) this.subjectTokenSupplier; + } else { + throw new IllegalArgumentException( + "Actor tokens are currently only supported for file-based credential sources."); + } + } else { + this.actorTokenSupplier = null; + } + + if (this.actorTokenSupplier != null + && (this.actorTokenType == null || this.actorTokenType.trim().isEmpty())) { + throw new IllegalArgumentException( + "An actorTokenType must be specified when an actorTokenSupplier is configured."); + } + if (this.actorTokenSupplier == null && this.actorTokenType != null) { + throw new IllegalArgumentException( + "An actorTokenSupplier must be specified when an actorTokenType is configured."); + } + + if (this.actorTokenSupplier != null + && !(this.transportFactory instanceof ContextRebuildableTransportFactory)) { + throw new IllegalArgumentException( + "Actor tokens are only supported for mTLS token exchanges. Please configure a certificate source or MtlsHttpTransportFactory."); + } + + this.x509Provider = builder.x509Provider; } @Override @@ -120,6 +170,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 +198,21 @@ IdentityPoolSubjectTokenSupplier getIdentityPoolSubjectTokenSupplier() { return this.subjectTokenSupplier; } + @VisibleForTesting + @Nullable IdentityPoolActorTokenSupplier getIdentityPoolActorTokenSupplier() { + return this.actorTokenSupplier; + } + + @VisibleForTesting + String getActorTokenType() { + return this.actorTokenType; + } + + @VisibleForTesting + HttpTransportFactory getTransportFactory() { + return this.transportFactory; + } + /** Clones the IdentityPoolCredentials with the specified scopes. */ @Override public IdentityPoolCredentials createScoped(Collection newScopes) { @@ -166,8 +236,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); @@ -205,6 +274,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 +284,10 @@ 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; + this.x509Provider = credentials.x509Provider; } /** @@ -244,6 +318,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/Slf4jLoggingHelpers.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/Slf4jLoggingHelpers.java index 02c1e2681539..510e54ba174b 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/Slf4jLoggingHelpers.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/Slf4jLoggingHelpers.java @@ -71,6 +71,7 @@ class Slf4jLoggingHelpers { "signedBlob", "authorization", "subject_token", + "actor_token", "id_token")); } diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/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/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/mtls/MtlsHttpTransportFactoryTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsHttpTransportFactoryTest.java new file mode 100644 index 000000000000..19cc05c53e1d --- /dev/null +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsHttpTransportFactoryTest.java @@ -0,0 +1,105 @@ +/* + * 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 1338c0d68fe9..c4f3d6f43231 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.oauth2.ExternalAccountCredentials.SubjectTokenTypes; import com.google.auth.oauth2.ExternalAccountCredentialsTest.TestExternalAccountCredentials.TestCredentialSource; @@ -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; @@ -200,6 +202,33 @@ void fromJson_identityPoolCredentialsWorkload() { assertEquals(GOOGLE_DEFAULT_UNIVERSE, credential.getUniverseDomain()); } + @Test + void fromJson_identityPoolCredentials_withActorTokenType() throws Exception { + GenericJson json = buildJsonIdentityPoolCredential(); + json.put("actor_token_type", "actorTokenType"); + + Map credentialSource = (Map) json.get("credential_source"); + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("actor_token_field_name", "actor_token"); + formatMap.put("subject_token_field_name", "subject_token"); + credentialSource.put("format", formatMap); + + java.security.KeyStore ks = + java.security.KeyStore.getInstance(java.security.KeyStore.getDefaultType()); + ks.load(null, null); + com.google.auth.mtls.MtlsHttpTransportFactory mockTransportFactory = + new com.google.auth.mtls.MtlsHttpTransportFactory(ks); + + ExternalAccountCredentials credential = + ExternalAccountCredentials.fromJson(json, mockTransportFactory); + + assertInstanceOf(IdentityPoolCredentials.class, credential); + IdentityPoolCredentials idpCreds = (IdentityPoolCredentials) credential; + assertEquals("subjectTokenType", idpCreds.getSubjectTokenType()); + assertEquals("actorTokenType", idpCreds.getActorTokenType()); + } + @Test void fromJson_identityPoolCredentialsWorkforce() { ExternalAccountCredentials credential = @@ -895,6 +924,90 @@ 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); + + ContextRebuildableTransportFactory rebuildableFactory = + new ContextRebuildableTransportFactory() { + @Override + public synchronized void rebuildContext() throws IOException { + rebuildCount.incrementAndGet(); + } + + @Override + public HttpTransport create() { + return mockTransport; + } + }; + + ExternalAccountCredentials credential = + ExternalAccountCredentials.fromJson(buildJsonIdentityPoolCredential(), rebuildableFactory); + StsTokenExchangeRequest stsRequest = + StsTokenExchangeRequest.newBuilder("credential", "subjectTokenType").build(); + + AccessToken accessToken = credential.exchangeExternalCredentialForAccessToken(stsRequest); + + assertEquals(1, rebuildCount.get()); + assertEquals(mockTransport.getAccessToken(), accessToken.getTokenValue()); + } + + @Test + void exchangeExternalCredentialForAccessToken_withMtls401Repeated_doesNotRetryIndefinitely() { + AtomicInteger rebuildCount = new AtomicInteger(0); + MockExternalAccountCredentialsTransport mockTransport = + new MockExternalAccountCredentialsTransport(); + mockTransport.addResponseStatusCodeSequence(401, 401); + + ContextRebuildableTransportFactory rebuildableFactory = + new ContextRebuildableTransportFactory() { + @Override + public synchronized void rebuildContext() throws IOException { + rebuildCount.incrementAndGet(); + } + + @Override + public HttpTransport create() { + return mockTransport; + } + }; + + ExternalAccountCredentials credential = + ExternalAccountCredentials.fromJson(buildJsonIdentityPoolCredential(), rebuildableFactory); + StsTokenExchangeRequest stsRequest = + StsTokenExchangeRequest.newBuilder("credential", "subjectTokenType").build(); + + assertThrows( + IOException.class, () -> credential.exchangeExternalCredentialForAccessToken(stsRequest)); + + assertEquals(1, rebuildCount.get()); + } + + @Test + void exchangeExternalCredentialForAccessToken_withNonRebuildableTransport401_doesNotRetry() { + MockExternalAccountCredentialsTransport mockTransport = + new MockExternalAccountCredentialsTransport(); + mockTransport.addResponseStatusCodeSequence(401); + + HttpTransportFactory standardFactory = () -> mockTransport; + + ExternalAccountCredentials credential = + ExternalAccountCredentials.fromJson(buildJsonIdentityPoolCredential(), standardFactory); + StsTokenExchangeRequest stsRequest = + StsTokenExchangeRequest.newBuilder("credential", "subjectTokenType").build(); + + assertThrows( + IOException.class, () -> credential.exchangeExternalCredentialForAccessToken(stsRequest)); + } + @Test void exchangeExternalCredentialForAccessToken_withInternalOptions() throws IOException { 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..b60e25f6277c --- /dev/null +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolTokenSupplierTest.java @@ -0,0 +1,305 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.oauth2; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; +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(StandardCharsets.UTF_8)); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", credentialFile.toString()); + + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); + FileIdentityPoolTokenSupplier supplier = + new FileIdentityPoolTokenSupplier(source); // TEXT doesn't need targetFieldName + + assertEquals("plain_token", supplier.getSubjectToken(null)); + + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> supplier.getActorToken(null)); + assertEquals( + "Actor tokens are only supported for JSON-formatted credential files with distinct field names.", + exception.getMessage()); + } + + @Test + void getToken_jsonFormat_cachingLogic(@TempDir Path tempDir) throws IOException { + Path credentialFile = tempDir.resolve("credential.json"); + Files.write( + credentialFile, + "{\"sub_token\": \"my_sub_token\", \"act_token\": \"my_act_token\"}" + .getBytes(StandardCharsets.UTF_8)); + Files.setLastModifiedTime(credentialFile, FileTime.fromMillis(10000)); + + 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); + + // Initial read + assertEquals("my_sub_token", supplier.getSubjectToken(null)); + assertEquals("my_act_token", supplier.getActorToken(null)); + + // Modify file with advance in modification time + Files.write( + credentialFile, + "{\"sub_token\": \"new_sub\", \"act_token\": \"new_act\"}" + .getBytes(StandardCharsets.UTF_8)); + Files.setLastModifiedTime(credentialFile, FileTime.fromMillis(20000)); + + // Validate we read the new token after file modification + assertEquals("new_sub", supplier.getSubjectToken(null)); + assertEquals("new_act", supplier.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(StandardCharsets.UTF_8)); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", credentialFile.toString()); + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "sub_token"); + formatMap.put("actor_token_field_name", "act_token"); + credentialSourceMap.put("format", formatMap); + + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); + FileIdentityPoolTokenSupplier supplier = new FileIdentityPoolTokenSupplier(source); + + int numThreads = 10; + java.util.concurrent.ExecutorService executor = + java.util.concurrent.Executors.newFixedThreadPool(numThreads); + java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(numThreads); + java.util.List> futures = new java.util.ArrayList<>(); + + for (int i = 0; i < numThreads; i++) { + futures.add( + executor.submit( + () -> { + latch.countDown(); + latch.await(); + assertEquals("my_sub_token", supplier.getSubjectToken(null)); + assertEquals("my_act_token", supplier.getActorToken(null)); + return null; + })); + } + + // Wait for all threads to complete and verify no exceptions were thrown + for (java.util.concurrent.Future future : futures) { + try { + future.get(); + } catch (Exception e) { + throw new RuntimeException("Thread execution failed", e); + } + } + executor.shutdown(); + } + + @Test + void getToken_jsonFormat_invalidField(@TempDir Path tempDir) throws IOException { + Path credentialFile = tempDir.resolve("credential.json"); + Files.write( + credentialFile, "{\"sub_token\": \"my_sub_token\"}".getBytes(StandardCharsets.UTF_8)); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", credentialFile.toString()); + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "sub_token"); + formatMap.put("actor_token_field_name", "act_token"); + credentialSourceMap.put("format", formatMap); + + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); + FileIdentityPoolTokenSupplier actSupplier = new FileIdentityPoolTokenSupplier(source); + + IOException exception = assertThrows(IOException.class, () -> actSupplier.getActorToken(null)); + assertEquals( + "Invalid token field name. No token was found for field: act_token", + exception.getMessage()); + } + + @Test + void parseToken_jsonFormat_nullField_throws(@TempDir Path tempDir) throws IOException { + Path credentialFile = tempDir.resolve("credential.json"); + Files.write(credentialFile, "{\"sub_token\": null}".getBytes(StandardCharsets.UTF_8)); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", credentialFile.toString()); + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "sub_token"); + credentialSourceMap.put("format", formatMap); + + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); + FileIdentityPoolTokenSupplier supplier = new FileIdentityPoolTokenSupplier(source); + + IOException exception = assertThrows(IOException.class, () -> supplier.getSubjectToken(null)); + assertTrue(exception.getMessage().contains("No token was found for field: sub_token")); + } + + @Test + void parseToken_jsonFormat_nonStringField_convertsToString(@TempDir Path tempDir) + throws IOException { + Path credentialFile = tempDir.resolve("credential.json"); + Files.write(credentialFile, "{\"sub_token\": 12345}".getBytes(StandardCharsets.UTF_8)); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", credentialFile.toString()); + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "sub_token"); + credentialSourceMap.put("format", formatMap); + + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); + FileIdentityPoolTokenSupplier supplier = new FileIdentityPoolTokenSupplier(source); + + assertEquals("12345", supplier.getSubjectToken(null)); + } + + @Test + void serialization_postCachePopulation_succeeds(@TempDir Path tempDir) throws Exception { + Path credentialFile = tempDir.resolve("credential.json"); + Files.write( + credentialFile, + "{\"sub_token\": \"my_sub_token\", \"act_token\": \"my_act_token\"}" + .getBytes(StandardCharsets.UTF_8)); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", credentialFile.toString()); + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "sub_token"); + formatMap.put("actor_token_field_name", "act_token"); + credentialSourceMap.put("format", formatMap); + + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); + FileIdentityPoolTokenSupplier supplier = new FileIdentityPoolTokenSupplier(source); + + // Populate cache + assertEquals("my_sub_token", supplier.getSubjectToken(null)); + + // Serialize and deserialize + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ObjectOutputStream oos = new ObjectOutputStream(baos)) { + oos.writeObject(supplier); + } + try (ObjectInputStream ois = + new ObjectInputStream(new ByteArrayInputStream(baos.toByteArray()))) { + FileIdentityPoolTokenSupplier deserialized = (FileIdentityPoolTokenSupplier) ois.readObject(); + assertNotNull(deserialized); + assertEquals("my_sub_token", deserialized.getSubjectToken(null)); + } + } + + @Test + void getToken_missingFile_throws(@TempDir Path tempDir) { + Path credentialFile = tempDir.resolve("missing_file.txt"); + + 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()); + } + + @Test + void parseToken_textFormat_succeeds() throws IOException { + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", "dummy.txt"); + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); + + ByteArrayInputStream stream = + new ByteArrayInputStream("plain_text_token".getBytes(StandardCharsets.UTF_8)); + String parsed = FileIdentityPoolTokenSupplier.parseToken(stream, source, null); + assertEquals("plain_text_token", parsed); + } + + @Test + void parseToken_jsonFormat_missingFieldName_throws() { + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", "dummy.json"); + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "sub_token"); + credentialSourceMap.put("format", formatMap); + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); + + ByteArrayInputStream stream = + new ByteArrayInputStream("{\"sub_token\": \"my_token\"}".getBytes(StandardCharsets.UTF_8)); + IOException exception = + assertThrows( + IOException.class, + () -> FileIdentityPoolTokenSupplier.parseToken(stream, source, null)); + assertEquals( + "Target field name must be specified for JSON credentials.", exception.getMessage()); + } +} diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ITMtlsCertRotationTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ITMtlsCertRotationTest.java new file mode 100644 index 000000000000..966aeadf3e14 --- /dev/null +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ITMtlsCertRotationTest.java @@ -0,0 +1,363 @@ +/* + * 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 PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.oauth2; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.api.client.json.GenericJson; +import com.google.auth.mtls.MtlsHttpTransportFactory; +import com.google.auth.mtls.X509Provider; +import java.io.BufferedReader; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.security.KeyFactory; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.cert.Certificate; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLServerSocket; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.TrustManagerFactory; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * End-to-end integration test verifying: 1) mTLS Dynamic Certificate Rotation + * (MtlsHttpTransportFactory + X509Provider) 2) STS 401 Unauthorized Retry Interception over real + * local HTTPS sockets requiring mTLS + * + *

Uses raw SSLServerSocket and static pre-generated test resource fixtures in + * testresources/mtls/ for zero host process / OpenSSL dependencies and zero JDK module + * instrumentation warnings. + */ +public class ITMtlsCertRotationTest { + + private static final String TESTRESOURCES_DIR = "testresources/mtls/"; + + @TempDir Path tempDir; + + private SSLServerSocket serverSocket; + private Thread serverThread; + private volatile boolean running = true; + private int serverPort; + private final List peerCertificatesReceived = + Collections.synchronizedList(new ArrayList<>()); + private final AtomicInteger requestCounter = new AtomicInteger(0); + private final CountDownLatch serverReadyLatch = new CountDownLatch(1); + + private Path certConfigPath; + private Path activeCertPath; + private Path activeKeyPath; + private Path cert1Path; + private Path key1Path; + private Path cert2Path; + private Path key2Path; + private Path serverCertPath; + private Path serverKeyPath; + private String oldTrustStore; + private String oldTrustStorePassword; + + @BeforeEach + void setUp() throws Exception { + cert1Path = Paths.get(TESTRESOURCES_DIR, "client_v1.crt"); + key1Path = Paths.get(TESTRESOURCES_DIR, "client_v1.pem.key"); + cert2Path = Paths.get(TESTRESOURCES_DIR, "client_v2.crt"); + key2Path = Paths.get(TESTRESOURCES_DIR, "client_v2.pem.key"); + serverCertPath = Paths.get(TESTRESOURCES_DIR, "server.crt"); + serverKeyPath = Paths.get(TESTRESOURCES_DIR, "server.pem.key"); + + activeCertPath = tempDir.resolve("active_client.crt"); + activeKeyPath = tempDir.resolve("active_client.pem.key"); + Files.copy(cert1Path, activeCertPath, StandardCopyOption.REPLACE_EXISTING); + Files.copy(key1Path, activeKeyPath, StandardCopyOption.REPLACE_EXISTING); + + certConfigPath = tempDir.resolve("certificate_config.json"); + String configJson = + "{\n" + + " \"cert_configs\": {\n" + + " \"workload\": {\n" + + " \"cert_path\": \"" + + activeCertPath.toString().replace("\\", "/") + + "\",\n" + + " \"key_path\": \"" + + activeKeyPath.toString().replace("\\", "/") + + "\"\n" + + " }\n" + + " }\n" + + "}\n"; + Files.write(certConfigPath, configJson.getBytes(StandardCharsets.UTF_8)); + + oldTrustStore = System.getProperty("javax.net.ssl.trustStore"); + oldTrustStorePassword = System.getProperty("javax.net.ssl.trustStorePassword"); + + Path clientTrustStorePath = tempDir.resolve("client_truststore.p12"); + KeyStore clientTrustStore = KeyStore.getInstance("PKCS12"); + clientTrustStore.load(null, null); + addCertToTrustStore(clientTrustStore, serverCertPath, "server"); + try (FileOutputStream fos = new FileOutputStream(clientTrustStorePath.toFile())) { + clientTrustStore.store(fos, "password".toCharArray()); + } + + System.setProperty("javax.net.ssl.trustStore", clientTrustStorePath.toString()); + System.setProperty("javax.net.ssl.trustStorePassword", "password"); + + startLocalMtlsServerSocket(); + } + + @AfterEach + void tearDown() { + running = false; + if (serverSocket != null && !serverSocket.isClosed()) { + try { + serverSocket.close(); + } catch (Exception ignored) { + } + } + if (oldTrustStore != null) { + System.setProperty("javax.net.ssl.trustStore", oldTrustStore); + } else { + System.clearProperty("javax.net.ssl.trustStore"); + } + if (oldTrustStorePassword != null) { + System.setProperty("javax.net.ssl.trustStorePassword", oldTrustStorePassword); + } else { + System.clearProperty("javax.net.ssl.trustStorePassword"); + } + } + + @Test + void endToEndMtlsCertRotation_on401Retry_reloadsRotatedCertAndSucceeds() throws Exception { + assertTrue( + serverReadyLatch.await(5, TimeUnit.SECONDS), "Server socket failed to start in time"); + + X509Provider x509Provider = new X509Provider(certConfigPath.toString()); + MtlsHttpTransportFactory transportFactory = new MtlsHttpTransportFactory(x509Provider); + + GenericJson json = new GenericJson(); + json.put("type", "external_account"); + json.put( + "audience", + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider"); + json.put("subject_token_type", "urn:ietf:params:oauth:token-type:id_token"); + json.put("token_url", "https://127.0.0.1:" + serverPort + "/sts/token"); + + Map credentialSource = new HashMap<>(); + credentialSource.put("file", activeCertPath.toString()); + json.put("credential_source", credentialSource); + + ExternalAccountCredentials credential = + ExternalAccountCredentials.fromJson(json, transportFactory); + + StsTokenExchangeRequest stsRequest = + StsTokenExchangeRequest.newBuilder( + "subject_token_payload", "urn:ietf:params:oauth:token-type:id_token") + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .build(); + + AccessToken accessToken = credential.exchangeExternalCredentialForAccessToken(stsRequest); + + assertNotNull(accessToken); + assertEquals("access_token_via_rotated_mtls_cert_v2", accessToken.getTokenValue()); + assertEquals(2, requestCounter.get()); + assertEquals(2, peerCertificatesReceived.size()); + + assertTrue(peerCertificatesReceived.get(0).contains("CN=client-v1")); + assertTrue(peerCertificatesReceived.get(1).contains("CN=client-v2")); + } + + private void startLocalMtlsServerSocket() throws Exception { + SSLContext serverSslContext = createServerSslContext(); + serverSocket = (SSLServerSocket) serverSslContext.getServerSocketFactory().createServerSocket(); + serverSocket.bind(new InetSocketAddress("127.0.0.1", 0)); + serverSocket.setNeedClientAuth(true); + serverPort = serverSocket.getLocalPort(); + + serverThread = + new Thread( + () -> { + serverReadyLatch.countDown(); + while (running) { + try (SSLSocket clientSocket = (SSLSocket) serverSocket.accept()) { + clientSocket.startHandshake(); + int count = requestCounter.incrementAndGet(); + String peerPrincipalName = "UNKNOWN"; + Certificate[] certs = clientSocket.getSession().getPeerCertificates(); + if (certs != null && certs.length > 0 && certs[0] instanceof X509Certificate) { + peerPrincipalName = + ((X509Certificate) certs[0]).getSubjectX500Principal().getName(); + peerCertificatesReceived.add(peerPrincipalName); + } + + BufferedReader reader = + new BufferedReader( + new InputStreamReader( + clientSocket.getInputStream(), StandardCharsets.UTF_8)); + String line; + int contentLength = 0; + while ((line = reader.readLine()) != null && !line.isEmpty()) { + if (line.toLowerCase().startsWith("content-length:")) { + contentLength = Integer.parseInt(line.split(":")[1].trim()); + } + } + if (contentLength > 0) { + char[] body = new char[contentLength]; + reader.read(body, 0, contentLength); + } + + OutputStream os = clientSocket.getOutputStream(); + if (peerPrincipalName.contains("client-v1")) { + Path tmpCert = tempDir.resolve("tmp_active_cert.crt"); + Path tmpKey = tempDir.resolve("tmp_active_key.pem.key"); + Files.copy(cert2Path, tmpCert, StandardCopyOption.REPLACE_EXISTING); + Files.copy(key2Path, tmpKey, StandardCopyOption.REPLACE_EXISTING); + Files.move(tmpCert, activeCertPath, StandardCopyOption.ATOMIC_MOVE); + Files.move(tmpKey, activeKeyPath, StandardCopyOption.ATOMIC_MOVE); + + String jsonError = + "{\"error\": \"invalid_grant\", \"error_description\": \"mTLS Certificate Expired\"}"; + byte[] payload = jsonError.getBytes(StandardCharsets.UTF_8); + String response = + "HTTP/1.1 401 Unauthorized\r\n" + + "Content-Type: application/json\r\n" + + "Content-Length: " + + payload.length + + "\r\n" + + "Connection: close\r\n\r\n"; + os.write(response.getBytes(StandardCharsets.UTF_8)); + os.write(payload); + os.flush(); + } else { + String jsonOk = + "{\"access_token\": \"access_token_via_rotated_mtls_cert_v2\"," + + " \"issued_token_type\": \"urn:ietf:params:oauth:token-type:access_token\"," + + " \"token_type\": \"Bearer\", \"expires_in\": 3600}"; + byte[] payload = jsonOk.getBytes(StandardCharsets.UTF_8); + String response = + "HTTP/1.1 200 OK\r\n" + + "Content-Type: application/json\r\n" + + "Content-Length: " + + payload.length + + "\r\n" + + "Connection: close\r\n\r\n"; + os.write(response.getBytes(StandardCharsets.UTF_8)); + os.write(payload); + os.flush(); + } + } catch (Exception e) { + if (running) { + e.printStackTrace(); + } + } + } + }); + serverThread.setDaemon(true); + serverThread.start(); + } + + private SSLContext createServerSslContext() throws Exception { + KeyStore keyStore = createKeyStoreFromPem(serverCertPath, serverKeyPath, "server"); + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(keyStore, "password".toCharArray()); + + KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); + trustStore.load(null, null); + addCertToTrustStore(trustStore, cert1Path, "client-v1"); + addCertToTrustStore(trustStore, cert2Path, "client-v2"); + + TrustManagerFactory tmf = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(trustStore); + + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null); + return sslContext; + } + + private void addCertToTrustStore(KeyStore trustStore, Path certPath, String alias) + throws Exception { + try (InputStream in = new FileInputStream(certPath.toFile())) { + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + X509Certificate cert = (X509Certificate) cf.generateCertificate(in); + trustStore.setCertificateEntry(alias, cert); + } + } + + private KeyStore createKeyStoreFromPem(Path certPath, Path keyPath, String alias) + throws Exception { + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + X509Certificate cert; + try (InputStream certIn = new FileInputStream(certPath.toFile())) { + cert = (X509Certificate) cf.generateCertificate(certIn); + } + + String pemKey = + new String(Files.readAllBytes(keyPath), StandardCharsets.UTF_8) + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s", ""); + byte[] keyBytes = Base64.getDecoder().decode(pemKey); + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes); + KeyFactory kf = KeyFactory.getInstance("RSA"); + PrivateKey privateKey = kf.generatePrivate(spec); + + KeyStore ks = KeyStore.getInstance("PKCS12"); + ks.load(null, null); + ks.setKeyEntry(alias, privateKey, "password".toCharArray(), new Certificate[] {cert}); + return ks; + } +} 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); + } } 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..7aa2ac2a2a1a 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java @@ -45,7 +45,9 @@ import com.google.api.client.json.GenericJson; 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.mtls.X509Provider; import com.google.auth.oauth2.GoogleCredentials.GoogleCredentialsInfo; import java.io.ByteArrayInputStream; @@ -75,6 +77,9 @@ class IdentityPoolCredentialsTest extends BaseSerializationTest { private static final IdentityPoolSubjectTokenSupplier testProvider = (ExternalAccountSupplierContext context) -> "testSubjectToken"; + private static final IdentityPoolActorTokenSupplier testActorSupplier = + (ExternalAccountSupplierContext context) -> "testActorToken"; + @Test void createdScoped_clonedCredentialWithAddedScopes() { IdentityPoolCredentials credentials = @@ -254,6 +259,35 @@ void retrieveSubjectToken_urlSourcedWithJsonFormat() throws IOException { assertEquals(transportFactory.transport.getSubjectToken(), subjectToken); } + @Test + void retrieveSubjectToken_urlSourcedWithJsonFormat_withActorTokenField() throws IOException { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + transportFactory.transport.setMetadataServerContentType("json"); + + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "subjectToken"); + formatMap.put("actor_token_field_name", "actorToken"); + + IdentityPoolCredentialSource credentialSource = + buildUrlBasedCredentialSource(transportFactory.transport.getMetadataUrl(), formatMap); + + UrlIdentityPoolSubjectTokenSupplier supplier = + new UrlIdentityPoolSubjectTokenSupplier(credentialSource, transportFactory); + + ExternalAccountSupplierContext dummyContext = + ExternalAccountSupplierContext.newBuilder() + .setAudience("aud") + .setSubjectTokenType("urn") + .build(); + + String subjectToken = supplier.getSubjectToken(dummyContext); + + assertEquals(transportFactory.transport.getSubjectToken(), subjectToken); + } + @Test void retrieveSubjectToken_urlSourcedCredential_throws() { MockExternalAccountCredentialsTransportFactory transportFactory = @@ -1265,6 +1299,14 @@ private IdentityPoolCredentialSource createFileCredentialSource() { return new IdentityPoolCredentialSource(fileCredentialSourceMap); } + private IdentityPoolCredentialSource createFileCredentialSource( + String filePath, Map formatMap) { + Map fileCredentialSourceMap = new HashMap<>(); + fileCredentialSourceMap.put("file", filePath); + fileCredentialSourceMap.put("format", formatMap); + return new IdentityPoolCredentialSource(fileCredentialSourceMap); + } + static class MockExternalAccountCredentialsTransportFactory implements HttpTransportFactory { MockExternalAccountCredentialsTransport transport = @@ -1299,4 +1341,336 @@ void setShouldThrowOnGetKeyStore(boolean shouldThrow) { this.shouldThrowOnGetKeyStore = shouldThrow; } } + + @Test + void builder_actorTokenWithNonMtlsTransportFactory_throws() { + IdentityPoolCredentialSource credentialSource = createFileCredentialSource(); + + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://invalid.googleapis.com/") + .setCredentialSource(credentialSource) + .setActorTokenType("actorTokenType") + .setActorTokenSupplier( + new IdentityPoolActorTokenSupplier() { + @Override + public String getActorToken(ExternalAccountSupplierContext context) { + return "token"; + } + }) + .build()); + + assertEquals( + "Actor tokens are only supported for mTLS token exchanges. Please configure a certificate source or MtlsHttpTransportFactory.", + e.getMessage()); + } + + @Test + void builder_actorTokenWithMissingTokenType_throws() { + IdentityPoolCredentialSource credentialSource = createFileCredentialSource(); + + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/") + .setCredentialSource(credentialSource) + .setActorTokenSupplier( + new IdentityPoolActorTokenSupplier() { + @Override + public String getActorToken(ExternalAccountSupplierContext context) { + return "token"; + } + }) + .build()); + + assertEquals( + "An actorTokenType must be specified when an actorTokenSupplier is configured.", + e.getMessage()); + } + + @Test + void builder_actorTokenWithInvalidCredentialSource_throws() { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "subject_token"); + formatMap.put("actor_token_field_name", "actor_token"); + + // Not a file credential source + IdentityPoolCredentialSource credentialSource = + buildUrlBasedCredentialSource(transportFactory.transport.getMetadataUrl(), formatMap); + + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/") // Valid URL + .setCredentialSource(credentialSource) // Invalid source for actor tokens + .build()); + + assertEquals( + "Actor tokens are currently only supported for file-based credential sources.", + e.getMessage()); + } + + @Test + void builder_supplierSourcedActorToken() throws Exception { + KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); + ks.load(null, null); + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setActorTokenSupplier(testActorSupplier) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setHttpTransportFactory(mtlsTransport) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build(); + + assertNotNull(credentials); + assertEquals("urn:ietf:params:oauth:token-type:jwt", credentials.getActorTokenType()); + } + + @Test + void createScoped_supplierSourcedWithActorToken_preservesCustomSuppliers() throws Exception { + KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); + ks.load(null, null); + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(mtlsTransport) + .setSubjectTokenSupplier(testProvider) + .setActorTokenSupplier(testActorSupplier) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build(); + + List newScopes = Arrays.asList("https://www.googleapis.com/auth/cloud-platform"); + IdentityPoolCredentials scoped = credentials.createScoped(newScopes); + + assertNotNull(scoped); + assertEquals(credentials.getActorTokenType(), scoped.getActorTokenType()); + assertEquals(newScopes, scoped.getScopes()); + assertSame(testProvider, scoped.getIdentityPoolSubjectTokenSupplier()); + assertSame(testActorSupplier, scoped.getIdentityPoolActorTokenSupplier()); + } + + @Test + void createScoped_fileSourcedWithActorToken_preservesSharedSupplierInstance() throws Exception { + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "subject_token"); + formatMap.put("actor_token_field_name", "actor_token"); + + IdentityPoolCredentialSource credentialSource = + createFileCredentialSource("credential.json", formatMap); + + KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); + ks.load(null, null); + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(mtlsTransport) + .setCredentialSource(credentialSource) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build(); + + // Verify initial instance shares the single supplier instance + assertSame( + credentials.getIdentityPoolSubjectTokenSupplier(), + credentials.getIdentityPoolActorTokenSupplier()); + + // Clone with new scopes + List newScopes = Arrays.asList("https://www.googleapis.com/auth/cloud-platform"); + IdentityPoolCredentials scoped = credentials.createScoped(newScopes); + + assertNotNull(scoped); + assertEquals(credentials.getActorTokenType(), scoped.getActorTokenType()); + assertEquals(newScopes, scoped.getScopes()); + // Verify scoped clone maintains a single shared supplier instance for its own cache + assertSame( + scoped.getIdentityPoolSubjectTokenSupplier(), scoped.getIdentityPoolActorTokenSupplier()); + } + + @Test + void refreshAccessToken_withActorToken_injectsActingPartyIntoStsRequest() throws Exception { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); + ks.load(null, null); + ContextRebuildableTransportFactory mtlsTransport = + new ContextRebuildableTransportFactory() { + @Override + public void rebuildContext() {} + + @Override + public com.google.api.client.http.HttpTransport create() { + return transportFactory.create(); + } + }; + + IdentityPoolCredentials credential = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setActorTokenSupplier(testActorSupplier) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl(transportFactory.transport.getStsUrl()) + .setHttpTransportFactory(mtlsTransport) + .build(); + + AccessToken token = credential.refreshAccessToken(); + assertEquals(transportFactory.transport.getAccessToken(), token.getTokenValue()); + + Map query = + TestUtils.parseQuery(transportFactory.transport.getLastRequest().getContentAsString()); + assertEquals("testActorToken", query.get("actor_token")); + assertEquals("urn:ietf:params:oauth:token-type:jwt", query.get("actor_token_type")); + } + + @Test + void builder_actorTokenSupplierWithNonMtlsTransport_throws() { + MockExternalAccountCredentialsTransportFactory standardFactory = + new MockExternalAccountCredentialsTransportFactory(); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setActorTokenSupplier(testActorSupplier) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.googleapis.com/v1/token") + .setHttpTransportFactory(standardFactory) + .build()); + + assertEquals( + "Actor tokens are only supported for mTLS token exchanges. Please configure a certificate source or MtlsHttpTransportFactory.", + exception.getMessage()); + } + + @Test + void serialization_withX509Provider_succeeds() throws Exception { + KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); + ks.load(null, null); + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); + X509Provider x509Provider = new TestX509Provider(ks, "certificate_config_location"); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(mtlsTransport) + .setSubjectTokenSupplier(testProvider) + .setX509Provider(x509Provider) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build(); + + serializeAndDeserialize(credentials); + } + + @Test + void builder_actorTokenTypeWithoutSupplier_throws() { + IdentityPoolCredentialSource credentialSource = createFileCredentialSource(); + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> + IdentityPoolCredentials.newBuilder() + .setCredentialSource(credentialSource) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.googleapis.com/v1/token") + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .build()); + assertEquals( + "An actorTokenSupplier must be specified when an actorTokenType is configured.", + exception.getMessage()); + } + + @Test + void builder_fileWithCertificateConfig_initializesMtlsTransport() throws Exception { + Map certMap = new HashMap<>(); + certMap.put("use_default_certificate_config", true); + + Map sourceMap = new HashMap<>(); + sourceMap.put("file", "credential.json"); + sourceMap.put("certificate", certMap); + + IdentityPoolCredentialSource credentialSource = new IdentityPoolCredentialSource(sourceMap); + + KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); + ks.load(null, null); + X509Provider x509Provider = new TestX509Provider(ks, "certificate_config_location"); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setCredentialSource(credentialSource) + .setX509Provider(x509Provider) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build(); + + assertNotNull(credentials); + assertNotNull(credentials.getTransportFactory()); + assertTrue(credentials.getTransportFactory() instanceof MtlsHttpTransportFactory); + } + + @Test + void toBuilder_preservesConfiguration() throws Exception { + KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); + ks.load(null, null); + MtlsHttpTransportFactory mtlsTransport = new MtlsHttpTransportFactory(ks); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(mtlsTransport) + .setSubjectTokenSupplier(testProvider) + .setActorTokenSupplier(testActorSupplier) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build(); + + IdentityPoolCredentials rebuilt = credentials.toBuilder().build(); + + assertNotNull(rebuilt); + assertEquals(credentials.getActorTokenType(), rebuilt.getActorTokenType()); + assertSame(testProvider, rebuilt.getIdentityPoolSubjectTokenSupplier()); + assertSame(testActorSupplier, rebuilt.getIdentityPoolActorTokenSupplier()); + } } 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. diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/Slf4jUtilsLogbackTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/Slf4jUtilsLogbackTest.java index 340b6d199176..56fe3ed76fe5 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/Slf4jUtilsLogbackTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/Slf4jUtilsLogbackTest.java @@ -32,6 +32,8 @@ package com.google.auth.oauth2; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -43,6 +45,7 @@ import com.google.api.client.http.HttpRequestFactory; import com.google.api.client.http.UrlEncodedContent; import com.google.api.client.util.GenericData; +import com.google.gson.JsonObject; import com.google.gson.JsonParser; import com.google.gson.JsonSyntaxException; import java.io.IOException; @@ -198,6 +201,49 @@ void testLogRequest() throws IOException { testAppender.stop(); } + @Test + void testLogRequest_masksActorTokenAndSubjectToken() throws IOException { + testEnvironmentProvider.setEnv(LoggingUtils.GOOGLE_SDK_JAVA_LOGGING, "true"); + LoggingUtils.setEnvironmentProvider(testEnvironmentProvider); + + TestAppender testAppender = setupTestLogger(); + + GenericData tokenRequest = new GenericData(); + tokenRequest.set("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange"); + tokenRequest.set("subject_token", "raw_secret_subject_token"); + tokenRequest.set("actor_token", "raw_secret_actor_token"); + UrlEncodedContent content = new UrlEncodedContent(tokenRequest); + + MockHttpTransportFactory mockHttpTransportFactory = new MockHttpTransportFactory(); + HttpRequestFactory requestFactory = mockHttpTransportFactory.create().createRequestFactory(); + HttpRequest request = + requestFactory.buildPostRequest(new GenericUrl(OAuth2Utils.TOKEN_SERVER_URI), content); + + LoggerProvider loggerProvider = mock(LoggerProvider.class, withSettings().withoutAnnotations()); + when(loggerProvider.getLogger()).thenReturn(LOGGER); + LoggingUtils.logRequest(request, loggerProvider, "STS Token Exchange"); + + assertEquals(1, testAppender.events.size()); + String payloadJson = null; + for (KeyValuePair kvp : testAppender.events.get(0).getKeyValuePairs()) { + if ("request.payload".equals(kvp.key)) { + payloadJson = (String) kvp.value; + } + } + assertNotNull(payloadJson); + JsonObject payload = JsonParser.parseString(payloadJson).getAsJsonObject(); + + // Verify that raw secrets are never logged in plaintext + assertNotEquals("raw_secret_subject_token", payload.get("subject_token").getAsString()); + assertNotEquals("raw_secret_actor_token", payload.get("actor_token").getAsString()); + + // Verify that masked values are 64-char SHA-256 hex hashes + assertEquals(64, payload.get("subject_token").getAsString().length()); + assertEquals(64, payload.get("actor_token").getAsString().length()); + + testAppender.stop(); + } + boolean isValidJson(String jsonString) { try { JsonParser.parseString(jsonString); diff --git a/google-auth-library-java/oauth2_http/testresources/mtls/client_v1.crt b/google-auth-library-java/oauth2_http/testresources/mtls/client_v1.crt new file mode 100644 index 000000000000..042c83831242 --- /dev/null +++ b/google-auth-library-java/oauth2_http/testresources/mtls/client_v1.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDCTCCAfGgAwIBAgIUcj6ruVvnrnLNhLekHoNMF05pAf4wDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJY2xpZW50LXYxMB4XDTI2MDgxMTAyMzAzM1oXDTM2MDgw +ODAyMzAzM1owFDESMBAGA1UEAwwJY2xpZW50LXYxMIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAtvlw2IAfZ2Ad7yJknKKDv7UnocCeGnPoA3FJiFAPue24 +R/jkyW7bspYHUB9mfH1FnQklvmay5sShh8+tOreQZ0V5Jls7/YEtYGX5NXMD6wcq +nWxYt63aCTG6JNBORtZBLCFiDyoj+ZPGk2hFXpU+zYu/XXtukNCrivh9MqVjJDlU +EU+g5836xUPt/rq8oQPVNRa5mcW1lU0OTVAvLW0IALKqm7Jb54cCK+YluesOLuRo +itOFm+jgjWyWxGpeeINJAvWnrZoctGa2E6XFZGYwZABwd4Of46RDKAX0Juo1ZzaL +QUClVMtnCuaoz99RW7PA+kqCVygphJ0BAdDkYIe5XwIDAQABo1MwUTAdBgNVHQ4E +FgQU+hmH5wIOI3pavaSlU7Knzt7TRl8wHwYDVR0jBBgwFoAU+hmH5wIOI3pavaSl +U7Knzt7TRl8wDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEApPtQ +ZF3wEIzJDyppcPCrwPxtYu26Ei6cLHtCa+YBg7boIFn1yi1wfcWt1iFDsbgpcSgt +wF14UU1eOC1R8U+7jsLMHP7287CdEMmvLUEBkFIVSE0tl3YTBSwwNFJWBG98XBSQ +GwlJtzgx3tJp66TjJ8IzK2W1Zq0lNOqHjqpVA99aQQnITttN2GZxNbXn8mn/KvZM +4XB7EmfTJn9L8ByuVRhLPfnncRv6oy3Ac3MuC2tO/O6e5NspLCp7qoUv7D4G5Gpg +USUmHFgy91hxbiB4t6QwJoDnaGF+5/tRVA4uW32SRrSvc/3+D7y0LyeJjmwNBsq8 +817XfO0PTLWS8OX2BA== +-----END CERTIFICATE----- diff --git a/google-auth-library-java/oauth2_http/testresources/mtls/client_v1.pem.key b/google-auth-library-java/oauth2_http/testresources/mtls/client_v1.pem.key new file mode 100644 index 000000000000..7f17f17b5849 --- /dev/null +++ b/google-auth-library-java/oauth2_http/testresources/mtls/client_v1.pem.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC2+XDYgB9nYB3v +ImScooO/tSehwJ4ac+gDcUmIUA+57bhH+OTJbtuylgdQH2Z8fUWdCSW+ZrLmxKGH +z606t5BnRXkmWzv9gS1gZfk1cwPrByqdbFi3rdoJMbok0E5G1kEsIWIPKiP5k8aT +aEVelT7Ni79de26Q0KuK+H0ypWMkOVQRT6DnzfrFQ+3+uryhA9U1FrmZxbWVTQ5N +UC8tbQgAsqqbslvnhwIr5iW56w4u5GiK04Wb6OCNbJbEal54g0kC9aetmhy0ZrYT +pcVkZjBkAHB3g5/jpEMoBfQm6jVnNotBQKVUy2cK5qjP31Fbs8D6SoJXKCmEnQEB +0ORgh7lfAgMBAAECggEATd5BBnocIz/V7WebHsfQvtzG9YEGJ+bMxe5H52F/F9bR +lpuXLhxmFg3gJdp/IJqlbguvCuUk3K2wup4IsFTfePupb8fi82Munvg2wubkM3j0 +y1MJPH4ZrNEDUVhu/5l92dyJIUADWFrp5uC4qORl+k6vTYbSioGrh3Rxv4R/+TFO +HLGJE3hnqt5iYHUtW7B9kVCX6cD5xVWB+1qXh6OTXQVVxYgFsHE3U+drXgqr6wl7 +47AIjbZNaSwZ2u914rC3RahnGNJuA8eVeNy0dXnOtSgPi1YFw6MGlqqaDkdNXGLL +4ySE7MoKNUNN+eRD74gQD0MOSaUoa2Il2ke9t3QEmQKBgQDkEvUN6AdON3fs4zBY +/U+g2Mkr/07P11WsYM1Bzf1XcbO8aA3ZcpsDVc9BP8vmMOtqIwMNqW9HA9n8Gntf +015KIrXmFUngUdBEPa+psC3xGnfuc4aoz8yUNX5ga1iPowiMn68MNg1drieA7aGw +g+MAobDfUXyNTtRG0WDpuEVhBwKBgQDNYNJ1w8ZVSfqtEFQHnVEgcJrYZwXs1G3E +ZRGveUheLarG6KGTMJ55TFBncJkwJ5g2s6sSdJglQIOoA5MwwnvEbUURgfoH0QQu ++5s8Uatqemqz3vSy4evUJHuzy5BwcfpX4sjeC4uYQkjEG+GOzKnLv2o1sTLkAYPb +YVdIojHG6QKBgCvDE3BCqDwy2nkCssEwgbnsPtWJXHb264Jy5I+O1eCUeZdaI+Yo +XmQKcAUmF5qoolMwOqLBcw+eRpJgBnJBnWL+PAbU6OL478xYRb96haYwnPiNBDaa +ALgjd4dKsaiF8NsCvNTL/k6OXxgXAKJc/A6f988x1INMr+CSxlSyPeW7AoGBALY0 +NcLgou47qbcsC0COItEi1V0zWSBY/eEEwYHpmXhkD2RUMjRBJ/b460Q5ss3z8qhl +EVpYMjqqV6MRON5DTEZhoqr7ovSYTJvaAEitM+RNIqjdHj4tDGmyzEQgTs5TcAPU +YNwNZhT4iVHiYmOr0t+9u95SjJGXfoF8hFEeBAcxAoGAJlGd9gUo10UYg1+W/+sB +nD4iz+CbP5o1sGfcWnxvzn10VqbDhawRZdJwALyIGrkFm/jas67q3+81YUN6UzdG +Erj9T/j9v6ZtnQ4KTHFYZjLdlfzYxDXVuKVCa611vFTK2KPfOWL6vUJqprx/X2B1 +N5DVnGhrfOcpoWYqHlrRXns= +-----END PRIVATE KEY----- diff --git a/google-auth-library-java/oauth2_http/testresources/mtls/client_v2.crt b/google-auth-library-java/oauth2_http/testresources/mtls/client_v2.crt new file mode 100644 index 000000000000..b26c3f2388f1 --- /dev/null +++ b/google-auth-library-java/oauth2_http/testresources/mtls/client_v2.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDCTCCAfGgAwIBAgIUOx7pQHyyTgM86hoxoHWg7hruW90wDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJY2xpZW50LXYyMB4XDTI2MDgxMTAyMzAzM1oXDTM2MDgw +ODAyMzAzM1owFDESMBAGA1UEAwwJY2xpZW50LXYyMIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAnaQqQDcrZUpN9I4p/6VfTRCn//00owBlfDw3Am+RXgHc +K0WczJAKisgBaYi1hJzRpGFLJPfgak+KxHJCH6sxbZoMs6MQdlPgxxcrTp4/M75F +xT9/nRIW68cuLCPVvmZi2NuRuD/RU50zWaklMMHc/PZAieokrOvnnUegRGAylj32 +WlBbNl7nKW1J+slpgSq+FLhAlIWtWfAGuiPXdtV0q71vI2zmyXU1eoEsCnoPlu4H +sxBdQl8TLg1a5Im36Qq3sXSrnqNUMAaXL6pE93jAbU7O1WcPG45F2TNIJZ72Yq5i +s/PAy3CHLVmIBwM10uYj3eYen3QfL7nMxZMPcEO+uQIDAQABo1MwUTAdBgNVHQ4E +FgQUOSBWnAlCvFLS/BhyKjv7TOhtYFEwHwYDVR0jBBgwFoAUOSBWnAlCvFLS/Bhy +Kjv7TOhtYFEwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAKY4p +lLleIllBrbsl0JENjk8/NTWBp8Uy92yCwDi3Lt+3AWDvTo1g3EVm3xozFUVNSXbe +2bzdwJ7N2qAotlDIAMnT57L+SsbVUzTMgpJJYFcnKCMXSxypcrS0ELimcV0D+2B1 +t+kn6ViWpXqDjDv5z/vg8fzKBAJ5KPyL5riBszfAeVgH6lUou5NqAE3S42hH+43A +p3f4Y7sLdVC2RGCcmZWURjiHMcK4hOb4UG3UiSGwHBFB0zAym+BYGWZZKT9uAZRB +vVs0pAPtSJTVsosxjQHUCHEufrEvgIM/5gr+UKMDssVQusflQF1AiXziFBrMv/G3 +9nEpOTZBRq4tgtyVxg== +-----END CERTIFICATE----- diff --git a/google-auth-library-java/oauth2_http/testresources/mtls/client_v2.pem.key b/google-auth-library-java/oauth2_http/testresources/mtls/client_v2.pem.key new file mode 100644 index 000000000000..6b64457c8c89 --- /dev/null +++ b/google-auth-library-java/oauth2_http/testresources/mtls/client_v2.pem.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCdpCpANytlSk30 +jin/pV9NEKf//TSjAGV8PDcCb5FeAdwrRZzMkAqKyAFpiLWEnNGkYUsk9+BqT4rE +ckIfqzFtmgyzoxB2U+DHFytOnj8zvkXFP3+dEhbrxy4sI9W+ZmLY25G4P9FTnTNZ +qSUwwdz89kCJ6iSs6+edR6BEYDKWPfZaUFs2XucpbUn6yWmBKr4UuECUha1Z8Aa6 +I9d21XSrvW8jbObJdTV6gSwKeg+W7gezEF1CXxMuDVrkibfpCrexdKueo1QwBpcv +qkT3eMBtTs7VZw8bjkXZM0glnvZirmKz88DLcIctWYgHAzXS5iPd5h6fdB8vuczF +kw9wQ765AgMBAAECggEACAAS3Ntv6l4RAr5MR1sfMQwNWqKBD1yvydRMfmUnHXw+ +rjuEL6WHw29PEwoLHEXRJeSCKcgirYKH6lxaGr1XbtaImn1GEptDJxd37S9+yaN4 +awBD+7a1TxX2s+rFqDoN/i92f8FNYE70EjLRXl4YVwYwVE97uQLn6EWATrgLGTDL +XWdvCNo21BDhwEUptyt99mzUIMQFUqXzy7ZBbwmOdMikscqP8+oPAV8KTFgt8Y2T +9GzAzCq2nM6YYN9rNEOlYtGal4J6hVBHKFWa9Kq2b2cnYGfDPozNRbhwbo3snFNu +9hjR/1QfW9x5+eq7n0VhMjWxIt9rK3WNxgH317vcOQKBgQDbf/sT0bqfeJTFxwtX +8j8auenkXcyzAEOx1yvFaQdt/g0HFqU022Vzr3qFgwclgUizAqXdyfxAZT1WiG5O +VP6kGdjf+MRCFzeAevR8gOFa3WJ4hISi/8O6XTCRsDaDLIQj9ZQWI2fax4/jOMh1 +La0MpbyUz5embvU5V+dTpZwYCwKBgQC32uMtM5fV6JgHuyNcgDQOWTSv1WwAdNJ5 +ZVnjKDS9/kSwhYZx2tF+uD3wnvb4urR0ZniQ8s4PAuiOVGz+dh4dQSw8Ps3TxIsC +9GHJhRZPsarabeQkgMV6gs5cosrhkPxFtQAQPpOgi6tUksEtK/8FFhLORW16iF7h +qboPtgzKywKBgGRiP11tCUBtUPyybwmljB6Y79K04yzp7gujMC10PyWajjKztJJb +1CX4FryAlAAfBDR5/YlVGrwIkOjGNEw2qs450+l6R6dCHuvvT2ixOF7p6GLdmBtD +hSvx+ohmYkfTUAtyAGuvfYucwL1V9rdAUGf8VCZqWhyPmi7DeNPUZXLJAoGAYFG+ +PooO+7PVIge6aNWJcBLv9UZcRIjvU0Xzp3wC1Z6GIexyGOfIZRjk6lB9lqVJsMmW +VGm/5kFh0F8OkukSscTZBd7pOg53vV6GdGrS24F6vuBfa3hC/QHWVtW6cB3i7Cn9 +FIWUFcHWSoJvzdiEzAdaZtIcntseyh5/KYf4f60CgYBrXCykGhc3r8eskRm83Pss +9gigB/frRYTdYEVMj24rWUAznW/Rdc3IpewGBCV7sb/vENsjXMvBDuSUABh/ZvOO +yuF6KSOzWxzcDvcEbxp2jaEapYyaR1M1bwvyTzArMdGWKsZGOcB7a8QfX7ktV5vB +dFNeaHyySY4o6hTqe2c9EQ== +-----END PRIVATE KEY----- diff --git a/google-auth-library-java/oauth2_http/testresources/mtls/server.crt b/google-auth-library-java/oauth2_http/testresources/mtls/server.crt new file mode 100644 index 000000000000..12d9bf3d9fa8 --- /dev/null +++ b/google-auth-library-java/oauth2_http/testresources/mtls/server.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDJTCCAg2gAwIBAgIUAhGeEPqfbP9uOIuKwtx85QFdG94wDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJMTI3LjAuMC4xMB4XDTI2MDgxMTAyMzAzM1oXDTM2MDgw +ODAyMzAzM1owFDESMBAGA1UEAwwJMTI3LjAuMC4xMIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEArZzXt67pOSKJIJAK7M/fWJprk14KcxZ1Hk9AhRqfGpor +0kwH0klJBfFLSCQwCQ0VGOodhWYXxTjugJ5XX2hf0BN3Peing+3N6CPAiNl2Ysjz +hvagAZhOe+OY5N314Q4o4kIIDOr0WR0VXb0kEU7+cK93tIUmc0aJgLg6oMdpfz/B +X/2Tm6TqVwqCPMiR5V81LVaezXEsSel5ThD8VP4EUXYCR6X1nlNLz5twNCFY2xgz +CzpzdEomofKnNIsgbTO25XVH3qdW3KwZ/CxcPBzd7ng0CblD0L/ybCp8ewjz3q00 +lrXECLABktMgW/6wzSZgU7PeGhAO8uYmscFqkDYdnwIDAQABo28wbTAdBgNVHQ4E +FgQUUhgV6eeAFncp7rQLump2dAZ4Te8wHwYDVR0jBBgwFoAUUhgV6eeAFncp7rQL +ump2dAZ4Te8wDwYDVR0TAQH/BAUwAwEB/zAaBgNVHREEEzARhwR/AAABgglsb2Nh +bGhvc3QwDQYJKoZIhvcNAQELBQADggEBAEYwf1pPDBSaAAuIM/pibZAxfqm3oVHj +kGO9iPqYhzBn8cDfxCJII8KOBh70MP4ByuElWk3OYKlVWxoaxL1e5bLCctaBFoXb +lrR2fEUQEDpuQJn2/o8Ab0zfpYtl5o6RcvBKQmN38DCdnnYz/m/RqPpzqlr1nadT +XmADoxkKADrKE0t+YzHcP1SPIYOE0MFAa9qODkxVahTiEsF0/ov60GKk/5gkBAnc +slXfexw33hoD0wAW95GEQklkXjzJOJvIxtuzZ80EUpKMO8aZi5CH2WTFHF7G83W4 +qH2rTO+Sps4Xjlc7WyqzjQSO8x4qXcjNuW5blHg2++3YqFEb7mSoO/4= +-----END CERTIFICATE----- diff --git a/google-auth-library-java/oauth2_http/testresources/mtls/server.pem.key b/google-auth-library-java/oauth2_http/testresources/mtls/server.pem.key new file mode 100644 index 000000000000..7eb262235371 --- /dev/null +++ b/google-auth-library-java/oauth2_http/testresources/mtls/server.pem.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCtnNe3ruk5Iokg +kArsz99YmmuTXgpzFnUeT0CFGp8amivSTAfSSUkF8UtIJDAJDRUY6h2FZhfFOO6A +nldfaF/QE3c96KeD7c3oI8CI2XZiyPOG9qABmE5745jk3fXhDijiQggM6vRZHRVd +vSQRTv5wr3e0hSZzRomAuDqgx2l/P8Ff/ZObpOpXCoI8yJHlXzUtVp7NcSxJ6XlO +EPxU/gRRdgJHpfWeU0vPm3A0IVjbGDMLOnN0Siah8qc0iyBtM7bldUfep1bcrBn8 +LFw8HN3ueDQJuUPQv/JsKnx7CPPerTSWtcQIsAGS0yBb/rDNJmBTs94aEA7y5iax +wWqQNh2fAgMBAAECggEAPHGrxP63ojW86kJcG2CwEOgKZg9KshDyi6/p9a10EU31 +zcy8uGDddJ0yqZY3xx7v5nGz/3qw8fBFUTBPmR78pLPyQvKaR9tmYdj4smyLxA7U +gnug7404Xfe6howki8tjPorgxKaUleYDR1SPlxsaiN7+XTIyVdYMD22Us99Zgnx8 +JSorEsMzBMpAzG6iSfFWygjzyFwrh77kTjJsEB49hmGDcBS4LmOPsV3H6XOGJi/f +JUD5yekt7WxbEZYoj/Z2MdnjJRZ93v7ya2rVM3EcTEGbMCZW1zJcje/kvs5H41DK +g/Cq7gX8UWz2wCeXLkpjKDn/LsvNZNdIVg83aO7qqQKBgQDWFUQMdHn+VidD3Rl7 +eDl82P8mlMOq6u3cGpouK9OImRqOduuFpzdif8ZIxkYLmP7+u8tcwzSwBzn0jJMk +1CXMVPM57OQ62gYcjXqusoRSaD6tNRw1/OCge8BG6wdJfGkHN91Xa0HiYU/iNcTH +f47HrBw9yGb6kCBbn9EdZ854FQKBgQDPmwgnKHGTJ51zUrWH5V7jLz2zMytehgiv +OAiXW5KbLFiayTK1rA1WCBapqjPaKJLJ3hdT38IXa73MHUHF6+Ogn4Thwu9nv8UQ +Wx/COTnDjSjZZikqcW2q0XolD8x0NVuBQIQ4y2uxztIr4d83/PvCxDwGI99+Oa2w ++vz5cijX4wKBgQDOVa7Bhl2yGd08VlRiIUzfHNJGoCk6ibV5Z5Zkm88En/PtjYaQ +ycriv54ftCH8uZhDckKeBHK6mixsnDSR0Xsgxluq37xVUJ+FU4MD74EDX//Qtxia +nEDvzHZUo9/hHuynjVtjDzhv9TSmJQak9TdrEWIi1g0SwGi/hnhpLAzexQKBgDt1 +eYZNjQnKZMvsulUrmluS4ib61scBuGcGs182OOz8bHwYd8+UcVVch7EcMDGhQTlT +xHafNWWj0/4ruvTGtLECPvqx7ANY50Xh81ybKzIYscRiABRJ3FD6IfOPbM0zovuH +0Na7XCLWy1cKH/ZXHHwR2+ceSxpJxrdTizSeP5hlAoGAEmtbEkaI73SYa1inJDko +YAchF1Ti9oiZPJJRGKjoDCVgFvJ/jIkeQfnwnSLqklll274cqqiip/1sfTMVMyS5 +yWxrnrdU1nS+SisygE2wc32UJulNbSrjBCookiYmdpO6kqizZ0QdLKlMxo24ARXY +FgNEpo+E7WGThwke8CfqZ54= +-----END PRIVATE KEY-----