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.authgoogle-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.0com.google.authgoogle-auth-library-bom
- 1.7.0
+ 1.8.0pomGoogle 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.authgoogle-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:
+ *
+ *
+ *
urn:ietf:params:oauth:token-type:id_token
+ *
urn:ietf:params:oauth:token-type:jwt
+ *
urn:ietf:params:oauth:token-type:saml2
+ *
+ *
+ * @return The 3rd party subject token type for success responses, null otherwise.
+ */
+ @Nullable
+ String getTokenType() {
+ return this.tokenType;
+ }
+
+ /** Returns the subject token if the execution was successful, null otherwise. */
+ @Nullable
+ String getSubjectToken() {
+ return this.subjectToken;
+ }
+
+ /** Returns the error code if the execution was unsuccessful, null otherwise. */
+ @Nullable
+ String getErrorCode() {
+ return this.errorCode;
+ }
+
+ /** Returns the error message if the execution was unsuccessful, null otherwise. */
+ @Nullable
+ String getErrorMessage() {
+ return this.errorMessage;
+ }
+
+ private static int parseIntField(Object field) {
+ if (field instanceof String) {
+ return Integer.parseInt((String) field);
+ }
+ if (field instanceof BigDecimal) {
+ return ((BigDecimal) field).intValue();
+ }
+ return (int) field;
+ }
+
+ private static long parseLongField(Object field) {
+ if (field instanceof String) {
+ return Long.parseLong((String) field);
+ }
+ if (field instanceof BigDecimal) {
+ return ((BigDecimal) field).longValue();
+ }
+ return (long) field;
+ }
+}
diff --git a/oauth2_http/java/com/google/auth/oauth2/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.
+ *
+ *
+ */
+ 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.authgoogle-auth-library-parent
- 1.7.0
+ 1.8.0../pom.xml
@@ -61,7 +61,7 @@
org.apache.maven.pluginsmaven-failsafe-plugin
- 3.0.0-M6
+ 3.0.0-M71200sponge_log
@@ -134,5 +134,17 @@
1.3test
+
+ 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.0com.google.authgoogle-auth-library-parent
- 1.7.0
+ 1.8.0pomGoogle Auth Library for JavaClient libraries providing authentication and
@@ -59,7 +59,7 @@
UTF-8
- 1.41.8
+ 1.42.05.8.231.0.1-android2.0.5
@@ -169,6 +169,7 @@
3.4.07
+ false
@@ -194,7 +195,7 @@
org.apache.maven.pluginsmaven-surefire-plugin
- 3.0.0-M6
+ 3.0.0-M7sponge_log
@@ -329,6 +330,7 @@
+ falsenone7${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