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 01/10] 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 02/10] 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 03/10] 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 04/10] 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 05/10] 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 06/10] 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 9b10c2c1ef30897340c5b6bbccadd743104627fe Mon Sep 17 00:00:00 2001 From: Leonardo Siracusa Date: Thu, 12 May 2022 14:58:20 -0700 Subject: [PATCH 07/10] feat: documents pluggable auth in README --- README.md | 110 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/README.md b/README.md index 9370e67e8..eafd95bd3 100644 --- a/README.md +++ b/README.md @@ -323,6 +323,116 @@ request to `$URL_TO_GET_OIDC_TOKEN`, e.g. `Metadata-Flavor=Google`. You can now [use the Auth library](#using-external-identities) to call Google Cloud resources from an OIDC provider. +#### Using Executable-sourced credentials with OIDC and SAML + +**Executable-sourced credentials** +For executable-sourced credentials, a local executable is used to retrieve the 3rd party token. +The executable must handle providing a valid, unexpired OIDC ID token or SAML assertion in JSON format +to stdout. + +To use executable-sourced credentials, the `GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES` +environment variable must be set to `1`. + +To generate an executable-sourced workload identity configuration, run the following command: + +```bash +# Generate a configuration file for executable-sourced credentials. +gcloud iam workload-identity-pools create-cred-config \ + projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/$POOL_ID/providers/$PROVIDER_ID \ + --service-account=$SERVICE_ACCOUNT_EMAIL \ + --subject-token-type=$SUBJECT_TOKEN_TYPE \ + # The absolute path for the program, including arguments. + # e.g. --executable-command="/path/to/command --foo=bar" + --executable-command=$EXECUTABLE_COMMAND \ + # Optional argument for the executable timeout. Defaults to 30s. + # --executable-timeout-millis=$EXECUTABLE_TIMEOUT \ + # Optional argument for the absolute path to the executable output file. + # See below on how this argument impacts the library behaviour. + # --executable-output-file=$EXECUTABLE_OUTPUT_FILE \ + --output-file /path/to/generated/config.json +``` +Where the following variables need to be substituted: +- `$PROJECT_NUMBER`: The Google Cloud project number. +- `$POOL_ID`: The workload identity pool ID. +- `$PROVIDER_ID`: The OIDC or SAML provider ID. +- `$SERVICE_ACCOUNT_EMAIL`: The email of the service account to impersonate. +- `SUBJECT_TOKEN_TYPE`: The subject token type. +- `$EXECUTABLE_COMMAND`: The full command to run, including arguments. Must be an absolute path to the program. + +To retrieve the 3rd party token, the library will call the executable +using the command specified. The executable's output must adhere to the response format +specified below. It must output the response to stdout. + +A sample successful executable OIDC response: +```json +{ + "version": 1, + "success": true, + "token_type": "urn:ietf:params:oauth:token-type:id_token", + "id_token": "HEADER.PAYLOAD.SIGNATURE", + "expiration_time": 1620499962 +} +``` + +A sample successful executable SAML response: +```json +{ + "version": 1, + "success": true, + "token_type": "urn:ietf:params:oauth:token-type:saml2", + "saml_response": "...", + "expiration_time": 1620499962 +} +``` +A sample executable error response: +```json +{ + "version": 1, + "success": false, + "code": "401", + "message": "Caller not authorized." +} +``` +These are all required fields for an error response. The code and message +fields will be used by library as part of the thrown exception. + +Response format fields summary: + * `version`: The version of the JSON output. Currently only version 1 is supported. + * `success`: The status of the response. When true, the response must contain the 3rd party token, + token type, and expiration. The executable must also exit with exit code 0. + When false, the response must contain the error code and message fields and exit with a non-zero value. + * `token_type`: The 3rd party subject token type. Must be *urn:ietf:params:oauth:token-type:jwt*, + *urn:ietf:params:oauth:token-type:id_token*, or *urn:ietf:params:oauth:token-type:saml2*. + * `id_token`: The 3rd party OIDC token. + * `saml_response`: The 3rd party SAML response. + * `expiration_time`: The 3rd party subject token expiration time in seconds (unix epoch time). + * `code`: The error code string. + * `message`: The error message. + +All response types must include both the `version` and `success` fields. + * Successful responses must include the `token_type`, `expiration_time`, and one of + `id_token` or `saml_response`. + * Error responses must include both the `code` and `message` fields. + +The library will populate the following environment variables when the executable is run: + * `GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE`: The audience field from the credential configuration. Always present. + * `GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL`: The service account email. Only present when service account impersonation is used. + * `GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE`: The output file location from the credential configuration. Only present when specified in the credential configuration. + +These environment variables can be used by the executable to avoid hard-coding these values. + +##### Security considerations +The following security practices are highly recommended: + * Access to the script should be restricted as it will be displaying credentials to stdout. This ensures that rogue processes do not gain access to the script. + * The configuration file should not be modifiable. Write access should be restricted to avoid processes modifying the executable command portion. + +Given the complexity of using executable-sourced credentials, it is recommended to use +the existing supported mechanisms (file-sourced/URL-sourced) for providing 3rd party +credentials unless they do not meet your specific requirements. + +You can now [use the Auth library](#using-external-identities) to call Google Cloud +resources from an OIDC or SAML providers. + #### Using External Identities External identities (AWS, Azure, and OIDC-based providers) can be used with From 64402e9d2c243214c0b17df85e19a9b4202c5a34 Mon Sep 17 00:00:00 2001 From: Leonardo Siracusa Date: Thu, 12 May 2022 15:02:37 -0700 Subject: [PATCH 08/10] fix: provider --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index eafd95bd3..16e8be095 100644 --- a/README.md +++ b/README.md @@ -431,7 +431,7 @@ the existing supported mechanisms (file-sourced/URL-sourced) for providing 3rd p credentials unless they do not meet your specific requirements. You can now [use the Auth library](#using-external-identities) to call Google Cloud -resources from an OIDC or SAML providers. +resources from an OIDC or SAML provider. #### Using External Identities From 655dfdccfcb6f367e19ae9b3110f780246e3bca7 Mon Sep 17 00:00:00 2001 From: Leonardo Siracusa Date: Fri, 27 May 2022 14:15:06 -0700 Subject: [PATCH 09/10] fix: update table of contents --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 16e8be095..e8a595609 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,10 @@ credentials as well as utility methods to create them and to get Application Def * [Application Default Credentials](#application-default-credentials) * [ImpersonatedCredentials](#impersonatedcredentials) * [Workload Identity Federation](#workload-identity-federation) + * [Accessing resources from AWS](#accessing-resources-from-aws) + * [Accessing resources from Azure](#access-resources-from-microsoft-azure) + * [Accessing resources from an OIDC identity provider](#accessing-resources-from-an-oidc-identity-provider) + * [Accessing resources using Executable-sourced credentials](#using-executable-sourced-credentials-with-oidc-and-saml) * [Downscoping with Credential Access Boundaries](#downscoping-with-credential-access-boundaries) * [Configuring a Proxy](#configuring-a-proxy) * [Using Credentials with google-http-client](#using-credentials-with-google-http-client) From 935b7b1ea14197394bca85761cb63c2f92f80729 Mon Sep 17 00:00:00 2001 From: Leonardo Siracusa Date: Wed, 8 Jun 2022 15:35:25 -0700 Subject: [PATCH 10/10] fix: update --- README.md | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e8a595609..b03d5aeb4 100644 --- a/README.md +++ b/README.md @@ -360,9 +360,24 @@ Where the following variables need to be substituted: - `$POOL_ID`: The workload identity pool ID. - `$PROVIDER_ID`: The OIDC or SAML provider ID. - `$SERVICE_ACCOUNT_EMAIL`: The email of the service account to impersonate. -- `SUBJECT_TOKEN_TYPE`: The subject token type. +- `$SUBJECT_TOKEN_TYPE`: The subject token type. - `$EXECUTABLE_COMMAND`: The full command to run, including arguments. Must be an absolute path to the program. +The `--executable-timeout-millis` flag is optional. This is the duration for which +the auth library will wait for the executable to finish, in milliseconds. +Defaults to 30 seconds when not provided. The maximum allowed value is 2 minutes. +The minimum is 5 seconds. + +The `--executable-output-file` flag is optional. If provided, the file path must +point to the 3PI credential response generated by the executable. This is useful +for caching the credentials. By specifying this path, the Auth libraries will first +check for its existence before running the executable. By caching the executable JSON +response to this file, it improves performance as it avoids the need to run the executable +until the cached credentials in the output file are expired. The executable must +handle writing to this file - the auth libraries will only attempt to read from +this location. The format of contents in the file should match the JSON format +expected by the executable shown below. + To retrieve the 3rd party token, the library will call the executable using the command specified. The executable's output must adhere to the response format specified below. It must output the response to stdout. @@ -398,7 +413,7 @@ A sample executable error response: } ``` These are all required fields for an error response. The code and message -fields will be used by library as part of the thrown exception. +fields will be used by the library as part of the thrown exception. Response format fields summary: * `version`: The version of the JSON output. Currently only version 1 is supported.