diff --git a/.github/workflows/downstream.yaml b/.github/workflows/downstream.yaml index 78e1940fa..6985f0ed4 100644 --- a/.github/workflows/downstream.yaml +++ b/.github/workflows/downstream.yaml @@ -134,9 +134,11 @@ jobs: - workflows steps: - uses: actions/checkout@v2 - - uses: actions/setup-java@v1 + - uses: actions/setup-java@v3 with: + distribution: zulu java-version: ${{matrix.java}} - run: java -version + - run: sudo apt-get update -y - run: sudo apt-get install libxml2-utils - run: .kokoro/downstream-client-library-check.sh google-auth-library-bom ${{matrix.repo}} diff --git a/oauth2_http/java/com/google/auth/oauth2/ExecutableHandler.java b/oauth2_http/java/com/google/auth/oauth2/ExecutableHandler.java new file mode 100644 index 000000000..a052f2a5b --- /dev/null +++ b/oauth2_http/java/com/google/auth/oauth2/ExecutableHandler.java @@ -0,0 +1,67 @@ +/* + * Copyright 2022 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.oauth2; + +import java.io.IOException; +import java.util.Map; +import javax.annotation.Nullable; + +/** An interface for 3rd party executable handling. */ +interface ExecutableHandler { + + /** An interface for required fields needed to call 3rd party executables. */ + interface ExecutableOptions { + + /** An absolute path to the command used to retrieve 3rd party tokens. */ + String getExecutableCommand(); + + /** A set of process-local environment variable mappings to be set for the script to execute. */ + Map getEnvironmentMap(); + + /** A timeout for waiting for the executable to finish, in milliseconds. */ + int getExecutableTimeoutMs(); + + /** + * An output file path which points to the 3rd party credentials generated by the executable. + */ + @Nullable + String getOutputFilePath(); + } + + /** + * Handles executing the 3rd party script and parsing the token from the response. + * + * @param options A set executable options for handling the executable. + * @return A 3rd party token. + */ + String retrieveTokenFromExecutable(ExecutableOptions options) throws IOException; +} diff --git a/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java index 547a04261..b88f98bc5 100644 --- a/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java @@ -39,6 +39,7 @@ import com.google.auth.http.HttpTransportFactory; import com.google.auth.oauth2.AwsCredentials.AwsCredentialSource; import com.google.auth.oauth2.IdentityPoolCredentials.IdentityPoolCredentialSource; +import com.google.auth.oauth2.PluggableAuthCredentials.PluggableAuthCredentialSource; import com.google.common.base.MoreObjects; import java.io.IOException; import java.io.InputStream; @@ -58,7 +59,8 @@ /** * Base external account credentials class. * - *

Handles initializing external credentials, calls to STS, and service account impersonation. + *

Handles initializing external credentials, calls to the Security Token Service, and service + * account impersonation. */ public abstract class ExternalAccountCredentials extends GoogleCredentials implements QuotaProjectIdProvider { @@ -75,6 +77,7 @@ abstract static class CredentialSource { "https://www.googleapis.com/auth/cloud-platform"; static final String EXTERNAL_ACCOUNT_FILE_TYPE = "external_account"; + static final String EXECUTABLE_SOURCE_KEY = "executable"; private final String transportFactoryClassName; private final String audience; @@ -89,13 +92,14 @@ abstract static class CredentialSource { @Nullable private final String clientId; @Nullable private final String clientSecret; - // This is used for Workforce Pools. It is passed to STS during token exchange in the - // `options` param and will be embedded in the token by STS. + // This is used for Workforce Pools. It is passed to the Security Token Service during token + // exchange in the `options` param and will be embedded in the token by the Security Token + // Service. @Nullable private final String workforcePoolUserProject; protected transient HttpTransportFactory transportFactory; - @Nullable protected final ImpersonatedCredentials impersonatedCredentials; + @Nullable protected ImpersonatedCredentials impersonatedCredentials; private EnvironmentProvider environmentProvider; @@ -104,18 +108,17 @@ abstract static class CredentialSource { * workforce credentials. * * @param transportFactory HTTP transport factory, creates the transport used to get access tokens - * @param audience the STS audience which is usually the fully specified resource name of the - * workload/workforce pool provider - * @param subjectTokenType the STS subject token type based on the OAuth 2.0 token exchange spec. - * Indicates the type of the security token in the credential file - * @param tokenUrl the STS token exchange endpoint + * @param audience the Security Token Service audience, which is usually the fully specified + * resource name of the workload/workforce pool provider + * @param subjectTokenType the Security Token Service subject token type based on the OAuth 2.0 + * token exchange spec. Indicates the type of the security token in the credential file + * @param tokenUrl the Security Token Service token exchange endpoint * @param tokenInfoUrl the endpoint used to retrieve account related information. Required for * gCloud session account identification. * @param credentialSource the external credential source * @param serviceAccountImpersonationUrl the URL for the service account impersonation request. - * This is only required for workload identity pools when APIs to be accessed have not - * integrated with UberMint. If this is not available, the STS returned GCP access token is - * directly used. May be null. + * This URL is required for some APIs. If this URL is not available, the access token from the + * Security Token Service is used directly. May be null. * @param quotaProjectId the project used for quota and billing purposes. May be null. * @param clientId client ID of the service account from the console. May be null. * @param clientSecret client secret of the service account from the console. May be null. @@ -238,7 +241,7 @@ protected ExternalAccountCredentials(ExternalAccountCredentials.Builder builder) this.impersonatedCredentials = initializeImpersonatedCredentials(); } - private ImpersonatedCredentials initializeImpersonatedCredentials() { + protected ImpersonatedCredentials initializeImpersonatedCredentials() { if (serviceAccountImpersonationUrl == null) { return null; } @@ -249,6 +252,11 @@ private ImpersonatedCredentials initializeImpersonatedCredentials() { AwsCredentials.newBuilder((AwsCredentials) this) .setServiceAccountImpersonationUrl(null) .build(); + } else if (this instanceof PluggableAuthCredentials) { + sourceCredentials = + PluggableAuthCredentials.newBuilder((PluggableAuthCredentials) this) + .setServiceAccountImpersonationUrl(null) + .build(); } else { sourceCredentials = IdentityPoolCredentials.newBuilder((IdentityPoolCredentials) this) @@ -372,8 +380,20 @@ static ExternalAccountCredentials fromJson( .setClientId(clientId) .setClientSecret(clientSecret) .build(); + } else if (isPluggableAuthCredential(credentialSourceMap)) { + return PluggableAuthCredentials.newBuilder() + .setHttpTransportFactory(transportFactory) + .setAudience(audience) + .setSubjectTokenType(subjectTokenType) + .setTokenUrl(tokenUrl) + .setTokenInfoUrl(tokenInfoUrl) + .setCredentialSource(new PluggableAuthCredentialSource(credentialSourceMap)) + .setServiceAccountImpersonationUrl(serviceAccountImpersonationUrl) + .setQuotaProjectId(quotaProjectId) + .setClientId(clientId) + .setClientSecret(clientSecret) + .build(); } - return IdentityPoolCredentials.newBuilder() .setHttpTransportFactory(transportFactory) .setAudience(audience) @@ -389,17 +409,22 @@ static ExternalAccountCredentials fromJson( .build(); } + private static boolean isPluggableAuthCredential(Map credentialSource) { + // Pluggable Auth is enabled via a nested executable field in the credential source. + return credentialSource.containsKey(EXECUTABLE_SOURCE_KEY); + } + private static boolean isAwsCredential(Map credentialSource) { return credentialSource.containsKey("environment_id") && ((String) credentialSource.get("environment_id")).startsWith("aws"); } /** - * Exchanges the external credential for a GCP access token. + * Exchanges the external credential for a Google Cloud access token. * - * @param stsTokenExchangeRequest the STS token exchange request - * @return the access token returned by STS - * @throws OAuthException if the call to STS fails + * @param stsTokenExchangeRequest the Security Token Service token exchange request + * @return the access token returned by the Security Token Service + * @throws OAuthException if the call to the Security Token Service fails */ protected AccessToken exchangeExternalCredentialForAccessToken( StsTokenExchangeRequest stsTokenExchangeRequest) throws IOException { @@ -413,7 +438,8 @@ protected AccessToken exchangeExternalCredentialForAccessToken( tokenUrl, stsTokenExchangeRequest, transportFactory.create().createRequestFactory()); // If this credential was initialized with a Workforce configuration then the - // workforcePoolUserProject must passed to STS via the the internal options param. + // workforcePoolUserProject must be passed to the Security Token Service via the internal + // options param. if (isWorkforcePoolConfiguration()) { GenericJson options = new GenericJson(); options.setFactory(OAuth2Utils.JSON_FACTORY); @@ -431,7 +457,7 @@ protected AccessToken exchangeExternalCredentialForAccessToken( } /** - * Retrieves the external subject token to be exchanged for a GCP access token. + * Retrieves the external subject token to be exchanged for a Google Cloud access token. * *

Must be implemented by subclasses as the retrieval method is dependent on the credential * source. @@ -465,6 +491,15 @@ public String getServiceAccountImpersonationUrl() { return serviceAccountImpersonationUrl; } + /** The service account email to be impersonated, if available. */ + @Nullable + public String getServiceAccountEmail() { + if (serviceAccountImpersonationUrl == null || serviceAccountImpersonationUrl.isEmpty()) { + return null; + } + return ImpersonatedCredentials.extractTargetPrincipal(serviceAccountImpersonationUrl); + } + @Override @Nullable public String getQuotaProjectId() { @@ -496,7 +531,7 @@ EnvironmentProvider getEnvironmentProvider() { } /** - * Returns whether or not the current configuration is for Workforce Pools (which enable 3p user + * Returns whether the current configuration is for Workforce Pools (which enable 3p user * identities, rather than workloads). */ public boolean isWorkforcePoolConfiguration() { @@ -603,8 +638,8 @@ public Builder setHttpTransportFactory(HttpTransportFactory transportFactory) { } /** - * Sets the STS audience which is usually the fully specified resource name of the - * workload/workforce pool provider. + * Sets the Security Token Service audience, which is usually the fully specified resource name + * of the workload/workforce pool provider. */ public Builder setAudience(String audience) { this.audience = audience; @@ -612,15 +647,15 @@ public Builder setAudience(String audience) { } /** - * Sets the STS subject token type based on the OAuth 2.0 token exchange spec. Indicates the - * type of the security token in the credential file. + * Sets the Security Token Service subject token type based on the OAuth 2.0 token exchange + * spec. Indicates the type of the security token in the credential file. */ public Builder setSubjectTokenType(String subjectTokenType) { this.subjectTokenType = subjectTokenType; return this; } - /** Sets the STS token exchange endpoint. */ + /** Sets the Security Token Service token exchange endpoint. */ public Builder setTokenUrl(String tokenUrl) { this.tokenUrl = tokenUrl; return this; @@ -633,9 +668,9 @@ public Builder setCredentialSource(CredentialSource credentialSource) { } /** - * Sets the optional URL used for service account impersonation. This is only required when APIs - * to be accessed have not integrated with UberMint. If this is not available, the STS returned - * GCP access token is directly used. + * Sets the optional URL used for service account impersonation, which is required for some + * APIs. If this URL is not available, the access token from the Security Token Service is used + * directly. */ public Builder setServiceAccountImpersonationUrl(String serviceAccountImpersonationUrl) { this.serviceAccountImpersonationUrl = serviceAccountImpersonationUrl; diff --git a/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java b/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java new file mode 100644 index 000000000..83c63a5d8 --- /dev/null +++ b/oauth2_http/java/com/google/auth/oauth2/PluggableAuthCredentials.java @@ -0,0 +1,319 @@ +/* + * Copyright 2022 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.oauth2; + +import com.google.auth.oauth2.ExecutableHandler.ExecutableOptions; +import com.google.common.annotations.VisibleForTesting; +import java.io.*; +import java.math.BigDecimal; +import java.util.*; +import javax.annotation.Nullable; + +/** + * PluggableAuthCredentials enables the exchange of workload identity pool external credentials for + * Google access tokens by retrieving 3rd party tokens through a user supplied executable. These + * scripts/executables are completely independent of the Google Cloud Auth libraries. These + * credentials plug into ADC and will call the specified executable to retrieve the 3rd party token + * to be exchanged for a Google access token. + * + *

To use these credentials, the GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES environment variable + * must be set to '1'. This is for security reasons. + * + *

Both OIDC and SAML are supported. The executable must adhere to a specific response format + * defined below. + * + *

The executable should print out the 3rd party token to STDOUT in JSON format. This is not + * required when an output_file is specified in the credential source, with the expectation being + * that the output file will contain the JSON response instead. + * + *

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

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

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

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

+   * Sample credential source for Pluggable Auth credentials:
+   * {
+   *   ...
+   *   "credential_source": {
+   *     "executable": {
+   *       "command": "/path/to/get/credentials.sh --arg1=value1 --arg2=value2",
+   *       "timeout_millis": 5000,
+   *       "output_file": "/path/to/generated/cached/credentials"
+   *     }
+   *   }
+   * }
+   * 
+ */ + static class PluggableAuthCredentialSource extends CredentialSource { + + // The default timeout for waiting for the executable to finish (30 seconds). + private static final int DEFAULT_EXECUTABLE_TIMEOUT_MS = 30 * 1000; + // The minimum timeout for waiting for the executable to finish (5 seconds). + private static final int MINIMUM_EXECUTABLE_TIMEOUT_MS = 5 * 1000; + // The maximum timeout for waiting for the executable to finish (120 seconds). + private static final int MAXIMUM_EXECUTABLE_TIMEOUT_MS = 120 * 1000; + + private static final String COMMAND_KEY = "command"; + private static final String TIMEOUT_MILLIS_KEY = "timeout_millis"; + private static final String OUTPUT_FILE_KEY = "output_file"; + + // Required. The command used to retrieve the 3rd party token. + private final String executableCommand; + + // Optional. Set to the default timeout when not provided. + private final int executableTimeoutMs; + + // Optional. Provided when the 3rd party executable caches the response at the specified + // location. + @Nullable private final String outputFilePath; + + PluggableAuthCredentialSource(Map credentialSourceMap) { + super(credentialSourceMap); + + if (!credentialSourceMap.containsKey(EXECUTABLE_SOURCE_KEY)) { + throw new IllegalArgumentException( + "Invalid credential source for PluggableAuth credentials."); + } + + Map executable = + (Map) credentialSourceMap.get(EXECUTABLE_SOURCE_KEY); + + // Command is the only required field. + if (!executable.containsKey(COMMAND_KEY)) { + throw new IllegalArgumentException( + "The PluggableAuthCredentialSource is missing the required 'command' field."); + } + + // Parse the executable timeout. + if (executable.containsKey(TIMEOUT_MILLIS_KEY)) { + Object timeout = executable.get(TIMEOUT_MILLIS_KEY); + if (timeout instanceof BigDecimal) { + executableTimeoutMs = ((BigDecimal) timeout).intValue(); + } else if (executable.get(TIMEOUT_MILLIS_KEY) instanceof Integer) { + executableTimeoutMs = (int) timeout; + } else { + executableTimeoutMs = Integer.parseInt((String) timeout); + } + } else { + executableTimeoutMs = DEFAULT_EXECUTABLE_TIMEOUT_MS; + } + + // Provided timeout must be between 5s and 120s. + if (executableTimeoutMs < MINIMUM_EXECUTABLE_TIMEOUT_MS + || executableTimeoutMs > MAXIMUM_EXECUTABLE_TIMEOUT_MS) { + throw new IllegalArgumentException( + String.format( + "The executable timeout must be between %s and %s milliseconds.", + MINIMUM_EXECUTABLE_TIMEOUT_MS, MAXIMUM_EXECUTABLE_TIMEOUT_MS)); + } + + executableCommand = (String) executable.get(COMMAND_KEY); + outputFilePath = (String) executable.get(OUTPUT_FILE_KEY); + } + + String getCommand() { + return executableCommand; + } + + int getTimeoutMs() { + return executableTimeoutMs; + } + + @Nullable + String getOutputFilePath() { + return outputFilePath; + } + } + + private final PluggableAuthCredentialSource config; + + private final ExecutableHandler handler; + + /** Internal constructor. See {@link Builder}. */ + PluggableAuthCredentials(Builder builder) { + super(builder); + this.config = (PluggableAuthCredentialSource) builder.credentialSource; + + if (builder.handler != null) { + handler = builder.handler; + } else { + // TODO(lsirac): Initialize handler. + handler = null; + } + + // Re-initialize impersonated credentials as the handler hasn't been set yet when + // this is called in the base class. + this.impersonatedCredentials = initializeImpersonatedCredentials(); + } + + @Override + public AccessToken refreshAccessToken() throws IOException { + String credential = retrieveSubjectToken(); + StsTokenExchangeRequest.Builder stsTokenExchangeRequest = + StsTokenExchangeRequest.newBuilder(credential, getSubjectTokenType()) + .setAudience(getAudience()); + + Collection scopes = getScopes(); + if (scopes != null && !scopes.isEmpty()) { + stsTokenExchangeRequest.setScopes(new ArrayList<>(scopes)); + } + return exchangeExternalCredentialForAccessToken(stsTokenExchangeRequest.build()); + } + + @Override + public String retrieveSubjectToken() throws IOException { + String executableCommand = config.getCommand(); + String outputFilePath = config.getOutputFilePath(); + int executableTimeoutMs = config.getTimeoutMs(); + + Map envMap = new HashMap<>(); + envMap.put("GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE", getAudience()); + envMap.put("GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE", getSubjectTokenType()); + // Always set to 0 for Workload Identity Federation. + envMap.put("GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE", "0"); + if (getServiceAccountEmail() != null) { + envMap.put("GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL", getServiceAccountEmail()); + } + if (outputFilePath != null && !outputFilePath.isEmpty()) { + envMap.put("GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE", outputFilePath); + } + + ExecutableOptions options = + new ExecutableOptions() { + @Override + public String getExecutableCommand() { + return executableCommand; + } + + @Override + public Map getEnvironmentMap() { + return envMap; + } + + @Override + public int getExecutableTimeoutMs() { + return executableTimeoutMs; + } + + @Nullable + @Override + public String getOutputFilePath() { + return outputFilePath; + } + }; + + // Delegate handling of the executable to the handler. + return this.handler.retrieveTokenFromExecutable(options); + } + + /** Clones the PluggableAuthCredentials with the specified scopes. */ + @Override + public PluggableAuthCredentials createScoped(Collection newScopes) { + return new PluggableAuthCredentials( + (PluggableAuthCredentials.Builder) newBuilder(this).setScopes(newScopes)); + } + + public static Builder newBuilder() { + return new Builder(); + } + + public static Builder newBuilder(PluggableAuthCredentials pluggableAuthCredentials) { + return new Builder(pluggableAuthCredentials); + } + + @VisibleForTesting + @Nullable + ExecutableHandler getExecutableHandler() { + return this.handler; + } + + public static class Builder extends ExternalAccountCredentials.Builder { + + private ExecutableHandler handler; + + Builder() {} + + Builder(PluggableAuthCredentials credentials) { + super(credentials); + this.handler = credentials.handler; + } + + public Builder setExecutableHandler(ExecutableHandler handler) { + this.handler = handler; + return this; + } + + @Override + public PluggableAuthCredentials build() { + return new PluggableAuthCredentials(this); + } + } +} diff --git a/oauth2_http/java/com/google/auth/oauth2/PluggableAuthException.java b/oauth2_http/java/com/google/auth/oauth2/PluggableAuthException.java new file mode 100644 index 000000000..894b324a9 --- /dev/null +++ b/oauth2_http/java/com/google/auth/oauth2/PluggableAuthException.java @@ -0,0 +1,48 @@ +/* + * Copyright 2022 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.oauth2; + +import static com.google.common.base.Preconditions.checkNotNull; + +/** Encapsulates the error response's for 3rd party executables defined by the executable spec. */ +class PluggableAuthException extends OAuthException { + + PluggableAuthException(String errorCode, String errorDescription) { + super(errorCode, checkNotNull(errorDescription), /* errorUri=*/ null); + } + + /** The message with format: Error code {errorCode}: {errorDescription}. */ + @Override + public String getMessage() { + return "Error code " + getErrorCode() + ": " + getErrorDescription(); + } +} diff --git a/oauth2_http/java/com/google/auth/oauth2/ServiceAccountCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ServiceAccountCredentials.java index 02aff547f..9b9c99c54 100644 --- a/oauth2_http/java/com/google/auth/oauth2/ServiceAccountCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/ServiceAccountCredentials.java @@ -1052,6 +1052,11 @@ public Builder setPrivateKey(PrivateKey privateKey) { return this; } + public Builder setPrivateKeyString(String privateKeyPkcs8) throws IOException { + this.privateKey = privateKeyFromPkcs8(privateKeyPkcs8); + return this; + } + public Builder setPrivateKeyId(String privateKeyId) { this.privateKeyId = privateKeyId; return this; diff --git a/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java index fb94bb93d..42413194c 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java @@ -43,6 +43,7 @@ import com.google.auth.TestUtils; import com.google.auth.http.HttpTransportFactory; import com.google.auth.oauth2.ExternalAccountCredentialsTest.TestExternalAccountCredentials.TestCredentialSource; +import com.google.auth.oauth2.PluggableAuthCredentials.PluggableAuthCredentialSource; import java.io.ByteArrayInputStream; import java.io.IOException; import java.net.URI; @@ -105,6 +106,16 @@ void fromStream_awsCredentials() throws IOException { assertTrue(credential instanceof AwsCredentials); } + @Test + void fromStream_pluggableAuthCredentials() throws IOException { + GenericJson json = buildJsonPluggableAuthCredential(); + + ExternalAccountCredentials credential = + ExternalAccountCredentials.fromStream(TestUtils.jsonToInputStream(json)); + + assertTrue(credential instanceof PluggableAuthCredentials); + } + @Test void fromStream_invalidStream_throws() { GenericJson json = buildJsonAwsCredential(); @@ -203,6 +214,53 @@ void fromJson_awsCredentials() { assertNotNull(credential.getCredentialSource()); } + @Test + void fromJson_pluggableAuthCredentials() { + ExternalAccountCredentials credential = + ExternalAccountCredentials.fromJson( + buildJsonPluggableAuthCredential(), OAuth2Utils.HTTP_TRANSPORT_FACTORY); + + assertTrue(credential instanceof PluggableAuthCredentials); + assertEquals("audience", credential.getAudience()); + assertEquals("subjectTokenType", credential.getSubjectTokenType()); + assertEquals(STS_URL, credential.getTokenUrl()); + assertEquals("tokenInfoUrl", credential.getTokenInfoUrl()); + assertNotNull(credential.getCredentialSource()); + + PluggableAuthCredentialSource source = + (PluggableAuthCredentialSource) credential.getCredentialSource(); + assertEquals("command", source.getCommand()); + assertEquals(30000, source.getTimeoutMs()); // Default timeout is 30s. + assertNull(source.getOutputFilePath()); + } + + @Test + void fromJson_pluggableAuthCredentials_allExecutableOptionsSet() { + GenericJson json = buildJsonPluggableAuthCredential(); + Map credentialSourceMap = (Map) json.get("credential_source"); + // Add optional params to the executable config (timeout, output file path). + Map executableConfig = + (Map) credentialSourceMap.get("executable"); + executableConfig.put("timeout_millis", 5000); + executableConfig.put("output_file", "path/to/output/file"); + + ExternalAccountCredentials credential = + ExternalAccountCredentials.fromJson(json, OAuth2Utils.HTTP_TRANSPORT_FACTORY); + + assertTrue(credential instanceof PluggableAuthCredentials); + assertEquals("audience", credential.getAudience()); + assertEquals("subjectTokenType", credential.getSubjectTokenType()); + assertEquals(STS_URL, credential.getTokenUrl()); + assertEquals("tokenInfoUrl", credential.getTokenInfoUrl()); + assertNotNull(credential.getCredentialSource()); + + PluggableAuthCredentialSource source = + (PluggableAuthCredentialSource) credential.getCredentialSource(); + assertEquals("command", source.getCommand()); + assertEquals("path/to/output/file", source.getOutputFilePath()); + assertEquals(5000, source.getTimeoutMs()); + } + @Test void fromJson_nullJson_throws() { assertThrows( @@ -704,6 +762,24 @@ private GenericJson buildJsonAwsCredential() { return json; } + private GenericJson buildJsonPluggableAuthCredential() { + GenericJson json = new GenericJson(); + json.put("audience", "audience"); + json.put("subject_token_type", "subjectTokenType"); + json.put("token_url", STS_URL); + json.put("token_info_url", "tokenInfoUrl"); + + Map> credentialSource = new HashMap<>(); + + Map executableConfig = new HashMap<>(); + executableConfig.put("command", "command"); + + credentialSource.put("executable", executableConfig); + json.put("credential_source", credentialSource); + + return json; + } + static class TestExternalAccountCredentials extends ExternalAccountCredentials { static class TestCredentialSource extends IdentityPoolCredentials.IdentityPoolCredentialSource { protected TestCredentialSource(Map credentialSourceMap) { diff --git a/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java index 028f9235e..f5bd15d2d 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java @@ -260,6 +260,29 @@ void fromStream_awsCredentials_providesToken() throws IOException { TestUtils.assertContainsBearerToken(metadata, transportFactory.transport.getAccessToken()); } + @Test + void fromStream_pluggableAuthCredentials_providesToken() throws IOException { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + InputStream stream = + PluggableAuthCredentialsTest.writeCredentialsStream(transportFactory.transport.getStsUrl()); + + GoogleCredentials credentials = GoogleCredentials.fromStream(stream, transportFactory); + + assertNotNull(credentials); + + // Create copy with mock executable handler. + PluggableAuthCredentials copy = + PluggableAuthCredentials.newBuilder((PluggableAuthCredentials) credentials) + .setExecutableHandler(options -> "pluggableAuthToken") + .build(); + + copy = copy.createScoped(SCOPES); + Map> metadata = copy.getRequestMetadata(CALL_URI); + TestUtils.assertContainsBearerToken(metadata, transportFactory.transport.getAccessToken()); + } + @Test void fromStream_Impersonation_providesToken_WithQuotaProject() throws IOException { MockTokenServerTransportFactory transportFactoryForSource = diff --git a/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java new file mode 100644 index 000000000..01185bdb0 --- /dev/null +++ b/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthCredentialsTest.java @@ -0,0 +1,444 @@ +/* + * Copyright 2022 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.oauth2; + +import static com.google.auth.oauth2.MockExternalAccountCredentialsTransport.SERVICE_ACCOUNT_IMPERSONATION_URL; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.google.api.client.http.HttpTransport; +import com.google.api.client.json.GenericJson; +import com.google.auth.TestUtils; +import com.google.auth.http.HttpTransportFactory; +import com.google.auth.oauth2.ExecutableHandler.ExecutableOptions; +import com.google.auth.oauth2.ExternalAccountCredentials.CredentialSource; +import com.google.auth.oauth2.PluggableAuthCredentials.PluggableAuthCredentialSource; +import java.io.IOException; +import java.io.InputStream; +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import javax.annotation.Nullable; +import org.junit.jupiter.api.Test; + +/** Tests for {@link PluggableAuthCredentials}. */ +class PluggableAuthCredentialsTest { + // The default timeout for waiting for the executable to finish (30 seconds). + private static final int DEFAULT_EXECUTABLE_TIMEOUT_MS = 30 * 1000; + // The minimum timeout for waiting for the executable to finish (5 seconds). + private static final int MINIMUM_EXECUTABLE_TIMEOUT_MS = 5 * 1000; + // The maximum timeout for waiting for the executable to finish (120 seconds). + private static final int MAXIMUM_EXECUTABLE_TIMEOUT_MS = 120 * 1000; + private static final String STS_URL = "https://sts.googleapis.com"; + + private static final PluggableAuthCredentials CREDENTIAL = + (PluggableAuthCredentials) + PluggableAuthCredentials.newBuilder() + .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY) + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl(STS_URL) + .setTokenInfoUrl("tokenInfoUrl") + .setCredentialSource(buildCredentialSource()) + .build(); + + static class MockExternalAccountCredentialsTransportFactory implements HttpTransportFactory { + + MockExternalAccountCredentialsTransport transport = + new MockExternalAccountCredentialsTransport(); + + @Override + public HttpTransport create() { + return transport; + } + } + + @Test + void retrieveSubjectToken_shouldDelegateToHandler() throws IOException { + PluggableAuthCredentials credential = + PluggableAuthCredentials.newBuilder(CREDENTIAL) + .setExecutableHandler(options -> "pluggableAuthToken") + .build(); + String subjectToken = credential.retrieveSubjectToken(); + assertEquals(subjectToken, "pluggableAuthToken"); + } + + @Test + void retrieveSubjectToken_shouldPassAllOptionsToHandler() throws IOException { + String command = "/path/to/executable"; + String timeout = "5000"; + String outputFile = "/path/to/output/file"; + + final ExecutableOptions[] providedOptions = {null}; + ExecutableHandler executableHandler = + options -> { + providedOptions[0] = options; + return "pluggableAuthToken"; + }; + + PluggableAuthCredentials credential = + (PluggableAuthCredentials) + PluggableAuthCredentials.newBuilder(CREDENTIAL) + .setExecutableHandler(executableHandler) + .setCredentialSource(buildCredentialSource(command, timeout, outputFile)) + .setServiceAccountImpersonationUrl(SERVICE_ACCOUNT_IMPERSONATION_URL) + .build(); + + String subjectToken = credential.retrieveSubjectToken(); + + assertEquals(subjectToken, "pluggableAuthToken"); + + // Validate that the correct options were passed to the executable handler. + ExecutableOptions options = providedOptions[0]; + assertEquals(options.getExecutableCommand(), command); + assertEquals(options.getExecutableTimeoutMs(), Integer.parseInt(timeout)); + assertEquals(options.getOutputFilePath(), outputFile); + + Map envMap = options.getEnvironmentMap(); + assertEquals(envMap.size(), 5); + assertEquals(envMap.get("GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE"), credential.getAudience()); + assertEquals( + envMap.get("GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE"), credential.getSubjectTokenType()); + assertEquals(envMap.get("GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE"), "0"); + assertEquals( + envMap.get("GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL"), + credential.getServiceAccountEmail()); + assertEquals(envMap.get("GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE"), outputFile); + } + + @Test + void retrieveSubjectToken_shouldPassMinimalOptionsToHandler() throws IOException { + String command = "/path/to/executable"; + + final ExecutableOptions[] providedOptions = {null}; + ExecutableHandler executableHandler = + options -> { + providedOptions[0] = options; + return "pluggableAuthToken"; + }; + + PluggableAuthCredentials credential = + (PluggableAuthCredentials) + PluggableAuthCredentials.newBuilder(CREDENTIAL) + .setExecutableHandler(executableHandler) + .setCredentialSource( + buildCredentialSource(command, /* timeoutMs= */ null, /* outputFile= */ null)) + .build(); + + String subjectToken = credential.retrieveSubjectToken(); + + assertEquals(subjectToken, "pluggableAuthToken"); + + // Validate that the correct options were passed to the executable handler. + ExecutableOptions options = providedOptions[0]; + assertEquals(options.getExecutableCommand(), command); + assertEquals(options.getExecutableTimeoutMs(), DEFAULT_EXECUTABLE_TIMEOUT_MS); + assertNull(options.getOutputFilePath()); + + Map envMap = options.getEnvironmentMap(); + assertEquals(envMap.size(), 3); + assertEquals(envMap.get("GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE"), credential.getAudience()); + assertEquals( + envMap.get("GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE"), credential.getSubjectTokenType()); + assertEquals(envMap.get("GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE"), "0"); + assertNull(envMap.get("GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL")); + assertNull(envMap.get("GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE")); + } + + @Test + void refreshAccessToken_withoutServiceAccountImpersonation() throws IOException { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + transportFactory.transport.setExpireTime(TestUtils.getDefaultExpireTime()); + + PluggableAuthCredentials credential = + (PluggableAuthCredentials) + PluggableAuthCredentials.newBuilder(CREDENTIAL) + .setExecutableHandler(options -> "pluggableAuthToken") + .setTokenUrl(transportFactory.transport.getStsUrl()) + .setHttpTransportFactory(transportFactory) + .build(); + + AccessToken accessToken = credential.refreshAccessToken(); + + assertEquals(transportFactory.transport.getAccessToken(), accessToken.getTokenValue()); + + // Validate that the correct subject token was passed to STS. + Map query = + TestUtils.parseQuery(transportFactory.transport.getRequests().get(0).getContentAsString()); + assertEquals(query.get("subject_token"), "pluggableAuthToken"); + } + + @Test + void refreshAccessToken_withServiceAccountImpersonation() throws IOException { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + transportFactory.transport.setExpireTime(TestUtils.getDefaultExpireTime()); + + PluggableAuthCredentials credential = + (PluggableAuthCredentials) + PluggableAuthCredentials.newBuilder(CREDENTIAL) + .setExecutableHandler(options -> "pluggableAuthToken") + .setTokenUrl(transportFactory.transport.getStsUrl()) + .setServiceAccountImpersonationUrl( + transportFactory.transport.getServiceAccountImpersonationUrl()) + .setHttpTransportFactory(transportFactory) + .build(); + + AccessToken accessToken = credential.refreshAccessToken(); + + assertEquals( + transportFactory.transport.getServiceAccountAccessToken(), accessToken.getTokenValue()); + + // Validate that the correct subject token was passed to STS. + Map query = + TestUtils.parseQuery(transportFactory.transport.getRequests().get(0).getContentAsString()); + assertEquals(query.get("subject_token"), "pluggableAuthToken"); + } + + @Test + void pluggableAuthCredentialSource_allFields() { + Map source = new HashMap<>(); + Map executable = new HashMap<>(); + source.put("executable", executable); + executable.put("command", "/path/to/executable"); + executable.put("timeout_millis", "10000"); + executable.put("output_file", "/path/to/output/file"); + + PluggableAuthCredentialSource credentialSource = new PluggableAuthCredentialSource(source); + + assertEquals(credentialSource.getCommand(), "/path/to/executable"); + assertEquals(credentialSource.getTimeoutMs(), 10000); + assertEquals(credentialSource.getOutputFilePath(), "/path/to/output/file"); + } + + @Test + void pluggableAuthCredentialSource_noTimeoutProvided_setToDefault() { + Map source = new HashMap<>(); + Map executable = new HashMap<>(); + source.put("executable", executable); + executable.put("command", "command"); + PluggableAuthCredentialSource credentialSource = new PluggableAuthCredentialSource(source); + + assertEquals(credentialSource.getCommand(), "command"); + assertEquals(credentialSource.getTimeoutMs(), DEFAULT_EXECUTABLE_TIMEOUT_MS); + assertNull(credentialSource.getOutputFilePath()); + } + + @Test + void pluggableAuthCredentialSource_timeoutProvidedOutOfRange_throws() { + Map source = new HashMap<>(); + Map executable = new HashMap<>(); + source.put("executable", executable); + + executable.put("command", "command"); + + int[] possibleOutOfRangeValues = new int[] {0, 4 * 1000, 121 * 1000}; + + for (int value : possibleOutOfRangeValues) { + executable.put("timeout_millis", value); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> { + new PluggableAuthCredentialSource(source); + }, + "Exception should be thrown."); + assertEquals( + String.format( + "The executable timeout must be between %s and %s milliseconds.", + MINIMUM_EXECUTABLE_TIMEOUT_MS, MAXIMUM_EXECUTABLE_TIMEOUT_MS), + exception.getMessage()); + } + } + + @Test + void pluggableAuthCredentialSource_validTimeoutProvided() { + Map source = new HashMap<>(); + Map executable = new HashMap<>(); + source.put("executable", executable); + + executable.put("command", "command"); + + Object[] possibleValues = new Object[] {"10000", 10000, BigDecimal.valueOf(10000L)}; + + for (Object value : possibleValues) { + executable.put("timeout_millis", value); + PluggableAuthCredentialSource credentialSource = new PluggableAuthCredentialSource(source); + + assertEquals(credentialSource.getCommand(), "command"); + assertEquals(credentialSource.getTimeoutMs(), 10000); + assertNull(credentialSource.getOutputFilePath()); + } + } + + @Test + void pluggableAuthCredentialSource_missingExecutableField_throws() { + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> new PluggableAuthCredentialSource(new HashMap<>()), + "Exception should be thrown."); + assertEquals( + "Invalid credential source for PluggableAuth credentials.", exception.getMessage()); + } + + @Test + void pluggableAuthCredentialSource_missingExecutableCommandField_throws() { + Map source = new HashMap<>(); + Map executable = new HashMap<>(); + source.put("executable", executable); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> new PluggableAuthCredentialSource(source), + "Exception should be thrown."); + assertEquals( + "The PluggableAuthCredentialSource is missing the required 'command' field.", + exception.getMessage()); + } + + @Test + void builder_allFields() { + List scopes = Arrays.asList("scope1", "scope2"); + + CredentialSource source = buildCredentialSource(); + ExecutableHandler handler = options -> "Token"; + + PluggableAuthCredentials credentials = + (PluggableAuthCredentials) + PluggableAuthCredentials.newBuilder() + .setExecutableHandler(handler) + .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl(STS_URL) + .setTokenInfoUrl("tokenInfoUrl") + .setCredentialSource(source) + .setServiceAccountImpersonationUrl(SERVICE_ACCOUNT_IMPERSONATION_URL) + .setQuotaProjectId("quotaProjectId") + .setClientId("clientId") + .setClientSecret("clientSecret") + .setScopes(scopes) + .build(); + + assertEquals(credentials.getExecutableHandler(), handler); + assertEquals("audience", credentials.getAudience()); + assertEquals("subjectTokenType", credentials.getSubjectTokenType()); + assertEquals(credentials.getTokenUrl(), STS_URL); + assertEquals(credentials.getTokenInfoUrl(), "tokenInfoUrl"); + assertEquals( + credentials.getServiceAccountImpersonationUrl(), SERVICE_ACCOUNT_IMPERSONATION_URL); + assertEquals(credentials.getCredentialSource(), source); + assertEquals(credentials.getQuotaProjectId(), "quotaProjectId"); + assertEquals(credentials.getClientId(), "clientId"); + assertEquals(credentials.getClientSecret(), "clientSecret"); + assertEquals(credentials.getScopes(), scopes); + assertEquals(credentials.getEnvironmentProvider(), SystemEnvironmentProvider.getInstance()); + } + + @Test + void createdScoped_clonedCredentialWithAddedScopes() { + PluggableAuthCredentials credentials = + (PluggableAuthCredentials) + PluggableAuthCredentials.newBuilder(CREDENTIAL) + .setExecutableHandler(options -> "pluggableAuthToken") + .setServiceAccountImpersonationUrl(SERVICE_ACCOUNT_IMPERSONATION_URL) + .setQuotaProjectId("quotaProjectId") + .setClientId("clientId") + .setClientSecret("clientSecret") + .build(); + + List newScopes = Arrays.asList("scope1", "scope2"); + + PluggableAuthCredentials newCredentials = credentials.createScoped(newScopes); + + assertEquals(credentials.getAudience(), newCredentials.getAudience()); + assertEquals(credentials.getSubjectTokenType(), newCredentials.getSubjectTokenType()); + assertEquals(credentials.getTokenUrl(), newCredentials.getTokenUrl()); + assertEquals(credentials.getTokenInfoUrl(), newCredentials.getTokenInfoUrl()); + assertEquals( + credentials.getServiceAccountImpersonationUrl(), + newCredentials.getServiceAccountImpersonationUrl()); + assertEquals(credentials.getCredentialSource(), newCredentials.getCredentialSource()); + assertEquals(newScopes, newCredentials.getScopes()); + assertEquals(credentials.getQuotaProjectId(), newCredentials.getQuotaProjectId()); + assertEquals(credentials.getClientId(), newCredentials.getClientId()); + assertEquals(credentials.getClientSecret(), newCredentials.getClientSecret()); + assertEquals(credentials.getExecutableHandler(), newCredentials.getExecutableHandler()); + } + + private static CredentialSource buildCredentialSource() { + return buildCredentialSource("command", null, null); + } + + private static CredentialSource buildCredentialSource( + String command, @Nullable String timeoutMs, @Nullable String outputFile) { + Map source = new HashMap<>(); + Map executable = new HashMap<>(); + source.put("executable", executable); + executable.put("command", command); + if (timeoutMs != null) { + executable.put("timeout_millis", timeoutMs); + } + if (outputFile != null) { + executable.put("output_file", outputFile); + } + + return new PluggableAuthCredentialSource(source); + } + + static InputStream writeCredentialsStream(String tokenUrl) throws IOException { + GenericJson json = new GenericJson(); + json.put("audience", "audience"); + json.put("subject_token_type", "subjectTokenType"); + json.put("token_url", tokenUrl); + json.put("token_info_url", "tokenInfoUrl"); + json.put("type", ExternalAccountCredentials.EXTERNAL_ACCOUNT_FILE_TYPE); + + GenericJson credentialSource = new GenericJson(); + GenericJson executable = new GenericJson(); + executable.put("command", "/path/to/executable"); + credentialSource.put("executable", executable); + + json.put("credential_source", credentialSource); + return TestUtils.jsonToInputStream(json); + } +} diff --git a/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthExceptionTest.java b/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthExceptionTest.java new file mode 100644 index 000000000..f924d4137 --- /dev/null +++ b/oauth2_http/javatests/com/google/auth/oauth2/PluggableAuthExceptionTest.java @@ -0,0 +1,71 @@ +/* + * Copyright 2022 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.oauth2; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +/** Tests for {@link PluggableAuthException}. */ +class PluggableAuthExceptionTest { + + private static final String MESSAGE_FORMAT = "Error code %s: %s"; + + @Test + void constructor() { + PluggableAuthException e = new PluggableAuthException("errorCode", "errorDescription"); + assertEquals("errorCode", e.getErrorCode()); + assertEquals("errorDescription", e.getErrorDescription()); + } + + @Test + void constructor_nullErrorCode_throws() { + assertThrows( + NullPointerException.class, + () -> new PluggableAuthException(/* errorCode= */ null, "errorDescription")); + } + + @Test + void constructor_nullErrorDescription_throws() { + assertThrows( + NullPointerException.class, + () -> new PluggableAuthException("errorCode", /* errorDescription= */ null)); + } + + @Test + void getMessage() { + PluggableAuthException e = new PluggableAuthException("errorCode", "errorDescription"); + String expectedMessage = String.format("Error code %s: %s", "errorCode", "errorDescription"); + assertEquals(expectedMessage, e.getMessage()); + } +} diff --git a/pom.xml b/pom.xml index 18027e07f..049943154 100644 --- a/pom.xml +++ b/pom.xml @@ -59,7 +59,7 @@ UTF-8 - 1.41.4 + 1.41.5 5.8.2 31.0.1-android 2.0.4