diff --git a/.github/.OwlBot.lock.yaml b/.github/.OwlBot.lock.yaml index f60d77493..f0625e4d9 100644 --- a/.github/.OwlBot.lock.yaml +++ b/.github/.OwlBot.lock.yaml @@ -13,5 +13,5 @@ # limitations under the License. docker: image: gcr.io/cloud-devrel-public-resources/owlbot-java:latest - digest: sha256:fc52b202aa298a50a12c64efd04fea3884d867947effe2fa85382a246c09e813 -# created: 2022-04-06T16:30:03.627422514Z + digest: sha256:1ec28a46062b19135b11178ceee60231e5f5a92dab454e23ae0aab72cd875906 +# created: 2022-06-27T15:01:06.405564326Z diff --git a/.kokoro/build.sh b/.kokoro/build.sh index 317bf8686..c483ec7bf 100755 --- a/.kokoro/build.sh +++ b/.kokoro/build.sh @@ -74,6 +74,11 @@ graalvm) mvn -B ${INTEGRATION_TEST_ARGS} -ntp -Pnative -Penable-integration-tests test RETURN_CODE=$? ;; +graalvm17) + # Run Unit and Integration Tests with Native Image + mvn -B ${INTEGRATION_TEST_ARGS} -ntp -Pnative -Penable-integration-tests test + RETURN_CODE=$? + ;; samples) SAMPLES_DIR=samples # only run ITs in snapshot/ on presubmit PRs. run ITs in all 3 samples/ subdirectories otherwise. diff --git a/.kokoro/common.sh b/.kokoro/common.sh index ace89f45a..f8f957af1 100644 --- a/.kokoro/common.sh +++ b/.kokoro/common.sh @@ -55,4 +55,6 @@ function retry_with_backoff { ## Helper functionss function now() { date +"%Y-%m-%d %H:%M:%S" | tr -d '\n'; } function msg() { println "$*" >&2; } -function println() { printf '%s\n' "$(now) $*"; } \ No newline at end of file +function println() { printf '%s\n' "$(now) $*"; } + +## Helper comment to trigger updated repo dependency release \ No newline at end of file diff --git a/.kokoro/presubmit/graalvm-native-17.cfg b/.kokoro/presubmit/graalvm-native-17.cfg new file mode 100644 index 000000000..a3f7fb9d4 --- /dev/null +++ b/.kokoro/presubmit/graalvm-native-17.cfg @@ -0,0 +1,33 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +# Configure the docker image for kokoro-trampoline. +env_vars: { + key: "TRAMPOLINE_IMAGE" + value: "gcr.io/cloud-devrel-kokoro-resources/graalvm17" +} + +env_vars: { + key: "JOB_TYPE" + value: "graalvm17" +} + +# TODO: remove this after we've migrated all tests and scripts +env_vars: { + key: "GCLOUD_PROJECT" + value: "gcloud-devel" +} + +env_vars: { + key: "GOOGLE_CLOUD_PROJECT" + value: "gcloud-devel" +} + +env_vars: { + key: "GOOGLE_APPLICATION_CREDENTIALS" + value: "secret_manager/java-it-service-account" +} + +env_vars: { + key: "SECRET_MANAGER_KEYS" + value: "java-it-service-account" +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 069f22b80..6a2dba2a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [1.8.0](https://github.com/googleapis/google-auth-library-java/compare/v1.7.0...v1.8.0) (2022-06-27) + + +### Features + +* add build scripts for native image testing in Java 17 ([#1440](https://github.com/googleapis/google-auth-library-java/issues/1440)) ([#923](https://github.com/googleapis/google-auth-library-java/issues/923)) ([bbb51ce](https://github.com/googleapis/google-auth-library-java/commit/bbb51ce7a9265cb991739cd90e1ccf65675d05dc)) +* Adds Pluggable Auth support (WIF) ([#908](https://github.com/googleapis/google-auth-library-java/issues/908)) ([c3e8d16](https://github.com/googleapis/google-auth-library-java/commit/c3e8d169704943735c6b3df7bd0187f04fdd9aa5)) + + +### Documentation + +* updates README for Pluggable Auth ([#921](https://github.com/googleapis/google-auth-library-java/issues/921)) ([23716b8](https://github.com/googleapis/google-auth-library-java/commit/23716b82fb3000f5210bb5604127aad7ef52cb76)) + ## [1.7.0](https://github.com/googleapis/google-auth-library-java/compare/v1.6.0...v1.7.0) (2022-05-12) diff --git a/README.md b/README.md index 9370e67e8..b03d5aeb4 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) @@ -323,6 +327,131 @@ 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. + +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. + +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 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. + * `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 provider. + #### Using External Identities External identities (AWS, Azure, and OIDC-based providers) can be used with diff --git a/appengine/pom.xml b/appengine/pom.xml index b23af89fc..c3048869b 100644 --- a/appengine/pom.xml +++ b/appengine/pom.xml @@ -5,7 +5,7 @@ com.google.auth google-auth-library-parent - 1.7.0 + 1.8.0 ../pom.xml diff --git a/bom/pom.xml b/bom/pom.xml index f9f23bfcb..5301c3955 100644 --- a/bom/pom.xml +++ b/bom/pom.xml @@ -3,7 +3,7 @@ 4.0.0 com.google.auth google-auth-library-bom - 1.7.0 + 1.8.0 pom Google Auth Library for Java BOM diff --git a/credentials/pom.xml b/credentials/pom.xml index 5fed3789b..a06f9dcbe 100644 --- a/credentials/pom.xml +++ b/credentials/pom.xml @@ -4,7 +4,7 @@ com.google.auth google-auth-library-parent - 1.7.0 + 1.8.0 ../pom.xml 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/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: + * + *

+ * + * @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/ExternalAccountCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java index 379e2a1cf..85af46335 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; @@ -76,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; @@ -99,6 +101,10 @@ abstract static class CredentialSource { @Nullable protected final ImpersonatedCredentials impersonatedCredentials; + // Internal override for impersonated credentials. This is done to keep + // impersonatedCredentials final. + @Nullable private ImpersonatedCredentials impersonatedCredentialsOverride; + private EnvironmentProvider environmentProvider; /** @@ -194,7 +200,7 @@ protected ExternalAccountCredentials( validateServiceAccountImpersonationInfoUrl(serviceAccountImpersonationUrl); } - this.impersonatedCredentials = initializeImpersonatedCredentials(); + this.impersonatedCredentials = buildImpersonatedCredentials(); } /** @@ -236,10 +242,10 @@ protected ExternalAccountCredentials(ExternalAccountCredentials.Builder builder) validateServiceAccountImpersonationInfoUrl(serviceAccountImpersonationUrl); } - this.impersonatedCredentials = initializeImpersonatedCredentials(); + this.impersonatedCredentials = buildImpersonatedCredentials(); } - private ImpersonatedCredentials initializeImpersonatedCredentials() { + ImpersonatedCredentials buildImpersonatedCredentials() { if (serviceAccountImpersonationUrl == null) { return null; } @@ -250,6 +256,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) @@ -269,6 +280,10 @@ private ImpersonatedCredentials initializeImpersonatedCredentials() { .build(); } + void overrideImpersonatedCredentials(ImpersonatedCredentials credentials) { + this.impersonatedCredentialsOverride = credentials; + } + @Override public void getRequestMetadata( URI uri, Executor executor, final RequestMetadataCallback callback) { @@ -374,8 +389,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) @@ -391,6 +418,11 @@ 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"); @@ -406,7 +438,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(); } @@ -468,6 +503,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() { @@ -499,7 +543,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() { 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..e3506c080 --- /dev/null +++ b/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java @@ -0,0 +1,327 @@ +/* + * 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.IOException; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +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 { + handler = new PluggableAuthHandler(getEnvironmentProvider()); + } + + // Re-initialize impersonated credentials as the handler hasn't been set yet when + // this is called in the base class. + overrideImpersonatedCredentials(buildImpersonatedCredentials()); + } + + @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()); + } + + /** + * 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(); + 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/PluggableAuthHandler.java b/oauth2_http/java/com/google/auth/oauth2/PluggableAuthHandler.java new file mode 100644 index 000000000..24b0978cd --- /dev/null +++ b/oauth2_http/java/com/google/auth/oauth2/PluggableAuthHandler.java @@ -0,0 +1,300 @@ +/* + * 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.File; +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.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 + * workload identity federation. + * + *

See {@link PluggableAuthCredentials}. + */ +final class PluggableAuthHandler implements ExecutableHandler { + + // 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 = getCachedExecutableResponse(options); + + // 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(); + } + + @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()); + + // 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; + String executableOutput = ""; + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + // Consume the input stream while waiting for the program to finish so that + // the process won't hang if the STDOUT buffer is filled. + 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. + throw new PluggableAuthException( + "TIMEOUT_EXCEEDED", "The executable failed to finish within the timeout specified."); + } + int exitCode = process.exitValue(); + if (exitCode != EXIT_CODE_SUCCESS) { + throw new PluggableAuthException( + "EXIT_CODE", String.format("The executable failed with exit code %s.", exitCode)); + } + + executableOutput = future.get(); + executor.shutdownNow(); + + JsonParser parser = OAuth2Utils.JSON_FACTORY.createJsonParser(executableOutput); + execResp = new ExecutableResponse(parser.parseAndClose(GenericJson.class)); + } catch (IOException e) { + // Destroy the process. + process.destroy(); + + // Shutdown executor if needed. + if (!executor.isShutdown()) { + executor.shutdownNow(); + } + + if (e instanceof PluggableAuthException) { + throw e; + } + // 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.destroy(); + return execResp; + } + + InternalProcessBuilder getProcessBuilder(List commandComponents) { + if (internalProcessBuilder != null) { + return internalProcessBuilder; + } + 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/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/ExternalAccountCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java index fb94bb93d..1b2b53a1c 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java @@ -43,9 +43,11 @@ 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; +import java.util.ArrayList; import java.util.Arrays; import java.util.Date; import java.util.HashMap; @@ -105,6 +107,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 +215,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( @@ -513,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 = @@ -704,6 +795,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 8294749b2..4505445ce 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/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()) 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/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..4e630d49c --- /dev/null +++ b/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthHandlerTest.java @@ -0,0 +1,813 @@ +/* + * 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)).destroy(); + 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)).destroy(); + 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_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 { + 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)).destroy(); + 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)).destroy(); + 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)).destroy(); + 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)).destroy(); + } + + @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)).destroy(); + 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)).destroy(); + } + + @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)).destroy(); + } + + @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)).destroy(); + } + + @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)).destroy(); + } + + 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 156c3c5d2..599fd91c2 100644 --- a/oauth2_http/pom.xml +++ b/oauth2_http/pom.xml @@ -5,7 +5,7 @@ com.google.auth google-auth-library-parent - 1.7.0 + 1.8.0 ../pom.xml @@ -61,7 +61,7 @@ org.apache.maven.plugins maven-failsafe-plugin - 3.0.0-M6 + 3.0.0-M7 1200 sponge_log @@ -134,5 +134,17 @@ 1.3 test + + junit + junit + 4.13.2 + test + + + org.mockito + mockito-core + 4.6.1 + test + diff --git a/pom.xml b/pom.xml index 190f87cb4..3db8436cb 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 com.google.auth google-auth-library-parent - 1.7.0 + 1.8.0 pom Google Auth Library for Java Client libraries providing authentication and @@ -59,7 +59,7 @@ UTF-8 - 1.41.8 + 1.42.0 5.8.2 31.0.1-android 2.0.5 @@ -169,6 +169,7 @@ 3.4.0 7 + false @@ -194,7 +195,7 @@ org.apache.maven.plugins maven-surefire-plugin - 3.0.0-M6 + 3.0.0-M7 sponge_log @@ -329,6 +330,7 @@ + false none 7 ${project.build.directory}/javadoc @@ -504,6 +506,7 @@ ${sourceFileExclude} + false diff --git a/versions.txt b/versions.txt index 3b95a5022..fc9c5d398 100644 --- a/versions.txt +++ b/versions.txt @@ -1,9 +1,9 @@ # Format: # module:released-version:current-version -google-auth-library:1.7.0:1.7.0 -google-auth-library-bom:1.7.0:1.7.0 -google-auth-library-parent:1.7.0:1.7.0 -google-auth-library-appengine:1.7.0:1.7.0 -google-auth-library-credentials:1.7.0:1.7.0 -google-auth-library-oauth2-http:1.7.0:1.7.0 +google-auth-library:1.8.0:1.8.0 +google-auth-library-bom:1.8.0:1.8.0 +google-auth-library-parent:1.8.0:1.8.0 +google-auth-library-appengine:1.8.0:1.8.0 +google-auth-library-credentials:1.8.0:1.8.0 +google-auth-library-oauth2-http:1.8.0:1.8.0