diff --git a/oauth2_http/java/com/google/auth/oauth2/ActingParty.java b/oauth2_http/java/com/google/auth/oauth2/ActingParty.java
new file mode 100644
index 000000000..ad1d452fc
--- /dev/null
+++ b/oauth2_http/java/com/google/auth/oauth2/ActingParty.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2021 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;
+
+/**
+ * The acting party as defined in OAuth 2.0 Token
+ * Exchange.
+ */
+final class ActingParty {
+ private final String actorToken;
+ private final String actorTokenType;
+
+ ActingParty(String actorToken, String actorTokenType) {
+ this.actorToken = checkNotNull(actorToken);
+ this.actorTokenType = checkNotNull(actorTokenType);
+ }
+
+ String getActorToken() {
+ return actorToken;
+ }
+
+ String getActorTokenType() {
+ return actorTokenType;
+ }
+}
diff --git a/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java b/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java
new file mode 100644
index 000000000..b12d4e1cf
--- /dev/null
+++ b/oauth2_http/java/com/google/auth/oauth2/AwsCredentials.java
@@ -0,0 +1,349 @@
+/*
+ * Copyright 2021 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.http.GenericUrl;
+import com.google.api.client.http.HttpRequest;
+import com.google.api.client.http.HttpRequestFactory;
+import com.google.api.client.http.HttpResponse;
+import com.google.api.client.json.GenericJson;
+import com.google.api.client.json.JsonParser;
+import com.google.auth.http.HttpTransportFactory;
+import com.google.common.annotations.VisibleForTesting;
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import javax.annotation.Nullable;
+
+/**
+ * AWS credentials representing a third-party identity for calling Google APIs.
+ *
+ *
By default, attempts to exchange the external credential for a GCP access token.
+ */
+public class AwsCredentials extends ExternalAccountCredentials {
+
+ /**
+ * The AWS credential source. Stores data required to retrieve the AWS credential from the AWS
+ * metadata server.
+ */
+ static class AwsCredentialSource extends CredentialSource {
+
+ private final String regionUrl;
+ private final String url;
+ private final String regionalCredentialVerificationUrl;
+
+ /**
+ * The source of the AWS credential. The credential source map must contain the
+ * `regional_cred_verification_url` field.
+ *
+ *
The `regional_cred_verification_url` is the regional GetCallerIdentity action URL, used to
+ * determine the account ID and its roles.
+ *
+ *
The `environment_id` is the environment identifier, in the format “aws${version}”. This
+ * indicates whether breaking changes were introduced to the underlying AWS implementation.
+ *
+ *
The `region_url` identifies the targeted region. Optional.
+ *
+ *
The `url` locates the metadata server used to retrieve the AWS credentials. Optional.
+ */
+ AwsCredentialSource(Map credentialSourceMap) {
+ super(credentialSourceMap);
+ if (!credentialSourceMap.containsKey("regional_cred_verification_url")) {
+ throw new IllegalArgumentException(
+ "A regional_cred_verification_url representing the"
+ + " GetCallerIdentity action URL must be specified.");
+ }
+
+ String environmentId = (String) credentialSourceMap.get("environment_id");
+
+ // Environment version is prefixed by "aws". e.g. "aws1".
+ Matcher matcher = Pattern.compile("(aws)([\\d]+)").matcher(environmentId);
+ if (!matcher.matches()) {
+ throw new IllegalArgumentException("Invalid AWS environment ID.");
+ }
+
+ int environmentVersion = Integer.parseInt(matcher.group(2));
+ if (environmentVersion != 1) {
+ throw new IllegalArgumentException(
+ String.format(
+ "AWS version %s is not supported in the current build.", environmentVersion));
+ }
+
+ this.regionUrl = (String) credentialSourceMap.get("region_url");
+ this.url = (String) credentialSourceMap.get("url");
+ this.regionalCredentialVerificationUrl =
+ (String) credentialSourceMap.get("regional_cred_verification_url");
+ }
+ }
+
+ private final AwsCredentialSource awsCredentialSource;
+
+ /**
+ * Internal constructor. See {@link
+ * ExternalAccountCredentials#ExternalAccountCredentials(HttpTransportFactory, String, String,
+ * String, CredentialSource, String, String, String, String, String, Collection)}
+ */
+ AwsCredentials(
+ HttpTransportFactory transportFactory,
+ String audience,
+ String subjectTokenType,
+ String tokenUrl,
+ AwsCredentialSource credentialSource,
+ @Nullable String tokenInfoUrl,
+ @Nullable String serviceAccountImpersonationUrl,
+ @Nullable String quotaProjectId,
+ @Nullable String clientId,
+ @Nullable String clientSecret,
+ @Nullable Collection scopes) {
+ super(
+ transportFactory,
+ audience,
+ subjectTokenType,
+ tokenUrl,
+ credentialSource,
+ tokenInfoUrl,
+ serviceAccountImpersonationUrl,
+ quotaProjectId,
+ clientId,
+ clientSecret,
+ scopes);
+ this.awsCredentialSource = credentialSource;
+ }
+
+ @Override
+ public AccessToken refreshAccessToken() throws IOException {
+ StsTokenExchangeRequest.Builder stsTokenExchangeRequest =
+ StsTokenExchangeRequest.newBuilder(retrieveSubjectToken(), getSubjectTokenType())
+ .setAudience(getAudience());
+
+ // Add scopes, if possible.
+ Collection scopes = getScopes();
+ if (scopes != null && !scopes.isEmpty()) {
+ stsTokenExchangeRequest.setScopes(new ArrayList<>(scopes));
+ }
+
+ return exchangeExternalCredentialForAccessToken(stsTokenExchangeRequest.build());
+ }
+
+ @Override
+ public String retrieveSubjectToken() throws IOException {
+ // The targeted region is required to generate the signed request. The regional
+ // endpoint must also be used.
+ String region = getAwsRegion();
+
+ AwsSecurityCredentials credentials = getAwsSecurityCredentials();
+
+ // Generate the signed request to the AWS STS GetCallerIdentity API.
+ Map headers = new HashMap<>();
+ headers.put("x-goog-cloud-target-resource", getAudience());
+
+ AwsRequestSigner signer =
+ AwsRequestSigner.newBuilder(
+ credentials,
+ "POST",
+ awsCredentialSource.regionalCredentialVerificationUrl.replace("{region}", region),
+ region)
+ .setAdditionalHeaders(headers)
+ .build();
+
+ AwsRequestSignature awsRequestSignature = signer.sign();
+ return buildSubjectToken(awsRequestSignature);
+ }
+
+ /** Clones the AwsCredentials with the specified scopes. */
+ @Override
+ public GoogleCredentials createScoped(Collection newScopes) {
+ return new AwsCredentials(
+ transportFactory,
+ getAudience(),
+ getSubjectTokenType(),
+ getTokenUrl(),
+ awsCredentialSource,
+ getTokenInfoUrl(),
+ getServiceAccountImpersonationUrl(),
+ getQuotaProjectId(),
+ getClientId(),
+ getClientSecret(),
+ newScopes);
+ }
+
+ private String retrieveResource(String url, String resourceName) throws IOException {
+ try {
+ HttpRequestFactory requestFactory = transportFactory.create().createRequestFactory();
+ HttpRequest request = requestFactory.buildGetRequest(new GenericUrl(url));
+ HttpResponse response = request.execute();
+ return response.parseAsString();
+ } catch (IOException e) {
+ throw new IOException(String.format("Failed to retrieve AWS %s.", resourceName), e);
+ }
+ }
+
+ private String buildSubjectToken(AwsRequestSignature signature)
+ throws UnsupportedEncodingException {
+ Map canonicalHeaders = signature.getCanonicalHeaders();
+ List headerList = new ArrayList<>();
+ for (String headerName : canonicalHeaders.keySet()) {
+ headerList.add(formatTokenHeaderForSts(headerName, canonicalHeaders.get(headerName)));
+ }
+
+ headerList.add(formatTokenHeaderForSts("Authorization", signature.getAuthorizationHeader()));
+
+ // The canonical resource name of the workload identity pool provider.
+ headerList.add(formatTokenHeaderForSts("x-goog-cloud-target-resource", getAudience()));
+
+ GenericJson token = new GenericJson();
+ token.setFactory(OAuth2Utils.JSON_FACTORY);
+
+ token.put("headers", headerList);
+ token.put("method", signature.getHttpMethod());
+ token.put(
+ "url",
+ awsCredentialSource.regionalCredentialVerificationUrl.replace(
+ "{region}", signature.getRegion()));
+ return URLEncoder.encode(token.toString(), "UTF-8");
+ }
+
+ private String getAwsRegion() throws IOException {
+ // For AWS Lambda, the region is retrieved through the AWS_REGION environment variable.
+ String region = getEnv("AWS_REGION");
+ if (region != null) {
+ return region;
+ }
+
+ if (awsCredentialSource.regionUrl == null || awsCredentialSource.regionUrl.isEmpty()) {
+ throw new IOException(
+ "Unable to determine the AWS region. The credential source does not contain the region URL.");
+ }
+
+ region = retrieveResource(awsCredentialSource.regionUrl, "region");
+
+ // There is an extra appended character that must be removed. If `us-east-1b` is returned,
+ // we want `us-east-1`.
+ return region.substring(0, region.length() - 1);
+ }
+
+ @VisibleForTesting
+ AwsSecurityCredentials getAwsSecurityCredentials() throws IOException {
+ // Check environment variables for credentials first.
+ String accessKeyId = getEnv("AWS_ACCESS_KEY_ID");
+ String secretAccessKey = getEnv("AWS_SECRET_ACCESS_KEY");
+ String token = getEnv("Token");
+ if (accessKeyId != null && secretAccessKey != null) {
+ return new AwsSecurityCredentials(accessKeyId, secretAccessKey, token);
+ }
+
+ // Credentials not retrievable from environment variables - call metadata server.
+ // Retrieve the IAM role that is attached to the VM. This is required to retrieve the AWS
+ // security credentials.
+ if (awsCredentialSource.url == null || awsCredentialSource.url.isEmpty()) {
+ throw new IOException(
+ "Unable to determine the AWS IAM role name. The credential source does not contain the"
+ + " url field.");
+ }
+ String roleName = retrieveResource(awsCredentialSource.url, "IAM role");
+
+ // Retrieve the AWS security credentials by calling the endpoint specified by the credential
+ // source.
+ String awsCredentials =
+ retrieveResource(awsCredentialSource.url + "/" + roleName, "credentials");
+
+ JsonParser parser = OAuth2Utils.JSON_FACTORY.createJsonParser(awsCredentials);
+ GenericJson genericJson = parser.parseAndClose(GenericJson.class);
+
+ accessKeyId = (String) genericJson.get("AccessKeyId");
+ secretAccessKey = (String) genericJson.get("SecretAccessKey");
+ token = (String) genericJson.get("Token");
+
+ // These credentials last for a few hours - we may consider caching these in the
+ // future.
+ return new AwsSecurityCredentials(accessKeyId, secretAccessKey, token);
+ }
+
+ @VisibleForTesting
+ String getEnv(String name) {
+ return System.getenv(name);
+ }
+
+ private static GenericJson formatTokenHeaderForSts(String key, String value) {
+ // The GCP STS endpoint expects the headers to be formatted as:
+ // [
+ // {key: 'x-amz-date', value: '...'},
+ // {key: 'Authorization', value: '...'},
+ // ...
+ // ]
+ GenericJson header = new GenericJson();
+ header.setFactory(OAuth2Utils.JSON_FACTORY);
+ header.put("key", key);
+ header.put("value", value);
+ return header;
+ }
+
+ public static AwsCredentials.Builder newBuilder() {
+ return new AwsCredentials.Builder();
+ }
+
+ public static AwsCredentials.Builder newBuilder(AwsCredentials awsCredentials) {
+ return new AwsCredentials.Builder(awsCredentials);
+ }
+
+ public static class Builder extends ExternalAccountCredentials.Builder {
+
+ Builder() {}
+
+ Builder(AwsCredentials credentials) {
+ super(credentials);
+ }
+
+ @Override
+ public AwsCredentials build() {
+ return new AwsCredentials(
+ transportFactory,
+ audience,
+ subjectTokenType,
+ tokenUrl,
+ (AwsCredentialSource) credentialSource,
+ tokenInfoUrl,
+ serviceAccountImpersonationUrl,
+ quotaProjectId,
+ clientId,
+ clientSecret,
+ scopes);
+ }
+ }
+}
diff --git a/oauth2_http/java/com/google/auth/oauth2/AwsDates.java b/oauth2_http/java/com/google/auth/oauth2/AwsDates.java
new file mode 100644
index 000000000..abf81add9
--- /dev/null
+++ b/oauth2_http/java/com/google/auth/oauth2/AwsDates.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright 2021 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;
+
+import java.text.DateFormat;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.TimeZone;
+
+/** Formats dates required for AWS Signature V4 request signing. */
+final class AwsDates {
+ private static final String X_AMZ_DATE_FORMAT = "yyyyMMdd'T'HHmmss'Z'";
+ private static final String HTTP_DATE_FORMAT = "E, dd MMM yyyy HH:mm:ss z";
+
+ private final String xAmzDate;
+ private final String originalDate;
+
+ private AwsDates(String amzDate) {
+ this.xAmzDate = checkNotNull(amzDate);
+ this.originalDate = amzDate;
+ }
+
+ private AwsDates(String xAmzDate, String originalDate) {
+ this.xAmzDate = checkNotNull(xAmzDate);
+ this.originalDate = checkNotNull(originalDate);
+ }
+
+ /**
+ * Returns the original date. This can either be the x-amz-date or a specified date in the format
+ * of E, dd MMM yyyy HH:mm:ss z.
+ */
+ String getOriginalDate() {
+ return originalDate;
+ }
+
+ /** Returns the x-amz-date in yyyyMMdd'T'HHmmss'Z' format. */
+ String getXAmzDate() {
+ return xAmzDate;
+ }
+
+ /** Returns the x-amz-date in YYYYMMDD format. */
+ String getFormattedDate() {
+ return xAmzDate.substring(0, 8);
+ }
+
+ static AwsDates fromXAmzDate(String xAmzDate) throws ParseException {
+ // Validate format
+ new SimpleDateFormat(AwsDates.X_AMZ_DATE_FORMAT).parse(xAmzDate);
+ return new AwsDates(xAmzDate);
+ }
+
+ static AwsDates fromDateHeader(String date) throws ParseException {
+ DateFormat dateFormat = new SimpleDateFormat(X_AMZ_DATE_FORMAT);
+ dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
+
+ Date inputDate = new SimpleDateFormat(HTTP_DATE_FORMAT).parse(date);
+ String xAmzDate = dateFormat.format(inputDate);
+ return new AwsDates(xAmzDate, date);
+ }
+
+ static AwsDates generateXAmzDate() {
+ DateFormat dateFormat = new SimpleDateFormat(X_AMZ_DATE_FORMAT);
+ dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
+ String xAmzDate = dateFormat.format(new Date(System.currentTimeMillis()));
+ return new AwsDates(xAmzDate);
+ }
+}
diff --git a/oauth2_http/java/com/google/auth/oauth2/AwsRequestSignature.java b/oauth2_http/java/com/google/auth/oauth2/AwsRequestSignature.java
new file mode 100644
index 000000000..463b84676
--- /dev/null
+++ b/oauth2_http/java/com/google/auth/oauth2/AwsRequestSignature.java
@@ -0,0 +1,191 @@
+/*
+ * Copyright 2021 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.util.HashMap;
+import java.util.Map;
+
+/**
+ * Stores the AWS API request signature based on the AWS Signature Version 4 signing process, and
+ * the parameters used in the signing process.
+ */
+class AwsRequestSignature {
+
+ private AwsSecurityCredentials awsSecurityCredentials;
+ private Map canonicalHeaders;
+
+ private String signature;
+ private String credentialScope;
+ private String url;
+ private String httpMethod;
+ private String date;
+ private String region;
+ private String authorizationHeader;
+
+ private AwsRequestSignature(
+ AwsSecurityCredentials awsSecurityCredentials,
+ Map canonicalHeaders,
+ String signature,
+ String credentialScope,
+ String url,
+ String httpMethod,
+ String date,
+ String region,
+ String authorizationHeader) {
+ this.awsSecurityCredentials = awsSecurityCredentials;
+ this.canonicalHeaders = canonicalHeaders;
+ this.signature = signature;
+ this.credentialScope = credentialScope;
+ this.url = url;
+ this.httpMethod = httpMethod;
+ this.date = date;
+ this.region = region;
+ this.authorizationHeader = authorizationHeader;
+ }
+
+ /** Returns the request signature based on the AWS Signature Version 4 signing process. */
+ String getSignature() {
+ return signature;
+ }
+
+ /** Returns the credential scope. e.g. 20150830/us-east-1/iam/aws4_request */
+ String getCredentialScope() {
+ return credentialScope;
+ }
+
+ /** Returns the AWS security credentials. */
+ AwsSecurityCredentials getSecurityCredentials() {
+ return awsSecurityCredentials;
+ }
+
+ /** Returns the request URL. */
+ String getUrl() {
+ return url;
+ }
+
+ /** Returns the HTTP request method. */
+ String getHttpMethod() {
+ return httpMethod;
+ }
+
+ /** Returns the HTTP request canonical headers. */
+ Map getCanonicalHeaders() {
+ return new HashMap<>(canonicalHeaders);
+ }
+
+ /** Returns the request date. */
+ String getDate() {
+ return date;
+ }
+
+ /** Returns the targeted region. */
+ String getRegion() {
+ return region;
+ }
+
+ /** Returns the authorization header. */
+ String getAuthorizationHeader() {
+ return authorizationHeader;
+ }
+
+ static class Builder {
+
+ private AwsSecurityCredentials awsSecurityCredentials;
+ private Map canonicalHeaders;
+
+ private String signature;
+ private String credentialScope;
+ private String url;
+ private String httpMethod;
+ private String date;
+ private String region;
+ private String authorizationHeader;
+
+ Builder setSignature(String signature) {
+ this.signature = signature;
+ return this;
+ }
+
+ Builder setCredentialScope(String credentialScope) {
+ this.credentialScope = credentialScope;
+ return this;
+ }
+
+ Builder setSecurityCredentials(AwsSecurityCredentials awsSecurityCredentials) {
+ this.awsSecurityCredentials = awsSecurityCredentials;
+ return this;
+ }
+
+ Builder setUrl(String url) {
+ this.url = url;
+ return this;
+ }
+
+ Builder setHttpMethod(String httpMethod) {
+ this.httpMethod = httpMethod;
+ return this;
+ }
+
+ Builder setCanonicalHeaders(Map canonicalHeaders) {
+ this.canonicalHeaders = new HashMap<>(canonicalHeaders);
+ return this;
+ }
+
+ Builder setDate(String date) {
+ this.date = date;
+ return this;
+ }
+
+ Builder setRegion(String region) {
+ this.region = region;
+ return this;
+ }
+
+ Builder setAuthorizationHeader(String authorizationHeader) {
+ this.authorizationHeader = authorizationHeader;
+ return this;
+ }
+
+ AwsRequestSignature build() {
+ return new AwsRequestSignature(
+ awsSecurityCredentials,
+ canonicalHeaders,
+ signature,
+ credentialScope,
+ url,
+ httpMethod,
+ date,
+ region,
+ authorizationHeader);
+ }
+ }
+}
diff --git a/oauth2_http/java/com/google/auth/oauth2/AwsRequestSigner.java b/oauth2_http/java/com/google/auth/oauth2/AwsRequestSigner.java
new file mode 100644
index 000000000..70d930bbe
--- /dev/null
+++ b/oauth2_http/java/com/google/auth/oauth2/AwsRequestSigner.java
@@ -0,0 +1,336 @@
+/*
+ * Copyright 2021 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;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.auth.ServiceAccountSigner.SigningException;
+import com.google.common.base.Joiner;
+import com.google.common.base.Splitter;
+import com.google.common.io.BaseEncoding;
+import java.net.URI;
+import java.security.InvalidKeyException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.text.ParseException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import javax.annotation.Nullable;
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+
+/**
+ * Internal utility that signs AWS API requests based on the AWS Signature Version 4 signing
+ * process.
+ *
+ * @see AWS
+ * Signature V4
+ */
+class AwsRequestSigner {
+
+ // AWS Signature Version 4 signing algorithm identifier.
+ private static final String HASHING_ALGORITHM = "AWS4-HMAC-SHA256";
+
+ // The termination string for the AWS credential scope value as defined in
+ // https://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html
+ private static final String AWS_REQUEST_TYPE = "aws4_request";
+
+ private final AwsSecurityCredentials awsSecurityCredentials;
+ private final Map additionalHeaders;
+ private final String httpMethod;
+ private final String region;
+ private final String requestPayload;
+ private final URI uri;
+ private final AwsDates dates;
+
+ /**
+ * Internal constructor.
+ *
+ * @param awsSecurityCredentials AWS security credentials
+ * @param httpMethod the HTTP request method
+ * @param url the request URL
+ * @param region the targeted region
+ * @param requestPayload the request payload
+ * @param additionalHeaders a map of additional HTTP headers to be included with the signed
+ * request
+ */
+ private AwsRequestSigner(
+ AwsSecurityCredentials awsSecurityCredentials,
+ String httpMethod,
+ String url,
+ String region,
+ @Nullable String requestPayload,
+ @Nullable Map additionalHeaders,
+ @Nullable AwsDates awsDates) {
+ this.awsSecurityCredentials = checkNotNull(awsSecurityCredentials);
+ this.httpMethod = checkNotNull(httpMethod);
+ this.uri = URI.create(url).normalize();
+ this.region = checkNotNull(region);
+ this.requestPayload = requestPayload == null ? "" : requestPayload;
+ this.additionalHeaders =
+ (additionalHeaders != null)
+ ? new HashMap<>(additionalHeaders)
+ : new HashMap();
+ this.dates = awsDates == null ? AwsDates.generateXAmzDate() : awsDates;
+ }
+
+ /**
+ * Signs the specified AWS API request.
+ *
+ * @return the {@link AwsRequestSignature}
+ */
+ AwsRequestSignature sign() {
+ // Retrieve the service name. For example: iam.amazonaws.com host => iam service.
+ String serviceName = Splitter.on(".").split(uri.getHost()).iterator().next();
+
+ Map canonicalHeaders = getCanonicalHeaders(dates.getOriginalDate());
+ // Headers must be sorted.
+ List sortedHeaderNames = new ArrayList<>();
+ for (String headerName : canonicalHeaders.keySet()) {
+ sortedHeaderNames.add(headerName.toLowerCase(Locale.US));
+ }
+ Collections.sort(sortedHeaderNames);
+
+ String canonicalRequestHash = createCanonicalRequestHash(canonicalHeaders, sortedHeaderNames);
+ String credentialScope =
+ dates.getFormattedDate() + "/" + region + "/" + serviceName + "/" + AWS_REQUEST_TYPE;
+ String stringToSign =
+ createStringToSign(canonicalRequestHash, dates.getXAmzDate(), credentialScope);
+ String signature =
+ calculateAwsV4Signature(
+ serviceName,
+ awsSecurityCredentials.getSecretAccessKey(),
+ dates.getFormattedDate(),
+ region,
+ stringToSign);
+
+ String authorizationHeader =
+ generateAuthorizationHeader(
+ sortedHeaderNames, awsSecurityCredentials.getAccessKeyId(), credentialScope, signature);
+
+ return new AwsRequestSignature.Builder()
+ .setSignature(signature)
+ .setCanonicalHeaders(canonicalHeaders)
+ .setHttpMethod(httpMethod)
+ .setSecurityCredentials(awsSecurityCredentials)
+ .setCredentialScope(credentialScope)
+ .setUrl(uri.toString())
+ .setDate(dates.getOriginalDate())
+ .setRegion(region)
+ .setAuthorizationHeader(authorizationHeader)
+ .build();
+ }
+
+ /** Task 1: Create a canonical request for Signature Version 4. */
+ private String createCanonicalRequestHash(
+ Map headers, List sortedHeaderNames) {
+ // Append the HTTP request method.
+ StringBuilder canonicalRequest = new StringBuilder(httpMethod).append("\n");
+
+ // Append the path.
+ String urlPath = uri.getRawPath().isEmpty() ? "/" : uri.getRawPath();
+ canonicalRequest.append(urlPath).append("\n");
+
+ // Append the canonical query string.
+ String actionQueryString = uri.getRawQuery() != null ? uri.getRawQuery() : "";
+ canonicalRequest.append(actionQueryString).append("\n");
+
+ // Append the canonical headers.
+ StringBuilder canonicalHeaders = new StringBuilder();
+ for (String headerName : sortedHeaderNames) {
+ canonicalHeaders.append(headerName).append(":").append(headers.get(headerName)).append("\n");
+ }
+ canonicalRequest.append(canonicalHeaders).append("\n");
+
+ // Append the signed headers.
+ canonicalRequest.append(Joiner.on(';').join(sortedHeaderNames)).append("\n");
+
+ // Append the hashed request payload.
+ canonicalRequest.append(getHexEncodedSha256Hash(requestPayload.getBytes(UTF_8)));
+
+ // Return the hashed canonical request.
+ return getHexEncodedSha256Hash(canonicalRequest.toString().getBytes(UTF_8));
+ }
+
+ /** Task 2: Create a string to sign for Signature Version 4. */
+ private String createStringToSign(
+ String canonicalRequestHash, String xAmzDate, String credentialScope) {
+ return HASHING_ALGORITHM
+ + "\n"
+ + xAmzDate
+ + "\n"
+ + credentialScope
+ + "\n"
+ + canonicalRequestHash;
+ }
+
+ /**
+ * Task 3: Calculate the signature for AWS Signature Version 4.
+ *
+ * @param date the date used in the hashing process in YYYYMMDD format
+ */
+ private String calculateAwsV4Signature(
+ String serviceName, String secret, String date, String region, String stringToSign) {
+ byte[] kDate = sign(("AWS4" + secret).getBytes(UTF_8), date.getBytes(UTF_8));
+ byte[] kRegion = sign(kDate, region.getBytes(UTF_8));
+ byte[] kService = sign(kRegion, serviceName.getBytes(UTF_8));
+ byte[] kSigning = sign(kService, AWS_REQUEST_TYPE.getBytes(UTF_8));
+ return BaseEncoding.base16().lowerCase().encode(sign(kSigning, stringToSign.getBytes(UTF_8)));
+ }
+
+ /** Task 4: Format the signature to be added to the HTTP request. */
+ private String generateAuthorizationHeader(
+ List sortedHeaderNames,
+ String accessKeyId,
+ String credentialScope,
+ String signature) {
+ return String.format(
+ "%s Credential=%s/%s, SignedHeaders=%s, Signature=%s",
+ HASHING_ALGORITHM,
+ accessKeyId,
+ credentialScope,
+ Joiner.on(';').join(sortedHeaderNames),
+ signature);
+ }
+
+ private Map getCanonicalHeaders(String defaultDate) {
+ Map headers = new HashMap<>();
+ headers.put("host", uri.getHost());
+
+ // Only add the date if it hasn't been specified through the "date" header.
+ if (!additionalHeaders.containsKey("date")) {
+ headers.put("x-amz-date", defaultDate);
+ }
+
+ if (awsSecurityCredentials.getToken() != null && !awsSecurityCredentials.getToken().isEmpty()) {
+ headers.put("x-amz-security-token", awsSecurityCredentials.getToken());
+ }
+
+ // Add all additional headers.
+ for (String key : additionalHeaders.keySet()) {
+ // Header keys need to be lowercase.
+ headers.put(key.toLowerCase(Locale.US), additionalHeaders.get(key));
+ }
+ return headers;
+ }
+
+ private static byte[] sign(byte[] key, byte[] value) {
+ try {
+ String algorithm = "HmacSHA256";
+ Mac mac = Mac.getInstance(algorithm);
+ mac.init(new SecretKeySpec(key, algorithm));
+ return mac.doFinal(value);
+ } catch (NoSuchAlgorithmException e) {
+ // Will not occur as HmacSHA256 is supported. We may allow other algorithms in the future.
+ throw new RuntimeException("HmacSHA256 must be supported by the JVM.", e);
+ } catch (InvalidKeyException e) {
+ throw new SigningException("Invalid key used when calculating the AWS V4 Signature", e);
+ }
+ }
+
+ private static String getHexEncodedSha256Hash(byte[] bytes) {
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ return BaseEncoding.base16().lowerCase().encode(digest.digest(bytes));
+ } catch (NoSuchAlgorithmException e) {
+ throw new RuntimeException("Failed to compute SHA-256 hash.", e);
+ }
+ }
+
+ static Builder newBuilder(
+ AwsSecurityCredentials awsSecurityCredentials, String httpMethod, String url, String region) {
+ return new Builder(awsSecurityCredentials, httpMethod, url, region);
+ }
+
+ static class Builder {
+
+ private final AwsSecurityCredentials awsSecurityCredentials;
+ private final String httpMethod;
+ private final String url;
+ private final String region;
+
+ @Nullable private String requestPayload;
+ @Nullable private Map additionalHeaders;
+ @Nullable private AwsDates dates;
+
+ private Builder(
+ AwsSecurityCredentials awsSecurityCredentials,
+ String httpMethod,
+ String url,
+ String region) {
+ this.awsSecurityCredentials = awsSecurityCredentials;
+ this.httpMethod = httpMethod;
+ this.url = url;
+ this.region = region;
+ }
+
+ Builder setRequestPayload(String requestPayload) {
+ this.requestPayload = requestPayload;
+ return this;
+ }
+
+ Builder setAdditionalHeaders(Map additionalHeaders) {
+ if (additionalHeaders.containsKey("date") && additionalHeaders.containsKey("x-amz-date")) {
+ throw new IllegalArgumentException("One of {date, x-amz-date} can be specified, not both.");
+ }
+ try {
+ if (additionalHeaders.containsKey("date")) {
+ this.dates = AwsDates.fromDateHeader(additionalHeaders.get("date"));
+ }
+ if (additionalHeaders.containsKey("x-amz-date")) {
+ this.dates = AwsDates.fromXAmzDate(additionalHeaders.get("x-amz-date"));
+ }
+ } catch (ParseException e) {
+ throw new IllegalArgumentException("The provided date header value is invalid.", e);
+ }
+
+ this.additionalHeaders = additionalHeaders;
+ return this;
+ }
+
+ AwsRequestSigner build() {
+ return new AwsRequestSigner(
+ awsSecurityCredentials,
+ httpMethod,
+ url,
+ region,
+ requestPayload,
+ additionalHeaders,
+ dates);
+ }
+ }
+}
diff --git a/oauth2_http/java/com/google/auth/oauth2/AwsSecurityCredentials.java b/oauth2_http/java/com/google/auth/oauth2/AwsSecurityCredentials.java
new file mode 100644
index 000000000..b7865049a
--- /dev/null
+++ b/oauth2_http/java/com/google/auth/oauth2/AwsSecurityCredentials.java
@@ -0,0 +1,65 @@
+/*
+ * Copyright 2021 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 javax.annotation.Nullable;
+
+/**
+ * Defines AWS security credentials. These are either retrieved from the AWS security_credentials
+ * endpoint or AWS environment variables.
+ */
+class AwsSecurityCredentials {
+
+ private final String accessKeyId;
+ private final String secretAccessKey;
+
+ @Nullable private final String token;
+
+ AwsSecurityCredentials(String accessKeyId, String secretAccessKey, @Nullable String token) {
+ this.accessKeyId = accessKeyId;
+ this.secretAccessKey = secretAccessKey;
+ this.token = token;
+ }
+
+ String getAccessKeyId() {
+ return accessKeyId;
+ }
+
+ String getSecretAccessKey() {
+ return secretAccessKey;
+ }
+
+ @Nullable
+ String getToken() {
+ return token;
+ }
+}
diff --git a/oauth2_http/java/com/google/auth/oauth2/CredentialFormatException.java b/oauth2_http/java/com/google/auth/oauth2/CredentialFormatException.java
new file mode 100644
index 000000000..4186bc029
--- /dev/null
+++ b/oauth2_http/java/com/google/auth/oauth2/CredentialFormatException.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2021 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;
+
+/** Indicates that the provided credential does not adhere to the required format. */
+class CredentialFormatException extends IOException {
+ CredentialFormatException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java
new file mode 100644
index 000000000..1373fcc54
--- /dev/null
+++ b/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java
@@ -0,0 +1,456 @@
+/*
+ * Copyright 2021 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;
+
+import com.google.api.client.json.GenericJson;
+import com.google.api.client.json.JsonObjectParser;
+import com.google.auth.http.HttpTransportFactory;
+import com.google.auth.oauth2.AwsCredentials.AwsCredentialSource;
+import com.google.auth.oauth2.IdentityPoolCredentials.IdentityPoolCredentialSource;
+import com.google.common.base.MoreObjects;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import javax.annotation.Nullable;
+
+/**
+ * Base external account credentials class.
+ *
+ * Handles initializing external credentials, calls to STS, and service account impersonation.
+ */
+public abstract class ExternalAccountCredentials extends GoogleCredentials
+ implements QuotaProjectIdProvider {
+
+ /** Base credential source class. Dictates the retrieval method of the external credential. */
+ abstract static class CredentialSource {
+
+ CredentialSource(Map credentialSourceMap) {
+ checkNotNull(credentialSourceMap);
+ }
+ }
+
+ private static final String CLOUD_PLATFORM_SCOPE =
+ "https://www.googleapis.com/auth/cloud-platform";
+
+ static final String EXTERNAL_ACCOUNT_FILE_TYPE = "external_account";
+
+ private final String transportFactoryClassName;
+ private final String audience;
+ private final String subjectTokenType;
+ private final String tokenUrl;
+ private final CredentialSource credentialSource;
+ private final Collection scopes;
+
+ @Nullable private final String tokenInfoUrl;
+ @Nullable private final String serviceAccountImpersonationUrl;
+ @Nullable private final String quotaProjectId;
+ @Nullable private final String clientId;
+ @Nullable private final String clientSecret;
+
+ protected transient HttpTransportFactory transportFactory;
+
+ @Nullable protected final ImpersonatedCredentials impersonatedCredentials;
+
+ /**
+ * Constructor with minimum identifying information and custom HTTP transport.
+ *
+ * @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 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.
+ * @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.
+ * @param scopes the scopes to request during the authorization grant. May be null.
+ */
+ protected ExternalAccountCredentials(
+ HttpTransportFactory transportFactory,
+ String audience,
+ String subjectTokenType,
+ String tokenUrl,
+ CredentialSource credentialSource,
+ @Nullable String tokenInfoUrl,
+ @Nullable String serviceAccountImpersonationUrl,
+ @Nullable String quotaProjectId,
+ @Nullable String clientId,
+ @Nullable String clientSecret,
+ @Nullable Collection scopes) {
+ this.transportFactory =
+ MoreObjects.firstNonNull(
+ transportFactory,
+ getFromServiceLoader(HttpTransportFactory.class, OAuth2Utils.HTTP_TRANSPORT_FACTORY));
+ this.transportFactoryClassName = checkNotNull(this.transportFactory.getClass().getName());
+ this.audience = checkNotNull(audience);
+ this.subjectTokenType = checkNotNull(subjectTokenType);
+ this.tokenUrl = checkNotNull(tokenUrl);
+ this.credentialSource = checkNotNull(credentialSource);
+ this.tokenInfoUrl = tokenInfoUrl;
+ this.serviceAccountImpersonationUrl = serviceAccountImpersonationUrl;
+ this.quotaProjectId = quotaProjectId;
+ this.clientId = clientId;
+ this.clientSecret = clientSecret;
+ this.scopes =
+ (scopes == null || scopes.isEmpty()) ? Arrays.asList(CLOUD_PLATFORM_SCOPE) : scopes;
+ this.impersonatedCredentials = initializeImpersonatedCredentials();
+ }
+
+ private ImpersonatedCredentials initializeImpersonatedCredentials() {
+ if (serviceAccountImpersonationUrl == null) {
+ return null;
+ }
+ // Create a copy of this instance without service account impersonation.
+ ExternalAccountCredentials sourceCredentials;
+ if (this instanceof AwsCredentials) {
+ sourceCredentials =
+ AwsCredentials.newBuilder((AwsCredentials) this)
+ .setServiceAccountImpersonationUrl(null)
+ .build();
+ } else {
+ sourceCredentials =
+ IdentityPoolCredentials.newBuilder((IdentityPoolCredentials) this)
+ .setServiceAccountImpersonationUrl(null)
+ .build();
+ }
+
+ String targetPrincipal = extractTargetPrincipal(serviceAccountImpersonationUrl);
+ return ImpersonatedCredentials.newBuilder()
+ .setSourceCredentials(sourceCredentials)
+ .setHttpTransportFactory(transportFactory)
+ .setTargetPrincipal(targetPrincipal)
+ .setScopes(new ArrayList<>(scopes))
+ .setLifetime(3600) // 1 hour in seconds
+ .build();
+ }
+
+ @Override
+ public Map> getRequestMetadata(URI uri) throws IOException {
+ Map> requestMetadata = super.getRequestMetadata(uri);
+ return addQuotaProjectIdToRequestMetadata(quotaProjectId, requestMetadata);
+ }
+
+ /**
+ * Returns credentials defined by a JSON file stream.
+ *
+ * Returns {@link IdentityPoolCredentials} or {@link AwsCredentials}.
+ *
+ * @param credentialsStream the stream with the credential definition
+ * @return the credential defined by the credentialsStream
+ * @throws IOException if the credential cannot be created from the stream
+ */
+ public static ExternalAccountCredentials fromStream(InputStream credentialsStream)
+ throws IOException {
+ return fromStream(credentialsStream, OAuth2Utils.HTTP_TRANSPORT_FACTORY);
+ }
+
+ /**
+ * Returns credentials defined by a JSON file stream.
+ *
+ *
Returns a {@link IdentityPoolCredentials} or {@link AwsCredentials}.
+ *
+ * @param credentialsStream the stream with the credential definition
+ * @param transportFactory the HTTP transport factory used to create the transport to get access
+ * tokens
+ * @return the credential defined by the credentialsStream
+ * @throws IOException if the credential cannot be created from the stream
+ */
+ public static ExternalAccountCredentials fromStream(
+ InputStream credentialsStream, HttpTransportFactory transportFactory) throws IOException {
+ checkNotNull(credentialsStream);
+ checkNotNull(transportFactory);
+
+ JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY);
+ GenericJson fileContents =
+ parser.parseAndClose(credentialsStream, StandardCharsets.UTF_8, GenericJson.class);
+ try {
+ return fromJson(fileContents, transportFactory);
+ } catch (ClassCastException e) {
+ throw new CredentialFormatException("An invalid input stream was provided.", e);
+ }
+ }
+
+ /**
+ * Returns external account credentials defined by JSON using the format generated by gCloud.
+ *
+ * @param json a map from the JSON representing the credentials
+ * @param transportFactory HTTP transport factory, creates the transport used to get access tokens
+ * @return the credentials defined by the JSON
+ */
+ static ExternalAccountCredentials fromJson(
+ Map json, HttpTransportFactory transportFactory) {
+ checkNotNull(json);
+ checkNotNull(transportFactory);
+
+ String audience = (String) json.get("audience");
+ String subjectTokenType = (String) json.get("subject_token_type");
+ String tokenUrl = (String) json.get("token_url");
+ String serviceAccountImpersonationUrl = (String) json.get("service_account_impersonation_url");
+
+ Map credentialSourceMap = (Map) json.get("credential_source");
+
+ // Optional params.
+ String tokenInfoUrl = (String) json.get("token_info_url");
+ String clientId = (String) json.get("client_id");
+ String clientSecret = (String) json.get("client_secret");
+ String quotaProjectId = (String) json.get("quota_project_id");
+
+ if (isAwsCredential(credentialSourceMap)) {
+ return new AwsCredentials(
+ transportFactory,
+ audience,
+ subjectTokenType,
+ tokenUrl,
+ new AwsCredentialSource(credentialSourceMap),
+ tokenInfoUrl,
+ serviceAccountImpersonationUrl,
+ quotaProjectId,
+ clientId,
+ clientSecret,
+ /* scopes= */ null);
+ }
+ return new IdentityPoolCredentials(
+ transportFactory,
+ audience,
+ subjectTokenType,
+ tokenUrl,
+ new IdentityPoolCredentialSource(credentialSourceMap),
+ tokenInfoUrl,
+ serviceAccountImpersonationUrl,
+ quotaProjectId,
+ clientId,
+ clientSecret,
+ /* scopes= */ null);
+ }
+
+ 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.
+ *
+ * @param stsTokenExchangeRequest the STS token exchange request
+ * @return the access token returned by STS
+ * @throws OAuthException if the call to STS fails
+ */
+ protected AccessToken exchangeExternalCredentialForAccessToken(
+ StsTokenExchangeRequest stsTokenExchangeRequest) throws IOException {
+ // Handle service account impersonation if necessary.
+ if (impersonatedCredentials != null) {
+ return impersonatedCredentials.refreshAccessToken();
+ }
+
+ StsRequestHandler requestHandler =
+ StsRequestHandler.newBuilder(
+ tokenUrl, stsTokenExchangeRequest, transportFactory.create().createRequestFactory())
+ .build();
+
+ StsTokenExchangeResponse response = requestHandler.exchangeToken();
+ return response.getAccessToken();
+ }
+
+ private static String extractTargetPrincipal(String serviceAccountImpersonationUrl) {
+ // Extract the target principal
+ int startIndex = serviceAccountImpersonationUrl.lastIndexOf('/');
+ int endIndex = serviceAccountImpersonationUrl.indexOf(":generateAccessToken");
+
+ if (startIndex != -1 && endIndex != -1 && startIndex < endIndex) {
+ return serviceAccountImpersonationUrl.substring(startIndex + 1, endIndex);
+ } else {
+ throw new IllegalArgumentException(
+ "Unable to determine target principal from service account impersonation URL.");
+ }
+ }
+
+ /**
+ * Retrieves the external subject token to be exchanged for a GCP access token.
+ *
+ * Must be implemented by subclasses as the retrieval method is dependent on the credential
+ * source.
+ *
+ * @return the external subject token
+ */
+ public abstract String retrieveSubjectToken() throws IOException;
+
+ public String getAudience() {
+ return audience;
+ }
+
+ public String getSubjectTokenType() {
+ return subjectTokenType;
+ }
+
+ public String getTokenUrl() {
+ return tokenUrl;
+ }
+
+ public String getTokenInfoUrl() {
+ return tokenInfoUrl;
+ }
+
+ public CredentialSource getCredentialSource() {
+ return credentialSource;
+ }
+
+ @Nullable
+ public String getServiceAccountImpersonationUrl() {
+ return serviceAccountImpersonationUrl;
+ }
+
+ @Override
+ @Nullable
+ public String getQuotaProjectId() {
+ return quotaProjectId;
+ }
+
+ @Nullable
+ public String getClientId() {
+ return clientId;
+ }
+
+ @Nullable
+ public String getClientSecret() {
+ return clientSecret;
+ }
+
+ @Nullable
+ public Collection getScopes() {
+ return scopes;
+ }
+
+ /** Base builder for external account credentials. */
+ public abstract static class Builder extends GoogleCredentials.Builder {
+
+ protected String audience;
+ protected String subjectTokenType;
+ protected String tokenUrl;
+ protected String tokenInfoUrl;
+ protected CredentialSource credentialSource;
+ protected HttpTransportFactory transportFactory;
+
+ @Nullable protected String serviceAccountImpersonationUrl;
+ @Nullable protected String quotaProjectId;
+ @Nullable protected String clientId;
+ @Nullable protected String clientSecret;
+ @Nullable protected Collection scopes;
+
+ protected Builder() {}
+
+ protected Builder(ExternalAccountCredentials credentials) {
+ this.transportFactory = credentials.transportFactory;
+ this.audience = credentials.audience;
+ this.subjectTokenType = credentials.subjectTokenType;
+ this.tokenUrl = credentials.tokenUrl;
+ this.tokenInfoUrl = credentials.tokenInfoUrl;
+ this.serviceAccountImpersonationUrl = credentials.serviceAccountImpersonationUrl;
+ this.credentialSource = credentials.credentialSource;
+ this.quotaProjectId = credentials.quotaProjectId;
+ this.clientId = credentials.clientId;
+ this.clientSecret = credentials.clientSecret;
+ this.scopes = credentials.scopes;
+ }
+
+ public Builder setAudience(String audience) {
+ this.audience = audience;
+ return this;
+ }
+
+ public Builder setSubjectTokenType(String subjectTokenType) {
+ this.subjectTokenType = subjectTokenType;
+ return this;
+ }
+
+ public Builder setTokenUrl(String tokenUrl) {
+ this.tokenUrl = tokenUrl;
+ return this;
+ }
+
+ public Builder setTokenInfoUrl(String tokenInfoUrl) {
+ this.tokenInfoUrl = tokenInfoUrl;
+ return this;
+ }
+
+ public Builder setServiceAccountImpersonationUrl(String serviceAccountImpersonationUrl) {
+ this.serviceAccountImpersonationUrl = serviceAccountImpersonationUrl;
+ return this;
+ }
+
+ public Builder setCredentialSource(CredentialSource credentialSource) {
+ this.credentialSource = credentialSource;
+ return this;
+ }
+
+ public Builder setScopes(Collection scopes) {
+ this.scopes = scopes;
+ return this;
+ }
+
+ public Builder setQuotaProjectId(String quotaProjectId) {
+ this.quotaProjectId = quotaProjectId;
+ return this;
+ }
+
+ public Builder setClientId(String clientId) {
+ this.clientId = clientId;
+ return this;
+ }
+
+ public Builder setClientSecret(String clientSecret) {
+ this.clientSecret = clientSecret;
+ return this;
+ }
+
+ public Builder setHttpTransportFactory(HttpTransportFactory transportFactory) {
+ this.transportFactory = transportFactory;
+ return this;
+ }
+
+ public abstract ExternalAccountCredentials build();
+ }
+}
diff --git a/oauth2_http/java/com/google/auth/oauth2/GoogleCredentials.java b/oauth2_http/java/com/google/auth/oauth2/GoogleCredentials.java
index c9ea810fb..3e61e5d60 100644
--- a/oauth2_http/java/com/google/auth/oauth2/GoogleCredentials.java
+++ b/oauth2_http/java/com/google/auth/oauth2/GoogleCredentials.java
@@ -50,8 +50,8 @@
public class GoogleCredentials extends OAuth2Credentials {
private static final long serialVersionUID = -1522852442442473691L;
- static final String QUOTA_PROJECT_ID_HEADER_KEY = "x-goog-user-project";
+ static final String QUOTA_PROJECT_ID_HEADER_KEY = "x-goog-user-project";
static final String USER_FILE_TYPE = "authorized_user";
static final String SERVICE_ACCOUNT_FILE_TYPE = "service_account";
@@ -166,6 +166,9 @@ public static GoogleCredentials fromStream(
if (SERVICE_ACCOUNT_FILE_TYPE.equals(fileType)) {
return ServiceAccountCredentials.fromJson(fileContents, transportFactory);
}
+ if (ExternalAccountCredentials.EXTERNAL_ACCOUNT_FILE_TYPE.equals(fileType)) {
+ return ExternalAccountCredentials.fromJson(fileContents, transportFactory);
+ }
throw new IOException(
String.format(
"Error reading credentials from stream, 'type' value '%s' not recognized."
diff --git a/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java b/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java
new file mode 100644
index 000000000..a82e3a638
--- /dev/null
+++ b/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java
@@ -0,0 +1,318 @@
+/*
+ * Copyright 2021 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.http.GenericUrl;
+import com.google.api.client.http.HttpHeaders;
+import com.google.api.client.http.HttpRequest;
+import com.google.api.client.http.HttpResponse;
+import com.google.api.client.json.GenericJson;
+import com.google.api.client.json.JsonObjectParser;
+import com.google.auth.http.HttpTransportFactory;
+import com.google.auth.oauth2.IdentityPoolCredentials.IdentityPoolCredentialSource.CredentialFormatType;
+import com.google.common.io.CharStreams;
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+import javax.annotation.Nullable;
+
+/**
+ * Url-sourced and file-sourced external account credentials.
+ *
+ * By default, attempts to exchange the external credential for a GCP access token.
+ */
+public class IdentityPoolCredentials extends ExternalAccountCredentials {
+
+ /**
+ * The IdentityPool credential source. Dictates the retrieval method of the external credential,
+ * which can either be through a metadata server or a local file.
+ */
+ static class IdentityPoolCredentialSource extends ExternalAccountCredentials.CredentialSource {
+
+ enum IdentityPoolCredentialSourceType {
+ FILE,
+ URL
+ }
+
+ enum CredentialFormatType {
+ TEXT,
+ JSON
+ }
+
+ private IdentityPoolCredentialSourceType credentialSourceType;
+ private CredentialFormatType credentialFormatType;
+ private String credentialLocation;
+
+ @Nullable private String subjectTokenFieldName;
+ @Nullable private Map headers;
+
+ /**
+ * The source of the 3P credential.
+ *
+ * If this is a file based 3P credential, the credentials file can be retrieved using the
+ * `file` key.
+ *
+ *
If this is URL-based 3p credential, the metadata server URL can be retrieved using the
+ * `url` key.
+ *
+ *
The third party credential can be provided in different formats, such as text or JSON. The
+ * format can be specified using the `format` header, which returns a map with keys `type` and
+ * `subject_token_field_name`. If the `type` is json, the `subject_token_field_name` must be
+ * provided. If no format is provided, we expect the token to be in the raw text format.
+ *
+ *
Optional headers can be present, and should be keyed by `headers`.
+ */
+ IdentityPoolCredentialSource(Map credentialSourceMap) {
+ super(credentialSourceMap);
+
+ if (credentialSourceMap.containsKey("file") && credentialSourceMap.containsKey("url")) {
+ throw new IllegalArgumentException(
+ "Only one credential source type can be set, either file or url.");
+ }
+
+ if (credentialSourceMap.containsKey("file")) {
+ credentialLocation = (String) credentialSourceMap.get("file");
+ credentialSourceType = IdentityPoolCredentialSourceType.FILE;
+ } else if (credentialSourceMap.containsKey("url")) {
+ credentialLocation = (String) credentialSourceMap.get("url");
+ credentialSourceType = IdentityPoolCredentialSourceType.URL;
+ } else {
+ throw new IllegalArgumentException(
+ "Missing credential source file location or URL. At least one must be specified.");
+ }
+
+ Map headersMap = (Map) credentialSourceMap.get("headers");
+ if (headersMap != null && !headersMap.isEmpty()) {
+ headers = new HashMap<>();
+ headers.putAll(headersMap);
+ }
+
+ // If the format is not provided, we expect the token to be in the raw text format.
+ credentialFormatType = CredentialFormatType.TEXT;
+
+ Map formatMap = (Map) credentialSourceMap.get("format");
+ if (formatMap != null && formatMap.containsKey("type")) {
+ String type = formatMap.get("type");
+ if (!"text".equals(type) && !"json".equals(type)) {
+ throw new IllegalArgumentException(
+ String.format("Invalid credential source format type: %s.", type));
+ }
+ credentialFormatType =
+ type.equals("text") ? CredentialFormatType.TEXT : CredentialFormatType.JSON;
+
+ if (!formatMap.containsKey("subject_token_field_name")) {
+ throw new IllegalArgumentException(
+ "When specifying a JSON credential type, the subject_token_field_name must be set.");
+ }
+ subjectTokenFieldName = formatMap.get("subject_token_field_name");
+ }
+ }
+
+ private boolean hasHeaders() {
+ return headers != null && !headers.isEmpty();
+ }
+ }
+
+ private final IdentityPoolCredentialSource identityPoolCredentialSource;
+
+ /**
+ * Internal constructor. See {@link
+ * ExternalAccountCredentials#ExternalAccountCredentials(HttpTransportFactory, String, String,
+ * String, CredentialSource, String, String, String, String, String, Collection)}
+ */
+ IdentityPoolCredentials(
+ HttpTransportFactory transportFactory,
+ String audience,
+ String subjectTokenType,
+ String tokenUrl,
+ IdentityPoolCredentialSource credentialSource,
+ @Nullable String tokenInfoUrl,
+ @Nullable String serviceAccountImpersonationUrl,
+ @Nullable String quotaProjectId,
+ @Nullable String clientId,
+ @Nullable String clientSecret,
+ @Nullable Collection scopes) {
+ super(
+ transportFactory,
+ audience,
+ subjectTokenType,
+ tokenUrl,
+ credentialSource,
+ tokenInfoUrl,
+ serviceAccountImpersonationUrl,
+ quotaProjectId,
+ clientId,
+ clientSecret,
+ scopes);
+ this.identityPoolCredentialSource = credentialSource;
+ }
+
+ @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 {
+ if (identityPoolCredentialSource.credentialSourceType
+ == IdentityPoolCredentialSource.IdentityPoolCredentialSourceType.FILE) {
+ return retrieveSubjectTokenFromCredentialFile();
+ }
+ return getSubjectTokenFromMetadataServer();
+ }
+
+ private String retrieveSubjectTokenFromCredentialFile() throws IOException {
+ String credentialFilePath = identityPoolCredentialSource.credentialLocation;
+ if (!Files.exists(Paths.get(credentialFilePath), LinkOption.NOFOLLOW_LINKS)) {
+ throw new IOException(
+ String.format(
+ "Invalid credential location. The file at %s does not exist.", credentialFilePath));
+ }
+ try {
+ return parseToken(new FileInputStream(new File(credentialFilePath)));
+ } catch (IOException e) {
+ throw new IOException(
+ "Error when attempting to read the subject token from the credential file.", e);
+ }
+ }
+
+ private String parseToken(InputStream inputStream) throws IOException {
+ if (identityPoolCredentialSource.credentialFormatType == CredentialFormatType.TEXT) {
+ BufferedReader reader =
+ new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
+ return CharStreams.toString(reader);
+ }
+
+ JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY);
+ GenericJson fileContents =
+ parser.parseAndClose(inputStream, StandardCharsets.UTF_8, GenericJson.class);
+
+ if (!fileContents.containsKey(identityPoolCredentialSource.subjectTokenFieldName)) {
+ throw new IOException("Invalid subject token field name. No subject token was found.");
+ }
+ return (String) fileContents.get(identityPoolCredentialSource.subjectTokenFieldName);
+ }
+
+ private String getSubjectTokenFromMetadataServer() throws IOException {
+ HttpRequest request =
+ transportFactory
+ .create()
+ .createRequestFactory()
+ .buildGetRequest(new GenericUrl(identityPoolCredentialSource.credentialLocation));
+ request.setParser(new JsonObjectParser(OAuth2Utils.JSON_FACTORY));
+
+ if (identityPoolCredentialSource.hasHeaders()) {
+ HttpHeaders headers = new HttpHeaders();
+ headers.putAll(identityPoolCredentialSource.headers);
+ request.setHeaders(headers);
+ }
+
+ try {
+ HttpResponse response = request.execute();
+ return parseToken(response.getContent());
+ } catch (IOException e) {
+ throw new IOException(
+ String.format("Error getting subject token from metadata server: %s", e.getMessage()), e);
+ }
+ }
+
+ /** Clones the IdentityPoolCredentials with the specified scopes. */
+ @Override
+ public IdentityPoolCredentials createScoped(Collection newScopes) {
+ return new IdentityPoolCredentials(
+ transportFactory,
+ getAudience(),
+ getSubjectTokenType(),
+ getTokenUrl(),
+ identityPoolCredentialSource,
+ getTokenInfoUrl(),
+ getServiceAccountImpersonationUrl(),
+ getQuotaProjectId(),
+ getClientId(),
+ getClientSecret(),
+ newScopes);
+ }
+
+ public static Builder newBuilder() {
+ return new Builder();
+ }
+
+ public static Builder newBuilder(IdentityPoolCredentials identityPoolCredentials) {
+ return new Builder(identityPoolCredentials);
+ }
+
+ public static class Builder extends ExternalAccountCredentials.Builder {
+
+ Builder() {}
+
+ Builder(IdentityPoolCredentials credentials) {
+ super(credentials);
+ }
+
+ @Override
+ public IdentityPoolCredentials build() {
+ return new IdentityPoolCredentials(
+ transportFactory,
+ audience,
+ subjectTokenType,
+ tokenUrl,
+ (IdentityPoolCredentialSource) credentialSource,
+ tokenInfoUrl,
+ serviceAccountImpersonationUrl,
+ quotaProjectId,
+ clientId,
+ clientSecret,
+ scopes);
+ }
+ }
+}
diff --git a/oauth2_http/java/com/google/auth/oauth2/OAuthException.java b/oauth2_http/java/com/google/auth/oauth2/OAuthException.java
new file mode 100644
index 000000000..b3f612a04
--- /dev/null
+++ b/oauth2_http/java/com/google/auth/oauth2/OAuthException.java
@@ -0,0 +1,81 @@
+/*
+ * Copyright 2021 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;
+
+import java.io.IOException;
+import javax.annotation.Nullable;
+
+/**
+ * Encapsulates the standard OAuth error response. See
+ * https://tools.ietf.org/html/rfc6749#section-5.2.
+ */
+class OAuthException extends IOException {
+
+ private final String errorCode;
+ @Nullable private final String errorDescription;
+ @Nullable private final String errorUri;
+
+ OAuthException(String errorCode, @Nullable String errorDescription, @Nullable String errorUri) {
+ this.errorCode = checkNotNull(errorCode);
+ this.errorDescription = errorDescription;
+ this.errorUri = errorUri;
+ }
+
+ @Override
+ public String getMessage() {
+ // Fully specified message will have the format Error code %s: %s - %s.
+ StringBuilder sb = new StringBuilder("Error code " + errorCode);
+ if (errorDescription != null) {
+ sb.append(": ").append(errorDescription);
+ }
+ if (errorUri != null) {
+ sb.append(" - ").append(errorUri);
+ }
+ return sb.toString();
+ }
+
+ String getErrorCode() {
+ return errorCode;
+ }
+
+ @Nullable
+ String getErrorDescription() {
+ return errorDescription;
+ }
+
+ @Nullable
+ String getErrorUri() {
+ return errorUri;
+ }
+}
diff --git a/oauth2_http/java/com/google/auth/oauth2/StsRequestHandler.java b/oauth2_http/java/com/google/auth/oauth2/StsRequestHandler.java
new file mode 100644
index 000000000..a6a14fcbf
--- /dev/null
+++ b/oauth2_http/java/com/google/auth/oauth2/StsRequestHandler.java
@@ -0,0 +1,226 @@
+/*
+ * Copyright 2021 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.http.GenericUrl;
+import com.google.api.client.http.HttpHeaders;
+import com.google.api.client.http.HttpRequest;
+import com.google.api.client.http.HttpRequestFactory;
+import com.google.api.client.http.HttpResponse;
+import com.google.api.client.http.HttpResponseException;
+import com.google.api.client.http.UrlEncodedContent;
+import com.google.api.client.json.GenericJson;
+import com.google.api.client.json.JsonObjectParser;
+import com.google.api.client.json.JsonParser;
+import com.google.api.client.util.GenericData;
+import com.google.common.base.Joiner;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import javax.annotation.Nullable;
+
+/** Implements the OAuth 2.0 token exchange based on https://tools.ietf.org/html/rfc8693. */
+final class StsRequestHandler {
+ private static final String TOKEN_EXCHANGE_GRANT_TYPE =
+ "urn:ietf:params:oauth:grant-type:token-exchange";
+ private static final String REQUESTED_TOKEN_TYPE =
+ "urn:ietf:params:oauth:token-type:access_token";
+ private static final String PARSE_ERROR_PREFIX = "Error parsing token response.";
+
+ private final String tokenExchangeEndpoint;
+ private final StsTokenExchangeRequest request;
+ private final HttpRequestFactory httpRequestFactory;
+
+ @Nullable private final HttpHeaders headers;
+ @Nullable private final String internalOptions;
+
+ /**
+ * Internal constructor.
+ *
+ * @param tokenExchangeEndpoint the token exchange endpoint
+ * @param request the token exchange request
+ * @param headers optional additional headers to pass along the request
+ * @param internalOptions optional GCP specific STS options
+ * @return an StsTokenExchangeResponse instance if the request was successful
+ */
+ private StsRequestHandler(
+ String tokenExchangeEndpoint,
+ StsTokenExchangeRequest request,
+ HttpRequestFactory httpRequestFactory,
+ @Nullable HttpHeaders headers,
+ @Nullable String internalOptions) {
+ this.tokenExchangeEndpoint = tokenExchangeEndpoint;
+ this.request = request;
+ this.httpRequestFactory = httpRequestFactory;
+ this.headers = headers;
+ this.internalOptions = internalOptions;
+ }
+
+ public static Builder newBuilder(
+ String tokenExchangeEndpoint,
+ StsTokenExchangeRequest stsTokenExchangeRequest,
+ HttpRequestFactory httpRequestFactory) {
+ return new Builder(tokenExchangeEndpoint, stsTokenExchangeRequest, httpRequestFactory);
+ }
+
+ /** Exchanges the provided token for another type of token based on the RFC 8693 spec. */
+ public StsTokenExchangeResponse exchangeToken() throws IOException {
+ UrlEncodedContent content = new UrlEncodedContent(buildTokenRequest());
+
+ HttpRequest httpRequest =
+ httpRequestFactory.buildPostRequest(new GenericUrl(tokenExchangeEndpoint), content);
+ httpRequest.setParser(new JsonObjectParser(OAuth2Utils.JSON_FACTORY));
+ if (headers != null) {
+ httpRequest.setHeaders(headers);
+ }
+
+ try {
+ HttpResponse response = httpRequest.execute();
+ GenericData responseData = response.parseAs(GenericData.class);
+ return buildResponse(responseData);
+ } catch (HttpResponseException e) {
+ GenericJson errorResponse = parseJson((e).getContent());
+ String errorCode = (String) errorResponse.get("error");
+ String errorDescription = null;
+ String errorUri = null;
+ if (errorResponse.containsKey("error_description")) {
+ errorDescription = (String) errorResponse.get("error_description");
+ }
+ if (errorResponse.containsKey("error_uri")) {
+ errorUri = (String) errorResponse.get("error_uri");
+ }
+ throw new OAuthException(errorCode, errorDescription, errorUri);
+ }
+ }
+
+ private GenericData buildTokenRequest() {
+ GenericData tokenRequest =
+ new GenericData()
+ .set("grant_type", TOKEN_EXCHANGE_GRANT_TYPE)
+ .set("subject_token_type", request.getSubjectTokenType())
+ .set("subject_token", request.getSubjectToken());
+
+ // Add scopes as a space-delimited string.
+ List scopes = new ArrayList<>();
+ if (request.hasScopes()) {
+ scopes.addAll(request.getScopes());
+ tokenRequest.set("scope", Joiner.on(' ').join(scopes));
+ }
+
+ // Set the requested token type, which defaults to
+ // urn:ietf:params:oauth:token-type:access_token.
+ String requestTokenType =
+ request.hasRequestedTokenType() ? request.getRequestedTokenType() : REQUESTED_TOKEN_TYPE;
+ tokenRequest.set("requested_token_type", requestTokenType);
+
+ // Add other optional params, if possible.
+ if (request.hasResource()) {
+ tokenRequest.set("resource", request.getResource());
+ }
+ if (request.hasAudience()) {
+ tokenRequest.set("audience", request.getAudience());
+ }
+
+ if (request.hasActingParty()) {
+ tokenRequest.set("actor_token", request.getActingParty().getActorToken());
+ tokenRequest.set("actor_token_type", request.getActingParty().getActorTokenType());
+ }
+
+ if (internalOptions != null && !internalOptions.isEmpty()) {
+ tokenRequest.set("options", internalOptions);
+ }
+ return tokenRequest;
+ }
+
+ private StsTokenExchangeResponse buildResponse(GenericData responseData) throws IOException {
+ String accessToken =
+ OAuth2Utils.validateString(responseData, "access_token", PARSE_ERROR_PREFIX);
+ String issuedTokenType =
+ OAuth2Utils.validateString(responseData, "issued_token_type", PARSE_ERROR_PREFIX);
+ String tokenType = OAuth2Utils.validateString(responseData, "token_type", PARSE_ERROR_PREFIX);
+ Long expiresInSeconds =
+ OAuth2Utils.validateLong(responseData, "expires_in", PARSE_ERROR_PREFIX);
+
+ StsTokenExchangeResponse.Builder builder =
+ StsTokenExchangeResponse.newBuilder(
+ accessToken, issuedTokenType, tokenType, expiresInSeconds);
+
+ if (responseData.containsKey("refresh_token")) {
+ builder.setRefreshToken(
+ OAuth2Utils.validateString(responseData, "refresh_token", PARSE_ERROR_PREFIX));
+ }
+ if (responseData.containsKey("scope")) {
+ String scope = OAuth2Utils.validateString(responseData, "scope", PARSE_ERROR_PREFIX);
+ builder.setScopes(Arrays.asList(scope.trim().split("\\s+")));
+ }
+ return builder.build();
+ }
+
+ private GenericJson parseJson(String json) throws IOException {
+ JsonParser parser = OAuth2Utils.JSON_FACTORY.createJsonParser(json);
+ return parser.parseAndClose(GenericJson.class);
+ }
+
+ public static class Builder {
+ private final String tokenExchangeEndpoint;
+ private final StsTokenExchangeRequest request;
+ private final HttpRequestFactory httpRequestFactory;
+
+ @Nullable private HttpHeaders headers;
+ @Nullable private String internalOptions;
+
+ private Builder(
+ String tokenExchangeEndpoint,
+ StsTokenExchangeRequest stsTokenExchangeRequest,
+ HttpRequestFactory httpRequestFactory) {
+ this.tokenExchangeEndpoint = tokenExchangeEndpoint;
+ this.request = stsTokenExchangeRequest;
+ this.httpRequestFactory = httpRequestFactory;
+ }
+
+ public StsRequestHandler.Builder setHeaders(HttpHeaders headers) {
+ this.headers = headers;
+ return this;
+ }
+
+ public StsRequestHandler.Builder setInternalOptions(String internalOptions) {
+ this.internalOptions = internalOptions;
+ return this;
+ }
+
+ public StsRequestHandler build() {
+ return new StsRequestHandler(
+ tokenExchangeEndpoint, request, httpRequestFactory, headers, internalOptions);
+ }
+ }
+}
diff --git a/oauth2_http/java/com/google/auth/oauth2/StsTokenExchangeRequest.java b/oauth2_http/java/com/google/auth/oauth2/StsTokenExchangeRequest.java
new file mode 100644
index 000000000..b9525bd68
--- /dev/null
+++ b/oauth2_http/java/com/google/auth/oauth2/StsTokenExchangeRequest.java
@@ -0,0 +1,184 @@
+/*
+ * Copyright 2021 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;
+
+import java.util.List;
+import javax.annotation.Nullable;
+
+/**
+ * Defines an OAuth 2.0 token exchange request. Based on
+ * https://tools.ietf.org/html/rfc8693#section-2.1.
+ */
+final class StsTokenExchangeRequest {
+ private static final String GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange";
+
+ private final String subjectToken;
+ private final String subjectTokenType;
+
+ @Nullable private final ActingParty actingParty;
+ @Nullable private final List scopes;
+ @Nullable private final String resource;
+ @Nullable private final String audience;
+ @Nullable private final String requestedTokenType;
+
+ private StsTokenExchangeRequest(
+ String subjectToken,
+ String subjectTokenType,
+ @Nullable ActingParty actingParty,
+ @Nullable List scopes,
+ @Nullable String resource,
+ @Nullable String audience,
+ @Nullable String requestedTokenType) {
+ this.subjectToken = checkNotNull(subjectToken);
+ this.subjectTokenType = checkNotNull(subjectTokenType);
+ this.actingParty = actingParty;
+ this.scopes = scopes;
+ this.resource = resource;
+ this.audience = audience;
+ this.requestedTokenType = requestedTokenType;
+ }
+
+ public static Builder newBuilder(String subjectToken, String subjectTokenType) {
+ return new Builder(subjectToken, subjectTokenType);
+ }
+
+ public String getGrantType() {
+ return GRANT_TYPE;
+ }
+
+ public String getSubjectToken() {
+ return subjectToken;
+ }
+
+ public String getSubjectTokenType() {
+ return subjectTokenType;
+ }
+
+ @Nullable
+ public String getResource() {
+ return resource;
+ }
+
+ @Nullable
+ public String getAudience() {
+ return audience;
+ }
+
+ @Nullable
+ public String getRequestedTokenType() {
+ return requestedTokenType;
+ }
+
+ @Nullable
+ public List getScopes() {
+ return scopes;
+ }
+
+ @Nullable
+ public ActingParty getActingParty() {
+ return actingParty;
+ }
+
+ public boolean hasResource() {
+ return resource != null && !resource.isEmpty();
+ }
+
+ public boolean hasAudience() {
+ return audience != null && !audience.isEmpty();
+ }
+
+ public boolean hasRequestedTokenType() {
+ return requestedTokenType != null && !requestedTokenType.isEmpty();
+ }
+
+ public boolean hasScopes() {
+ return scopes != null && !scopes.isEmpty();
+ }
+
+ public boolean hasActingParty() {
+ return actingParty != null;
+ }
+
+ public static class Builder {
+ private final String subjectToken;
+ private final String subjectTokenType;
+
+ @Nullable private String resource;
+ @Nullable private String audience;
+ @Nullable private String requestedTokenType;
+ @Nullable private List scopes;
+ @Nullable private ActingParty actingParty;
+
+ private Builder(String subjectToken, String subjectTokenType) {
+ this.subjectToken = subjectToken;
+ this.subjectTokenType = subjectTokenType;
+ }
+
+ public StsTokenExchangeRequest.Builder setResource(String resource) {
+ this.resource = resource;
+ return this;
+ }
+
+ public StsTokenExchangeRequest.Builder setAudience(String audience) {
+ this.audience = audience;
+ return this;
+ }
+
+ public StsTokenExchangeRequest.Builder setRequestTokenType(String requestedTokenType) {
+ this.requestedTokenType = requestedTokenType;
+ return this;
+ }
+
+ public StsTokenExchangeRequest.Builder setScopes(List scopes) {
+ this.scopes = scopes;
+ return this;
+ }
+
+ public StsTokenExchangeRequest.Builder setActingParty(ActingParty actingParty) {
+ this.actingParty = actingParty;
+ return this;
+ }
+
+ public StsTokenExchangeRequest build() {
+ return new StsTokenExchangeRequest(
+ subjectToken,
+ subjectTokenType,
+ actingParty,
+ scopes,
+ resource,
+ audience,
+ requestedTokenType);
+ }
+ }
+}
diff --git a/oauth2_http/java/com/google/auth/oauth2/StsTokenExchangeResponse.java b/oauth2_http/java/com/google/auth/oauth2/StsTokenExchangeResponse.java
new file mode 100644
index 000000000..a16f5a329
--- /dev/null
+++ b/oauth2_http/java/com/google/auth/oauth2/StsTokenExchangeResponse.java
@@ -0,0 +1,139 @@
+/*
+ * Copyright 2021 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;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import javax.annotation.Nullable;
+
+/**
+ * Defines an OAuth 2.0 token exchange successful response. Based on
+ * https://tools.ietf.org/html/rfc8693#section-2.2.1.
+ */
+final class StsTokenExchangeResponse {
+ private final AccessToken accessToken;
+ private final String issuedTokenType;
+ private final String tokenType;
+ private final Long expiresInSeconds;
+
+ @Nullable private final String refreshToken;
+ @Nullable private final List scopes;
+
+ private StsTokenExchangeResponse(
+ String accessToken,
+ String issuedTokenType,
+ String tokenType,
+ Long expiresInSeconds,
+ @Nullable String refreshToken,
+ @Nullable List scopes) {
+ checkNotNull(accessToken);
+ this.expiresInSeconds = checkNotNull(expiresInSeconds);
+ long expiresAtMilliseconds = System.currentTimeMillis() + expiresInSeconds * 1000L;
+ this.accessToken = new AccessToken(accessToken, new Date(expiresAtMilliseconds));
+ this.issuedTokenType = checkNotNull(issuedTokenType);
+ this.tokenType = checkNotNull(tokenType);
+ this.refreshToken = refreshToken;
+ this.scopes = scopes;
+ }
+
+ public static Builder newBuilder(
+ String accessToken, String issuedTokenType, String tokenType, Long expiresIn) {
+ return new Builder(accessToken, issuedTokenType, tokenType, expiresIn);
+ }
+
+ public AccessToken getAccessToken() {
+ return accessToken;
+ }
+
+ public String getIssuedTokenType() {
+ return issuedTokenType;
+ }
+
+ public String getTokenType() {
+ return tokenType;
+ }
+
+ public Long getExpiresInSeconds() {
+ return expiresInSeconds;
+ }
+
+ @Nullable
+ public String getRefreshToken() {
+ return refreshToken;
+ }
+
+ @Nullable
+ public List getScopes() {
+ if (scopes == null) {
+ return null;
+ }
+ return new ArrayList<>(scopes);
+ }
+
+ public static class Builder {
+ private final String accessToken;
+ private final String issuedTokenType;
+ private final String tokenType;
+ private final Long expiresInSeconds;
+
+ @Nullable private String refreshToken;
+ @Nullable private List scopes;
+
+ private Builder(
+ String accessToken, String issuedTokenType, String tokenType, Long expiresInSeconds) {
+ this.accessToken = accessToken;
+ this.issuedTokenType = issuedTokenType;
+ this.tokenType = tokenType;
+ this.expiresInSeconds = expiresInSeconds;
+ }
+
+ public StsTokenExchangeResponse.Builder setRefreshToken(String refreshToken) {
+ this.refreshToken = refreshToken;
+ return this;
+ }
+
+ public StsTokenExchangeResponse.Builder setScopes(List scopes) {
+ if (scopes != null) {
+ this.scopes = new ArrayList<>(scopes);
+ }
+ return this;
+ }
+
+ public StsTokenExchangeResponse build() {
+ return new StsTokenExchangeResponse(
+ accessToken, issuedTokenType, tokenType, expiresInSeconds, refreshToken, scopes);
+ }
+ }
+}
diff --git a/oauth2_http/javatests/com/google/auth/TestUtils.java b/oauth2_http/javatests/com/google/auth/TestUtils.java
index 5f43dbba4..b9c2b6d75 100644
--- a/oauth2_http/javatests/com/google/auth/TestUtils.java
+++ b/oauth2_http/javatests/com/google/auth/TestUtils.java
@@ -34,6 +34,8 @@
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
+import com.google.api.client.http.HttpHeaders;
+import com.google.api.client.http.HttpResponseException;
import com.google.api.client.json.GenericJson;
import com.google.api.client.json.JsonFactory;
import com.google.api.client.json.gson.GsonFactory;
@@ -45,15 +47,17 @@
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
+import java.text.SimpleDateFormat;
+import java.util.Calendar;
+import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import javax.annotation.Nullable;
/** Utilities for test code under com.google.auth. */
public class TestUtils {
- public static final String UTF_8 = "UTF-8";
-
private static final JsonFactory JSON_FACTORY = GsonFactory.getDefaultInstance();
public static void assertContainsBearerToken(Map> metadata, String token) {
@@ -84,12 +88,12 @@ private static boolean hasBearerToken(Map> metadata, String
public static InputStream jsonToInputStream(GenericJson json) throws IOException {
json.setFactory(JSON_FACTORY);
String text = json.toPrettyString();
- return new ByteArrayInputStream(text.getBytes(UTF_8));
+ return new ByteArrayInputStream(text.getBytes("UTF-8"));
}
public static InputStream stringToInputStream(String text) {
try {
- return new ByteArrayInputStream(text.getBytes(TestUtils.UTF_8));
+ return new ByteArrayInputStream(text.getBytes("UTF-8"));
} catch (UnsupportedEncodingException e) {
throw new RuntimeException("Unexpected encoding exception", e);
}
@@ -103,8 +107,8 @@ public static Map parseQuery(String query) throws IOException {
if (sides.size() != 2) {
throw new IOException("Invalid Query String");
}
- String key = URLDecoder.decode(sides.get(0), UTF_8);
- String value = URLDecoder.decode(sides.get(1), UTF_8);
+ String key = URLDecoder.decode(sides.get(0), "UTF-8");
+ String value = URLDecoder.decode(sides.get(1), "UTF-8");
map.put(key, value);
}
return map;
@@ -119,5 +123,30 @@ public static String errorJson(String message) throws IOException {
return errorResponse.toPrettyString();
}
+ public static HttpResponseException buildHttpResponseException(
+ String error, @Nullable String errorDescription, @Nullable String errorUri)
+ throws IOException {
+ GenericJson json = new GenericJson();
+ json.setFactory(GsonFactory.getDefaultInstance());
+ json.set("error", error);
+ if (errorDescription != null) {
+ json.set("error_description", errorDescription);
+ }
+ if (errorUri != null) {
+ json.set("error_uri", errorUri);
+ }
+ return new HttpResponseException.Builder(
+ /* statusCode= */ 400, /* statusMessage= */ "statusMessage", new HttpHeaders())
+ .setContent(json.toPrettyString())
+ .build();
+ }
+
+ public static String getDefaultExpireTime() {
+ Calendar calendar = Calendar.getInstance();
+ calendar.setTime(new Date());
+ calendar.add(Calendar.SECOND, 300);
+ return new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'").format(calendar.getTime());
+ }
+
private TestUtils() {}
}
diff --git a/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java
new file mode 100644
index 000000000..dc86a516f
--- /dev/null
+++ b/oauth2_http/javatests/com/google/auth/oauth2/AwsCredentialsTest.java
@@ -0,0 +1,539 @@
+/*
+ * Copyright 2021 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.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.api.client.json.GenericJson;
+import com.google.api.client.json.JsonParser;
+import com.google.auth.TestUtils;
+import com.google.auth.http.HttpTransportFactory;
+import com.google.auth.oauth2.AwsCredentials.AwsCredentialSource;
+import com.google.auth.oauth2.ExternalAccountCredentialsTest.MockExternalAccountCredentialsTransportFactory;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URLDecoder;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import javax.annotation.Nullable;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Tests for {@link AwsCredentials}. */
+@RunWith(JUnit4.class)
+public class AwsCredentialsTest {
+
+ private static final String GET_CALLER_IDENTITY_URL =
+ "https://sts.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15";
+
+ private static final String SERVICE_ACCOUNT_IMPERSONATION_URL =
+ "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/testn@test.iam.gserviceaccount.com:generateAccessToken";
+
+ private static final Map AWS_CREDENTIAL_SOURCE_MAP =
+ new HashMap() {
+ {
+ put("environment_id", "aws1");
+ put("region_url", "regionUrl");
+ put("url", "url");
+ put("regional_cred_verification_url", "regionalCredVerificationUrl");
+ }
+ };
+
+ private static final AwsCredentialSource AWS_CREDENTIAL_SOURCE =
+ new AwsCredentialSource(AWS_CREDENTIAL_SOURCE_MAP);
+
+ private static final AwsCredentials AWS_CREDENTIAL =
+ (AwsCredentials)
+ AwsCredentials.newBuilder()
+ .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY)
+ .setAudience("audience")
+ .setSubjectTokenType("subjectTokenType")
+ .setTokenUrl("tokenUrl")
+ .setTokenInfoUrl("tokenInfoUrl")
+ .setCredentialSource(AWS_CREDENTIAL_SOURCE)
+ .build();
+
+ @Test
+ public void refreshAccessToken_withoutServiceAccountImpersonation() throws IOException {
+ MockExternalAccountCredentialsTransportFactory transportFactory =
+ new MockExternalAccountCredentialsTransportFactory();
+
+ AwsCredentials awsCredential =
+ (AwsCredentials)
+ AwsCredentials.newBuilder(AWS_CREDENTIAL)
+ .setTokenUrl(transportFactory.transport.getStsUrl())
+ .setHttpTransportFactory(transportFactory)
+ .setCredentialSource(buildAwsCredentialSource(transportFactory))
+ .build();
+
+ AccessToken accessToken = awsCredential.refreshAccessToken();
+
+ assertEquals(transportFactory.transport.getAccessToken(), accessToken.getTokenValue());
+ }
+
+ @Test
+ public void refreshAccessToken_withServiceAccountImpersonation() throws IOException {
+ MockExternalAccountCredentialsTransportFactory transportFactory =
+ new MockExternalAccountCredentialsTransportFactory();
+
+ transportFactory.transport.setExpireTime(TestUtils.getDefaultExpireTime());
+
+ AwsCredentials awsCredential =
+ (AwsCredentials)
+ AwsCredentials.newBuilder(AWS_CREDENTIAL)
+ .setTokenUrl(transportFactory.transport.getStsUrl())
+ .setServiceAccountImpersonationUrl(
+ transportFactory.transport.getServiceAccountImpersonationUrl())
+ .setHttpTransportFactory(transportFactory)
+ .setCredentialSource(buildAwsCredentialSource(transportFactory))
+ .build();
+
+ AccessToken accessToken = awsCredential.refreshAccessToken();
+
+ assertEquals(
+ transportFactory.transport.getServiceAccountAccessToken(), accessToken.getTokenValue());
+ }
+
+ @Test
+ public void retrieveSubjectToken() throws IOException {
+ MockExternalAccountCredentialsTransportFactory transportFactory =
+ new MockExternalAccountCredentialsTransportFactory();
+
+ AwsCredentials awsCredential =
+ (AwsCredentials)
+ AwsCredentials.newBuilder(AWS_CREDENTIAL)
+ .setHttpTransportFactory(transportFactory)
+ .setCredentialSource(buildAwsCredentialSource(transportFactory))
+ .build();
+
+ String subjectToken = URLDecoder.decode(awsCredential.retrieveSubjectToken(), "UTF-8");
+
+ JsonParser parser = OAuth2Utils.JSON_FACTORY.createJsonParser(subjectToken);
+ GenericJson json = parser.parseAndClose(GenericJson.class);
+
+ List