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) [![WhiteSource Renovate](https://app.renovatebot.com/images/banner.svg)](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` | [![age](https://badges.renovateapi.com/packages/maven/com.google.http-client:google-http-client-bom/1.41.5/age-slim)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://badges.renovateapi.com/packages/maven/com.google.http-client:google-http-client-bom/1.41.5/adoption-slim)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://badges.renovateapi.com/packages/maven/com.google.http-client:google-http-client-bom/1.41.5/compatibility-slim/1.41.4)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://badges.renovateapi.com/packages/maven/com.google.http-client:google-http-client-bom/1.41.5/confidence-slim/1.41.4)](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. + * + *

+ * OIDC response sample:
+ * {
+ *   "version": 1,
+ *   "success": true,
+ *   "token_type": "urn:ietf:params:oauth:token-type:id_token",
+ *   "id_token": "HEADER.PAYLOAD.SIGNATURE",
+ *   "expiration_time": 1620433341
+ * }
+ *
+ * SAML2 response sample:
+ * {
+ *   "version": 1,
+ *   "success": true,
+ *   "token_type": "urn:ietf:params:oauth:token-type:saml2",
+ *   "saml_response": "...",
+ *   "expiration_time": 1620433341
+ * }
+ *
+ * Error response sample:
+ * {
+ *   "version": 1,
+ *   "success": false,
+ *   "code": "401",
+ *   "message": "Error message."
+ * }
+ *
+ * The auth libraries will populate certain environment variables that will be accessible by the
+ * executable, such as: GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE, GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE,
+ * GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE, GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL, and
+ * GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE.
+ *
+ * 

Please see this repositories README for a complete executable request/response specification. + *

+ */ +public class PluggableAuthCredentials extends ExternalAccountCredentials { + + /** + * Encapsulates the credential source portion of the configuration for PluggableAuthCredentials. + * + *

Command is the only required field. If timeout_millis is not specified, the library will + * default to a 30 second timeout. + * + *

+   * Sample credential source for Pluggable Auth credentials:
+   * {
+   *   ...
+   *   "credential_source": {
+   *     "executable": {
+   *       "command": "/path/to/get/credentials.sh --arg1=value1 --arg2=value2",
+   *       "timeout_millis": 5000,
+   *       "output_file": "/path/to/generated/cached/credentials"
+   *     }
+   *   }
+   * }
+   * 
+ */ + 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.5 5.8.2 31.0.1-android 2.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.3 test + + 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.2 7 + false @@ -329,6 +330,7 @@ + false none 7 ${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; }