From 6f4778164d1f12529e8467f6933765a7adacd23d Mon Sep 17 00:00:00 2001
From: Leo <39062083+lsirac@users.noreply.github.com>
Date: Mon, 4 Apr 2022 14:55:54 -0700
Subject: [PATCH 1/7] feat: Adds Pluggable Auth support to ADC (#895)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* chore(deps): update dependency com.google.http-client:google-http-client-bom to v1.41.5 (#896)
[](https://renovatebot.com)
This PR contains the following updates:
| Package | Change | Age | Adoption | Passing | Confidence |
|---|---|---|---|---|---|
| [com.google.http-client:google-http-client-bom](https://togithub.com/googleapis/google-http-java-client) | `1.41.4` -> `1.41.5` | [](https://docs.renovatebot.com/merge-confidence/) | [](https://docs.renovatebot.com/merge-confidence/) | [](https://docs.renovatebot.com/merge-confidence/) | [](https://docs.renovatebot.com/merge-confidence/) |
---
### Release Notes
googleapis/google-http-java-client
### [`v1.41.5`](https://togithub.com/googleapis/google-http-java-client/blob/HEAD/CHANGELOG.md#1415-httpsgithubcomgoogleapisgoogle-http-java-clientcomparev1414v1415-2022-03-21)
[Compare Source](https://togithub.com/googleapis/google-http-java-client/compare/v1.41.4...v1.41.5)
---
### Configuration
📅 **Schedule**: At any time (no schedule defined).
🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.
â™» **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.
🔕 **Ignore**: Close this PR and you won't be reminded about this update again.
---
- [ ] If you want to rebase/retry this PR, click this checkbox.
---
This PR has been generated by [WhiteSource Renovate](https://renovate.whitesourcesoftware.com). View repository job log [here](https://app.renovatebot.com/dashboard#github/googleapis/google-auth-library-java).
* feat: Add ability to provide PrivateKey as Pkcs8 encoded string #883 (#889)
* feat: Add ability to provide PrivateKey as Pkcs8 encoded string #883
This change adds a new method `setPrivateKeyString` in `ServiceAccountCredentials.Builder` to accept Pkcs8 encoded string representation of private keys.
Co-authored-by: Timur Sadykov
* chore: fix downstream check (#898)
* fix: update branding in ExternalAccountCredentials (#893)
These changes align the Javadoc comments with the branding that Google uses externally:
+ STS -> Security Token Service
+ GCP -> Google Cloud
+ Remove references to a Google-internal token type
Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly:
- [ ] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/google-auth-library-java/issues/new/choose) before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea
- [ ] Ensure the tests and linter pass: Tests are failing, but I don't think that was caused by the changes in this PR
- [ ] Code coverage does not decrease (if any source code was changed): n/a
- [ ] Appropriate docs were updated (if necessary): n/a
* feat: Adds the ExecutableHandler interface for Pluggable Auth
* feat: Adds a Pluggable Auth specific exception
* feat: Adds new PluggableAuthCredentials class that plug into ADC
* feat: Adds unit tests for PluggableAuthCredentials and ExternalAccountCredentials
* Add units tests for GoogleCredentials
* fix: update javadoc/comments
* fix: A concrete ExecutableOptions implementation is not needed
* review: javadoc changes + constants
Co-authored-by: WhiteSource Renovate
Co-authored-by: Navina Ramesh
Co-authored-by: Timur Sadykov
Co-authored-by: Neenu Shaji
Co-authored-by: Jeff Williams
---
.github/workflows/downstream.yaml | 4 +-
.../google/auth/oauth2/ExecutableHandler.java | 67 +++
.../oauth2/ExternalAccountCredentials.java | 93 ++--
.../auth/oauth2/PluggableAuthCredentials.java | 319 +++++++++++++
.../auth/oauth2/PluggableAuthException.java | 48 ++
.../oauth2/ServiceAccountCredentials.java | 5 +
.../ExternalAccountCredentialsTest.java | 76 +++
.../auth/oauth2/GoogleCredentialsTest.java | 23 +
.../oauth2/PluggableAuthCredentialsTest.java | 444 ++++++++++++++++++
.../oauth2/PluggableAuthExceptionTest.java | 71 +++
pom.xml | 2 +-
11 files changed, 1121 insertions(+), 31 deletions(-)
create mode 100644 oauth2_http/java/com/google/auth/oauth2/ExecutableHandler.java
create mode 100644 oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java
create mode 100644 oauth2_http/java/com/google/auth/oauth2/PluggableAuthException.java
create mode 100644 oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java
create mode 100644 oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthExceptionTest.java
diff --git a/.github/workflows/downstream.yaml b/.github/workflows/downstream.yaml
index 78e1940fa..6985f0ed4 100644
--- a/.github/workflows/downstream.yaml
+++ b/.github/workflows/downstream.yaml
@@ -134,9 +134,11 @@ jobs:
- workflows
steps:
- uses: actions/checkout@v2
- - uses: actions/setup-java@v1
+ - uses: actions/setup-java@v3
with:
+ distribution: zulu
java-version: ${{matrix.java}}
- run: java -version
+ - run: sudo apt-get update -y
- run: sudo apt-get install libxml2-utils
- run: .kokoro/downstream-client-library-check.sh google-auth-library-bom ${{matrix.repo}}
diff --git a/oauth2_http/java/com/google/auth/oauth2/ExecutableHandler.java b/oauth2_http/java/com/google/auth/oauth2/ExecutableHandler.java
new file mode 100644
index 000000000..a052f2a5b
--- /dev/null
+++ b/oauth2_http/java/com/google/auth/oauth2/ExecutableHandler.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2022 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 java.util.Map;
+import javax.annotation.Nullable;
+
+/** An interface for 3rd party executable handling. */
+interface ExecutableHandler {
+
+ /** An interface for required fields needed to call 3rd party executables. */
+ interface ExecutableOptions {
+
+ /** An absolute path to the command used to retrieve 3rd party tokens. */
+ String getExecutableCommand();
+
+ /** A set of process-local environment variable mappings to be set for the script to execute. */
+ Map getEnvironmentMap();
+
+ /** A timeout for waiting for the executable to finish, in milliseconds. */
+ int getExecutableTimeoutMs();
+
+ /**
+ * An output file path which points to the 3rd party credentials generated by the executable.
+ */
+ @Nullable
+ String getOutputFilePath();
+ }
+
+ /**
+ * Handles executing the 3rd party script and parsing the token from the response.
+ *
+ * @param options A set executable options for handling the executable.
+ * @return A 3rd party token.
+ */
+ String retrieveTokenFromExecutable(ExecutableOptions options) throws IOException;
+}
diff --git a/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java
index 547a04261..b88f98bc5 100644
--- a/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java
+++ b/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java
@@ -39,6 +39,7 @@
import com.google.auth.http.HttpTransportFactory;
import com.google.auth.oauth2.AwsCredentials.AwsCredentialSource;
import com.google.auth.oauth2.IdentityPoolCredentials.IdentityPoolCredentialSource;
+import com.google.auth.oauth2.PluggableAuthCredentials.PluggableAuthCredentialSource;
import com.google.common.base.MoreObjects;
import java.io.IOException;
import java.io.InputStream;
@@ -58,7 +59,8 @@
/**
* Base external account credentials class.
*
- *
Handles initializing external credentials, calls to STS, and service account impersonation.
+ *
Handles initializing external credentials, calls to the Security Token Service, and service
+ * account impersonation.
*/
public abstract class ExternalAccountCredentials extends GoogleCredentials
implements QuotaProjectIdProvider {
@@ -75,6 +77,7 @@ abstract static class CredentialSource {
"https://www.googleapis.com/auth/cloud-platform";
static final String EXTERNAL_ACCOUNT_FILE_TYPE = "external_account";
+ static final String EXECUTABLE_SOURCE_KEY = "executable";
private final String transportFactoryClassName;
private final String audience;
@@ -89,13 +92,14 @@ abstract static class CredentialSource {
@Nullable private final String clientId;
@Nullable private final String clientSecret;
- // This is used for Workforce Pools. It is passed to STS during token exchange in the
- // `options` param and will be embedded in the token by STS.
+ // This is used for Workforce Pools. It is passed to the Security Token Service during token
+ // exchange in the `options` param and will be embedded in the token by the Security Token
+ // Service.
@Nullable private final String workforcePoolUserProject;
protected transient HttpTransportFactory transportFactory;
- @Nullable protected final ImpersonatedCredentials impersonatedCredentials;
+ @Nullable protected ImpersonatedCredentials impersonatedCredentials;
private EnvironmentProvider environmentProvider;
@@ -104,18 +108,17 @@ abstract static class CredentialSource {
* workforce credentials.
*
* @param transportFactory HTTP transport factory, creates the transport used to get access tokens
- * @param audience the STS audience which is usually the fully specified resource name of the
- * workload/workforce pool provider
- * @param subjectTokenType the STS subject token type based on the OAuth 2.0 token exchange spec.
- * Indicates the type of the security token in the credential file
- * @param tokenUrl the STS token exchange endpoint
+ * @param audience the Security Token Service audience, which is usually the fully specified
+ * resource name of the workload/workforce pool provider
+ * @param subjectTokenType the Security Token Service subject token type based on the OAuth 2.0
+ * token exchange spec. Indicates the type of the security token in the credential file
+ * @param tokenUrl the Security Token Service token exchange endpoint
* @param tokenInfoUrl the endpoint used to retrieve account related information. Required for
* gCloud session account identification.
* @param credentialSource the external credential source
* @param serviceAccountImpersonationUrl the URL for the service account impersonation request.
- * This is only required for workload identity pools when APIs to be accessed have not
- * integrated with UberMint. If this is not available, the STS returned GCP access token is
- * directly used. May be null.
+ * This URL is required for some APIs. If this URL is not available, the access token from the
+ * Security Token Service is used directly. May be null.
* @param quotaProjectId the project used for quota and billing purposes. May be null.
* @param clientId client ID of the service account from the console. May be null.
* @param clientSecret client secret of the service account from the console. May be null.
@@ -238,7 +241,7 @@ protected ExternalAccountCredentials(ExternalAccountCredentials.Builder builder)
this.impersonatedCredentials = initializeImpersonatedCredentials();
}
- private ImpersonatedCredentials initializeImpersonatedCredentials() {
+ protected ImpersonatedCredentials initializeImpersonatedCredentials() {
if (serviceAccountImpersonationUrl == null) {
return null;
}
@@ -249,6 +252,11 @@ private ImpersonatedCredentials initializeImpersonatedCredentials() {
AwsCredentials.newBuilder((AwsCredentials) this)
.setServiceAccountImpersonationUrl(null)
.build();
+ } else if (this instanceof PluggableAuthCredentials) {
+ sourceCredentials =
+ PluggableAuthCredentials.newBuilder((PluggableAuthCredentials) this)
+ .setServiceAccountImpersonationUrl(null)
+ .build();
} else {
sourceCredentials =
IdentityPoolCredentials.newBuilder((IdentityPoolCredentials) this)
@@ -372,8 +380,20 @@ static ExternalAccountCredentials fromJson(
.setClientId(clientId)
.setClientSecret(clientSecret)
.build();
+ } else if (isPluggableAuthCredential(credentialSourceMap)) {
+ return PluggableAuthCredentials.newBuilder()
+ .setHttpTransportFactory(transportFactory)
+ .setAudience(audience)
+ .setSubjectTokenType(subjectTokenType)
+ .setTokenUrl(tokenUrl)
+ .setTokenInfoUrl(tokenInfoUrl)
+ .setCredentialSource(new PluggableAuthCredentialSource(credentialSourceMap))
+ .setServiceAccountImpersonationUrl(serviceAccountImpersonationUrl)
+ .setQuotaProjectId(quotaProjectId)
+ .setClientId(clientId)
+ .setClientSecret(clientSecret)
+ .build();
}
-
return IdentityPoolCredentials.newBuilder()
.setHttpTransportFactory(transportFactory)
.setAudience(audience)
@@ -389,17 +409,22 @@ static ExternalAccountCredentials fromJson(
.build();
}
+ private static boolean isPluggableAuthCredential(Map credentialSource) {
+ // Pluggable Auth is enabled via a nested executable field in the credential source.
+ return credentialSource.containsKey(EXECUTABLE_SOURCE_KEY);
+ }
+
private static boolean isAwsCredential(Map credentialSource) {
return credentialSource.containsKey("environment_id")
&& ((String) credentialSource.get("environment_id")).startsWith("aws");
}
/**
- * Exchanges the external credential for a GCP access token.
+ * Exchanges the external credential for a Google Cloud access token.
*
- * @param stsTokenExchangeRequest the STS token exchange request
- * @return the access token returned by STS
- * @throws OAuthException if the call to STS fails
+ * @param stsTokenExchangeRequest the Security Token Service token exchange request
+ * @return the access token returned by the Security Token Service
+ * @throws OAuthException if the call to the Security Token Service fails
*/
protected AccessToken exchangeExternalCredentialForAccessToken(
StsTokenExchangeRequest stsTokenExchangeRequest) throws IOException {
@@ -413,7 +438,8 @@ protected AccessToken exchangeExternalCredentialForAccessToken(
tokenUrl, stsTokenExchangeRequest, transportFactory.create().createRequestFactory());
// If this credential was initialized with a Workforce configuration then the
- // workforcePoolUserProject must passed to STS via the the internal options param.
+ // workforcePoolUserProject must be passed to the Security Token Service via the internal
+ // options param.
if (isWorkforcePoolConfiguration()) {
GenericJson options = new GenericJson();
options.setFactory(OAuth2Utils.JSON_FACTORY);
@@ -431,7 +457,7 @@ protected AccessToken exchangeExternalCredentialForAccessToken(
}
/**
- * Retrieves the external subject token to be exchanged for a GCP access token.
+ * Retrieves the external subject token to be exchanged for a Google Cloud access token.
*
*
Must be implemented by subclasses as the retrieval method is dependent on the credential
* source.
@@ -465,6 +491,15 @@ public String getServiceAccountImpersonationUrl() {
return serviceAccountImpersonationUrl;
}
+ /** The service account email to be impersonated, if available. */
+ @Nullable
+ public String getServiceAccountEmail() {
+ if (serviceAccountImpersonationUrl == null || serviceAccountImpersonationUrl.isEmpty()) {
+ return null;
+ }
+ return ImpersonatedCredentials.extractTargetPrincipal(serviceAccountImpersonationUrl);
+ }
+
@Override
@Nullable
public String getQuotaProjectId() {
@@ -496,7 +531,7 @@ EnvironmentProvider getEnvironmentProvider() {
}
/**
- * Returns whether or not the current configuration is for Workforce Pools (which enable 3p user
+ * Returns whether the current configuration is for Workforce Pools (which enable 3p user
* identities, rather than workloads).
*/
public boolean isWorkforcePoolConfiguration() {
@@ -603,8 +638,8 @@ public Builder setHttpTransportFactory(HttpTransportFactory transportFactory) {
}
/**
- * Sets the STS audience which is usually the fully specified resource name of the
- * workload/workforce pool provider.
+ * Sets the Security Token Service audience, which is usually the fully specified resource name
+ * of the workload/workforce pool provider.
*/
public Builder setAudience(String audience) {
this.audience = audience;
@@ -612,15 +647,15 @@ public Builder setAudience(String audience) {
}
/**
- * Sets the STS subject token type based on the OAuth 2.0 token exchange spec. Indicates the
- * type of the security token in the credential file.
+ * Sets the Security Token Service subject token type based on the OAuth 2.0 token exchange
+ * spec. Indicates the type of the security token in the credential file.
*/
public Builder setSubjectTokenType(String subjectTokenType) {
this.subjectTokenType = subjectTokenType;
return this;
}
- /** Sets the STS token exchange endpoint. */
+ /** Sets the Security Token Service token exchange endpoint. */
public Builder setTokenUrl(String tokenUrl) {
this.tokenUrl = tokenUrl;
return this;
@@ -633,9 +668,9 @@ public Builder setCredentialSource(CredentialSource credentialSource) {
}
/**
- * Sets the optional URL used for service account impersonation. This is only required when APIs
- * to be accessed have not integrated with UberMint. If this is not available, the STS returned
- * GCP access token is directly used.
+ * Sets the optional URL used for service account impersonation, which is required for some
+ * APIs. If this URL is not available, the access token from the Security Token Service is used
+ * directly.
*/
public Builder setServiceAccountImpersonationUrl(String serviceAccountImpersonationUrl) {
this.serviceAccountImpersonationUrl = serviceAccountImpersonationUrl;
diff --git a/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java b/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java
new file mode 100644
index 000000000..83c63a5d8
--- /dev/null
+++ b/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java
@@ -0,0 +1,319 @@
+/*
+ * Copyright 2022 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.auth.oauth2.ExecutableHandler.ExecutableOptions;
+import com.google.common.annotations.VisibleForTesting;
+import java.io.*;
+import java.math.BigDecimal;
+import java.util.*;
+import javax.annotation.Nullable;
+
+/**
+ * PluggableAuthCredentials enables the exchange of workload identity pool external credentials for
+ * Google access tokens by retrieving 3rd party tokens through a user supplied executable. These
+ * scripts/executables are completely independent of the Google Cloud Auth libraries. These
+ * credentials plug into ADC and will call the specified executable to retrieve the 3rd party token
+ * to be exchanged for a Google access token.
+ *
+ *
To use these credentials, the GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES environment variable
+ * must be set to '1'. This is for security reasons.
+ *
+ *
Both OIDC and SAML are supported. The executable must adhere to a specific response format
+ * defined below.
+ *
+ *
The executable should print out the 3rd party token to STDOUT in JSON format. This is not
+ * required when an output_file is specified in the credential source, with the expectation being
+ * that the output file will contain the JSON response instead.
+ *
+ *
+ */
+ static class PluggableAuthCredentialSource extends CredentialSource {
+
+ // The default timeout for waiting for the executable to finish (30 seconds).
+ private static final int DEFAULT_EXECUTABLE_TIMEOUT_MS = 30 * 1000;
+ // The minimum timeout for waiting for the executable to finish (5 seconds).
+ private static final int MINIMUM_EXECUTABLE_TIMEOUT_MS = 5 * 1000;
+ // The maximum timeout for waiting for the executable to finish (120 seconds).
+ private static final int MAXIMUM_EXECUTABLE_TIMEOUT_MS = 120 * 1000;
+
+ private static final String COMMAND_KEY = "command";
+ private static final String TIMEOUT_MILLIS_KEY = "timeout_millis";
+ private static final String OUTPUT_FILE_KEY = "output_file";
+
+ // Required. The command used to retrieve the 3rd party token.
+ private final String executableCommand;
+
+ // Optional. Set to the default timeout when not provided.
+ private final int executableTimeoutMs;
+
+ // Optional. Provided when the 3rd party executable caches the response at the specified
+ // location.
+ @Nullable private final String outputFilePath;
+
+ PluggableAuthCredentialSource(Map credentialSourceMap) {
+ super(credentialSourceMap);
+
+ if (!credentialSourceMap.containsKey(EXECUTABLE_SOURCE_KEY)) {
+ throw new IllegalArgumentException(
+ "Invalid credential source for PluggableAuth credentials.");
+ }
+
+ Map executable =
+ (Map) credentialSourceMap.get(EXECUTABLE_SOURCE_KEY);
+
+ // Command is the only required field.
+ if (!executable.containsKey(COMMAND_KEY)) {
+ throw new IllegalArgumentException(
+ "The PluggableAuthCredentialSource is missing the required 'command' field.");
+ }
+
+ // Parse the executable timeout.
+ if (executable.containsKey(TIMEOUT_MILLIS_KEY)) {
+ Object timeout = executable.get(TIMEOUT_MILLIS_KEY);
+ if (timeout instanceof BigDecimal) {
+ executableTimeoutMs = ((BigDecimal) timeout).intValue();
+ } else if (executable.get(TIMEOUT_MILLIS_KEY) instanceof Integer) {
+ executableTimeoutMs = (int) timeout;
+ } else {
+ executableTimeoutMs = Integer.parseInt((String) timeout);
+ }
+ } else {
+ executableTimeoutMs = DEFAULT_EXECUTABLE_TIMEOUT_MS;
+ }
+
+ // Provided timeout must be between 5s and 120s.
+ if (executableTimeoutMs < MINIMUM_EXECUTABLE_TIMEOUT_MS
+ || executableTimeoutMs > MAXIMUM_EXECUTABLE_TIMEOUT_MS) {
+ throw new IllegalArgumentException(
+ String.format(
+ "The executable timeout must be between %s and %s milliseconds.",
+ MINIMUM_EXECUTABLE_TIMEOUT_MS, MAXIMUM_EXECUTABLE_TIMEOUT_MS));
+ }
+
+ executableCommand = (String) executable.get(COMMAND_KEY);
+ outputFilePath = (String) executable.get(OUTPUT_FILE_KEY);
+ }
+
+ String getCommand() {
+ return executableCommand;
+ }
+
+ int getTimeoutMs() {
+ return executableTimeoutMs;
+ }
+
+ @Nullable
+ String getOutputFilePath() {
+ return outputFilePath;
+ }
+ }
+
+ private final PluggableAuthCredentialSource config;
+
+ private final ExecutableHandler handler;
+
+ /** Internal constructor. See {@link Builder}. */
+ PluggableAuthCredentials(Builder builder) {
+ super(builder);
+ this.config = (PluggableAuthCredentialSource) builder.credentialSource;
+
+ if (builder.handler != null) {
+ handler = builder.handler;
+ } else {
+ // TODO(lsirac): Initialize handler.
+ handler = null;
+ }
+
+ // Re-initialize impersonated credentials as the handler hasn't been set yet when
+ // this is called in the base class.
+ this.impersonatedCredentials = initializeImpersonatedCredentials();
+ }
+
+ @Override
+ public AccessToken refreshAccessToken() throws IOException {
+ String credential = retrieveSubjectToken();
+ StsTokenExchangeRequest.Builder stsTokenExchangeRequest =
+ StsTokenExchangeRequest.newBuilder(credential, getSubjectTokenType())
+ .setAudience(getAudience());
+
+ Collection scopes = getScopes();
+ if (scopes != null && !scopes.isEmpty()) {
+ stsTokenExchangeRequest.setScopes(new ArrayList<>(scopes));
+ }
+ return exchangeExternalCredentialForAccessToken(stsTokenExchangeRequest.build());
+ }
+
+ @Override
+ public String retrieveSubjectToken() throws IOException {
+ String executableCommand = config.getCommand();
+ String outputFilePath = config.getOutputFilePath();
+ int executableTimeoutMs = config.getTimeoutMs();
+
+ Map envMap = new HashMap<>();
+ envMap.put("GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE", getAudience());
+ envMap.put("GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE", getSubjectTokenType());
+ // Always set to 0 for Workload Identity Federation.
+ envMap.put("GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE", "0");
+ if (getServiceAccountEmail() != null) {
+ envMap.put("GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL", getServiceAccountEmail());
+ }
+ if (outputFilePath != null && !outputFilePath.isEmpty()) {
+ envMap.put("GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE", outputFilePath);
+ }
+
+ ExecutableOptions options =
+ new ExecutableOptions() {
+ @Override
+ public String getExecutableCommand() {
+ return executableCommand;
+ }
+
+ @Override
+ public Map getEnvironmentMap() {
+ return envMap;
+ }
+
+ @Override
+ public int getExecutableTimeoutMs() {
+ return executableTimeoutMs;
+ }
+
+ @Nullable
+ @Override
+ public String getOutputFilePath() {
+ return outputFilePath;
+ }
+ };
+
+ // Delegate handling of the executable to the handler.
+ return this.handler.retrieveTokenFromExecutable(options);
+ }
+
+ /** Clones the PluggableAuthCredentials with the specified scopes. */
+ @Override
+ public PluggableAuthCredentials createScoped(Collection newScopes) {
+ return new PluggableAuthCredentials(
+ (PluggableAuthCredentials.Builder) newBuilder(this).setScopes(newScopes));
+ }
+
+ public static Builder newBuilder() {
+ return new Builder();
+ }
+
+ public static Builder newBuilder(PluggableAuthCredentials pluggableAuthCredentials) {
+ return new Builder(pluggableAuthCredentials);
+ }
+
+ @VisibleForTesting
+ @Nullable
+ ExecutableHandler getExecutableHandler() {
+ return this.handler;
+ }
+
+ public static class Builder extends ExternalAccountCredentials.Builder {
+
+ private ExecutableHandler handler;
+
+ Builder() {}
+
+ Builder(PluggableAuthCredentials credentials) {
+ super(credentials);
+ this.handler = credentials.handler;
+ }
+
+ public Builder setExecutableHandler(ExecutableHandler handler) {
+ this.handler = handler;
+ return this;
+ }
+
+ @Override
+ public PluggableAuthCredentials build() {
+ return new PluggableAuthCredentials(this);
+ }
+ }
+}
diff --git a/oauth2_http/java/com/google/auth/oauth2/PluggableAuthException.java b/oauth2_http/java/com/google/auth/oauth2/PluggableAuthException.java
new file mode 100644
index 000000000..894b324a9
--- /dev/null
+++ b/oauth2_http/java/com/google/auth/oauth2/PluggableAuthException.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2022 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;
+
+/** Encapsulates the error response's for 3rd party executables defined by the executable spec. */
+class PluggableAuthException extends OAuthException {
+
+ PluggableAuthException(String errorCode, String errorDescription) {
+ super(errorCode, checkNotNull(errorDescription), /* errorUri=*/ null);
+ }
+
+ /** The message with format: Error code {errorCode}: {errorDescription}. */
+ @Override
+ public String getMessage() {
+ return "Error code " + getErrorCode() + ": " + getErrorDescription();
+ }
+}
diff --git a/oauth2_http/java/com/google/auth/oauth2/ServiceAccountCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ServiceAccountCredentials.java
index 02aff547f..9b9c99c54 100644
--- a/oauth2_http/java/com/google/auth/oauth2/ServiceAccountCredentials.java
+++ b/oauth2_http/java/com/google/auth/oauth2/ServiceAccountCredentials.java
@@ -1052,6 +1052,11 @@ public Builder setPrivateKey(PrivateKey privateKey) {
return this;
}
+ public Builder setPrivateKeyString(String privateKeyPkcs8) throws IOException {
+ this.privateKey = privateKeyFromPkcs8(privateKeyPkcs8);
+ return this;
+ }
+
public Builder setPrivateKeyId(String privateKeyId) {
this.privateKeyId = privateKeyId;
return this;
diff --git a/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java
index fb94bb93d..42413194c 100644
--- a/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java
+++ b/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java
@@ -43,6 +43,7 @@
import com.google.auth.TestUtils;
import com.google.auth.http.HttpTransportFactory;
import com.google.auth.oauth2.ExternalAccountCredentialsTest.TestExternalAccountCredentials.TestCredentialSource;
+import com.google.auth.oauth2.PluggableAuthCredentials.PluggableAuthCredentialSource;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.net.URI;
@@ -105,6 +106,16 @@ void fromStream_awsCredentials() throws IOException {
assertTrue(credential instanceof AwsCredentials);
}
+ @Test
+ void fromStream_pluggableAuthCredentials() throws IOException {
+ GenericJson json = buildJsonPluggableAuthCredential();
+
+ ExternalAccountCredentials credential =
+ ExternalAccountCredentials.fromStream(TestUtils.jsonToInputStream(json));
+
+ assertTrue(credential instanceof PluggableAuthCredentials);
+ }
+
@Test
void fromStream_invalidStream_throws() {
GenericJson json = buildJsonAwsCredential();
@@ -203,6 +214,53 @@ void fromJson_awsCredentials() {
assertNotNull(credential.getCredentialSource());
}
+ @Test
+ void fromJson_pluggableAuthCredentials() {
+ ExternalAccountCredentials credential =
+ ExternalAccountCredentials.fromJson(
+ buildJsonPluggableAuthCredential(), OAuth2Utils.HTTP_TRANSPORT_FACTORY);
+
+ assertTrue(credential instanceof PluggableAuthCredentials);
+ assertEquals("audience", credential.getAudience());
+ assertEquals("subjectTokenType", credential.getSubjectTokenType());
+ assertEquals(STS_URL, credential.getTokenUrl());
+ assertEquals("tokenInfoUrl", credential.getTokenInfoUrl());
+ assertNotNull(credential.getCredentialSource());
+
+ PluggableAuthCredentialSource source =
+ (PluggableAuthCredentialSource) credential.getCredentialSource();
+ assertEquals("command", source.getCommand());
+ assertEquals(30000, source.getTimeoutMs()); // Default timeout is 30s.
+ assertNull(source.getOutputFilePath());
+ }
+
+ @Test
+ void fromJson_pluggableAuthCredentials_allExecutableOptionsSet() {
+ GenericJson json = buildJsonPluggableAuthCredential();
+ Map credentialSourceMap = (Map) json.get("credential_source");
+ // Add optional params to the executable config (timeout, output file path).
+ Map executableConfig =
+ (Map) credentialSourceMap.get("executable");
+ executableConfig.put("timeout_millis", 5000);
+ executableConfig.put("output_file", "path/to/output/file");
+
+ ExternalAccountCredentials credential =
+ ExternalAccountCredentials.fromJson(json, OAuth2Utils.HTTP_TRANSPORT_FACTORY);
+
+ assertTrue(credential instanceof PluggableAuthCredentials);
+ assertEquals("audience", credential.getAudience());
+ assertEquals("subjectTokenType", credential.getSubjectTokenType());
+ assertEquals(STS_URL, credential.getTokenUrl());
+ assertEquals("tokenInfoUrl", credential.getTokenInfoUrl());
+ assertNotNull(credential.getCredentialSource());
+
+ PluggableAuthCredentialSource source =
+ (PluggableAuthCredentialSource) credential.getCredentialSource();
+ assertEquals("command", source.getCommand());
+ assertEquals("path/to/output/file", source.getOutputFilePath());
+ assertEquals(5000, source.getTimeoutMs());
+ }
+
@Test
void fromJson_nullJson_throws() {
assertThrows(
@@ -704,6 +762,24 @@ private GenericJson buildJsonAwsCredential() {
return json;
}
+ private GenericJson buildJsonPluggableAuthCredential() {
+ GenericJson json = new GenericJson();
+ json.put("audience", "audience");
+ json.put("subject_token_type", "subjectTokenType");
+ json.put("token_url", STS_URL);
+ json.put("token_info_url", "tokenInfoUrl");
+
+ Map> credentialSource = new HashMap<>();
+
+ Map executableConfig = new HashMap<>();
+ executableConfig.put("command", "command");
+
+ credentialSource.put("executable", executableConfig);
+ json.put("credential_source", credentialSource);
+
+ return json;
+ }
+
static class TestExternalAccountCredentials extends ExternalAccountCredentials {
static class TestCredentialSource extends IdentityPoolCredentials.IdentityPoolCredentialSource {
protected TestCredentialSource(Map credentialSourceMap) {
diff --git a/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java
index 028f9235e..f5bd15d2d 100644
--- a/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java
+++ b/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java
@@ -260,6 +260,29 @@ void fromStream_awsCredentials_providesToken() throws IOException {
TestUtils.assertContainsBearerToken(metadata, transportFactory.transport.getAccessToken());
}
+ @Test
+ void fromStream_pluggableAuthCredentials_providesToken() throws IOException {
+ MockExternalAccountCredentialsTransportFactory transportFactory =
+ new MockExternalAccountCredentialsTransportFactory();
+
+ InputStream stream =
+ PluggableAuthCredentialsTest.writeCredentialsStream(transportFactory.transport.getStsUrl());
+
+ GoogleCredentials credentials = GoogleCredentials.fromStream(stream, transportFactory);
+
+ assertNotNull(credentials);
+
+ // Create copy with mock executable handler.
+ PluggableAuthCredentials copy =
+ PluggableAuthCredentials.newBuilder((PluggableAuthCredentials) credentials)
+ .setExecutableHandler(options -> "pluggableAuthToken")
+ .build();
+
+ copy = copy.createScoped(SCOPES);
+ Map> metadata = copy.getRequestMetadata(CALL_URI);
+ TestUtils.assertContainsBearerToken(metadata, transportFactory.transport.getAccessToken());
+ }
+
@Test
void fromStream_Impersonation_providesToken_WithQuotaProject() throws IOException {
MockTokenServerTransportFactory transportFactoryForSource =
diff --git a/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java
new file mode 100644
index 000000000..01185bdb0
--- /dev/null
+++ b/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java
@@ -0,0 +1,444 @@
+/*
+ * Copyright 2022 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.auth.oauth2.MockExternalAccountCredentialsTransport.SERVICE_ACCOUNT_IMPERSONATION_URL;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import com.google.api.client.http.HttpTransport;
+import com.google.api.client.json.GenericJson;
+import com.google.auth.TestUtils;
+import com.google.auth.http.HttpTransportFactory;
+import com.google.auth.oauth2.ExecutableHandler.ExecutableOptions;
+import com.google.auth.oauth2.ExternalAccountCredentials.CredentialSource;
+import com.google.auth.oauth2.PluggableAuthCredentials.PluggableAuthCredentialSource;
+import java.io.IOException;
+import java.io.InputStream;
+import java.math.BigDecimal;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import javax.annotation.Nullable;
+import org.junit.jupiter.api.Test;
+
+/** Tests for {@link PluggableAuthCredentials}. */
+class PluggableAuthCredentialsTest {
+ // The default timeout for waiting for the executable to finish (30 seconds).
+ private static final int DEFAULT_EXECUTABLE_TIMEOUT_MS = 30 * 1000;
+ // The minimum timeout for waiting for the executable to finish (5 seconds).
+ private static final int MINIMUM_EXECUTABLE_TIMEOUT_MS = 5 * 1000;
+ // The maximum timeout for waiting for the executable to finish (120 seconds).
+ private static final int MAXIMUM_EXECUTABLE_TIMEOUT_MS = 120 * 1000;
+ private static final String STS_URL = "https://sts.googleapis.com";
+
+ private static final PluggableAuthCredentials CREDENTIAL =
+ (PluggableAuthCredentials)
+ PluggableAuthCredentials.newBuilder()
+ .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY)
+ .setAudience(
+ "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider")
+ .setSubjectTokenType("subjectTokenType")
+ .setTokenUrl(STS_URL)
+ .setTokenInfoUrl("tokenInfoUrl")
+ .setCredentialSource(buildCredentialSource())
+ .build();
+
+ static class MockExternalAccountCredentialsTransportFactory implements HttpTransportFactory {
+
+ MockExternalAccountCredentialsTransport transport =
+ new MockExternalAccountCredentialsTransport();
+
+ @Override
+ public HttpTransport create() {
+ return transport;
+ }
+ }
+
+ @Test
+ void retrieveSubjectToken_shouldDelegateToHandler() throws IOException {
+ PluggableAuthCredentials credential =
+ PluggableAuthCredentials.newBuilder(CREDENTIAL)
+ .setExecutableHandler(options -> "pluggableAuthToken")
+ .build();
+ String subjectToken = credential.retrieveSubjectToken();
+ assertEquals(subjectToken, "pluggableAuthToken");
+ }
+
+ @Test
+ void retrieveSubjectToken_shouldPassAllOptionsToHandler() throws IOException {
+ String command = "/path/to/executable";
+ String timeout = "5000";
+ String outputFile = "/path/to/output/file";
+
+ final ExecutableOptions[] providedOptions = {null};
+ ExecutableHandler executableHandler =
+ options -> {
+ providedOptions[0] = options;
+ return "pluggableAuthToken";
+ };
+
+ PluggableAuthCredentials credential =
+ (PluggableAuthCredentials)
+ PluggableAuthCredentials.newBuilder(CREDENTIAL)
+ .setExecutableHandler(executableHandler)
+ .setCredentialSource(buildCredentialSource(command, timeout, outputFile))
+ .setServiceAccountImpersonationUrl(SERVICE_ACCOUNT_IMPERSONATION_URL)
+ .build();
+
+ String subjectToken = credential.retrieveSubjectToken();
+
+ assertEquals(subjectToken, "pluggableAuthToken");
+
+ // Validate that the correct options were passed to the executable handler.
+ ExecutableOptions options = providedOptions[0];
+ assertEquals(options.getExecutableCommand(), command);
+ assertEquals(options.getExecutableTimeoutMs(), Integer.parseInt(timeout));
+ assertEquals(options.getOutputFilePath(), outputFile);
+
+ Map envMap = options.getEnvironmentMap();
+ assertEquals(envMap.size(), 5);
+ assertEquals(envMap.get("GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE"), credential.getAudience());
+ assertEquals(
+ envMap.get("GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE"), credential.getSubjectTokenType());
+ assertEquals(envMap.get("GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE"), "0");
+ assertEquals(
+ envMap.get("GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL"),
+ credential.getServiceAccountEmail());
+ assertEquals(envMap.get("GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE"), outputFile);
+ }
+
+ @Test
+ void retrieveSubjectToken_shouldPassMinimalOptionsToHandler() throws IOException {
+ String command = "/path/to/executable";
+
+ final ExecutableOptions[] providedOptions = {null};
+ ExecutableHandler executableHandler =
+ options -> {
+ providedOptions[0] = options;
+ return "pluggableAuthToken";
+ };
+
+ PluggableAuthCredentials credential =
+ (PluggableAuthCredentials)
+ PluggableAuthCredentials.newBuilder(CREDENTIAL)
+ .setExecutableHandler(executableHandler)
+ .setCredentialSource(
+ buildCredentialSource(command, /* timeoutMs= */ null, /* outputFile= */ null))
+ .build();
+
+ String subjectToken = credential.retrieveSubjectToken();
+
+ assertEquals(subjectToken, "pluggableAuthToken");
+
+ // Validate that the correct options were passed to the executable handler.
+ ExecutableOptions options = providedOptions[0];
+ assertEquals(options.getExecutableCommand(), command);
+ assertEquals(options.getExecutableTimeoutMs(), DEFAULT_EXECUTABLE_TIMEOUT_MS);
+ assertNull(options.getOutputFilePath());
+
+ Map envMap = options.getEnvironmentMap();
+ assertEquals(envMap.size(), 3);
+ assertEquals(envMap.get("GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE"), credential.getAudience());
+ assertEquals(
+ envMap.get("GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE"), credential.getSubjectTokenType());
+ assertEquals(envMap.get("GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE"), "0");
+ assertNull(envMap.get("GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL"));
+ assertNull(envMap.get("GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE"));
+ }
+
+ @Test
+ void refreshAccessToken_withoutServiceAccountImpersonation() throws IOException {
+ MockExternalAccountCredentialsTransportFactory transportFactory =
+ new MockExternalAccountCredentialsTransportFactory();
+
+ transportFactory.transport.setExpireTime(TestUtils.getDefaultExpireTime());
+
+ PluggableAuthCredentials credential =
+ (PluggableAuthCredentials)
+ PluggableAuthCredentials.newBuilder(CREDENTIAL)
+ .setExecutableHandler(options -> "pluggableAuthToken")
+ .setTokenUrl(transportFactory.transport.getStsUrl())
+ .setHttpTransportFactory(transportFactory)
+ .build();
+
+ AccessToken accessToken = credential.refreshAccessToken();
+
+ assertEquals(transportFactory.transport.getAccessToken(), accessToken.getTokenValue());
+
+ // Validate that the correct subject token was passed to STS.
+ Map query =
+ TestUtils.parseQuery(transportFactory.transport.getRequests().get(0).getContentAsString());
+ assertEquals(query.get("subject_token"), "pluggableAuthToken");
+ }
+
+ @Test
+ void refreshAccessToken_withServiceAccountImpersonation() throws IOException {
+ MockExternalAccountCredentialsTransportFactory transportFactory =
+ new MockExternalAccountCredentialsTransportFactory();
+
+ transportFactory.transport.setExpireTime(TestUtils.getDefaultExpireTime());
+
+ PluggableAuthCredentials credential =
+ (PluggableAuthCredentials)
+ PluggableAuthCredentials.newBuilder(CREDENTIAL)
+ .setExecutableHandler(options -> "pluggableAuthToken")
+ .setTokenUrl(transportFactory.transport.getStsUrl())
+ .setServiceAccountImpersonationUrl(
+ transportFactory.transport.getServiceAccountImpersonationUrl())
+ .setHttpTransportFactory(transportFactory)
+ .build();
+
+ AccessToken accessToken = credential.refreshAccessToken();
+
+ assertEquals(
+ transportFactory.transport.getServiceAccountAccessToken(), accessToken.getTokenValue());
+
+ // Validate that the correct subject token was passed to STS.
+ Map query =
+ TestUtils.parseQuery(transportFactory.transport.getRequests().get(0).getContentAsString());
+ assertEquals(query.get("subject_token"), "pluggableAuthToken");
+ }
+
+ @Test
+ void pluggableAuthCredentialSource_allFields() {
+ Map source = new HashMap<>();
+ Map executable = new HashMap<>();
+ source.put("executable", executable);
+ executable.put("command", "/path/to/executable");
+ executable.put("timeout_millis", "10000");
+ executable.put("output_file", "/path/to/output/file");
+
+ PluggableAuthCredentialSource credentialSource = new PluggableAuthCredentialSource(source);
+
+ assertEquals(credentialSource.getCommand(), "/path/to/executable");
+ assertEquals(credentialSource.getTimeoutMs(), 10000);
+ assertEquals(credentialSource.getOutputFilePath(), "/path/to/output/file");
+ }
+
+ @Test
+ void pluggableAuthCredentialSource_noTimeoutProvided_setToDefault() {
+ Map source = new HashMap<>();
+ Map executable = new HashMap<>();
+ source.put("executable", executable);
+ executable.put("command", "command");
+ PluggableAuthCredentialSource credentialSource = new PluggableAuthCredentialSource(source);
+
+ assertEquals(credentialSource.getCommand(), "command");
+ assertEquals(credentialSource.getTimeoutMs(), DEFAULT_EXECUTABLE_TIMEOUT_MS);
+ assertNull(credentialSource.getOutputFilePath());
+ }
+
+ @Test
+ void pluggableAuthCredentialSource_timeoutProvidedOutOfRange_throws() {
+ Map source = new HashMap<>();
+ Map executable = new HashMap<>();
+ source.put("executable", executable);
+
+ executable.put("command", "command");
+
+ int[] possibleOutOfRangeValues = new int[] {0, 4 * 1000, 121 * 1000};
+
+ for (int value : possibleOutOfRangeValues) {
+ executable.put("timeout_millis", value);
+
+ IllegalArgumentException exception =
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> {
+ new PluggableAuthCredentialSource(source);
+ },
+ "Exception should be thrown.");
+ assertEquals(
+ String.format(
+ "The executable timeout must be between %s and %s milliseconds.",
+ MINIMUM_EXECUTABLE_TIMEOUT_MS, MAXIMUM_EXECUTABLE_TIMEOUT_MS),
+ exception.getMessage());
+ }
+ }
+
+ @Test
+ void pluggableAuthCredentialSource_validTimeoutProvided() {
+ Map source = new HashMap<>();
+ Map executable = new HashMap<>();
+ source.put("executable", executable);
+
+ executable.put("command", "command");
+
+ Object[] possibleValues = new Object[] {"10000", 10000, BigDecimal.valueOf(10000L)};
+
+ for (Object value : possibleValues) {
+ executable.put("timeout_millis", value);
+ PluggableAuthCredentialSource credentialSource = new PluggableAuthCredentialSource(source);
+
+ assertEquals(credentialSource.getCommand(), "command");
+ assertEquals(credentialSource.getTimeoutMs(), 10000);
+ assertNull(credentialSource.getOutputFilePath());
+ }
+ }
+
+ @Test
+ void pluggableAuthCredentialSource_missingExecutableField_throws() {
+ IllegalArgumentException exception =
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> new PluggableAuthCredentialSource(new HashMap<>()),
+ "Exception should be thrown.");
+ assertEquals(
+ "Invalid credential source for PluggableAuth credentials.", exception.getMessage());
+ }
+
+ @Test
+ void pluggableAuthCredentialSource_missingExecutableCommandField_throws() {
+ Map source = new HashMap<>();
+ Map executable = new HashMap<>();
+ source.put("executable", executable);
+
+ IllegalArgumentException exception =
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> new PluggableAuthCredentialSource(source),
+ "Exception should be thrown.");
+ assertEquals(
+ "The PluggableAuthCredentialSource is missing the required 'command' field.",
+ exception.getMessage());
+ }
+
+ @Test
+ void builder_allFields() {
+ List scopes = Arrays.asList("scope1", "scope2");
+
+ CredentialSource source = buildCredentialSource();
+ ExecutableHandler handler = options -> "Token";
+
+ PluggableAuthCredentials credentials =
+ (PluggableAuthCredentials)
+ PluggableAuthCredentials.newBuilder()
+ .setExecutableHandler(handler)
+ .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY)
+ .setAudience("audience")
+ .setSubjectTokenType("subjectTokenType")
+ .setTokenUrl(STS_URL)
+ .setTokenInfoUrl("tokenInfoUrl")
+ .setCredentialSource(source)
+ .setServiceAccountImpersonationUrl(SERVICE_ACCOUNT_IMPERSONATION_URL)
+ .setQuotaProjectId("quotaProjectId")
+ .setClientId("clientId")
+ .setClientSecret("clientSecret")
+ .setScopes(scopes)
+ .build();
+
+ assertEquals(credentials.getExecutableHandler(), handler);
+ assertEquals("audience", credentials.getAudience());
+ assertEquals("subjectTokenType", credentials.getSubjectTokenType());
+ assertEquals(credentials.getTokenUrl(), STS_URL);
+ assertEquals(credentials.getTokenInfoUrl(), "tokenInfoUrl");
+ assertEquals(
+ credentials.getServiceAccountImpersonationUrl(), SERVICE_ACCOUNT_IMPERSONATION_URL);
+ assertEquals(credentials.getCredentialSource(), source);
+ assertEquals(credentials.getQuotaProjectId(), "quotaProjectId");
+ assertEquals(credentials.getClientId(), "clientId");
+ assertEquals(credentials.getClientSecret(), "clientSecret");
+ assertEquals(credentials.getScopes(), scopes);
+ assertEquals(credentials.getEnvironmentProvider(), SystemEnvironmentProvider.getInstance());
+ }
+
+ @Test
+ void createdScoped_clonedCredentialWithAddedScopes() {
+ PluggableAuthCredentials credentials =
+ (PluggableAuthCredentials)
+ PluggableAuthCredentials.newBuilder(CREDENTIAL)
+ .setExecutableHandler(options -> "pluggableAuthToken")
+ .setServiceAccountImpersonationUrl(SERVICE_ACCOUNT_IMPERSONATION_URL)
+ .setQuotaProjectId("quotaProjectId")
+ .setClientId("clientId")
+ .setClientSecret("clientSecret")
+ .build();
+
+ List newScopes = Arrays.asList("scope1", "scope2");
+
+ PluggableAuthCredentials newCredentials = credentials.createScoped(newScopes);
+
+ assertEquals(credentials.getAudience(), newCredentials.getAudience());
+ assertEquals(credentials.getSubjectTokenType(), newCredentials.getSubjectTokenType());
+ assertEquals(credentials.getTokenUrl(), newCredentials.getTokenUrl());
+ assertEquals(credentials.getTokenInfoUrl(), newCredentials.getTokenInfoUrl());
+ assertEquals(
+ credentials.getServiceAccountImpersonationUrl(),
+ newCredentials.getServiceAccountImpersonationUrl());
+ assertEquals(credentials.getCredentialSource(), newCredentials.getCredentialSource());
+ assertEquals(newScopes, newCredentials.getScopes());
+ assertEquals(credentials.getQuotaProjectId(), newCredentials.getQuotaProjectId());
+ assertEquals(credentials.getClientId(), newCredentials.getClientId());
+ assertEquals(credentials.getClientSecret(), newCredentials.getClientSecret());
+ assertEquals(credentials.getExecutableHandler(), newCredentials.getExecutableHandler());
+ }
+
+ private static CredentialSource buildCredentialSource() {
+ return buildCredentialSource("command", null, null);
+ }
+
+ private static CredentialSource buildCredentialSource(
+ String command, @Nullable String timeoutMs, @Nullable String outputFile) {
+ Map source = new HashMap<>();
+ Map executable = new HashMap<>();
+ source.put("executable", executable);
+ executable.put("command", command);
+ if (timeoutMs != null) {
+ executable.put("timeout_millis", timeoutMs);
+ }
+ if (outputFile != null) {
+ executable.put("output_file", outputFile);
+ }
+
+ return new PluggableAuthCredentialSource(source);
+ }
+
+ static InputStream writeCredentialsStream(String tokenUrl) throws IOException {
+ GenericJson json = new GenericJson();
+ json.put("audience", "audience");
+ json.put("subject_token_type", "subjectTokenType");
+ json.put("token_url", tokenUrl);
+ json.put("token_info_url", "tokenInfoUrl");
+ json.put("type", ExternalAccountCredentials.EXTERNAL_ACCOUNT_FILE_TYPE);
+
+ GenericJson credentialSource = new GenericJson();
+ GenericJson executable = new GenericJson();
+ executable.put("command", "/path/to/executable");
+ credentialSource.put("executable", executable);
+
+ json.put("credential_source", credentialSource);
+ return TestUtils.jsonToInputStream(json);
+ }
+}
diff --git a/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthExceptionTest.java b/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthExceptionTest.java
new file mode 100644
index 000000000..f924d4137
--- /dev/null
+++ b/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthExceptionTest.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ * * Redistributions in binary form must reproduce the above
+ * copyright notice, this list of conditions and the following disclaimer
+ * in the documentation and/or other materials provided with the
+ * distribution.
+ *
+ * * Neither the name of Google LLC nor the names of its
+ * contributors may be used to endorse or promote products derived from
+ * this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package com.google.auth.oauth2;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.junit.jupiter.api.Test;
+
+/** Tests for {@link PluggableAuthException}. */
+class PluggableAuthExceptionTest {
+
+ private static final String MESSAGE_FORMAT = "Error code %s: %s";
+
+ @Test
+ void constructor() {
+ PluggableAuthException e = new PluggableAuthException("errorCode", "errorDescription");
+ assertEquals("errorCode", e.getErrorCode());
+ assertEquals("errorDescription", e.getErrorDescription());
+ }
+
+ @Test
+ void constructor_nullErrorCode_throws() {
+ assertThrows(
+ NullPointerException.class,
+ () -> new PluggableAuthException(/* errorCode= */ null, "errorDescription"));
+ }
+
+ @Test
+ void constructor_nullErrorDescription_throws() {
+ assertThrows(
+ NullPointerException.class,
+ () -> new PluggableAuthException("errorCode", /* errorDescription= */ null));
+ }
+
+ @Test
+ void getMessage() {
+ PluggableAuthException e = new PluggableAuthException("errorCode", "errorDescription");
+ String expectedMessage = String.format("Error code %s: %s", "errorCode", "errorDescription");
+ assertEquals(expectedMessage, e.getMessage());
+ }
+}
diff --git a/pom.xml b/pom.xml
index 18027e07f..049943154 100644
--- a/pom.xml
+++ b/pom.xml
@@ -59,7 +59,7 @@
UTF-8
- 1.41.4
+ 1.41.55.8.231.0.1-android2.0.4
From d6f242d6c13f2ea4efc6177881aa53911b0f4597 Mon Sep 17 00:00:00 2001
From: Leo <39062083+lsirac@users.noreply.github.com>
Date: Wed, 6 Apr 2022 21:15:20 -0700
Subject: [PATCH 2/7] feat: finalizes PluggableAuth implementation (#906)
* Adds ExecutableResponse class
* Adds unit tests for ExecutableResponse
* Adds 3rd party executable handler
* Adds unit tests for PluggableAuthHandler
* Fix build issues
---
.../auth/oauth2/ExecutableResponse.java | 206 +++++
.../auth/oauth2/PluggableAuthCredentials.java | 3 +-
.../auth/oauth2/PluggableAuthHandler.java | 241 ++++++
.../auth/oauth2/ExecutableResponseTest.java | 302 ++++++++
.../auth/oauth2/PluggableAuthHandlerTest.java | 722 ++++++++++++++++++
oauth2_http/pom.xml | 12 +
6 files changed, 1484 insertions(+), 2 deletions(-)
create mode 100644 oauth2_http/java/com/google/auth/oauth2/ExecutableResponse.java
create mode 100644 oauth2_http/java/com/google/auth/oauth2/PluggableAuthHandler.java
create mode 100644 oauth2_http/javatests/com/google/auth/oauth2/ExecutableResponseTest.java
create mode 100644 oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthHandlerTest.java
diff --git a/oauth2_http/java/com/google/auth/oauth2/ExecutableResponse.java b/oauth2_http/java/com/google/auth/oauth2/ExecutableResponse.java
new file mode 100644
index 000000000..5559b5442
--- /dev/null
+++ b/oauth2_http/java/com/google/auth/oauth2/ExecutableResponse.java
@@ -0,0 +1,206 @@
+/*
+ * Copyright 2022 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 java.io.IOException;
+import java.math.BigDecimal;
+import java.time.Instant;
+import javax.annotation.Nullable;
+
+/**
+ * Encapsulates response values for the 3rd party executable response (e.g. OIDC, SAML, error
+ * responses).
+ */
+class ExecutableResponse {
+
+ private static final String SAML_SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:saml2";
+
+ private final int version;
+ private final boolean success;
+
+ @Nullable private Long expirationTime;
+ @Nullable private String tokenType;
+ @Nullable private String subjectToken;
+ @Nullable private String errorCode;
+ @Nullable private String errorMessage;
+
+ ExecutableResponse(GenericJson json) throws IOException {
+ if (!json.containsKey("version")) {
+ throw new PluggableAuthException(
+ "INVALID_EXECUTABLE_RESPONSE", "The executable response is missing the `version` field.");
+ }
+
+ if (!json.containsKey("success")) {
+ throw new PluggableAuthException(
+ "INVALID_EXECUTABLE_RESPONSE", "The executable response is missing the `success` field.");
+ }
+
+ this.version = parseIntField(json.get("version"));
+ this.success = (boolean) json.get("success");
+
+ if (success) {
+ if (!json.containsKey("token_type")) {
+ throw new PluggableAuthException(
+ "INVALID_EXECUTABLE_RESPONSE",
+ "The executable response is missing the `token_type` field.");
+ }
+
+ if (!json.containsKey("expiration_time")) {
+ throw new PluggableAuthException(
+ "INVALID_EXECUTABLE_RESPONSE",
+ "The executable response is missing the `expiration_time` field.");
+ }
+
+ this.tokenType = (String) json.get("token_type");
+ this.expirationTime = parseLongField(json.get("expiration_time"));
+
+ if (SAML_SUBJECT_TOKEN_TYPE.equals(tokenType)) {
+ this.subjectToken = (String) json.get("saml_response");
+ } else {
+ this.subjectToken = (String) json.get("id_token");
+ }
+ if (subjectToken == null || subjectToken.isEmpty()) {
+ throw new PluggableAuthException(
+ "INVALID_EXECUTABLE_RESPONSE",
+ "The executable response does not contain a valid token.");
+ }
+ } else {
+ // Error response must contain both an error code and message.
+ this.errorCode = (String) json.get("code");
+ this.errorMessage = (String) json.get("message");
+ if (errorCode == null
+ || errorCode.isEmpty()
+ || errorMessage == null
+ || errorMessage.isEmpty()) {
+ throw new PluggableAuthException(
+ "INVALID_EXECUTABLE_RESPONSE",
+ "The executable response must contain `error` and `message` fields when unsuccessful.");
+ }
+ }
+ }
+
+ /**
+ * Returns the version of the executable output. Only version `1` is currently supported. This is
+ * useful for future changes to the expected output format.
+ *
+ * @return The version of the JSON output.
+ */
+ int getVersion() {
+ return this.version;
+ }
+
+ /**
+ * Returns the status of the response.
+ *
+ *
When this is true, the response will contain the 3rd party token for a sign in / refresh
+ * operation. When this is false, the response should contain an additional error code and
+ * message.
+ *
+ * @return Whether the `success` field in the executable response is true.
+ */
+ boolean isSuccessful() {
+ return this.success;
+ }
+
+ /** Returns true if the subject token is expired or not present, false otherwise. */
+ boolean isExpired() {
+ return this.expirationTime == null || this.expirationTime <= Instant.now().getEpochSecond();
+ }
+
+ /** Returns whether the execution was successful and returned an unexpired token. */
+ boolean isValid() {
+ return isSuccessful() && !isExpired();
+ }
+
+ /** Returns the subject token expiration time in seconds (Unix epoch time). */
+ @Nullable
+ Long getExpirationTime() {
+ return this.expirationTime;
+ }
+
+ /**
+ * Returns the 3rd party subject token type.
+ *
+ *
Possible valid values:
+ *
+ *
+ *
urn:ietf:params:oauth:token-type:id_token
+ *
urn:ietf:params:oauth:token-type:jwt
+ *
urn:ietf:params:oauth:token-type:saml2
+ *
+ *
+ * @return The 3rd party subject token type for success responses, null otherwise.
+ */
+ @Nullable
+ String getTokenType() {
+ return this.tokenType;
+ }
+
+ /** Returns the subject token if the execution was successful, null otherwise. */
+ @Nullable
+ String getSubjectToken() {
+ return this.subjectToken;
+ }
+
+ /** Returns the error code if the execution was unsuccessful, null otherwise. */
+ @Nullable
+ String getErrorCode() {
+ return this.errorCode;
+ }
+
+ /** Returns the error message if the execution was unsuccessful, null otherwise. */
+ @Nullable
+ String getErrorMessage() {
+ return this.errorMessage;
+ }
+
+ private static int parseIntField(Object field) {
+ if (field instanceof String) {
+ return Integer.parseInt((String) field);
+ }
+ if (field instanceof BigDecimal) {
+ return ((BigDecimal) field).intValue();
+ }
+ return (int) field;
+ }
+
+ private static long parseLongField(Object field) {
+ if (field instanceof String) {
+ return Long.parseLong((String) field);
+ }
+ if (field instanceof BigDecimal) {
+ return ((BigDecimal) field).longValue();
+ }
+ return (long) field;
+ }
+}
diff --git a/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java b/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java
index 83c63a5d8..17cff7ef0 100644
--- a/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java
+++ b/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java
@@ -205,8 +205,7 @@ String getOutputFilePath() {
if (builder.handler != null) {
handler = builder.handler;
} else {
- // TODO(lsirac): Initialize handler.
- handler = null;
+ handler = new PluggableAuthHandler(getEnvironmentProvider());
}
// Re-initialize impersonated credentials as the handler hasn't been set yet when
diff --git a/oauth2_http/java/com/google/auth/oauth2/PluggableAuthHandler.java b/oauth2_http/java/com/google/auth/oauth2/PluggableAuthHandler.java
new file mode 100644
index 000000000..d21abed67
--- /dev/null
+++ b/oauth2_http/java/com/google/auth/oauth2/PluggableAuthHandler.java
@@ -0,0 +1,241 @@
+/*
+ * Copyright 2022 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.JsonParser;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Splitter;
+import java.io.BufferedReader;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Internal handler for retrieving 3rd party tokens from user defined scripts/executables for
+ * workload identity federation.
+ *
+ *
See {@link PluggableAuthCredentials}.
+ */
+final class PluggableAuthHandler implements ExecutableHandler {
+
+ /** An interface for creating and managing a process. */
+ abstract static class InternalProcessBuilder {
+
+ abstract Map environment();
+
+ abstract InternalProcessBuilder redirectErrorStream(boolean redirectErrorStream);
+
+ abstract Process start() throws IOException;
+ }
+
+ /**
+ * The default implementation that wraps {@link ProcessBuilder} for creating and managing a
+ * process.
+ */
+ static final class DefaultProcessBuilder extends InternalProcessBuilder {
+ ProcessBuilder processBuilder;
+
+ DefaultProcessBuilder(ProcessBuilder processBuilder) {
+ this.processBuilder = processBuilder;
+ }
+
+ @Override
+ Map environment() {
+ return this.processBuilder.environment();
+ }
+
+ @Override
+ InternalProcessBuilder redirectErrorStream(boolean redirectErrorStream) {
+ this.processBuilder.redirectErrorStream(redirectErrorStream);
+ return this;
+ }
+
+ @Override
+ Process start() throws IOException {
+ return this.processBuilder.start();
+ }
+ }
+
+ // The maximum supported version for the executable response.
+ // The executable response always includes a version number that is used
+ // to detect compatibility with the response and library verions.
+ private static final int EXECUTABLE_SUPPORTED_MAX_VERSION = 1;
+
+ // The GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES dictates if this feature is enabled.
+ // The GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES environment variable must be set to '1' for
+ // security reasons.
+ private static final String GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES =
+ "GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES";
+
+ // The exit status of the 3P script that represents a successful execution.
+ private static final int EXIT_CODE_SUCCESS = 0;
+
+ private final EnvironmentProvider environmentProvider;
+ private InternalProcessBuilder internalProcessBuilder;
+
+ PluggableAuthHandler(EnvironmentProvider environmentProvider) {
+ this.environmentProvider = environmentProvider;
+ }
+
+ @VisibleForTesting
+ PluggableAuthHandler(
+ EnvironmentProvider environmentProvider, InternalProcessBuilder internalProcessBuilder) {
+ this.environmentProvider = environmentProvider;
+ this.internalProcessBuilder = internalProcessBuilder;
+ }
+
+ @Override
+ public String retrieveTokenFromExecutable(ExecutableOptions options) throws IOException {
+ // Validate that executables are allowed to run. To use Pluggable Auth,
+ // The GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES environment variable must be set to 1
+ // for security reasons.
+ if (!"1".equals(this.environmentProvider.getEnv(GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES))) {
+ throw new PluggableAuthException(
+ "PLUGGABLE_AUTH_DISABLED",
+ "Pluggable Auth executables need "
+ + "to be explicitly allowed to run by setting the "
+ + "GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES environment variable to 1.");
+ }
+
+ // Users can specify an output file path in the Pluggable Auth ADC configuration.
+ // This is the file's absolute path. Their executable will handle writing the 3P credentials to
+ // this file.
+ // If specified, we will first check if we have valid unexpired credentials stored in this
+ // location to avoid running the executable until they are expired.
+ ExecutableResponse executableResponse = null;
+ if (options.getOutputFilePath() != null && !options.getOutputFilePath().isEmpty()) {
+ // Read cached response from output_file.
+ InputStream inputStream = new FileInputStream(options.getOutputFilePath());
+ BufferedReader reader =
+ new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
+ JsonParser parser = OAuth2Utils.JSON_FACTORY.createJsonParser(reader);
+
+ ExecutableResponse cachedResponse =
+ new ExecutableResponse(parser.parseAndClose(GenericJson.class));
+
+ // If the cached response is successful and unexpired, we can use it.
+ // Response version will be validated below.
+ if (cachedResponse.isValid()) {
+ executableResponse = cachedResponse;
+ }
+ }
+
+ // If the output_file does not contain a valid response, call the executable.
+ if (executableResponse == null) {
+ executableResponse = getExecutableResponse(options);
+ }
+
+ // The executable response includes a version. Validate that the version is compatible
+ // with the library.
+ if (executableResponse.getVersion() > EXECUTABLE_SUPPORTED_MAX_VERSION) {
+ throw new PluggableAuthException(
+ "UNSUPPORTED_VERSION",
+ "The version of the executable response is not supported. "
+ + String.format(
+ "The maximum version currently supported is %s.",
+ EXECUTABLE_SUPPORTED_MAX_VERSION));
+ }
+
+ if (!executableResponse.isSuccessful()) {
+ throw new PluggableAuthException(
+ executableResponse.getErrorCode(), executableResponse.getErrorMessage());
+ }
+
+ if (executableResponse.isExpired()) {
+ throw new PluggableAuthException("INVALID_RESPONSE", "The executable response is expired.");
+ }
+
+ // Subject token is valid and can be returned.
+ return executableResponse.getSubjectToken();
+ }
+
+ ExecutableResponse getExecutableResponse(ExecutableOptions options) throws IOException {
+ List components = Splitter.on(" ").splitToList(options.getExecutableCommand());
+
+ // Create the process.
+ InternalProcessBuilder processBuilder = getProcessBuilder(components);
+
+ // Inject environment variables.
+ Map envMap = processBuilder.environment();
+ envMap.putAll(options.getEnvironmentMap());
+
+ // Redirect error stream.
+ processBuilder.redirectErrorStream(true);
+
+ // Start the process.
+ Process process = processBuilder.start();
+
+ ExecutableResponse execResp;
+ try {
+ boolean success = process.waitFor(options.getExecutableTimeoutMs(), TimeUnit.MILLISECONDS);
+ if (!success) {
+ // Process has not terminated within the specified timeout.
+ process.destroyForcibly();
+ throw new PluggableAuthException(
+ "TIMEOUT_EXCEEDED", "The executable failed to finish within the timeout specified.");
+ }
+ int exitCode = process.exitValue();
+ if (exitCode != EXIT_CODE_SUCCESS) {
+ process.destroyForcibly();
+ throw new PluggableAuthException(
+ "EXIT_CODE", String.format("The executable failed with exit code %s.", exitCode));
+ }
+ BufferedReader reader =
+ new BufferedReader(
+ new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8));
+ JsonParser parser = OAuth2Utils.JSON_FACTORY.createJsonParser(reader);
+
+ execResp = new ExecutableResponse(parser.parseAndClose(GenericJson.class));
+ } catch (InterruptedException e) {
+ // Destroy the process.
+ process.destroyForcibly();
+ throw new PluggableAuthException(
+ "INTERRUPTED", String.format("The execution was interrupted: %s.", e));
+ }
+
+ process.destroyForcibly();
+ return execResp;
+ }
+
+ InternalProcessBuilder getProcessBuilder(List commandComponents) {
+ if (internalProcessBuilder != null) {
+ return internalProcessBuilder;
+ }
+ return new DefaultProcessBuilder(new ProcessBuilder(commandComponents));
+ }
+}
diff --git a/oauth2_http/javatests/com/google/auth/oauth2/ExecutableResponseTest.java b/oauth2_http/javatests/com/google/auth/oauth2/ExecutableResponseTest.java
new file mode 100644
index 000000000..b6f85684a
--- /dev/null
+++ b/oauth2_http/javatests/com/google/auth/oauth2/ExecutableResponseTest.java
@@ -0,0 +1,302 @@
+/*
+ * Copyright 2022 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.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.google.api.client.json.GenericJson;
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.time.Instant;
+import org.junit.jupiter.api.Test;
+
+/** Tests for {@link ExecutableResponse}. */
+class ExecutableResponseTest {
+
+ private static final String TOKEN_TYPE_OIDC = "urn:ietf:params:oauth:token-type:id_token";
+ private static final String TOKEN_TYPE_SAML = "urn:ietf:params:oauth:token-type:saml2";
+ private static final String ID_TOKEN = "header.payload.signature";
+ private static final String SAML_RESPONSE = "samlResponse";
+
+ private static final int EXECUTABLE_SUPPORTED_MAX_VERSION = 1;
+ private static final int EXPIRATION_DURATION = 3600;
+
+ @Test
+ void constructor_successOidcResponse() throws IOException {
+ ExecutableResponse response = new ExecutableResponse(buildOidcResponse());
+
+ assertTrue(response.isSuccessful());
+ assertTrue(response.isValid());
+ assertEquals(1, response.getVersion());
+ assertEquals(TOKEN_TYPE_OIDC, response.getTokenType());
+ assertEquals(ID_TOKEN, response.getSubjectToken());
+ assertEquals(
+ Instant.now().getEpochSecond() + EXPIRATION_DURATION, response.getExpirationTime());
+ assertEquals(1, response.getVersion());
+ }
+
+ @Test
+ void constructor_successSamlResponse() throws IOException {
+ ExecutableResponse response = new ExecutableResponse(buildSamlResponse());
+
+ assertTrue(response.isSuccessful());
+ assertTrue(response.isValid());
+ assertEquals(EXECUTABLE_SUPPORTED_MAX_VERSION, response.getVersion());
+ assertEquals(TOKEN_TYPE_SAML, response.getTokenType());
+ assertEquals(SAML_RESPONSE, response.getSubjectToken());
+ assertEquals(
+ Instant.now().getEpochSecond() + EXPIRATION_DURATION, response.getExpirationTime());
+ }
+
+ @Test
+ void constructor_validErrorResponse() throws IOException {
+ ExecutableResponse response = new ExecutableResponse(buildErrorResponse());
+
+ assertFalse(response.isSuccessful());
+ assertFalse(response.isValid());
+ assertTrue(response.isExpired());
+ assertNull(response.getSubjectToken());
+ assertNull(response.getTokenType());
+ assertNull(response.getExpirationTime());
+ assertEquals(1, response.getVersion());
+ assertEquals("401", response.getErrorCode());
+ assertEquals("Caller not authorized.", response.getErrorMessage());
+ }
+
+ @Test
+ void constructor_errorResponseMissingCode_throws() {
+ GenericJson jsonResponse = buildErrorResponse();
+
+ Object[] values = new Object[] {null, ""};
+ for (Object value : values) {
+ jsonResponse.put("code", value);
+
+ PluggableAuthException exception =
+ assertThrows(
+ PluggableAuthException.class,
+ () -> new ExecutableResponse(jsonResponse),
+ "Exception should be thrown.");
+
+ assertEquals(
+ "Error code INVALID_EXECUTABLE_RESPONSE: The executable response must contain "
+ + "`error` and `message` fields when unsuccessful.",
+ exception.getMessage());
+ }
+ }
+
+ @Test
+ void constructor_errorResponseMissingMessage_throws() {
+ GenericJson jsonResponse = buildErrorResponse();
+
+ Object[] values = new Object[] {null, ""};
+ for (Object value : values) {
+ jsonResponse.put("message", value);
+
+ PluggableAuthException exception =
+ assertThrows(
+ PluggableAuthException.class,
+ () -> new ExecutableResponse(jsonResponse),
+ "Exception should be thrown.");
+
+ assertEquals(
+ "Error code INVALID_EXECUTABLE_RESPONSE: The executable response must contain "
+ + "`error` and `message` fields when unsuccessful.",
+ exception.getMessage());
+ }
+ }
+
+ @Test
+ void constructor_successResponseMissingVersionField_throws() {
+ GenericJson jsonResponse = buildOidcResponse();
+ jsonResponse.remove("version");
+
+ PluggableAuthException exception =
+ assertThrows(
+ PluggableAuthException.class,
+ () -> new ExecutableResponse(jsonResponse),
+ "Exception should be thrown.");
+
+ assertEquals(
+ "Error code INVALID_EXECUTABLE_RESPONSE: The executable response is missing the "
+ + "`version` field.",
+ exception.getMessage());
+ }
+
+ @Test
+ void constructor_successResponseMissingSuccessField_throws() {
+ GenericJson jsonResponse = buildOidcResponse();
+ jsonResponse.remove("success");
+
+ PluggableAuthException exception =
+ assertThrows(
+ PluggableAuthException.class,
+ () -> new ExecutableResponse(jsonResponse),
+ "Exception should be thrown.");
+
+ assertEquals(
+ "Error code INVALID_EXECUTABLE_RESPONSE: The executable response is missing the "
+ + "`success` field.",
+ exception.getMessage());
+ }
+
+ @Test
+ void constructor_successResponseMissingTokenTypeField_throws() {
+ GenericJson jsonResponse = buildOidcResponse();
+ jsonResponse.remove("token_type");
+
+ PluggableAuthException exception =
+ assertThrows(
+ PluggableAuthException.class,
+ () -> new ExecutableResponse(jsonResponse),
+ "Exception should be thrown.");
+
+ assertEquals(
+ "Error code INVALID_EXECUTABLE_RESPONSE: The executable response is missing the "
+ + "`token_type` field.",
+ exception.getMessage());
+ }
+
+ @Test
+ void constructor_successResponseMissingExpirationTimeField_throws() {
+ GenericJson jsonResponse = buildOidcResponse();
+ jsonResponse.remove("expiration_time");
+
+ PluggableAuthException exception =
+ assertThrows(
+ PluggableAuthException.class,
+ () -> new ExecutableResponse(jsonResponse),
+ "Exception should be thrown.");
+
+ assertEquals(
+ "Error code INVALID_EXECUTABLE_RESPONSE: The executable response is missing the "
+ + "`expiration_time` field.",
+ exception.getMessage());
+ }
+
+ @Test
+ void constructor_samlResponseMissingSubjectToken_throws() {
+ GenericJson jsonResponse = buildSamlResponse();
+
+ Object[] values = new Object[] {null, ""};
+ for (Object value : values) {
+ jsonResponse.put("saml_response", value);
+
+ PluggableAuthException exception =
+ assertThrows(
+ PluggableAuthException.class,
+ () -> new ExecutableResponse(jsonResponse),
+ "Exception should be thrown.");
+
+ assertEquals(
+ "Error code INVALID_EXECUTABLE_RESPONSE: The executable response does not "
+ + "contain a valid token.",
+ exception.getMessage());
+ }
+ }
+
+ @Test
+ void constructor_oidcResponseMissingSubjectToken_throws() {
+ GenericJson jsonResponse = buildOidcResponse();
+
+ Object[] values = new Object[] {null, ""};
+ for (Object value : values) {
+ jsonResponse.put("id_token", value);
+
+ PluggableAuthException exception =
+ assertThrows(
+ PluggableAuthException.class,
+ () -> new ExecutableResponse(jsonResponse),
+ "Exception should be thrown.");
+
+ assertEquals(
+ "Error code INVALID_EXECUTABLE_RESPONSE: The executable response does not "
+ + "contain a valid token.",
+ exception.getMessage());
+ }
+ }
+
+ @Test
+ void isExpired() throws IOException {
+ GenericJson jsonResponse = buildOidcResponse();
+
+ BigDecimal[] values =
+ new BigDecimal[] {
+ BigDecimal.valueOf(Instant.now().getEpochSecond() - 1000),
+ BigDecimal.valueOf(Instant.now().getEpochSecond() + 1000)
+ };
+ boolean[] expectedResults = new boolean[] {true, false};
+
+ for (int i = 0; i < values.length; i++) {
+ jsonResponse.put("expiration_time", values[i]);
+
+ ExecutableResponse response = new ExecutableResponse(jsonResponse);
+
+ assertEquals(expectedResults[i], response.isExpired());
+ }
+ }
+
+ private static GenericJson buildOidcResponse() {
+ GenericJson json = new GenericJson();
+ json.setFactory(OAuth2Utils.JSON_FACTORY);
+ json.put("version", EXECUTABLE_SUPPORTED_MAX_VERSION);
+ json.put("success", true);
+ json.put("token_type", TOKEN_TYPE_OIDC);
+ json.put("id_token", ID_TOKEN);
+ json.put("expiration_time", Instant.now().getEpochSecond() + EXPIRATION_DURATION);
+ return json;
+ }
+
+ private static GenericJson buildSamlResponse() {
+ GenericJson json = new GenericJson();
+ json.setFactory(OAuth2Utils.JSON_FACTORY);
+ json.put("version", EXECUTABLE_SUPPORTED_MAX_VERSION);
+ json.put("success", true);
+ json.put("token_type", TOKEN_TYPE_SAML);
+ json.put("saml_response", "samlResponse");
+ json.put("expiration_time", Instant.now().getEpochSecond() + EXPIRATION_DURATION);
+ return json;
+ }
+
+ private static GenericJson buildErrorResponse() {
+ GenericJson json = new GenericJson();
+ json.setFactory(OAuth2Utils.JSON_FACTORY);
+ json.put("version", EXECUTABLE_SUPPORTED_MAX_VERSION);
+ json.put("success", false);
+ json.put("code", "401");
+ json.put("message", "Caller not authorized.");
+ return json;
+ }
+}
diff --git a/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthHandlerTest.java b/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthHandlerTest.java
new file mode 100644
index 000000000..31690ebbc
--- /dev/null
+++ b/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthHandlerTest.java
@@ -0,0 +1,722 @@
+/*
+ * Copyright 2022 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.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.google.api.client.json.GenericJson;
+import com.google.auth.oauth2.ExecutableHandler.ExecutableOptions;
+import com.google.auth.oauth2.PluggableAuthHandler.InternalProcessBuilder;
+import com.google.common.collect.ImmutableMap;
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import javax.annotation.Nullable;
+import org.junit.jupiter.api.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mockito;
+import org.mockito.junit.MockitoJUnitRunner;
+
+/** Tests for {@link PluggableAuthHandler}. */
+@RunWith(MockitoJUnitRunner.class)
+class PluggableAuthHandlerTest {
+ private static final String TOKEN_TYPE_OIDC = "urn:ietf:params:oauth:token-type:id_token";
+ private static final String TOKEN_TYPE_SAML = "urn:ietf:params:oauth:token-type:saml2";
+ private static final String ID_TOKEN = "header.payload.signature";
+ private static final String SAML_RESPONSE = "samlResponse";
+
+ private static final int EXECUTABLE_SUPPORTED_MAX_VERSION = 1;
+ private static final int EXPIRATION_DURATION = 3600;
+ private static final int EXIT_CODE_SUCCESS = 0;
+ private static final int EXIT_CODE_FAIL = 1;
+
+ private static final ExecutableOptions DEFAULT_OPTIONS =
+ new ExecutableOptions() {
+ @Override
+ public String getExecutableCommand() {
+ return "/path/to/executable";
+ }
+
+ @Override
+ public Map getEnvironmentMap() {
+ return ImmutableMap.of("optionKey1", "optionValue1", "optionValue2", "optionValue2");
+ }
+
+ @Override
+ public int getExecutableTimeoutMs() {
+ return 30000;
+ }
+
+ @Nullable
+ @Override
+ public String getOutputFilePath() {
+ return null;
+ }
+ };
+
+ @Test
+ void retrieveTokenFromExecutable_oidcResponse() throws IOException, InterruptedException {
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ environmentProvider.setEnv("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES", "1");
+
+ Map currentEnv = new HashMap<>();
+ currentEnv.put("currentEnvKey1", "currentEnvValue1");
+ currentEnv.put("currentEnvKey2", "currentEnvValue2");
+
+ // Expected environment mappings.
+ HashMap expectedMap = new HashMap<>();
+ expectedMap.putAll(DEFAULT_OPTIONS.getEnvironmentMap());
+ expectedMap.putAll(currentEnv);
+
+ // Mock executable handling.
+ Process mockProcess = Mockito.mock(Process.class);
+ when(mockProcess.waitFor(anyLong(), any(TimeUnit.class))).thenReturn(true);
+ when(mockProcess.exitValue()).thenReturn(EXIT_CODE_SUCCESS);
+
+ when(mockProcess.getInputStream())
+ .thenReturn(
+ new ByteArrayInputStream(
+ buildOidcResponse().toString().getBytes(StandardCharsets.UTF_8)));
+
+ InternalProcessBuilder processBuilder =
+ buildInternalProcessBuilder(
+ currentEnv, mockProcess, DEFAULT_OPTIONS.getExecutableCommand());
+
+ PluggableAuthHandler handler = new PluggableAuthHandler(environmentProvider, processBuilder);
+
+ // Call retrieveTokenFromExecutable().
+ String token = handler.retrieveTokenFromExecutable(DEFAULT_OPTIONS);
+
+ verify(mockProcess, times(1)).destroyForcibly();
+ verify(mockProcess, times(1))
+ .waitFor(
+ eq(Long.valueOf(DEFAULT_OPTIONS.getExecutableTimeoutMs())), eq(TimeUnit.MILLISECONDS));
+ assertEquals(ID_TOKEN, token);
+
+ // Current env map should include the mappings from options.
+ assertEquals(4, currentEnv.size());
+ assertEquals(expectedMap, currentEnv);
+ }
+
+ @Test
+ void retrieveTokenFromExecutable_samlResponse() throws IOException, InterruptedException {
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ environmentProvider.setEnv("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES", "1");
+
+ Map currentEnv = new HashMap<>();
+ currentEnv.put("currentEnvKey1", "currentEnvValue1");
+ currentEnv.put("currentEnvKey2", "currentEnvValue2");
+
+ // Expected environment mappings.
+ HashMap expectedMap = new HashMap<>();
+ expectedMap.putAll(DEFAULT_OPTIONS.getEnvironmentMap());
+ expectedMap.putAll(currentEnv);
+
+ // Mock executable handling.
+ Process mockProcess = Mockito.mock(Process.class);
+ when(mockProcess.waitFor(anyLong(), any(TimeUnit.class))).thenReturn(true);
+ when(mockProcess.exitValue()).thenReturn(EXIT_CODE_SUCCESS);
+
+ // SAML response.
+ when(mockProcess.getInputStream())
+ .thenReturn(
+ new ByteArrayInputStream(
+ buildSamlResponse().toString().getBytes(StandardCharsets.UTF_8)));
+
+ InternalProcessBuilder processBuilder =
+ buildInternalProcessBuilder(
+ currentEnv, mockProcess, DEFAULT_OPTIONS.getExecutableCommand());
+
+ PluggableAuthHandler handler = new PluggableAuthHandler(environmentProvider, processBuilder);
+
+ // Call retrieveTokenFromExecutable().
+ String token = handler.retrieveTokenFromExecutable(DEFAULT_OPTIONS);
+
+ verify(mockProcess, times(1)).destroyForcibly();
+ verify(mockProcess, times(1))
+ .waitFor(
+ eq(Long.valueOf(DEFAULT_OPTIONS.getExecutableTimeoutMs())), eq(TimeUnit.MILLISECONDS));
+ assertEquals(SAML_RESPONSE, token);
+
+ // Current env map should include the mappings from options.
+ assertEquals(4, currentEnv.size());
+ assertEquals(expectedMap, currentEnv);
+ }
+
+ @Test
+ void retrieveTokenFromExecutable_errorResponse_throws() throws InterruptedException {
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ environmentProvider.setEnv("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES", "1");
+
+ // Mock executable handling.
+ Process mockProcess = Mockito.mock(Process.class);
+ when(mockProcess.waitFor(anyLong(), any(TimeUnit.class))).thenReturn(true);
+ when(mockProcess.exitValue()).thenReturn(EXIT_CODE_SUCCESS);
+
+ // Error response.
+ when(mockProcess.getInputStream())
+ .thenReturn(
+ new ByteArrayInputStream(
+ buildErrorResponse().toString().getBytes(StandardCharsets.UTF_8)));
+
+ InternalProcessBuilder processBuilder =
+ buildInternalProcessBuilder(
+ new HashMap<>(), mockProcess, DEFAULT_OPTIONS.getExecutableCommand());
+
+ PluggableAuthHandler handler = new PluggableAuthHandler(environmentProvider, processBuilder);
+
+ // Call retrieveTokenFromExecutable().
+ PluggableAuthException e =
+ assertThrows(
+ PluggableAuthException.class,
+ () -> handler.retrieveTokenFromExecutable(DEFAULT_OPTIONS));
+
+ assertEquals("401", e.getErrorCode());
+ assertEquals("Caller not authorized.", e.getErrorDescription());
+ }
+
+ @Test
+ void retrieveTokenFromExecutable_withOutputFile_usesCachedResponse()
+ throws IOException, InterruptedException {
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ environmentProvider.setEnv("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES", "1");
+
+ // Build output_file.
+ File file = File.createTempFile("output_file", /* suffix= */ null, /* directory= */ null);
+ file.deleteOnExit();
+
+ OAuth2Utils.writeInputStreamToFile(
+ new ByteArrayInputStream(buildOidcResponse().toString().getBytes(StandardCharsets.UTF_8)),
+ file.getAbsolutePath());
+
+ // Options with output file specified.
+ ExecutableOptions options =
+ new ExecutableOptions() {
+ @Override
+ public String getExecutableCommand() {
+ return "/path/to/executable";
+ }
+
+ @Override
+ public Map getEnvironmentMap() {
+ return ImmutableMap.of();
+ }
+
+ @Override
+ public int getExecutableTimeoutMs() {
+ return 30000;
+ }
+
+ @Override
+ public String getOutputFilePath() {
+ return file.getAbsolutePath();
+ }
+ };
+
+ // Mock executable handling that does nothing since we are using the output file.
+ Process mockProcess = Mockito.mock(Process.class);
+ InternalProcessBuilder processBuilder =
+ buildInternalProcessBuilder(new HashMap<>(), mockProcess, options.getExecutableCommand());
+
+ PluggableAuthHandler handler = new PluggableAuthHandler(environmentProvider, processBuilder);
+
+ // Call retrieveTokenFromExecutable().
+ String token = handler.retrieveTokenFromExecutable(options);
+
+ // Validate executable not invoked.
+ verify(mockProcess, times(0)).destroyForcibly();
+ verify(mockProcess, times(0))
+ .waitFor(eq(Long.valueOf(options.getExecutableTimeoutMs())), eq(TimeUnit.MILLISECONDS));
+
+ assertEquals(ID_TOKEN, token);
+ }
+
+ @Test
+ void retrieveTokenFromExecutable_expiredOutputFileResponse_callsExecutable()
+ throws IOException, InterruptedException {
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ environmentProvider.setEnv("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES", "1");
+
+ // Build output_file.
+ File file = File.createTempFile("output_file", /* suffix= */ null, /* directory= */ null);
+ file.deleteOnExit();
+
+ // Create an expired response.
+ GenericJson json = buildOidcResponse();
+ json.put("expiration_time", Instant.now().getEpochSecond() - 1);
+
+ OAuth2Utils.writeInputStreamToFile(
+ new ByteArrayInputStream(json.toString().getBytes(StandardCharsets.UTF_8)),
+ file.getAbsolutePath());
+
+ // Options with output file specified.
+ ExecutableOptions options =
+ new ExecutableOptions() {
+ @Override
+ public String getExecutableCommand() {
+ return "/path/to/executable";
+ }
+
+ @Override
+ public Map getEnvironmentMap() {
+ return ImmutableMap.of();
+ }
+
+ @Override
+ public int getExecutableTimeoutMs() {
+ return 30000;
+ }
+
+ @Override
+ public String getOutputFilePath() {
+ return file.getAbsolutePath();
+ }
+ };
+
+ // Mock executable handling.
+ Process mockProcess = Mockito.mock(Process.class);
+ when(mockProcess.waitFor(anyLong(), any(TimeUnit.class))).thenReturn(true);
+ when(mockProcess.exitValue()).thenReturn(EXIT_CODE_SUCCESS);
+ when(mockProcess.getInputStream())
+ .thenReturn(
+ new ByteArrayInputStream(
+ buildOidcResponse().toString().getBytes(StandardCharsets.UTF_8)));
+
+ InternalProcessBuilder processBuilder =
+ buildInternalProcessBuilder(new HashMap<>(), mockProcess, options.getExecutableCommand());
+
+ PluggableAuthHandler handler = new PluggableAuthHandler(environmentProvider, processBuilder);
+
+ // Call retrieveTokenFromExecutable().
+ String token = handler.retrieveTokenFromExecutable(options);
+
+ // Validate that the executable was called.
+ verify(mockProcess, times(1)).destroyForcibly();
+ verify(mockProcess, times(1))
+ .waitFor(eq(Long.valueOf(options.getExecutableTimeoutMs())), eq(TimeUnit.MILLISECONDS));
+
+ assertEquals(ID_TOKEN, token);
+ }
+
+ @Test
+ void retrieveTokenFromExecutable_expiredResponse_throws() throws InterruptedException {
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ environmentProvider.setEnv("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES", "1");
+
+ // Create expired response.
+ GenericJson json = buildOidcResponse();
+ json.put("expiration_time", Instant.now().getEpochSecond() - 1);
+
+ // Mock executable handling.
+ Process mockProcess = Mockito.mock(Process.class);
+ when(mockProcess.waitFor(anyLong(), any(TimeUnit.class))).thenReturn(true);
+ when(mockProcess.exitValue()).thenReturn(EXIT_CODE_SUCCESS);
+ when(mockProcess.getInputStream())
+ .thenReturn(new ByteArrayInputStream(json.toString().getBytes(StandardCharsets.UTF_8)));
+
+ InternalProcessBuilder processBuilder =
+ buildInternalProcessBuilder(
+ new HashMap<>(), mockProcess, DEFAULT_OPTIONS.getExecutableCommand());
+
+ PluggableAuthHandler handler = new PluggableAuthHandler(environmentProvider, processBuilder);
+
+ // Call retrieveTokenFromExecutable().
+ PluggableAuthException e =
+ assertThrows(
+ PluggableAuthException.class,
+ () -> handler.retrieveTokenFromExecutable(DEFAULT_OPTIONS));
+
+ assertEquals("INVALID_RESPONSE", e.getErrorCode());
+ assertEquals("The executable response is expired.", e.getErrorDescription());
+ }
+
+ @Test
+ void retrieveTokenFromExecutable_invalidVersion_throws() throws InterruptedException {
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ environmentProvider.setEnv("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES", "1");
+
+ // Mock executable handling.
+ Process mockProcess = Mockito.mock(Process.class);
+ when(mockProcess.waitFor(anyLong(), any(TimeUnit.class))).thenReturn(true);
+ when(mockProcess.exitValue()).thenReturn(EXIT_CODE_SUCCESS);
+
+ // SAML response.
+ GenericJson json = buildSamlResponse();
+ // Only version `1` is supported.
+ json.put("version", 2);
+ when(mockProcess.getInputStream())
+ .thenReturn(new ByteArrayInputStream(json.toString().getBytes(StandardCharsets.UTF_8)));
+
+ InternalProcessBuilder processBuilder =
+ buildInternalProcessBuilder(
+ new HashMap<>(), mockProcess, DEFAULT_OPTIONS.getExecutableCommand());
+
+ PluggableAuthHandler handler = new PluggableAuthHandler(environmentProvider, processBuilder);
+
+ // Call retrieveTokenFromExecutable().
+ PluggableAuthException e =
+ assertThrows(
+ PluggableAuthException.class,
+ () -> handler.retrieveTokenFromExecutable(DEFAULT_OPTIONS));
+
+ assertEquals("UNSUPPORTED_VERSION", e.getErrorCode());
+ assertEquals(
+ "The version of the executable response is not supported. "
+ + String.format(
+ "The maximum version currently supported is %s.", EXECUTABLE_SUPPORTED_MAX_VERSION),
+ e.getErrorDescription());
+ }
+
+ @Test
+ void retrieveTokenFromExecutable_allowExecutablesDisabled_throws() {
+ // In order to use Pluggable Auth, GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES must be set to 1.
+ // If set to 0, a runtime exception should be thrown.
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ environmentProvider.setEnv("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES", "0");
+
+ PluggableAuthHandler handler = new PluggableAuthHandler(environmentProvider);
+
+ PluggableAuthException e =
+ assertThrows(
+ PluggableAuthException.class,
+ () -> handler.retrieveTokenFromExecutable(DEFAULT_OPTIONS));
+
+ assertEquals("PLUGGABLE_AUTH_DISABLED", e.getErrorCode());
+ assertEquals(
+ "Pluggable Auth executables need to be explicitly allowed to run by "
+ + "setting the GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES environment variable to 1.",
+ e.getErrorDescription());
+ }
+
+ @Test
+ void getExecutableResponse_oidcResponse() throws IOException, InterruptedException {
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ environmentProvider.setEnv("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES", "1");
+
+ Map currentEnv = new HashMap<>();
+ currentEnv.put("currentEnvKey1", "currentEnvValue1");
+ currentEnv.put("currentEnvKey2", "currentEnvValue2");
+
+ // Expected environment mappings.
+ HashMap expectedMap = new HashMap<>();
+ expectedMap.putAll(DEFAULT_OPTIONS.getEnvironmentMap());
+ expectedMap.putAll(currentEnv);
+
+ // Mock executable handling.
+ Process mockProcess = Mockito.mock(Process.class);
+ when(mockProcess.waitFor(anyLong(), any(TimeUnit.class))).thenReturn(true);
+ when(mockProcess.exitValue()).thenReturn(EXIT_CODE_SUCCESS);
+
+ // OIDC response.
+ when(mockProcess.getInputStream())
+ .thenReturn(
+ new ByteArrayInputStream(
+ buildOidcResponse().toString().getBytes(StandardCharsets.UTF_8)));
+
+ InternalProcessBuilder processBuilder =
+ buildInternalProcessBuilder(
+ currentEnv, mockProcess, DEFAULT_OPTIONS.getExecutableCommand());
+
+ PluggableAuthHandler handler = new PluggableAuthHandler(environmentProvider, processBuilder);
+
+ ExecutableResponse response = handler.getExecutableResponse(DEFAULT_OPTIONS);
+
+ verify(mockProcess, times(1)).destroyForcibly();
+ verify(mockProcess, times(1))
+ .waitFor(
+ eq(Long.valueOf(DEFAULT_OPTIONS.getExecutableTimeoutMs())), eq(TimeUnit.MILLISECONDS));
+ assertEquals(EXECUTABLE_SUPPORTED_MAX_VERSION, response.getVersion());
+ assertTrue(response.isSuccessful());
+ assertEquals(TOKEN_TYPE_OIDC, response.getTokenType());
+ assertEquals(ID_TOKEN, response.getSubjectToken());
+ assertEquals(
+ Instant.now().getEpochSecond() + EXPIRATION_DURATION, response.getExpirationTime());
+ // Current env map should include the mappings from options.
+ assertEquals(4, currentEnv.size());
+ assertEquals(expectedMap, currentEnv);
+ }
+
+ @Test
+ void getExecutableResponse_samlResponse() throws IOException, InterruptedException {
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ environmentProvider.setEnv("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES", "1");
+
+ Map currentEnv = new HashMap<>();
+ currentEnv.put("currentEnvKey1", "currentEnvValue1");
+ currentEnv.put("currentEnvKey2", "currentEnvValue2");
+
+ // Expected environment mappings.
+ HashMap expectedMap = new HashMap<>();
+ expectedMap.putAll(DEFAULT_OPTIONS.getEnvironmentMap());
+ expectedMap.putAll(currentEnv);
+
+ // Mock executable handling.
+ Process mockProcess = Mockito.mock(Process.class);
+ when(mockProcess.waitFor(anyLong(), any(TimeUnit.class))).thenReturn(true);
+ when(mockProcess.exitValue()).thenReturn(EXIT_CODE_SUCCESS);
+
+ // SAML response.
+ when(mockProcess.getInputStream())
+ .thenReturn(
+ new ByteArrayInputStream(
+ buildSamlResponse().toString().getBytes(StandardCharsets.UTF_8)));
+
+ InternalProcessBuilder processBuilder =
+ buildInternalProcessBuilder(
+ currentEnv, mockProcess, DEFAULT_OPTIONS.getExecutableCommand());
+
+ PluggableAuthHandler handler = new PluggableAuthHandler(environmentProvider, processBuilder);
+ ExecutableResponse response = handler.getExecutableResponse(DEFAULT_OPTIONS);
+
+ verify(mockProcess, times(1)).destroyForcibly();
+ verify(mockProcess, times(1))
+ .waitFor(
+ eq(Long.valueOf(DEFAULT_OPTIONS.getExecutableTimeoutMs())), eq(TimeUnit.MILLISECONDS));
+ assertEquals(EXECUTABLE_SUPPORTED_MAX_VERSION, response.getVersion());
+ assertTrue(response.isSuccessful());
+ assertEquals(TOKEN_TYPE_SAML, response.getTokenType());
+ assertEquals(SAML_RESPONSE, response.getSubjectToken());
+ assertEquals(
+ Instant.now().getEpochSecond() + EXPIRATION_DURATION, response.getExpirationTime());
+
+ // Current env map should include the mappings from options.
+ assertEquals(4, currentEnv.size());
+ assertEquals(expectedMap, currentEnv);
+
+ verify(mockProcess, times(1)).destroyForcibly();
+ }
+
+ @Test
+ void getExecutableResponse_errorResponse() throws IOException, InterruptedException {
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ environmentProvider.setEnv("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES", "1");
+
+ Map currentEnv = new HashMap<>();
+ currentEnv.put("currentEnvKey1", "currentEnvValue1");
+ currentEnv.put("currentEnvKey2", "currentEnvValue2");
+
+ // Expected environment mappings.
+ HashMap expectedMap = new HashMap<>();
+ expectedMap.putAll(DEFAULT_OPTIONS.getEnvironmentMap());
+ expectedMap.putAll(currentEnv);
+
+ // Mock executable handling.
+ Process mockProcess = Mockito.mock(Process.class);
+ when(mockProcess.waitFor(anyLong(), any(TimeUnit.class))).thenReturn(true);
+ when(mockProcess.exitValue()).thenReturn(EXIT_CODE_SUCCESS);
+
+ // Error response.
+ when(mockProcess.getInputStream())
+ .thenReturn(
+ new ByteArrayInputStream(
+ buildErrorResponse().toString().getBytes(StandardCharsets.UTF_8)));
+
+ InternalProcessBuilder processBuilder =
+ buildInternalProcessBuilder(
+ currentEnv, mockProcess, DEFAULT_OPTIONS.getExecutableCommand());
+ PluggableAuthHandler handler = new PluggableAuthHandler(environmentProvider, processBuilder);
+
+ // Call getExecutableResponse().
+ ExecutableResponse response = handler.getExecutableResponse(DEFAULT_OPTIONS);
+
+ verify(mockProcess, times(1)).destroyForcibly();
+ verify(mockProcess, times(1))
+ .waitFor(
+ eq(Long.valueOf(DEFAULT_OPTIONS.getExecutableTimeoutMs())), eq(TimeUnit.MILLISECONDS));
+ assertEquals(EXECUTABLE_SUPPORTED_MAX_VERSION, response.getVersion());
+ assertFalse(response.isSuccessful());
+ assertEquals("401", response.getErrorCode());
+ assertEquals("Caller not authorized.", response.getErrorMessage());
+
+ // Current env map should include the mappings from options.
+ assertEquals(4, currentEnv.size());
+ assertEquals(expectedMap, currentEnv);
+ }
+
+ @Test
+ void getExecutableResponse_timeoutExceeded_throws() throws InterruptedException {
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ environmentProvider.setEnv("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES", "1");
+
+ // Mock executable handling.
+ Process mockProcess = Mockito.mock(Process.class);
+ when(mockProcess.waitFor(anyLong(), any(TimeUnit.class))).thenReturn(false);
+
+ InternalProcessBuilder processBuilder =
+ buildInternalProcessBuilder(
+ new HashMap<>(), mockProcess, DEFAULT_OPTIONS.getExecutableCommand());
+ PluggableAuthHandler handler = new PluggableAuthHandler(environmentProvider, processBuilder);
+
+ // Call getExecutableResponse().
+ PluggableAuthException e =
+ assertThrows(
+ PluggableAuthException.class, () -> handler.getExecutableResponse(DEFAULT_OPTIONS));
+
+ assertEquals("TIMEOUT_EXCEEDED", e.getErrorCode());
+ assertEquals(
+ "The executable failed to finish within the timeout specified.", e.getErrorDescription());
+ verify(mockProcess, times(1))
+ .waitFor(
+ eq(Long.valueOf(DEFAULT_OPTIONS.getExecutableTimeoutMs())), eq(TimeUnit.MILLISECONDS));
+ verify(mockProcess, times(1)).destroyForcibly();
+ }
+
+ @Test
+ void getExecutableResponse_nonZeroExitCode_throws() throws InterruptedException {
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ environmentProvider.setEnv("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES", "1");
+
+ // Mock executable handling.
+ Process mockProcess = Mockito.mock(Process.class);
+ when(mockProcess.waitFor(anyLong(), any(TimeUnit.class))).thenReturn(true);
+ when(mockProcess.exitValue()).thenReturn(EXIT_CODE_FAIL);
+
+ InternalProcessBuilder processBuilder =
+ buildInternalProcessBuilder(
+ new HashMap<>(), mockProcess, DEFAULT_OPTIONS.getExecutableCommand());
+
+ PluggableAuthHandler handler = new PluggableAuthHandler(environmentProvider, processBuilder);
+
+ // Call getExecutableResponse().
+ PluggableAuthException e =
+ assertThrows(
+ PluggableAuthException.class, () -> handler.getExecutableResponse(DEFAULT_OPTIONS));
+
+ assertEquals("EXIT_CODE", e.getErrorCode());
+ assertEquals(
+ String.format("The executable failed with exit code %s.", EXIT_CODE_FAIL),
+ e.getErrorDescription());
+
+ verify(mockProcess, times(1))
+ .waitFor(
+ eq(Long.valueOf(DEFAULT_OPTIONS.getExecutableTimeoutMs())), eq(TimeUnit.MILLISECONDS));
+ verify(mockProcess, times(1)).destroyForcibly();
+ }
+
+ @Test
+ void getExecutableResponse_processInterrupted_throws() throws InterruptedException {
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ environmentProvider.setEnv("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES", "1");
+
+ // Mock executable handling.
+ Process mockProcess = Mockito.mock(Process.class);
+ when(mockProcess.waitFor(anyLong(), any(TimeUnit.class))).thenThrow(new InterruptedException());
+
+ InternalProcessBuilder processBuilder =
+ buildInternalProcessBuilder(
+ new HashMap<>(), mockProcess, DEFAULT_OPTIONS.getExecutableCommand());
+
+ PluggableAuthHandler handler = new PluggableAuthHandler(environmentProvider, processBuilder);
+
+ // Call getExecutableResponse().
+ PluggableAuthException e =
+ assertThrows(
+ PluggableAuthException.class, () -> handler.getExecutableResponse(DEFAULT_OPTIONS));
+
+ assertEquals("INTERRUPTED", e.getErrorCode());
+ assertEquals(
+ String.format("The execution was interrupted: %s.", new InterruptedException()),
+ e.getErrorDescription());
+
+ verify(mockProcess, times(1))
+ .waitFor(
+ eq(Long.valueOf(DEFAULT_OPTIONS.getExecutableTimeoutMs())), eq(TimeUnit.MILLISECONDS));
+ verify(mockProcess, times(1)).destroyForcibly();
+ }
+
+ private static GenericJson buildOidcResponse() {
+ GenericJson json = new GenericJson();
+ json.setFactory(OAuth2Utils.JSON_FACTORY);
+ json.put("version", EXECUTABLE_SUPPORTED_MAX_VERSION);
+ json.put("success", true);
+ json.put("token_type", TOKEN_TYPE_OIDC);
+ json.put("id_token", ID_TOKEN);
+ json.put("expiration_time", Instant.now().getEpochSecond() + EXPIRATION_DURATION);
+ return json;
+ }
+
+ private static GenericJson buildSamlResponse() {
+ GenericJson json = new GenericJson();
+ json.setFactory(OAuth2Utils.JSON_FACTORY);
+ json.put("version", EXECUTABLE_SUPPORTED_MAX_VERSION);
+ json.put("success", true);
+ json.put("token_type", TOKEN_TYPE_SAML);
+ json.put("saml_response", SAML_RESPONSE);
+ json.put("expiration_time", Instant.now().getEpochSecond() + EXPIRATION_DURATION);
+ return json;
+ }
+
+ private static GenericJson buildErrorResponse() {
+ GenericJson json = new GenericJson();
+ json.setFactory(OAuth2Utils.JSON_FACTORY);
+ json.put("version", EXECUTABLE_SUPPORTED_MAX_VERSION);
+ json.put("success", false);
+ json.put("code", "401");
+ json.put("message", "Caller not authorized.");
+ return json;
+ }
+
+ private static InternalProcessBuilder buildInternalProcessBuilder(
+ Map currentEnv, Process process, String command) {
+ return new InternalProcessBuilder() {
+
+ @Override
+ Map environment() {
+ return currentEnv;
+ }
+
+ @Override
+ InternalProcessBuilder redirectErrorStream(boolean redirectErrorStream) {
+ return this;
+ }
+
+ @Override
+ Process start() {
+ return process;
+ }
+ };
+ }
+}
diff --git a/oauth2_http/pom.xml b/oauth2_http/pom.xml
index 22131c7e6..a126b73b1 100644
--- a/oauth2_http/pom.xml
+++ b/oauth2_http/pom.xml
@@ -134,5 +134,17 @@
1.3test
+
+ junit
+ junit
+ 4.13.2
+ test
+
+
+ org.mockito
+ mockito-core
+ 2.23.4
+ test
+
From 19971b1a36e6f35a561183fb446d824d18a23a82 Mon Sep 17 00:00:00 2001
From: Emily Ball
Date: Thu, 7 Apr 2022 12:51:01 -0700
Subject: [PATCH 3/7] don't fail on javadoc errors
---
pom.xml | 3 +++
1 file changed, 3 insertions(+)
diff --git a/pom.xml b/pom.xml
index 20534a130..43c4e706c 100644
--- a/pom.xml
+++ b/pom.xml
@@ -169,6 +169,7 @@
3.3.27
+ false
@@ -329,6 +330,7 @@
+ falsenone7${project.build.directory}/javadoc
@@ -504,6 +506,7 @@
${sourceFileExclude}
+ false
From e3785f9ef6b0a61a27a46928891ac6d5e6ac8692 Mon Sep 17 00:00:00 2001
From: Leo <39062083+lsirac@users.noreply.github.com>
Date: Thu, 14 Apr 2022 16:25:30 -0700
Subject: [PATCH 4/7] feat: Improve Pluggable Auth error handling (#912)
* feat: improves pluggable auth error handling
* cleanup
---
.../auth/oauth2/PluggableAuthHandler.java | 53 +++++++----
.../auth/oauth2/PluggableAuthHandlerTest.java | 90 +++++++++++++++++++
2 files changed, 126 insertions(+), 17 deletions(-)
diff --git a/oauth2_http/java/com/google/auth/oauth2/PluggableAuthHandler.java b/oauth2_http/java/com/google/auth/oauth2/PluggableAuthHandler.java
index d21abed67..d83e541d7 100644
--- a/oauth2_http/java/com/google/auth/oauth2/PluggableAuthHandler.java
+++ b/oauth2_http/java/com/google/auth/oauth2/PluggableAuthHandler.java
@@ -35,7 +35,9 @@
import com.google.api.client.json.JsonParser;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Splitter;
+import com.google.common.io.CharStreams;
import java.io.BufferedReader;
+import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
@@ -139,19 +141,27 @@ public String retrieveTokenFromExecutable(ExecutableOptions options) throws IOEx
// location to avoid running the executable until they are expired.
ExecutableResponse executableResponse = null;
if (options.getOutputFilePath() != null && !options.getOutputFilePath().isEmpty()) {
- // Read cached response from output_file.
- InputStream inputStream = new FileInputStream(options.getOutputFilePath());
- BufferedReader reader =
- new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
- JsonParser parser = OAuth2Utils.JSON_FACTORY.createJsonParser(reader);
-
- ExecutableResponse cachedResponse =
- new ExecutableResponse(parser.parseAndClose(GenericJson.class));
-
- // If the cached response is successful and unexpired, we can use it.
- // Response version will be validated below.
- if (cachedResponse.isValid()) {
- executableResponse = cachedResponse;
+ // Try reading cached response from output_file.
+ try {
+ File outputFile = new File(options.getOutputFilePath());
+ // Check if the output file is valid and not empty.
+ if (outputFile.isFile() && outputFile.length() > 0) {
+ InputStream inputStream = new FileInputStream(options.getOutputFilePath());
+ BufferedReader reader =
+ new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
+ JsonParser parser = OAuth2Utils.JSON_FACTORY.createJsonParser(reader);
+ ExecutableResponse cachedResponse =
+ new ExecutableResponse(parser.parseAndClose(GenericJson.class));
+ // If the cached response is successful and unexpired, we can use it.
+ // Response version will be validated below.
+ if (cachedResponse.isValid()) {
+ executableResponse = cachedResponse;
+ }
+ }
+ } catch (Exception e) {
+ throw new PluggableAuthException(
+ "INVALID_OUTPUT_FILE",
+ "The output_file specified contains an invalid or malformed response." + e);
}
}
@@ -201,33 +211,42 @@ ExecutableResponse getExecutableResponse(ExecutableOptions options) throws IOExc
Process process = processBuilder.start();
ExecutableResponse execResp;
+ String executableOutput = "";
try {
boolean success = process.waitFor(options.getExecutableTimeoutMs(), TimeUnit.MILLISECONDS);
if (!success) {
// Process has not terminated within the specified timeout.
- process.destroyForcibly();
throw new PluggableAuthException(
"TIMEOUT_EXCEEDED", "The executable failed to finish within the timeout specified.");
}
int exitCode = process.exitValue();
if (exitCode != EXIT_CODE_SUCCESS) {
- process.destroyForcibly();
throw new PluggableAuthException(
"EXIT_CODE", String.format("The executable failed with exit code %s.", exitCode));
}
BufferedReader reader =
new BufferedReader(
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8));
- JsonParser parser = OAuth2Utils.JSON_FACTORY.createJsonParser(reader);
+ executableOutput = CharStreams.toString(reader);
+ JsonParser parser = OAuth2Utils.JSON_FACTORY.createJsonParser(executableOutput);
execResp = new ExecutableResponse(parser.parseAndClose(GenericJson.class));
} catch (InterruptedException e) {
// Destroy the process.
process.destroyForcibly();
throw new PluggableAuthException(
"INTERRUPTED", String.format("The execution was interrupted: %s.", e));
+ } catch (IOException e) {
+ // Destroy the process.
+ process.destroyForcibly();
+ if (e instanceof PluggableAuthException) {
+ throw e;
+ }
+ // An error may have occurred in the executable and needs to be surfaced.
+ throw new PluggableAuthException(
+ "INVALID_RESPONSE",
+ String.format("The executable returned an invalid response: %s.", executableOutput));
}
-
process.destroyForcibly();
return execResp;
}
diff --git a/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthHandlerTest.java b/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthHandlerTest.java
index 31690ebbc..5233509cf 100644
--- a/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthHandlerTest.java
+++ b/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthHandlerTest.java
@@ -274,6 +274,59 @@ public String getOutputFilePath() {
assertEquals(ID_TOKEN, token);
}
+ @Test
+ void retrieveTokenFromExecutable_withInvalidOutputFile_throws()
+ throws IOException, InterruptedException {
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ environmentProvider.setEnv("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES", "1");
+
+ // Build output_file.
+ File file = File.createTempFile("output_file", /* suffix= */ null, /* directory= */ null);
+ file.deleteOnExit();
+
+ OAuth2Utils.writeInputStreamToFile(
+ new ByteArrayInputStream("Bad response.".getBytes(StandardCharsets.UTF_8)),
+ file.getAbsolutePath());
+
+ // Options with output file specified.
+ ExecutableOptions options =
+ new ExecutableOptions() {
+ @Override
+ public String getExecutableCommand() {
+ return "/path/to/executable";
+ }
+
+ @Override
+ public Map getEnvironmentMap() {
+ return ImmutableMap.of();
+ }
+
+ @Override
+ public int getExecutableTimeoutMs() {
+ return 30000;
+ }
+
+ @Override
+ public String getOutputFilePath() {
+ return file.getAbsolutePath();
+ }
+ };
+
+ // Mock executable handling that does nothing since we are using the output file.
+ Process mockProcess = Mockito.mock(Process.class);
+ InternalProcessBuilder processBuilder =
+ buildInternalProcessBuilder(new HashMap<>(), mockProcess, options.getExecutableCommand());
+
+ PluggableAuthHandler handler = new PluggableAuthHandler(environmentProvider, processBuilder);
+
+ // Call retrieveTokenFromExecutable().
+ PluggableAuthException e =
+ assertThrows(
+ PluggableAuthException.class, () -> handler.retrieveTokenFromExecutable(options));
+
+ assertEquals("INVALID_OUTPUT_FILE", e.getErrorCode());
+ }
+
@Test
void retrieveTokenFromExecutable_expiredOutputFileResponse_callsExecutable()
throws IOException, InterruptedException {
@@ -667,6 +720,43 @@ void getExecutableResponse_processInterrupted_throws() throws InterruptedExcepti
verify(mockProcess, times(1)).destroyForcibly();
}
+ @Test
+ void getExecutableResponse_invalidResponse_throws() throws InterruptedException {
+ TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
+ environmentProvider.setEnv("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES", "1");
+
+ // Mock executable handling.
+ Process mockProcess = Mockito.mock(Process.class);
+ when(mockProcess.waitFor(anyLong(), any(TimeUnit.class))).thenReturn(true);
+ when(mockProcess.exitValue()).thenReturn(EXIT_CODE_SUCCESS);
+
+ // Mock bad executable response.
+ String badResponse = "badResponse";
+ when(mockProcess.getInputStream())
+ .thenReturn(new ByteArrayInputStream(badResponse.getBytes(StandardCharsets.UTF_8)));
+
+ InternalProcessBuilder processBuilder =
+ buildInternalProcessBuilder(
+ new HashMap<>(), mockProcess, DEFAULT_OPTIONS.getExecutableCommand());
+
+ PluggableAuthHandler handler = new PluggableAuthHandler(environmentProvider, processBuilder);
+
+ // Call getExecutableResponse().
+ PluggableAuthException e =
+ assertThrows(
+ PluggableAuthException.class, () -> handler.getExecutableResponse(DEFAULT_OPTIONS));
+
+ assertEquals("INVALID_RESPONSE", e.getErrorCode());
+ assertEquals(
+ String.format("The executable returned an invalid response: %s.", badResponse),
+ e.getErrorDescription());
+
+ verify(mockProcess, times(1))
+ .waitFor(
+ eq(Long.valueOf(DEFAULT_OPTIONS.getExecutableTimeoutMs())), eq(TimeUnit.MILLISECONDS));
+ verify(mockProcess, times(1)).destroyForcibly();
+ }
+
private static GenericJson buildOidcResponse() {
GenericJson json = new GenericJson();
json.setFactory(OAuth2Utils.JSON_FACTORY);
From db8a0d0c71e8ec7586c81413a50fb0679757827a Mon Sep 17 00:00:00 2001
From: Leo <39062083+lsirac@users.noreply.github.com>
Date: Wed, 20 Apr 2022 16:48:43 -0700
Subject: [PATCH 5/7] fix: consume input stream immediately for Pluggable Auth
(#915)
* feat: improves pluggable auth error handling
* cleanup
* fix: consume input stream immediately so that the spawned process will not hang if the STDOUT buffer is filled.
* fix: fix merge
* fix: review comments
---
.../auth/oauth2/PluggableAuthCredentials.java | 13 ++-
.../auth/oauth2/PluggableAuthHandler.java | 107 +++++++++++-------
.../auth/oauth2/PluggableAuthHandlerTest.java | 23 ++--
3 files changed, 92 insertions(+), 51 deletions(-)
diff --git a/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java b/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java
index 17cff7ef0..7bb465118 100644
--- a/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java
+++ b/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java
@@ -33,9 +33,12 @@
import com.google.auth.oauth2.ExecutableHandler.ExecutableOptions;
import com.google.common.annotations.VisibleForTesting;
-import java.io.*;
+import java.io.IOException;
import java.math.BigDecimal;
-import java.util.*;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
import javax.annotation.Nullable;
/**
@@ -227,6 +230,12 @@ public AccessToken refreshAccessToken() throws IOException {
return exchangeExternalCredentialForAccessToken(stsTokenExchangeRequest.build());
}
+ /**
+ * Returns the 3rd party subject token by calling the executable specified in the credential
+ * source.
+ *
+ * @throws IOException if an error occurs with the executable execution.
+ */
@Override
public String retrieveSubjectToken() throws IOException {
String executableCommand = config.getCommand();
diff --git a/oauth2_http/java/com/google/auth/oauth2/PluggableAuthHandler.java b/oauth2_http/java/com/google/auth/oauth2/PluggableAuthHandler.java
index d83e541d7..afc0b9840 100644
--- a/oauth2_http/java/com/google/auth/oauth2/PluggableAuthHandler.java
+++ b/oauth2_http/java/com/google/auth/oauth2/PluggableAuthHandler.java
@@ -35,7 +35,6 @@
import com.google.api.client.json.JsonParser;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Splitter;
-import com.google.common.io.CharStreams;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
@@ -45,7 +44,12 @@
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
+import javax.annotation.Nullable;
/**
* Internal handler for retrieving 3rd party tokens from user defined scripts/executables for
@@ -139,31 +143,7 @@ public String retrieveTokenFromExecutable(ExecutableOptions options) throws IOEx
// this file.
// If specified, we will first check if we have valid unexpired credentials stored in this
// location to avoid running the executable until they are expired.
- ExecutableResponse executableResponse = null;
- if (options.getOutputFilePath() != null && !options.getOutputFilePath().isEmpty()) {
- // Try reading cached response from output_file.
- try {
- File outputFile = new File(options.getOutputFilePath());
- // Check if the output file is valid and not empty.
- if (outputFile.isFile() && outputFile.length() > 0) {
- InputStream inputStream = new FileInputStream(options.getOutputFilePath());
- BufferedReader reader =
- new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
- JsonParser parser = OAuth2Utils.JSON_FACTORY.createJsonParser(reader);
- ExecutableResponse cachedResponse =
- new ExecutableResponse(parser.parseAndClose(GenericJson.class));
- // If the cached response is successful and unexpired, we can use it.
- // Response version will be validated below.
- if (cachedResponse.isValid()) {
- executableResponse = cachedResponse;
- }
- }
- } catch (Exception e) {
- throw new PluggableAuthException(
- "INVALID_OUTPUT_FILE",
- "The output_file specified contains an invalid or malformed response." + e);
- }
- }
+ ExecutableResponse executableResponse = getCachedExecutableResponse(options);
// If the output_file does not contain a valid response, call the executable.
if (executableResponse == null) {
@@ -194,6 +174,37 @@ public String retrieveTokenFromExecutable(ExecutableOptions options) throws IOEx
return executableResponse.getSubjectToken();
}
+ @Nullable
+ ExecutableResponse getCachedExecutableResponse(ExecutableOptions options)
+ throws PluggableAuthException {
+ ExecutableResponse executableResponse = null;
+ if (options.getOutputFilePath() != null && !options.getOutputFilePath().isEmpty()) {
+ // Try reading cached response from output_file.
+ try {
+ File outputFile = new File(options.getOutputFilePath());
+ // Check if the output file is valid and not empty.
+ if (outputFile.isFile() && outputFile.length() > 0) {
+ InputStream inputStream = new FileInputStream(options.getOutputFilePath());
+ BufferedReader reader =
+ new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
+ JsonParser parser = OAuth2Utils.JSON_FACTORY.createJsonParser(reader);
+ ExecutableResponse cachedResponse =
+ new ExecutableResponse(parser.parseAndClose(GenericJson.class));
+ // If the cached response is successful and unexpired, we can use it.
+ // Response version will be validated below.
+ if (cachedResponse.isValid()) {
+ executableResponse = cachedResponse;
+ }
+ }
+ } catch (Exception e) {
+ throw new PluggableAuthException(
+ "INVALID_OUTPUT_FILE",
+ "The output_file specified contains an invalid or malformed response." + e);
+ }
+ }
+ return executableResponse;
+ }
+
ExecutableResponse getExecutableResponse(ExecutableOptions options) throws IOException {
List components = Splitter.on(" ").splitToList(options.getExecutableCommand());
@@ -213,6 +224,24 @@ ExecutableResponse getExecutableResponse(ExecutableOptions options) throws IOExc
ExecutableResponse execResp;
String executableOutput = "";
try {
+ // Consume the input stream while waiting for the program to finish so that
+ // the process won't hang if the STDOUT buffer is filled.
+ ExecutorService executor = Executors.newSingleThreadExecutor();
+ Future future =
+ executor.submit(
+ () -> {
+ BufferedReader reader =
+ new BufferedReader(
+ new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8));
+
+ StringBuilder sb = new StringBuilder();
+ String line;
+ while ((line = reader.readLine()) != null) {
+ sb.append(line).append(System.lineSeparator());
+ }
+ return sb.toString().trim();
+ });
+
boolean success = process.waitFor(options.getExecutableTimeoutMs(), TimeUnit.MILLISECONDS);
if (!success) {
// Process has not terminated within the specified timeout.
@@ -224,30 +253,32 @@ ExecutableResponse getExecutableResponse(ExecutableOptions options) throws IOExc
throw new PluggableAuthException(
"EXIT_CODE", String.format("The executable failed with exit code %s.", exitCode));
}
- BufferedReader reader =
- new BufferedReader(
- new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8));
- executableOutput = CharStreams.toString(reader);
+ executableOutput = future.get();
+ executor.shutdownNow();
+
JsonParser parser = OAuth2Utils.JSON_FACTORY.createJsonParser(executableOutput);
execResp = new ExecutableResponse(parser.parseAndClose(GenericJson.class));
- } catch (InterruptedException e) {
- // Destroy the process.
- process.destroyForcibly();
- throw new PluggableAuthException(
- "INTERRUPTED", String.format("The execution was interrupted: %s.", e));
} catch (IOException e) {
// Destroy the process.
- process.destroyForcibly();
+ process.destroy();
+
if (e instanceof PluggableAuthException) {
throw e;
}
- // An error may have occurred in the executable and needs to be surfaced.
+ // An error may have occurred in the executable and should be surfaced.
throw new PluggableAuthException(
"INVALID_RESPONSE",
String.format("The executable returned an invalid response: %s.", executableOutput));
+ } catch (InterruptedException | ExecutionException e) {
+ // Destroy the process.
+ process.destroy();
+
+ throw new PluggableAuthException(
+ "INTERRUPTED", String.format("The execution was interrupted: %s.", e));
}
- process.destroyForcibly();
+
+ process.destroy();
return execResp;
}
diff --git a/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthHandlerTest.java b/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthHandlerTest.java
index 5233509cf..4e630d49c 100644
--- a/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthHandlerTest.java
+++ b/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthHandlerTest.java
@@ -130,7 +130,7 @@ void retrieveTokenFromExecutable_oidcResponse() throws IOException, InterruptedE
// Call retrieveTokenFromExecutable().
String token = handler.retrieveTokenFromExecutable(DEFAULT_OPTIONS);
- verify(mockProcess, times(1)).destroyForcibly();
+ verify(mockProcess, times(1)).destroy();
verify(mockProcess, times(1))
.waitFor(
eq(Long.valueOf(DEFAULT_OPTIONS.getExecutableTimeoutMs())), eq(TimeUnit.MILLISECONDS));
@@ -175,7 +175,7 @@ void retrieveTokenFromExecutable_samlResponse() throws IOException, InterruptedE
// Call retrieveTokenFromExecutable().
String token = handler.retrieveTokenFromExecutable(DEFAULT_OPTIONS);
- verify(mockProcess, times(1)).destroyForcibly();
+ verify(mockProcess, times(1)).destroy();
verify(mockProcess, times(1))
.waitFor(
eq(Long.valueOf(DEFAULT_OPTIONS.getExecutableTimeoutMs())), eq(TimeUnit.MILLISECONDS));
@@ -387,7 +387,7 @@ public String getOutputFilePath() {
String token = handler.retrieveTokenFromExecutable(options);
// Validate that the executable was called.
- verify(mockProcess, times(1)).destroyForcibly();
+ verify(mockProcess, times(1)).destroy();
verify(mockProcess, times(1))
.waitFor(eq(Long.valueOf(options.getExecutableTimeoutMs())), eq(TimeUnit.MILLISECONDS));
@@ -517,7 +517,7 @@ void getExecutableResponse_oidcResponse() throws IOException, InterruptedExcepti
ExecutableResponse response = handler.getExecutableResponse(DEFAULT_OPTIONS);
- verify(mockProcess, times(1)).destroyForcibly();
+ verify(mockProcess, times(1)).destroy();
verify(mockProcess, times(1))
.waitFor(
eq(Long.valueOf(DEFAULT_OPTIONS.getExecutableTimeoutMs())), eq(TimeUnit.MILLISECONDS));
@@ -564,7 +564,7 @@ void getExecutableResponse_samlResponse() throws IOException, InterruptedExcepti
PluggableAuthHandler handler = new PluggableAuthHandler(environmentProvider, processBuilder);
ExecutableResponse response = handler.getExecutableResponse(DEFAULT_OPTIONS);
- verify(mockProcess, times(1)).destroyForcibly();
+ verify(mockProcess, times(1)).destroy();
verify(mockProcess, times(1))
.waitFor(
eq(Long.valueOf(DEFAULT_OPTIONS.getExecutableTimeoutMs())), eq(TimeUnit.MILLISECONDS));
@@ -579,7 +579,7 @@ void getExecutableResponse_samlResponse() throws IOException, InterruptedExcepti
assertEquals(4, currentEnv.size());
assertEquals(expectedMap, currentEnv);
- verify(mockProcess, times(1)).destroyForcibly();
+ verify(mockProcess, times(1)).destroy();
}
@Test
@@ -598,6 +598,7 @@ void getExecutableResponse_errorResponse() throws IOException, InterruptedExcept
// Mock executable handling.
Process mockProcess = Mockito.mock(Process.class);
+
when(mockProcess.waitFor(anyLong(), any(TimeUnit.class))).thenReturn(true);
when(mockProcess.exitValue()).thenReturn(EXIT_CODE_SUCCESS);
@@ -615,7 +616,7 @@ void getExecutableResponse_errorResponse() throws IOException, InterruptedExcept
// Call getExecutableResponse().
ExecutableResponse response = handler.getExecutableResponse(DEFAULT_OPTIONS);
- verify(mockProcess, times(1)).destroyForcibly();
+ verify(mockProcess, times(1)).destroy();
verify(mockProcess, times(1))
.waitFor(
eq(Long.valueOf(DEFAULT_OPTIONS.getExecutableTimeoutMs())), eq(TimeUnit.MILLISECONDS));
@@ -654,7 +655,7 @@ void getExecutableResponse_timeoutExceeded_throws() throws InterruptedException
verify(mockProcess, times(1))
.waitFor(
eq(Long.valueOf(DEFAULT_OPTIONS.getExecutableTimeoutMs())), eq(TimeUnit.MILLISECONDS));
- verify(mockProcess, times(1)).destroyForcibly();
+ verify(mockProcess, times(1)).destroy();
}
@Test
@@ -686,7 +687,7 @@ void getExecutableResponse_nonZeroExitCode_throws() throws InterruptedException
verify(mockProcess, times(1))
.waitFor(
eq(Long.valueOf(DEFAULT_OPTIONS.getExecutableTimeoutMs())), eq(TimeUnit.MILLISECONDS));
- verify(mockProcess, times(1)).destroyForcibly();
+ verify(mockProcess, times(1)).destroy();
}
@Test
@@ -717,7 +718,7 @@ void getExecutableResponse_processInterrupted_throws() throws InterruptedExcepti
verify(mockProcess, times(1))
.waitFor(
eq(Long.valueOf(DEFAULT_OPTIONS.getExecutableTimeoutMs())), eq(TimeUnit.MILLISECONDS));
- verify(mockProcess, times(1)).destroyForcibly();
+ verify(mockProcess, times(1)).destroy();
}
@Test
@@ -754,7 +755,7 @@ void getExecutableResponse_invalidResponse_throws() throws InterruptedException
verify(mockProcess, times(1))
.waitFor(
eq(Long.valueOf(DEFAULT_OPTIONS.getExecutableTimeoutMs())), eq(TimeUnit.MILLISECONDS));
- verify(mockProcess, times(1)).destroyForcibly();
+ verify(mockProcess, times(1)).destroy();
}
private static GenericJson buildOidcResponse() {
From 94385da0e5896469af676c57bf2e76a9e41393db Mon Sep 17 00:00:00 2001
From: Leo <39062083+lsirac@users.noreply.github.com>
Date: Thu, 21 Apr 2022 16:54:59 -0700
Subject: [PATCH 6/7] fix: refactor to keep ImpersonatedCredentials final
(#917)
* fix: adds more documentation for InternalProcessBuilder and moves it to the bottom of the file
* fix: keep ImpersonatedCredentials final
---
.../oauth2/ExternalAccountCredentials.java | 21 +++--
.../auth/oauth2/PluggableAuthCredentials.java | 2 +-
.../auth/oauth2/PluggableAuthHandler.java | 80 ++++++++++---------
.../ExternalAccountCredentialsTest.java | 33 ++++++++
...ckExternalAccountCredentialsTransport.java | 5 +-
5 files changed, 96 insertions(+), 45 deletions(-)
diff --git a/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java
index b88f98bc5..56a774b70 100644
--- a/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java
+++ b/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java
@@ -99,7 +99,11 @@ abstract static class CredentialSource {
protected transient HttpTransportFactory transportFactory;
- @Nullable protected ImpersonatedCredentials impersonatedCredentials;
+ @Nullable protected final ImpersonatedCredentials impersonatedCredentials;
+
+ // Internal override for impersonated credentials. This is done to keep
+ // impersonatedCredentials final.
+ @Nullable private ImpersonatedCredentials impersonatedCredentialsOverride;
private EnvironmentProvider environmentProvider;
@@ -196,7 +200,7 @@ protected ExternalAccountCredentials(
validateServiceAccountImpersonationInfoUrl(serviceAccountImpersonationUrl);
}
- this.impersonatedCredentials = initializeImpersonatedCredentials();
+ this.impersonatedCredentials = buildImpersonatedCredentials();
}
/**
@@ -238,10 +242,10 @@ protected ExternalAccountCredentials(ExternalAccountCredentials.Builder builder)
validateServiceAccountImpersonationInfoUrl(serviceAccountImpersonationUrl);
}
- this.impersonatedCredentials = initializeImpersonatedCredentials();
+ this.impersonatedCredentials = buildImpersonatedCredentials();
}
- protected ImpersonatedCredentials initializeImpersonatedCredentials() {
+ ImpersonatedCredentials buildImpersonatedCredentials() {
if (serviceAccountImpersonationUrl == null) {
return null;
}
@@ -275,6 +279,10 @@ protected ImpersonatedCredentials initializeImpersonatedCredentials() {
.build();
}
+ void overrideImpersonatedCredentials(ImpersonatedCredentials credentials) {
+ this.impersonatedCredentialsOverride = credentials;
+ }
+
@Override
public void getRequestMetadata(
URI uri, Executor executor, final RequestMetadataCallback callback) {
@@ -429,7 +437,10 @@ private static boolean isAwsCredential(Map credentialSource) {
protected AccessToken exchangeExternalCredentialForAccessToken(
StsTokenExchangeRequest stsTokenExchangeRequest) throws IOException {
// Handle service account impersonation if necessary.
- if (impersonatedCredentials != null) {
+ // Internal override takes priority.
+ if (impersonatedCredentialsOverride != null) {
+ return impersonatedCredentialsOverride.refreshAccessToken();
+ } else if (impersonatedCredentials != null) {
return impersonatedCredentials.refreshAccessToken();
}
diff --git a/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java b/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java
index 7bb465118..e3506c080 100644
--- a/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java
+++ b/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java
@@ -213,7 +213,7 @@ String getOutputFilePath() {
// Re-initialize impersonated credentials as the handler hasn't been set yet when
// this is called in the base class.
- this.impersonatedCredentials = initializeImpersonatedCredentials();
+ overrideImpersonatedCredentials(buildImpersonatedCredentials());
}
@Override
diff --git a/oauth2_http/java/com/google/auth/oauth2/PluggableAuthHandler.java b/oauth2_http/java/com/google/auth/oauth2/PluggableAuthHandler.java
index afc0b9840..608574158 100644
--- a/oauth2_http/java/com/google/auth/oauth2/PluggableAuthHandler.java
+++ b/oauth2_http/java/com/google/auth/oauth2/PluggableAuthHandler.java
@@ -59,44 +59,6 @@
*/
final class PluggableAuthHandler implements ExecutableHandler {
- /** An interface for creating and managing a process. */
- abstract static class InternalProcessBuilder {
-
- abstract Map environment();
-
- abstract InternalProcessBuilder redirectErrorStream(boolean redirectErrorStream);
-
- abstract Process start() throws IOException;
- }
-
- /**
- * The default implementation that wraps {@link ProcessBuilder} for creating and managing a
- * process.
- */
- static final class DefaultProcessBuilder extends InternalProcessBuilder {
- ProcessBuilder processBuilder;
-
- DefaultProcessBuilder(ProcessBuilder processBuilder) {
- this.processBuilder = processBuilder;
- }
-
- @Override
- Map environment() {
- return this.processBuilder.environment();
- }
-
- @Override
- InternalProcessBuilder redirectErrorStream(boolean redirectErrorStream) {
- this.processBuilder.redirectErrorStream(redirectErrorStream);
- return this;
- }
-
- @Override
- Process start() throws IOException {
- return this.processBuilder.start();
- }
- }
-
// The maximum supported version for the executable response.
// The executable response always includes a version number that is used
// to detect compatibility with the response and library verions.
@@ -288,4 +250,46 @@ InternalProcessBuilder getProcessBuilder(List commandComponents) {
}
return new DefaultProcessBuilder(new ProcessBuilder(commandComponents));
}
+
+ /**
+ * An interface for creating and managing a process.
+ *
+ *
ProcessBuilder is final and does not implement any interface. This class allows concrete
+ * implementations to be specified to test these changes.
+ */
+ abstract static class InternalProcessBuilder {
+
+ abstract Map environment();
+
+ abstract InternalProcessBuilder redirectErrorStream(boolean redirectErrorStream);
+
+ abstract Process start() throws IOException;
+ }
+
+ /**
+ * A default implementation for {@link InternalProcessBuilder} that wraps {@link ProcessBuilder}.
+ */
+ static final class DefaultProcessBuilder extends InternalProcessBuilder {
+ ProcessBuilder processBuilder;
+
+ DefaultProcessBuilder(ProcessBuilder processBuilder) {
+ this.processBuilder = processBuilder;
+ }
+
+ @Override
+ Map environment() {
+ return this.processBuilder.environment();
+ }
+
+ @Override
+ InternalProcessBuilder redirectErrorStream(boolean redirectErrorStream) {
+ this.processBuilder.redirectErrorStream(redirectErrorStream);
+ return this;
+ }
+
+ @Override
+ Process start() throws IOException {
+ return this.processBuilder.start();
+ }
+ }
}
diff --git a/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java
index 42413194c..1b2b53a1c 100644
--- a/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java
+++ b/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java
@@ -47,6 +47,7 @@
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.net.URI;
+import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
@@ -571,6 +572,38 @@ void exchangeExternalCredentialForAccessToken_withServiceAccountImpersonation()
transportFactory.transport.getServiceAccountAccessToken(), returnedToken.getTokenValue());
}
+ @Test
+ void exchangeExternalCredentialForAccessToken_withServiceAccountImpersonationOverride()
+ throws IOException {
+ transportFactory.transport.setExpireTime(TestUtils.getDefaultExpireTime());
+
+ String serviceAccountEmail = "different@different.iam.gserviceaccount.com";
+ ExternalAccountCredentials credential =
+ ExternalAccountCredentials.fromStream(
+ IdentityPoolCredentialsTest.writeIdentityPoolCredentialsStream(
+ transportFactory.transport.getStsUrl(),
+ transportFactory.transport.getMetadataUrl(),
+ transportFactory.transport.getServiceAccountImpersonationUrl()),
+ transportFactory);
+
+ // Override impersonated credentials.
+ ExternalAccountCredentials sourceCredentials =
+ IdentityPoolCredentials.newBuilder((IdentityPoolCredentials) credential)
+ .setServiceAccountImpersonationUrl(null)
+ .build();
+ credential.overrideImpersonatedCredentials(
+ new ImpersonatedCredentials.Builder(sourceCredentials, serviceAccountEmail)
+ .setScopes(new ArrayList<>(sourceCredentials.getScopes()))
+ .setHttpTransportFactory(transportFactory)
+ .build());
+
+ credential.exchangeExternalCredentialForAccessToken(
+ StsTokenExchangeRequest.newBuilder("credential", "subjectTokenType").build());
+
+ assertTrue(
+ transportFactory.transport.getRequests().get(2).getUrl().contains(serviceAccountEmail));
+ }
+
@Test
void exchangeExternalCredentialForAccessToken_throws() throws IOException {
ExternalAccountCredentials credential =
diff --git a/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java b/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java
index 74f4771ca..1199ac1f7 100644
--- a/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java
+++ b/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java
@@ -84,6 +84,8 @@ public class MockExternalAccountCredentialsTransport extends MockHttpTransport {
static final String SERVICE_ACCOUNT_IMPERSONATION_URL =
"https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/testn@test.iam.gserviceaccount.com:generateAccessToken";
+ static final String IAM_ENDPOINT = "https://iamcredentials.googleapis.com";
+
private Queue responseSequence = new ArrayDeque<>();
private Queue responseErrorSequence = new ArrayDeque<>();
private Queue refreshTokenSequence = new ArrayDeque<>();
@@ -193,7 +195,8 @@ public LowLevelHttpResponse execute() throws IOException {
.setContentType(Json.MEDIA_TYPE)
.setContent(response.toPrettyString());
}
- if (SERVICE_ACCOUNT_IMPERSONATION_URL.equals(url)) {
+
+ if (url.contains(IAM_ENDPOINT)) {
GenericJson query =
OAuth2Utils.JSON_FACTORY
.createJsonParser(getContentAsString())
From be3a0a8c2802a3f5d63f27418324c8b2f96e8e19 Mon Sep 17 00:00:00 2001
From: lsirac
Date: Fri, 24 Jun 2022 15:24:56 -0700
Subject: [PATCH 7/7] fix: make sure executor is shutdown
---
.../java/com/google/auth/oauth2/PluggableAuthHandler.java | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/oauth2_http/java/com/google/auth/oauth2/PluggableAuthHandler.java b/oauth2_http/java/com/google/auth/oauth2/PluggableAuthHandler.java
index 608574158..24b0978cd 100644
--- a/oauth2_http/java/com/google/auth/oauth2/PluggableAuthHandler.java
+++ b/oauth2_http/java/com/google/auth/oauth2/PluggableAuthHandler.java
@@ -185,10 +185,10 @@ ExecutableResponse getExecutableResponse(ExecutableOptions options) throws IOExc
ExecutableResponse execResp;
String executableOutput = "";
+ ExecutorService executor = Executors.newSingleThreadExecutor();
try {
// Consume the input stream while waiting for the program to finish so that
// the process won't hang if the STDOUT buffer is filled.
- ExecutorService executor = Executors.newSingleThreadExecutor();
Future future =
executor.submit(
() -> {
@@ -225,6 +225,11 @@ ExecutableResponse getExecutableResponse(ExecutableOptions options) throws IOExc
// Destroy the process.
process.destroy();
+ // Shutdown executor if needed.
+ if (!executor.isShutdown()) {
+ executor.shutdownNow();
+ }
+
if (e instanceof PluggableAuthException) {
throw e;
}