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> headersList = (List>) json.get("headers"); + Map headers = new HashMap<>(); + for (Map header : headersList) { + headers.put(header.get("key"), header.get("value")); + } + + assertEquals("POST", json.get("method")); + assertEquals(GET_CALLER_IDENTITY_URL, json.get("url")); + assertEquals(URI.create(GET_CALLER_IDENTITY_URL).getHost(), headers.get("host")); + assertEquals("token", headers.get("x-amz-security-token")); + assertEquals(awsCredential.getAudience(), headers.get("x-goog-cloud-target-resource")); + assertTrue(headers.containsKey("x-amz-date")); + assertNotNull(headers.get("Authorization")); + } + + @Test + public void retrieveSubjectToken_noRegion_expectThrows() { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + IOException response = new IOException(); + transportFactory.transport.addResponseErrorSequence(response); + + AwsCredentials awsCredential = + (AwsCredentials) + AwsCredentials.newBuilder(AWS_CREDENTIAL) + .setHttpTransportFactory(transportFactory) + .setCredentialSource(buildAwsCredentialSource(transportFactory)) + .build(); + + try { + awsCredential.retrieveSubjectToken(); + fail("Exception should be thrown."); + } catch (IOException e) { + assertEquals("Failed to retrieve AWS region.", e.getMessage()); + } + } + + @Test + public void retrieveSubjectToken_noRole_expectThrows() { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + IOException response = new IOException(); + transportFactory.transport.addResponseErrorSequence(response); + transportFactory.transport.addResponseSequence(true, false); + + AwsCredentials awsCredential = + (AwsCredentials) + AwsCredentials.newBuilder(AWS_CREDENTIAL) + .setHttpTransportFactory(transportFactory) + .setCredentialSource(buildAwsCredentialSource(transportFactory)) + .build(); + + try { + awsCredential.retrieveSubjectToken(); + fail("Exception should be thrown."); + } catch (IOException e) { + assertEquals("Failed to retrieve AWS IAM role.", e.getMessage()); + } + } + + @Test + public void retrieveSubjectToken_noCredentials_expectThrows() { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + IOException response = new IOException(); + transportFactory.transport.addResponseErrorSequence(response); + transportFactory.transport.addResponseSequence(true, true, false); + + AwsCredentials awsCredential = + (AwsCredentials) + AwsCredentials.newBuilder(AWS_CREDENTIAL) + .setHttpTransportFactory(transportFactory) + .setCredentialSource(buildAwsCredentialSource(transportFactory)) + .build(); + + try { + awsCredential.retrieveSubjectToken(); + fail("Exception should be thrown."); + } catch (IOException e) { + assertEquals("Failed to retrieve AWS credentials.", e.getMessage()); + } + } + + @Test + public void retrieveSubjectToken_noRegionUrlProvided() { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + Map credentialSource = new HashMap<>(); + credentialSource.put("environment_id", "aws1"); + credentialSource.put("regional_cred_verification_url", GET_CALLER_IDENTITY_URL); + + AwsCredentials awsCredential = + (AwsCredentials) + AwsCredentials.newBuilder(AWS_CREDENTIAL) + .setHttpTransportFactory(transportFactory) + .setCredentialSource(new AwsCredentialSource(credentialSource)) + .build(); + + try { + awsCredential.retrieveSubjectToken(); + fail("Exception should be thrown."); + } catch (IOException e) { + assertEquals( + "Unable to determine the AWS region. The credential source does not " + + "contain the region URL.", + e.getMessage()); + } + } + + @Test + public void getAwsSecurityCredentials_fromEnvironmentVariablesNoToken() throws IOException { + TestAwsCredentials testAwsCredentials = TestAwsCredentials.newBuilder(AWS_CREDENTIAL).build(); + testAwsCredentials.setEnv("AWS_ACCESS_KEY_ID", "awsAccessKeyId"); + testAwsCredentials.setEnv("AWS_SECRET_ACCESS_KEY", "awsSecretAccessKey"); + + AwsSecurityCredentials credentials = testAwsCredentials.getAwsSecurityCredentials(); + + assertEquals("awsAccessKeyId", credentials.getAccessKeyId()); + assertEquals("awsSecretAccessKey", credentials.getSecretAccessKey()); + assertNull(credentials.getToken()); + } + + @Test + public void getAwsSecurityCredentials_fromEnvironmentVariablesWithToken() throws IOException { + TestAwsCredentials testAwsCredentials = TestAwsCredentials.newBuilder(AWS_CREDENTIAL).build(); + testAwsCredentials.setEnv("AWS_ACCESS_KEY_ID", "awsAccessKeyId"); + testAwsCredentials.setEnv("AWS_SECRET_ACCESS_KEY", "awsSecretAccessKey"); + testAwsCredentials.setEnv("Token", "token"); + + AwsSecurityCredentials credentials = testAwsCredentials.getAwsSecurityCredentials(); + + assertEquals("awsAccessKeyId", credentials.getAccessKeyId()); + assertEquals("awsSecretAccessKey", credentials.getSecretAccessKey()); + assertEquals("token", credentials.getToken()); + } + + @Test + public void getAwsSecurityCredentials_fromMetadataServer() throws IOException { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + AwsCredentials awsCredential = + (AwsCredentials) + AwsCredentials.newBuilder(AWS_CREDENTIAL) + .setHttpTransportFactory(transportFactory) + .setCredentialSource(buildAwsCredentialSource(transportFactory)) + .build(); + + AwsSecurityCredentials credentials = awsCredential.getAwsSecurityCredentials(); + + assertEquals("accessKeyId", credentials.getAccessKeyId()); + assertEquals("secretAccessKey", credentials.getSecretAccessKey()); + assertEquals("token", credentials.getToken()); + } + + @Test + public void getAwsSecurityCredentials_fromMetadataServer_noUrlProvided() { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + Map credentialSource = new HashMap<>(); + credentialSource.put("environment_id", "aws1"); + credentialSource.put("regional_cred_verification_url", GET_CALLER_IDENTITY_URL); + + AwsCredentials awsCredential = + (AwsCredentials) + AwsCredentials.newBuilder(AWS_CREDENTIAL) + .setHttpTransportFactory(transportFactory) + .setCredentialSource(new AwsCredentialSource(credentialSource)) + .build(); + + try { + awsCredential.getAwsSecurityCredentials(); + fail("Exception should be thrown."); + } catch (IOException e) { + assertEquals( + "Unable to determine the AWS IAM role name. The credential source does not contain the url field.", + e.getMessage()); + } + } + + @Test + public void createdScoped_clonedCredentialWithAddedScopes() { + AwsCredentials credentials = + (AwsCredentials) + AwsCredentials.newBuilder(AWS_CREDENTIAL) + .setServiceAccountImpersonationUrl(SERVICE_ACCOUNT_IMPERSONATION_URL) + .setQuotaProjectId("quotaProjectId") + .setClientId("clientId") + .setClientSecret("clientSecret") + .build(); + + List newScopes = Arrays.asList("scope1", "scope2"); + + AwsCredentials newCredentials = (AwsCredentials) credentials.createScoped(newScopes); + + assertEquals(credentials.getAudience(), newCredentials.getAudience()); + assertEquals(credentials.getSubjectTokenType(), newCredentials.getSubjectTokenType()); + assertEquals(credentials.getTokenUrl(), newCredentials.getTokenUrl()); + assertEquals(credentials.getTokenInfoUrl(), newCredentials.getTokenInfoUrl()); + assertEquals( + credentials.getServiceAccountImpersonationUrl(), + newCredentials.getServiceAccountImpersonationUrl()); + assertEquals(credentials.getCredentialSource(), newCredentials.getCredentialSource()); + assertEquals(credentials.getQuotaProjectId(), newCredentials.getQuotaProjectId()); + assertEquals(credentials.getClientId(), newCredentials.getClientId()); + assertEquals(credentials.getClientSecret(), newCredentials.getClientSecret()); + assertEquals(newScopes, newCredentials.getScopes()); + } + + @Test + public void credentialSource_invalidAwsEnvironmentId() { + Map credentialSource = new HashMap<>(); + credentialSource.put("regional_cred_verification_url", GET_CALLER_IDENTITY_URL); + credentialSource.put("environment_id", "azure1"); + + try { + new AwsCredentialSource(credentialSource); + fail("Exception should be thrown."); + } catch (IllegalArgumentException e) { + assertEquals("Invalid AWS environment ID.", e.getMessage()); + } + } + + @Test + public void credentialSource_invalidAwsEnvironmentVersion() { + Map credentialSource = new HashMap<>(); + int environmentVersion = 2; + credentialSource.put("regional_cred_verification_url", GET_CALLER_IDENTITY_URL); + credentialSource.put("environment_id", "aws" + environmentVersion); + + try { + new AwsCredentialSource(credentialSource); + fail("Exception should be thrown."); + } catch (IllegalArgumentException e) { + assertEquals( + String.format( + "AWS version %s is not supported in the current build.", environmentVersion), + e.getMessage()); + } + } + + @Test + public void credentialSource_missingRegionalCredVerificationUrl() { + try { + new AwsCredentialSource(new HashMap()); + fail("Exception should be thrown."); + } catch (IllegalArgumentException e) { + assertEquals( + "A regional_cred_verification_url representing the GetCallerIdentity action URL must be specified.", + e.getMessage()); + } + } + + @Test + public void builder() { + List scopes = Arrays.asList("scope1", "scope2"); + + AwsCredentials credentials = + (AwsCredentials) + AwsCredentials.newBuilder() + .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("tokenUrl") + .setCredentialSource(AWS_CREDENTIAL_SOURCE) + .setTokenInfoUrl("tokenInfoUrl") + .setServiceAccountImpersonationUrl(SERVICE_ACCOUNT_IMPERSONATION_URL) + .setQuotaProjectId("quotaProjectId") + .setClientId("clientId") + .setClientSecret("clientSecret") + .setScopes(scopes) + .build(); + + assertEquals("audience", credentials.getAudience()); + assertEquals("subjectTokenType", credentials.getSubjectTokenType()); + assertEquals(credentials.getTokenUrl(), "tokenUrl"); + assertEquals(credentials.getTokenInfoUrl(), "tokenInfoUrl"); + assertEquals( + credentials.getServiceAccountImpersonationUrl(), SERVICE_ACCOUNT_IMPERSONATION_URL); + assertEquals(credentials.getCredentialSource(), AWS_CREDENTIAL_SOURCE); + assertEquals(credentials.getQuotaProjectId(), "quotaProjectId"); + assertEquals(credentials.getClientId(), "clientId"); + assertEquals(credentials.getClientSecret(), "clientSecret"); + assertEquals(credentials.getScopes(), scopes); + } + + private static AwsCredentialSource buildAwsCredentialSource( + MockExternalAccountCredentialsTransportFactory transportFactory) { + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("environment_id", "aws1"); + credentialSourceMap.put("region_url", transportFactory.transport.getAwsRegionUrl()); + credentialSourceMap.put("url", transportFactory.transport.getAwsCredentialsUrl()); + credentialSourceMap.put("regional_cred_verification_url", GET_CALLER_IDENTITY_URL); + return new AwsCredentialSource(credentialSourceMap); + } + + static InputStream writeAwsCredentialsStream(String stsUrl, String regionUrl, String metadataUrl) + throws IOException { + GenericJson json = new GenericJson(); + json.put("audience", "audience"); + json.put("subject_token_type", "subjectTokenType"); + json.put("token_url", stsUrl); + json.put("token_info_url", "tokenInfoUrl"); + json.put("type", ExternalAccountCredentials.EXTERNAL_ACCOUNT_FILE_TYPE); + + GenericJson credentialSource = new GenericJson(); + credentialSource.put("environment_id", "aws1"); + credentialSource.put("region_url", regionUrl); + credentialSource.put("url", metadataUrl); + credentialSource.put("regional_cred_verification_url", GET_CALLER_IDENTITY_URL); + json.put("credential_source", credentialSource); + + return TestUtils.jsonToInputStream(json); + } + + /** Used to test the retrieval of AWS credentials from environment variables. */ + private static class TestAwsCredentials extends AwsCredentials { + + private final Map environmentVariables = new HashMap<>(); + + TestAwsCredentials( + HttpTransportFactory transportFactory, + String audience, + String subjectTokenType, + String tokenUrl, + String tokenInfoUrl, + AwsCredentialSource credentialSource, + @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); + } + + public static TestAwsCredentials.Builder newBuilder(AwsCredentials awsCredentials) { + return new TestAwsCredentials.Builder(awsCredentials); + } + + public static class Builder extends AwsCredentials.Builder { + + private Builder(AwsCredentials credentials) { + super(credentials); + } + + @Override + public TestAwsCredentials build() { + return new TestAwsCredentials( + transportFactory, + audience, + subjectTokenType, + tokenUrl, + tokenInfoUrl, + (AwsCredentialSource) credentialSource, + serviceAccountImpersonationUrl, + quotaProjectId, + clientId, + clientSecret, + scopes); + } + } + + @Override + String getEnv(String name) { + return environmentVariables.get(name); + } + + void setEnv(String name, String value) { + environmentVariables.put(name, value); + } + } +} diff --git a/oauth2_http/javatests/com/google/auth/oauth2/AwsRequestSignerTest.java b/oauth2_http/javatests/com/google/auth/oauth2/AwsRequestSignerTest.java new file mode 100644 index 000000000..ebb14091e --- /dev/null +++ b/oauth2_http/javatests/com/google/auth/oauth2/AwsRequestSignerTest.java @@ -0,0 +1,544 @@ +/* + * 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 com.google.api.client.json.GenericJson; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.json.JsonObjectParser; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; + +/** + * Tests for {@link AwsRequestSigner}. + * + *

Examples of sigv4 signed requests: + * https://docs.aws.amazon.com/general/latest/gr/sigv4-signed-request-examples.html + */ +public class AwsRequestSignerTest { + + private static final String DATE = "Mon, 09 Sep 2011 23:36:00 GMT"; + private static final String X_AMZ_DATE = "20200811T065522Z"; + + private static final AwsSecurityCredentials BOTOCORE_CREDENTIALS = + new AwsSecurityCredentials( + "AKIDEXAMPLE", "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", /* token= */ null); + + private AwsSecurityCredentials awsSecurityCredentials; + + @Before + public void setUp() throws IOException { + awsSecurityCredentials = retrieveAwsSecurityCredentials(); + } + + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-vanilla.req + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-vanilla.sreq + @Test + public void sign_getHost() { + String url = "https://host.foo.com"; + + Map headers = new HashMap<>(); + headers.put("date", DATE); + + AwsRequestSigner signer = + AwsRequestSigner.newBuilder(BOTOCORE_CREDENTIALS, "GET", url, "us-east-1") + .setAdditionalHeaders(headers) + .build(); + + AwsRequestSignature signature = signer.sign(); + + String expectedSignature = "b27ccfbfa7df52a200ff74193ca6e32d4b48b8856fab7ebf1c595d0670a7e470"; + String expectedAuthHeader = + "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/" + + "aws4_request, SignedHeaders=date;host, Signature=" + + expectedSignature; + + assertEquals(expectedSignature, signature.getSignature()); + assertEquals(expectedAuthHeader, signature.getAuthorizationHeader()); + assertEquals(BOTOCORE_CREDENTIALS, signature.getSecurityCredentials()); + assertEquals(DATE, signature.getDate()); + assertEquals("GET", signature.getHttpMethod()); + assertEquals("us-east-1", signature.getRegion()); + assertEquals(URI.create(url).normalize().toString(), signature.getUrl()); + } + + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-relative-relative.req + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-relative-relative.sreq + @Test + public void sign_getHostRelativePath() { + String url = "https://host.foo.com/foo/bar/../.."; + + Map headers = new HashMap<>(); + headers.put("date", DATE); + + AwsRequestSigner signer = + AwsRequestSigner.newBuilder(BOTOCORE_CREDENTIALS, "GET", url, "us-east-1") + .setAdditionalHeaders(headers) + .build(); + + AwsRequestSignature signature = signer.sign(); + + String expectedSignature = "b27ccfbfa7df52a200ff74193ca6e32d4b48b8856fab7ebf1c595d0670a7e470"; + String expectedAuthHeader = + "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/" + + "aws4_request, SignedHeaders=date;host, Signature=" + + expectedSignature; + + assertEquals(expectedSignature, signature.getSignature()); + assertEquals(expectedAuthHeader, signature.getAuthorizationHeader()); + assertEquals(BOTOCORE_CREDENTIALS, signature.getSecurityCredentials()); + assertEquals(DATE, signature.getDate()); + assertEquals("GET", signature.getHttpMethod()); + assertEquals("us-east-1", signature.getRegion()); + assertEquals(URI.create(url).normalize().toString(), signature.getUrl()); + } + + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-slash-dot-slash.req + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-slash-dot-slash.sreq + @Test + public void sign_getHostInvalidPath() { + String url = "https://host.foo.com/./"; + + Map headers = new HashMap<>(); + headers.put("date", DATE); + + AwsRequestSigner signer = + AwsRequestSigner.newBuilder(BOTOCORE_CREDENTIALS, "GET", url, "us-east-1") + .setAdditionalHeaders(headers) + .build(); + + AwsRequestSignature signature = signer.sign(); + + String expectedSignature = "b27ccfbfa7df52a200ff74193ca6e32d4b48b8856fab7ebf1c595d0670a7e470"; + String expectedAuthHeader = + "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/" + + "aws4_request, SignedHeaders=date;host, Signature=" + + expectedSignature; + + assertEquals(expectedSignature, signature.getSignature()); + assertEquals(expectedAuthHeader, signature.getAuthorizationHeader()); + assertEquals(BOTOCORE_CREDENTIALS, signature.getSecurityCredentials()); + assertEquals(DATE, signature.getDate()); + assertEquals("GET", signature.getHttpMethod()); + assertEquals("us-east-1", signature.getRegion()); + assertEquals(URI.create(url).normalize().toString(), signature.getUrl()); + } + + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-slash-pointless-dot.req + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-slash-pointless-dot.sreq + @Test + public void sign_getHostDotPath() { + String url = "https://host.foo.com/./foo"; + + Map headers = new HashMap<>(); + headers.put("date", DATE); + + AwsRequestSigner signer = + AwsRequestSigner.newBuilder(BOTOCORE_CREDENTIALS, "GET", url, "us-east-1") + .setAdditionalHeaders(headers) + .build(); + + AwsRequestSignature signature = signer.sign(); + + String expectedSignature = "910e4d6c9abafaf87898e1eb4c929135782ea25bb0279703146455745391e63a"; + String expectedAuthHeader = + "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/" + + "aws4_request, SignedHeaders=date;host, Signature=" + + expectedSignature; + + assertEquals(expectedSignature, signature.getSignature()); + assertEquals(expectedAuthHeader, signature.getAuthorizationHeader()); + assertEquals(BOTOCORE_CREDENTIALS, signature.getSecurityCredentials()); + assertEquals(DATE, signature.getDate()); + assertEquals("GET", signature.getHttpMethod()); + assertEquals("us-east-1", signature.getRegion()); + assertEquals(URI.create(url).normalize().toString(), signature.getUrl()); + } + + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-utf8.req + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-utf8.sreq + @Test + public void sign_getHostUtf8Path() { + String url = "https://host.foo.com/%E1%88%B4"; + + Map headers = new HashMap<>(); + headers.put("date", DATE); + + AwsRequestSigner signer = + AwsRequestSigner.newBuilder(BOTOCORE_CREDENTIALS, "GET", url, "us-east-1") + .setAdditionalHeaders(headers) + .build(); + + AwsRequestSignature signature = signer.sign(); + + String expectedSignature = "8d6634c189aa8c75c2e51e106b6b5121bed103fdb351f7d7d4381c738823af74"; + String expectedAuthHeader = + "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/" + + "aws4_request, SignedHeaders=date;host, Signature=" + + expectedSignature; + + assertEquals(expectedSignature, signature.getSignature()); + assertEquals(expectedAuthHeader, signature.getAuthorizationHeader()); + assertEquals(BOTOCORE_CREDENTIALS, signature.getSecurityCredentials()); + assertEquals(DATE, signature.getDate()); + assertEquals("GET", signature.getHttpMethod()); + assertEquals("us-east-1", signature.getRegion()); + assertEquals(URI.create(url).normalize().toString(), signature.getUrl()); + } + + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-vanilla-query-order-key-case.req + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-vanilla-query-order-key-case.sreq + @Test + public void sign_getHostDuplicateQueryParam() { + String url = "https://host.foo.com/?foo=Zoo&foo=aha"; + + Map headers = new HashMap<>(); + headers.put("date", DATE); + + AwsRequestSigner signer = + AwsRequestSigner.newBuilder(BOTOCORE_CREDENTIALS, "GET", url, "us-east-1") + .setAdditionalHeaders(headers) + .build(); + + AwsRequestSignature signature = signer.sign(); + + String expectedSignature = "be7148d34ebccdc6423b19085378aa0bee970bdc61d144bd1a8c48c33079ab09"; + String expectedAuthHeader = + "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/" + + "aws4_request, SignedHeaders=date;host, Signature=" + + expectedSignature; + + assertEquals(expectedSignature, signature.getSignature()); + assertEquals(expectedAuthHeader, signature.getAuthorizationHeader()); + assertEquals(BOTOCORE_CREDENTIALS, signature.getSecurityCredentials()); + assertEquals(DATE, signature.getDate()); + assertEquals("GET", signature.getHttpMethod()); + assertEquals("us-east-1", signature.getRegion()); + assertEquals(URI.create(url).normalize().toString(), signature.getUrl()); + } + + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/post-header-key-sort.req + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/post-header-key-sort.sreq + @Test + public void sign_postWithUpperCaseHeaderKey() { + String url = "https://host.foo.com/"; + String headerKey = "ZOO"; + String headerValue = "zoobar"; + + Map headers = new HashMap<>(); + headers.put("date", DATE); + headers.put(headerKey, headerValue); + + AwsRequestSigner signer = + AwsRequestSigner.newBuilder(BOTOCORE_CREDENTIALS, "POST", url, "us-east-1") + .setAdditionalHeaders(headers) + .build(); + + AwsRequestSignature signature = signer.sign(); + + String expectedSignature = "b7a95a52518abbca0964a999a880429ab734f35ebbf1235bd79a5de87756dc4a"; + String expectedAuthHeader = + "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/" + + "aws4_request, SignedHeaders=date;host;zoo, Signature=" + + expectedSignature; + + assertEquals(expectedSignature, signature.getSignature()); + assertEquals(expectedAuthHeader, signature.getAuthorizationHeader()); + assertEquals(BOTOCORE_CREDENTIALS, signature.getSecurityCredentials()); + assertEquals(DATE, signature.getDate()); + assertEquals("POST", signature.getHttpMethod()); + assertEquals("us-east-1", signature.getRegion()); + assertEquals(URI.create(url).normalize().toString(), signature.getUrl()); + assertEquals(headerValue, signature.getCanonicalHeaders().get(headerKey.toLowerCase())); + } + + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/post-header-value-case.req + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/post-header-value-case.sreq + @Test + public void sign_postWithUpperCaseHeaderValue() { + String url = "https://host.foo.com/"; + String headerKey = "zoo"; + String headerValue = "ZOOBAR"; + + Map headers = new HashMap<>(); + headers.put("date", DATE); + headers.put("zoo", "ZOOBAR"); + + AwsRequestSigner signer = + AwsRequestSigner.newBuilder(BOTOCORE_CREDENTIALS, "POST", url, "us-east-1") + .setAdditionalHeaders(headers) + .build(); + + AwsRequestSignature signature = signer.sign(); + + String expectedSignature = "273313af9d0c265c531e11db70bbd653f3ba074c1009239e8559d3987039cad7"; + String expectedAuthHeader = + "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/" + + "aws4_request, SignedHeaders=date;host;zoo, Signature=" + + expectedSignature; + + assertEquals(expectedSignature, signature.getSignature()); + assertEquals(expectedAuthHeader, signature.getAuthorizationHeader()); + assertEquals(BOTOCORE_CREDENTIALS, signature.getSecurityCredentials()); + assertEquals(DATE, signature.getDate()); + assertEquals("POST", signature.getHttpMethod()); + assertEquals("us-east-1", signature.getRegion()); + assertEquals(URI.create(url).normalize().toString(), signature.getUrl()); + assertEquals(headerValue, signature.getCanonicalHeaders().get(headerKey.toLowerCase())); + } + + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-header-value-trim.req + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-header-value-trim.sreq + @Test + public void sign_postWithHeader() { + String url = "https://host.foo.com/"; + String headerKey = "p"; + String headerValue = "phfft"; + + Map headers = new HashMap<>(); + headers.put("date", DATE); + headers.put(headerKey, headerValue); + + AwsRequestSigner signer = + AwsRequestSigner.newBuilder(BOTOCORE_CREDENTIALS, "POST", url, "us-east-1") + .setAdditionalHeaders(headers) + .build(); + + AwsRequestSignature signature = signer.sign(); + + String expectedSignature = "debf546796015d6f6ded8626f5ce98597c33b47b9164cf6b17b4642036fcb592"; + String expectedAuthHeader = + "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/" + + "aws4_request, SignedHeaders=date;host;p, Signature=" + + expectedSignature; + + assertEquals(expectedSignature, signature.getSignature()); + assertEquals(expectedAuthHeader, signature.getAuthorizationHeader()); + assertEquals(BOTOCORE_CREDENTIALS, signature.getSecurityCredentials()); + assertEquals(DATE, signature.getDate()); + assertEquals("POST", signature.getHttpMethod()); + assertEquals("us-east-1", signature.getRegion()); + assertEquals(URI.create(url).normalize().toString(), signature.getUrl()); + assertEquals(headerValue, signature.getCanonicalHeaders().get(headerKey.toLowerCase())); + } + + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/post-x-www-form-urlencoded.req + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/post-x-www-form-urlencoded.sreq + @Test + public void sign_postWithBodyNoCustomHeaders() { + String url = "https://host.foo.com/"; + String headerKey = "Content-Type"; + String headerValue = "application/x-www-form-urlencoded"; + + Map headers = new HashMap<>(); + headers.put("date", DATE); + headers.put(headerKey, headerValue); + + AwsRequestSigner signer = + AwsRequestSigner.newBuilder(BOTOCORE_CREDENTIALS, "POST", url, "us-east-1") + .setAdditionalHeaders(headers) + .setRequestPayload("foo=bar") + .build(); + + AwsRequestSignature signature = signer.sign(); + + String expectedSignature = "5a15b22cf462f047318703b92e6f4f38884e4a7ab7b1d6426ca46a8bd1c26cbc"; + String expectedAuthHeader = + "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/" + + "aws4_request, SignedHeaders=content-type;date;host, Signature=" + + expectedSignature; + + assertEquals(expectedSignature, signature.getSignature()); + assertEquals(expectedAuthHeader, signature.getAuthorizationHeader()); + assertEquals(BOTOCORE_CREDENTIALS, signature.getSecurityCredentials()); + assertEquals(DATE, signature.getDate()); + assertEquals("POST", signature.getHttpMethod()); + assertEquals("us-east-1", signature.getRegion()); + assertEquals(URI.create(url).normalize().toString(), signature.getUrl()); + assertEquals(headerValue, signature.getCanonicalHeaders().get(headerKey.toLowerCase())); + } + + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/post-vanilla-query.req + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/post-vanilla-query.sreq + @Test + public void sign_postWithQueryString() { + String url = "https://host.foo.com/?foo=bar"; + + Map headers = new HashMap<>(); + headers.put("date", DATE); + + AwsRequestSigner signer = + AwsRequestSigner.newBuilder(BOTOCORE_CREDENTIALS, "POST", url, "us-east-1") + .setAdditionalHeaders(headers) + .build(); + + AwsRequestSignature signature = signer.sign(); + + String expectedSignature = "b6e3b79003ce0743a491606ba1035a804593b0efb1e20a11cba83f8c25a57a92"; + String expectedAuthHeader = + "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/" + + "aws4_request, SignedHeaders=date;host, Signature=" + + expectedSignature; + + assertEquals(expectedSignature, signature.getSignature()); + assertEquals(expectedAuthHeader, signature.getAuthorizationHeader()); + assertEquals(BOTOCORE_CREDENTIALS, signature.getSecurityCredentials()); + assertEquals(DATE, signature.getDate()); + assertEquals("POST", signature.getHttpMethod()); + assertEquals("us-east-1", signature.getRegion()); + assertEquals(URI.create(url).normalize().toString(), signature.getUrl()); + } + + @Test + public void sign_getDescribeRegions() { + String url = "https://ec2.us-east-2.amazonaws.com?Action=DescribeRegions&Version=2013-10-15"; + + Map additionalHeaders = new HashMap<>(); + additionalHeaders.put("x-amz-date", X_AMZ_DATE); + + AwsRequestSigner signer = + AwsRequestSigner.newBuilder(awsSecurityCredentials, "GET", url, "us-east-2") + .setAdditionalHeaders(additionalHeaders) + .build(); + + AwsRequestSignature signature = signer.sign(); + + String expectedSignature = "631ea80cddfaa545fdadb120dc92c9f18166e38a5c47b50fab9fce476e022855"; + String expectedAuthHeader = + "AWS4-HMAC-SHA256 Credential=" + + awsSecurityCredentials.getAccessKeyId() + + "/20200811/us-east-2/ec2/" + + "aws4_request, SignedHeaders=host;x-amz-date;x-amz-security-token, Signature=" + + expectedSignature; + + assertEquals(expectedSignature, signature.getSignature()); + assertEquals(expectedAuthHeader, signature.getAuthorizationHeader()); + assertEquals(awsSecurityCredentials, signature.getSecurityCredentials()); + assertEquals(X_AMZ_DATE, signature.getDate()); + assertEquals("GET", signature.getHttpMethod()); + assertEquals("us-east-2", signature.getRegion()); + assertEquals(URI.create(url).normalize().toString(), signature.getUrl()); + } + + @Test + public void sign_postGetCallerIdentity() { + String url = "https://sts.us-east-2.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15"; + + Map additionalHeaders = new HashMap<>(); + additionalHeaders.put("x-amz-date", X_AMZ_DATE); + + AwsRequestSigner signer = + AwsRequestSigner.newBuilder(awsSecurityCredentials, "POST", url, "us-east-2") + .setAdditionalHeaders(additionalHeaders) + .build(); + + AwsRequestSignature signature = signer.sign(); + + String expectedSignature = "73452984e4a880ffdc5c392355733ec3f5ba310d5e0609a89244440cadfe7a7a"; + String expectedAuthHeader = + "AWS4-HMAC-SHA256 Credential=" + + awsSecurityCredentials.getAccessKeyId() + + "/20200811/us-east-2/sts/" + + "aws4_request, SignedHeaders=host;x-amz-date;x-amz-security-token, Signature=" + + expectedSignature; + + assertEquals(expectedSignature, signature.getSignature()); + assertEquals(expectedAuthHeader, signature.getAuthorizationHeader()); + assertEquals(awsSecurityCredentials, signature.getSecurityCredentials()); + assertEquals(X_AMZ_DATE, signature.getDate()); + assertEquals("POST", signature.getHttpMethod()); + assertEquals("us-east-2", signature.getRegion()); + assertEquals(URI.create(url).normalize().toString(), signature.getUrl()); + } + + @Test + public void sign_postGetCallerIdentityNoToken() { + String url = "https://sts.us-east-2.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15"; + + AwsSecurityCredentials awsSecurityCredentialsWithoutToken = + new AwsSecurityCredentials( + awsSecurityCredentials.getAccessKeyId(), + awsSecurityCredentials.getSecretAccessKey(), + /* token= */ null); + + Map additionalHeaders = new HashMap<>(); + additionalHeaders.put("x-amz-date", X_AMZ_DATE); + + AwsRequestSigner signer = + AwsRequestSigner.newBuilder(awsSecurityCredentialsWithoutToken, "POST", url, "us-east-2") + .setAdditionalHeaders(additionalHeaders) + .build(); + + AwsRequestSignature signature = signer.sign(); + + String expectedSignature = "d095ba304919cd0d5570ba8a3787884ee78b860f268ed040ba23831d55536d56"; + String expectedAuthHeader = + "AWS4-HMAC-SHA256 Credential=" + + awsSecurityCredentials.getAccessKeyId() + + "/20200811/us-east-2/sts/" + + "aws4_request, SignedHeaders=host;x-amz-date, Signature=" + + expectedSignature; + + assertEquals(expectedSignature, signature.getSignature()); + assertEquals(expectedAuthHeader, signature.getAuthorizationHeader()); + assertEquals(awsSecurityCredentialsWithoutToken, signature.getSecurityCredentials()); + assertEquals(X_AMZ_DATE, signature.getDate()); + assertEquals("POST", signature.getHttpMethod()); + assertEquals("us-east-2", signature.getRegion()); + assertEquals(URI.create(url).normalize().toString(), signature.getUrl()); + } + + public AwsSecurityCredentials retrieveAwsSecurityCredentials() throws IOException { + InputStream stream = + AwsRequestSignerTest.class + .getClassLoader() + .getResourceAsStream("aws_security_credentials.json"); + + JsonFactory jsonFactory = OAuth2Utils.JSON_FACTORY; + JsonObjectParser parser = new JsonObjectParser(jsonFactory); + + GenericJson json = parser.parseAndClose(stream, StandardCharsets.UTF_8, GenericJson.class); + + String awsToken = (String) json.get("Token"); + String secretAccessKey = (String) json.get("SecretAccessKey"); + String accessKeyId = (String) json.get("AccessKeyId"); + + return new AwsSecurityCredentials(accessKeyId, secretAccessKey, awsToken); + } +} diff --git a/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java new file mode 100644 index 000000000..a9d3ce49b --- /dev/null +++ b/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java @@ -0,0 +1,358 @@ +/* + * 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.assertTrue; +import static org.junit.Assert.fail; + +import com.google.api.client.http.HttpTransport; +import com.google.api.client.json.GenericJson; +import com.google.auth.TestUtils; +import com.google.auth.http.HttpTransportFactory; +import com.google.auth.oauth2.ExternalAccountCredentialsTest.TestExternalAccountCredentials.TestCredentialSource; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.URI; +import java.util.Collection; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import javax.annotation.Nullable; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Tests for {@link ExternalAccountCredentials}. */ +@RunWith(JUnit4.class) +public class ExternalAccountCredentialsTest { + + private static final String STS_URL = "https://www.sts.google.com"; + + static class MockExternalAccountCredentialsTransportFactory implements HttpTransportFactory { + + MockExternalAccountCredentialsTransport transport = + new MockExternalAccountCredentialsTransport(); + + @Override + public HttpTransport create() { + return transport; + } + } + + private MockExternalAccountCredentialsTransportFactory transportFactory; + + @Before + public void setup() { + transportFactory = new MockExternalAccountCredentialsTransportFactory(); + } + + @Test + public void fromStream_identityPoolCredentials() throws IOException { + GenericJson json = buildJsonIdentityPoolCredential(); + + ExternalAccountCredentials credential = + ExternalAccountCredentials.fromStream(TestUtils.jsonToInputStream(json)); + + assertTrue(credential instanceof IdentityPoolCredentials); + } + + @Test + public void fromStream_awsCredentials() throws IOException { + GenericJson json = buildJsonAwsCredential(); + + ExternalAccountCredentials credential = + ExternalAccountCredentials.fromStream(TestUtils.jsonToInputStream(json)); + + assertTrue(credential instanceof AwsCredentials); + } + + @Test + public void fromStream_invalidStream_throws() throws IOException { + GenericJson json = buildJsonAwsCredential(); + + json.put("audience", new HashMap<>()); + + try { + ExternalAccountCredentials.fromStream(TestUtils.jsonToInputStream(json)); + fail("Should fail."); + } catch (CredentialFormatException e) { + assertEquals("An invalid input stream was provided.", e.getMessage()); + } + } + + @Test + public void fromStream_nullTransport_throws() throws IOException { + try { + ExternalAccountCredentials.fromStream( + new ByteArrayInputStream("foo".getBytes()), /* transportFactory= */ null); + fail("NullPointerException should be thrown."); + } catch (NullPointerException e) { + // Expected. + } + } + + @Test + public void fromStream_nullStream_throws() throws IOException { + try { + ExternalAccountCredentials.fromStream( + /* credentialsStream= */ null, OAuth2Utils.HTTP_TRANSPORT_FACTORY); + fail("NullPointerException should be thrown."); + } catch (NullPointerException e) { + // Expected. + } + } + + @Test + public void fromJson_identityPoolCredentials() { + ExternalAccountCredentials credential = + ExternalAccountCredentials.fromJson( + buildJsonIdentityPoolCredential(), OAuth2Utils.HTTP_TRANSPORT_FACTORY); + + assertTrue(credential instanceof IdentityPoolCredentials); + assertEquals("audience", credential.getAudience()); + assertEquals("subjectTokenType", credential.getSubjectTokenType()); + assertEquals(STS_URL, credential.getTokenUrl()); + assertEquals("tokenInfoUrl", credential.getTokenInfoUrl()); + assertNotNull(credential.getCredentialSource()); + } + + @Test + public void fromJson_awsCredentials() { + ExternalAccountCredentials credential = + ExternalAccountCredentials.fromJson( + buildJsonAwsCredential(), OAuth2Utils.HTTP_TRANSPORT_FACTORY); + + assertTrue(credential instanceof AwsCredentials); + assertEquals("audience", credential.getAudience()); + assertEquals("subjectTokenType", credential.getSubjectTokenType()); + assertEquals(STS_URL, credential.getTokenUrl()); + assertEquals("tokenInfoUrl", credential.getTokenInfoUrl()); + assertNotNull(credential.getCredentialSource()); + } + + @Test + public void fromJson_nullJson_throws() { + try { + ExternalAccountCredentials.fromJson(/* json= */ null, OAuth2Utils.HTTP_TRANSPORT_FACTORY); + fail("Exception should be thrown."); + } catch (NullPointerException e) { + // Expected. + } + } + + @Test + public void fromJson_invalidServiceAccountImpersonationUrl_throws() { + GenericJson json = buildJsonIdentityPoolCredential(); + json.put("service_account_impersonation_url", "invalid_url"); + + try { + ExternalAccountCredentials.fromJson(json, OAuth2Utils.HTTP_TRANSPORT_FACTORY); + fail("Exception should be thrown."); + } catch (IllegalArgumentException e) { + assertEquals( + "Unable to determine target principal from service account impersonation URL.", + e.getMessage()); + } + } + + @Test + public void fromJson_nullTransport_throws() { + try { + ExternalAccountCredentials.fromJson( + new HashMap(), /* transportFactory= */ null); + fail("Exception should be thrown."); + } catch (NullPointerException e) { + // Expected. + } + } + + @Test + public void exchangeExternalCredentialForAccessToken() throws IOException { + ExternalAccountCredentials credential = + ExternalAccountCredentials.fromJson(buildJsonIdentityPoolCredential(), transportFactory); + + StsTokenExchangeRequest stsTokenExchangeRequest = + StsTokenExchangeRequest.newBuilder("credential", "subjectTokenType").build(); + + AccessToken accessToken = + credential.exchangeExternalCredentialForAccessToken(stsTokenExchangeRequest); + + assertEquals(transportFactory.transport.getAccessToken(), accessToken.getTokenValue()); + } + + @Test + public void exchangeExternalCredentialForAccessToken_withServiceAccountImpersonation() + throws IOException { + transportFactory.transport.setExpireTime(TestUtils.getDefaultExpireTime()); + + ExternalAccountCredentials credential = + ExternalAccountCredentials.fromStream( + IdentityPoolCredentialsTest.writeIdentityPoolCredentialsStream( + transportFactory.transport.getStsUrl(), + transportFactory.transport.getMetadataUrl(), + transportFactory.transport.getServiceAccountImpersonationUrl()), + transportFactory); + + StsTokenExchangeRequest stsTokenExchangeRequest = + StsTokenExchangeRequest.newBuilder("credential", "subjectTokenType").build(); + + AccessToken returnedToken = + credential.exchangeExternalCredentialForAccessToken(stsTokenExchangeRequest); + + assertEquals( + transportFactory.transport.getServiceAccountAccessToken(), returnedToken.getTokenValue()); + } + + @Test + public void exchangeExternalCredentialForAccessToken_throws() throws IOException { + ExternalAccountCredentials credential = + ExternalAccountCredentials.fromJson(buildJsonIdentityPoolCredential(), transportFactory); + + String errorCode = "invalidRequest"; + String errorDescription = "errorDescription"; + String errorUri = "errorUri"; + transportFactory.transport.addResponseErrorSequence( + TestUtils.buildHttpResponseException(errorCode, errorDescription, errorUri)); + + StsTokenExchangeRequest stsTokenExchangeRequest = + StsTokenExchangeRequest.newBuilder("credential", "subjectTokenType").build(); + + try { + credential.exchangeExternalCredentialForAccessToken(stsTokenExchangeRequest); + fail("Exception should be thrown."); + } catch (OAuthException e) { + assertEquals(errorCode, e.getErrorCode()); + assertEquals(errorDescription, e.getErrorDescription()); + assertEquals(errorUri, e.getErrorUri()); + } + } + + @Test + public void getRequestMetadata_withQuotaProjectId() throws IOException { + TestExternalAccountCredentials testCredentials = + new TestExternalAccountCredentials( + transportFactory, + "audience", + "subjectTokenType", + "tokenUrl", + "tokenInfoUrl", + new TestCredentialSource(new HashMap()), + /* serviceAccountImpersonationUrl= */ null, + "quotaProjectId", + /* clientId= */ null, + /* clientSecret= */ null, + /* scopes= */ null); + + Map> requestMetadata = + testCredentials.getRequestMetadata(URI.create("http://googleapis.com/foo/bar")); + + assertEquals("quotaProjectId", requestMetadata.get("x-goog-user-project").get(0)); + } + + private GenericJson buildJsonIdentityPoolCredential() { + GenericJson json = new GenericJson(); + json.put("audience", "audience"); + json.put("subject_token_type", "subjectTokenType"); + json.put("token_url", STS_URL); + json.put("token_info_url", "tokenInfoUrl"); + + Map map = new HashMap<>(); + map.put("file", "file"); + json.put("credential_source", map); + return json; + } + + private GenericJson buildJsonAwsCredential() { + GenericJson json = new GenericJson(); + json.put("audience", "audience"); + json.put("subject_token_type", "subjectTokenType"); + json.put("token_url", STS_URL); + json.put("token_info_url", "tokenInfoUrl"); + + Map map = new HashMap<>(); + map.put("environment_id", "aws1"); + map.put("region_url", "regionUrl"); + map.put("url", "url"); + map.put("regional_cred_verification_url", "regionalCredVerificationUrl"); + json.put("credential_source", map); + + return json; + } + + static class TestExternalAccountCredentials extends ExternalAccountCredentials { + static class TestCredentialSource extends ExternalAccountCredentials.CredentialSource { + protected TestCredentialSource(Map credentialSourceMap) { + super(credentialSourceMap); + } + } + + protected TestExternalAccountCredentials( + HttpTransportFactory transportFactory, + String audience, + String subjectTokenType, + String tokenUrl, + String tokenInfoUrl, + CredentialSource credentialSource, + @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); + } + + @Override + public AccessToken refreshAccessToken() { + return new AccessToken("accessToken", new Date()); + } + + @Override + public String retrieveSubjectToken() { + return "subjectToken"; + } + } +} diff --git a/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java index ebf60a8ce..6af637284 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java @@ -40,6 +40,7 @@ import com.google.api.client.testing.http.MockHttpTransport; import com.google.auth.TestUtils; import com.google.auth.http.HttpTransportFactory; +import com.google.auth.oauth2.IdentityPoolCredentialsTest.MockExternalAccountCredentialsTransportFactory; import com.google.common.collect.ImmutableList; import java.io.ByteArrayInputStream; import java.io.IOException; @@ -224,6 +225,45 @@ public void fromStream_userNoRefreshToken_throws() throws IOException { testFromStreamException(userStream, "refresh_token"); } + @Test + public void fromStream_identityPoolCredentials_providesToken() throws IOException { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + InputStream identityPoolCredentialStream = + IdentityPoolCredentialsTest.writeIdentityPoolCredentialsStream( + transportFactory.transport.getStsUrl(), + transportFactory.transport.getMetadataUrl(), + /* serviceAccountImpersonationUrl= */ null); + + GoogleCredentials credentials = + GoogleCredentials.fromStream(identityPoolCredentialStream, transportFactory); + + assertNotNull(credentials); + credentials = credentials.createScoped(SCOPES); + Map> metadata = credentials.getRequestMetadata(CALL_URI); + TestUtils.assertContainsBearerToken(metadata, transportFactory.transport.getAccessToken()); + } + + @Test + public void fromStream_awsCredentials_providesToken() throws IOException { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + InputStream awsCredentialStream = + AwsCredentialsTest.writeAwsCredentialsStream( + transportFactory.transport.getStsUrl(), + transportFactory.transport.getAwsRegionUrl(), + transportFactory.transport.getAwsCredentialsUrl()); + + GoogleCredentials credentials = + GoogleCredentials.fromStream(awsCredentialStream, transportFactory); + + assertNotNull(credentials); + credentials = credentials.createScoped(SCOPES); + Map> metadata = credentials.getRequestMetadata(CALL_URI); + TestUtils.assertContainsBearerToken(metadata, transportFactory.transport.getAccessToken()); + } + @Test public void createScoped_overloadCallsImplementation() { final AtomicReference> called = new AtomicReference<>(); diff --git a/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java new file mode 100644 index 000000000..4095edcad --- /dev/null +++ b/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java @@ -0,0 +1,488 @@ +/* + * 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.auth.oauth2.MockExternalAccountCredentialsTransport.SERVICE_ACCOUNT_IMPERSONATION_URL; +import static com.google.auth.oauth2.OAuth2Utils.JSON_FACTORY; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +import com.google.api.client.http.HttpTransport; +import com.google.api.client.json.GenericJson; +import com.google.auth.TestUtils; +import com.google.auth.http.HttpTransportFactory; +import com.google.auth.oauth2.IdentityPoolCredentials.IdentityPoolCredentialSource; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +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 IdentityPoolCredentials}. */ +@RunWith(JUnit4.class) +public class IdentityPoolCredentialsTest { + + private static final Map FILE_CREDENTIAL_SOURCE_MAP = + new HashMap() { + { + put("file", "file"); + } + }; + + private static final IdentityPoolCredentialSource FILE_CREDENTIAL_SOURCE = + new IdentityPoolCredentialSource(FILE_CREDENTIAL_SOURCE_MAP); + + private static final IdentityPoolCredentials FILE_SOURCED_CREDENTIAL = + (IdentityPoolCredentials) + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("tokenUrl") + .setTokenInfoUrl("tokenInfoUrl") + .setCredentialSource(FILE_CREDENTIAL_SOURCE) + .build(); + + static class MockExternalAccountCredentialsTransportFactory implements HttpTransportFactory { + + MockExternalAccountCredentialsTransport transport = + new MockExternalAccountCredentialsTransport(); + + @Override + public HttpTransport create() { + return transport; + } + } + + @Test + public void createdScoped_clonedCredentialWithAddedScopes() { + IdentityPoolCredentials credentials = + (IdentityPoolCredentials) + IdentityPoolCredentials.newBuilder(FILE_SOURCED_CREDENTIAL) + .setServiceAccountImpersonationUrl(SERVICE_ACCOUNT_IMPERSONATION_URL) + .setQuotaProjectId("quotaProjectId") + .setClientId("clientId") + .setClientSecret("clientSecret") + .build(); + + List newScopes = Arrays.asList("scope1", "scope2"); + + IdentityPoolCredentials newCredentials = + (IdentityPoolCredentials) credentials.createScoped(newScopes); + + assertEquals(credentials.getAudience(), newCredentials.getAudience()); + assertEquals(credentials.getSubjectTokenType(), newCredentials.getSubjectTokenType()); + assertEquals(credentials.getTokenUrl(), newCredentials.getTokenUrl()); + assertEquals(credentials.getTokenInfoUrl(), newCredentials.getTokenInfoUrl()); + assertEquals( + credentials.getServiceAccountImpersonationUrl(), + newCredentials.getServiceAccountImpersonationUrl()); + assertEquals(credentials.getCredentialSource(), newCredentials.getCredentialSource()); + assertEquals(newScopes, newCredentials.getScopes()); + assertEquals(credentials.getQuotaProjectId(), newCredentials.getQuotaProjectId()); + assertEquals(credentials.getClientId(), newCredentials.getClientId()); + assertEquals(credentials.getClientSecret(), newCredentials.getClientSecret()); + } + + @Test + public void retrieveSubjectToken_fileSourced() throws IOException { + File file = + File.createTempFile("RETRIEVE_SUBJECT_TOKEN", /* suffix= */ null, /* directory= */ null); + file.deleteOnExit(); + + String credential = "credential"; + OAuth2Utils.writeInputStreamToFile( + new ByteArrayInputStream(credential.getBytes(StandardCharsets.UTF_8)), + file.getAbsolutePath()); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", file.getAbsolutePath()); + IdentityPoolCredentialSource credentialSource = + new IdentityPoolCredentialSource(credentialSourceMap); + + IdentityPoolCredentials credentials = + (IdentityPoolCredentials) + IdentityPoolCredentials.newBuilder(FILE_SOURCED_CREDENTIAL) + .setCredentialSource(credentialSource) + .build(); + + String subjectToken = credentials.retrieveSubjectToken(); + + assertEquals(credential, subjectToken); + } + + @Test + public void retrieveSubjectToken_fileSourcedWithJsonFormat() throws IOException { + File file = + File.createTempFile("RETRIEVE_SUBJECT_TOKEN", /* suffix= */ null, /* directory= */ null); + file.deleteOnExit(); + + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + transportFactory.transport.setMetadataServerContentType("json"); + + Map credentialSourceMap = new HashMap<>(); + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "subjectToken"); + + credentialSourceMap.put("file", file.getAbsolutePath()); + credentialSourceMap.put("format", formatMap); + + IdentityPoolCredentialSource credentialSource = + new IdentityPoolCredentialSource(credentialSourceMap); + + GenericJson response = new GenericJson(); + response.setFactory(JSON_FACTORY); + response.put("subjectToken", "subjectToken"); + + OAuth2Utils.writeInputStreamToFile( + new ByteArrayInputStream(response.toString().getBytes(StandardCharsets.UTF_8)), + file.getAbsolutePath()); + + IdentityPoolCredentials credential = + (IdentityPoolCredentials) + IdentityPoolCredentials.newBuilder(FILE_SOURCED_CREDENTIAL) + .setHttpTransportFactory(transportFactory) + .setCredentialSource(credentialSource) + .build(); + + String subjectToken = credential.retrieveSubjectToken(); + + assertEquals("subjectToken", subjectToken); + } + + @Test + public void retrieveSubjectToken_fileSourcedWithNullFormat_throws() throws IOException { + File file = + File.createTempFile("RETRIEVE_SUBJECT_TOKEN", /* suffix= */ null, /* directory= */ null); + file.deleteOnExit(); + + Map credentialSourceMap = new HashMap<>(); + Map formatMap = new HashMap<>(); + formatMap.put("type", null); + + credentialSourceMap.put("file", file.getAbsolutePath()); + credentialSourceMap.put("format", formatMap); + + try { + new IdentityPoolCredentialSource(credentialSourceMap); + fail("Exception should be thrown due to null format."); + } catch (IllegalArgumentException e) { + assertEquals("Invalid credential source format type: null.", e.getMessage()); + } + } + + @Test + public void retrieveSubjectToken_noFile_throws() { + Map credentialSourceMap = new HashMap<>(); + String path = "badPath"; + credentialSourceMap.put("file", path); + IdentityPoolCredentialSource credentialSource = + new IdentityPoolCredentialSource(credentialSourceMap); + + IdentityPoolCredentials credentials = + (IdentityPoolCredentials) + IdentityPoolCredentials.newBuilder(FILE_SOURCED_CREDENTIAL) + .setCredentialSource(credentialSource) + .build(); + + try { + credentials.retrieveSubjectToken(); + fail("Exception should be thrown."); + } catch (IOException e) { + assertEquals( + String.format("Invalid credential location. The file at %s does not exist.", path), + e.getMessage()); + } + } + + @Test + public void retrieveSubjectToken_urlSourced() throws IOException { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + IdentityPoolCredentials credential = + (IdentityPoolCredentials) + IdentityPoolCredentials.newBuilder(FILE_SOURCED_CREDENTIAL) + .setHttpTransportFactory(transportFactory) + .setCredentialSource( + buildUrlBasedCredentialSource(transportFactory.transport.getMetadataUrl())) + .build(); + + String subjectToken = credential.retrieveSubjectToken(); + + assertEquals(transportFactory.transport.getSubjectToken(), subjectToken); + } + + @Test + public void retrieveSubjectToken_urlSourcedWithJsonFormat() throws IOException { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + transportFactory.transport.setMetadataServerContentType("json"); + + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "subjectToken"); + + IdentityPoolCredentialSource credentialSource = + buildUrlBasedCredentialSource(transportFactory.transport.getMetadataUrl(), formatMap); + + IdentityPoolCredentials credential = + (IdentityPoolCredentials) + IdentityPoolCredentials.newBuilder(FILE_SOURCED_CREDENTIAL) + .setHttpTransportFactory(transportFactory) + .setCredentialSource(credentialSource) + .build(); + + String subjectToken = credential.retrieveSubjectToken(); + + assertEquals(transportFactory.transport.getSubjectToken(), subjectToken); + } + + @Test + public void retrieveSubjectToken_urlSourcedCredential_throws() { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + IOException response = new IOException(); + transportFactory.transport.addResponseErrorSequence(response); + + IdentityPoolCredentials credential = + (IdentityPoolCredentials) + IdentityPoolCredentials.newBuilder(FILE_SOURCED_CREDENTIAL) + .setHttpTransportFactory(transportFactory) + .setCredentialSource( + buildUrlBasedCredentialSource(transportFactory.transport.getMetadataUrl())) + .build(); + + try { + credential.retrieveSubjectToken(); + fail("Exception should be thrown."); + } catch (IOException e) { + assertEquals( + String.format( + "Error getting subject token from metadata server: %s", response.getMessage()), + e.getMessage()); + } + } + + @Test + public void refreshAccessToken_withoutServiceAccountImpersonation() throws IOException { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + IdentityPoolCredentials credential = + (IdentityPoolCredentials) + IdentityPoolCredentials.newBuilder(FILE_SOURCED_CREDENTIAL) + .setTokenUrl(transportFactory.transport.getStsUrl()) + .setHttpTransportFactory(transportFactory) + .setCredentialSource( + buildUrlBasedCredentialSource(transportFactory.transport.getMetadataUrl())) + .build(); + + AccessToken accessToken = credential.refreshAccessToken(); + + assertEquals(transportFactory.transport.getAccessToken(), accessToken.getTokenValue()); + } + + @Test + public void refreshAccessToken_withServiceAccountImpersonation() throws IOException { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + transportFactory.transport.setExpireTime(TestUtils.getDefaultExpireTime()); + IdentityPoolCredentials credential = + (IdentityPoolCredentials) + IdentityPoolCredentials.newBuilder(FILE_SOURCED_CREDENTIAL) + .setTokenUrl(transportFactory.transport.getStsUrl()) + .setServiceAccountImpersonationUrl( + transportFactory.transport.getServiceAccountImpersonationUrl()) + .setHttpTransportFactory(transportFactory) + .setCredentialSource( + buildUrlBasedCredentialSource(transportFactory.transport.getMetadataUrl())) + .build(); + + AccessToken accessToken = credential.refreshAccessToken(); + + assertEquals( + transportFactory.transport.getServiceAccountAccessToken(), accessToken.getTokenValue()); + } + + @Test + public void identityPoolCredentialSource_invalidSourceType() { + try { + new IdentityPoolCredentialSource(new HashMap()); + fail("Exception should be thrown."); + } catch (IllegalArgumentException e) { + assertEquals( + "Missing credential source file location or URL. At least one must be specified.", + e.getMessage()); + } + } + + @Test + public void identityPoolCredentialSource_invalidFormatType() { + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("url", "url"); + + Map format = new HashMap<>(); + format.put("type", "unsupportedType"); + credentialSourceMap.put("format", format); + + try { + new IdentityPoolCredentialSource(credentialSourceMap); + fail("Exception should be thrown."); + } catch (IllegalArgumentException e) { + assertEquals("Invalid credential source format type: unsupportedType.", e.getMessage()); + } + } + + @Test + public void identityPoolCredentialSource_nullFormatType() { + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("url", "url"); + + Map format = new HashMap<>(); + format.put("type", null); + credentialSourceMap.put("format", format); + + try { + new IdentityPoolCredentialSource(credentialSourceMap); + fail("Exception should be thrown."); + } catch (IllegalArgumentException e) { + assertEquals("Invalid credential source format type: null.", e.getMessage()); + } + } + + @Test + public void identityPoolCredentialSource_subjectTokenFieldNameUnset() { + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("url", "url"); + + Map format = new HashMap<>(); + format.put("type", "json"); + credentialSourceMap.put("format", format); + + try { + new IdentityPoolCredentialSource(credentialSourceMap); + fail("Exception should be thrown."); + } catch (IllegalArgumentException e) { + assertEquals( + "When specifying a JSON credential type, the subject_token_field_name must be set.", + e.getMessage()); + } + } + + @Test + public void builder() { + List scopes = Arrays.asList("scope1", "scope2"); + + IdentityPoolCredentials credentials = + (IdentityPoolCredentials) + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("tokenUrl") + .setCredentialSource(FILE_CREDENTIAL_SOURCE) + .setTokenInfoUrl("tokenInfoUrl") + .setServiceAccountImpersonationUrl(SERVICE_ACCOUNT_IMPERSONATION_URL) + .setQuotaProjectId("quotaProjectId") + .setClientId("clientId") + .setClientSecret("clientSecret") + .setScopes(scopes) + .build(); + + assertEquals("audience", credentials.getAudience()); + assertEquals("subjectTokenType", credentials.getSubjectTokenType()); + assertEquals(credentials.getTokenUrl(), "tokenUrl"); + assertEquals(credentials.getTokenInfoUrl(), "tokenInfoUrl"); + assertEquals( + credentials.getServiceAccountImpersonationUrl(), SERVICE_ACCOUNT_IMPERSONATION_URL); + assertEquals(credentials.getCredentialSource(), FILE_CREDENTIAL_SOURCE); + assertEquals(credentials.getQuotaProjectId(), "quotaProjectId"); + assertEquals(credentials.getClientId(), "clientId"); + assertEquals(credentials.getClientSecret(), "clientSecret"); + assertEquals(credentials.getScopes(), scopes); + } + + static InputStream writeIdentityPoolCredentialsStream( + String tokenUrl, String url, @Nullable String serviceAccountImpersonationUrl) + throws IOException { + GenericJson json = new GenericJson(); + json.put("audience", "audience"); + json.put("subject_token_type", "subjectTokenType"); + json.put("token_url", tokenUrl); + json.put("token_info_url", "tokenInfoUrl"); + json.put("type", ExternalAccountCredentials.EXTERNAL_ACCOUNT_FILE_TYPE); + + if (serviceAccountImpersonationUrl != null) { + json.put("service_account_impersonation_url", serviceAccountImpersonationUrl); + } + + GenericJson credentialSource = new GenericJson(); + GenericJson headers = new GenericJson(); + headers.put("Metadata-Flavor", "Google"); + credentialSource.put("url", url); + credentialSource.put("headers", headers); + + json.put("credential_source", credentialSource); + return TestUtils.jsonToInputStream(json); + } + + private static IdentityPoolCredentialSource buildUrlBasedCredentialSource(String url) { + return buildUrlBasedCredentialSource(url, /* formatMap= */ null); + } + + private static IdentityPoolCredentialSource buildUrlBasedCredentialSource( + String url, Map formatMap) { + Map credentialSourceMap = new HashMap<>(); + Map headers = new HashMap<>(); + headers.put("Metadata-Flavor", "Google"); + credentialSourceMap.put("url", url); + credentialSourceMap.put("headers", headers); + credentialSourceMap.put("format", formatMap); + + return new IdentityPoolCredentialSource(credentialSourceMap); + } +} diff --git a/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java b/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java new file mode 100644 index 000000000..fc7e0cdb9 --- /dev/null +++ b/oauth2_http/javatests/com/google/auth/oauth2/MockExternalAccountCredentialsTransport.java @@ -0,0 +1,263 @@ +/* + * 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.assertTrue; + +import com.google.api.client.http.LowLevelHttpRequest; +import com.google.api.client.http.LowLevelHttpResponse; +import com.google.api.client.json.GenericJson; +import com.google.api.client.json.Json; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.json.gson.GsonFactory; +import com.google.api.client.testing.http.MockHttpTransport; +import com.google.api.client.testing.http.MockLowLevelHttpRequest; +import com.google.api.client.testing.http.MockLowLevelHttpResponse; +import com.google.auth.TestUtils; +import com.google.common.base.Joiner; +import java.io.IOException; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Queue; + +/** + * Mock transport that handles the necessary steps to exchange an external credential for a GCP + * access-token. + */ +public class MockExternalAccountCredentialsTransport extends MockHttpTransport { + + private static final String EXPECTED_GRANT_TYPE = + "urn:ietf:params:oauth:grant-type:token-exchange"; + private static final String CLOUD_PLATFORM_SCOPE = + "https://www.googleapis.com/auth/cloud-platform"; + private static final String ISSUED_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"; + private static final String AWS_CREDENTIALS_URL = "https://www.aws-credentials.com"; + private static final String AWS_REGION_URL = "https://www.aws-region.com"; + private static final String METADATA_SERVER_URL = "https://www.metadata.google.com"; + private static final String STS_URL = "https://www.sts.google.com"; + + private static final String SUBJECT_TOKEN = "subjectToken"; + private static final String TOKEN_TYPE = "Bearer"; + private static final String ACCESS_TOKEN = "accessToken"; + private static final String SERVICE_ACCOUNT_ACCESS_TOKEN = "serviceAccountAccessToken"; + private static final Long EXPIRES_IN = 3600L; + + private static final JsonFactory JSON_FACTORY = new GsonFactory(); + + static final String SERVICE_ACCOUNT_IMPERSONATION_URL = + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/testn@test.iam.gserviceaccount.com:generateAccessToken"; + + private Queue responseSequence = new ArrayDeque<>(); + private Queue responseErrorSequence = new ArrayDeque<>(); + private Queue refreshTokenSequence = new ArrayDeque<>(); + private Queue> scopeSequence = new ArrayDeque<>(); + private MockLowLevelHttpRequest request; + private String expireTime; + private String metadataServerContentType; + + public void addResponseErrorSequence(IOException... errors) { + Collections.addAll(responseErrorSequence, errors); + } + + public void addResponseSequence(Boolean... responses) { + Collections.addAll(responseSequence, responses); + } + + public void addRefreshTokenSequence(String... refreshTokens) { + Collections.addAll(refreshTokenSequence, refreshTokens); + } + + public void addScopeSequence(List... scopes) { + Collections.addAll(scopeSequence, scopes); + } + + @Override + public LowLevelHttpRequest buildRequest(final String method, final String url) { + this.request = + new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() throws IOException { + boolean successfulResponse = !responseSequence.isEmpty() && responseSequence.poll(); + + if (!responseErrorSequence.isEmpty() && !successfulResponse) { + throw responseErrorSequence.poll(); + } + + if (AWS_REGION_URL.equals(url)) { + return new MockLowLevelHttpResponse() + .setContentType("text/html") + .setContent("us-east-1b"); + } + if (AWS_CREDENTIALS_URL.equals(url)) { + return new MockLowLevelHttpResponse() + .setContentType("text/html") + .setContent("roleName"); + } + if ((AWS_CREDENTIALS_URL + "/" + "roleName").equals(url)) { + GenericJson response = new GenericJson(); + response.setFactory(JSON_FACTORY); + response.put("AccessKeyId", "accessKeyId"); + response.put("SecretAccessKey", "secretAccessKey"); + response.put("Token", "token"); + + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } + + if (METADATA_SERVER_URL.equals(url)) { + String metadataRequestHeader = getFirstHeaderValue("Metadata-Flavor"); + if (!"Google".equals(metadataRequestHeader)) { + throw new IOException("Metadata request header not found."); + } + + if (metadataServerContentType != null && metadataServerContentType.equals("json")) { + GenericJson response = new GenericJson(); + response.setFactory(JSON_FACTORY); + response.put("subjectToken", SUBJECT_TOKEN); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toString()); + } + return new MockLowLevelHttpResponse() + .setContentType("text/html") + .setContent(SUBJECT_TOKEN); + } + if (STS_URL.equals(url)) { + Map query = TestUtils.parseQuery(getContentAsString()); + assertEquals(EXPECTED_GRANT_TYPE, query.get("grant_type")); + assertNotNull(query.get("subject_token_type")); + assertNotNull(query.get("subject_token")); + + GenericJson response = new GenericJson(); + response.setFactory(JSON_FACTORY); + response.put("token_type", TOKEN_TYPE); + response.put("expires_in", EXPIRES_IN); + response.put("access_token", ACCESS_TOKEN); + response.put("issued_token_type", ISSUED_TOKEN_TYPE); + + if (!refreshTokenSequence.isEmpty()) { + response.put("refresh_token", refreshTokenSequence.poll()); + } + if (!scopeSequence.isEmpty()) { + response.put("scope", Joiner.on(' ').join(scopeSequence.poll())); + } + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toPrettyString()); + } + if (SERVICE_ACCOUNT_IMPERSONATION_URL.equals(url)) { + GenericJson query = + OAuth2Utils.JSON_FACTORY + .createJsonParser(getContentAsString()) + .parseAndClose(GenericJson.class); + assertEquals(CLOUD_PLATFORM_SCOPE, ((ArrayList) query.get("scope")).get(0)); + assertEquals(1, getHeaders().get("authorization").size()); + assertTrue(getHeaders().containsKey("authorization")); + assertNotNull(getHeaders().get("authorization").get(0)); + + GenericJson response = new GenericJson(); + response.setFactory(JSON_FACTORY); + response.put("accessToken", SERVICE_ACCOUNT_ACCESS_TOKEN); + response.put("expireTime", expireTime); + + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(response.toPrettyString()); + } + return null; + } + }; + return this.request; + } + + public MockLowLevelHttpRequest getRequest() { + return request; + } + + public String getTokenType() { + return TOKEN_TYPE; + } + + public String getAccessToken() { + return ACCESS_TOKEN; + } + + public String getServiceAccountAccessToken() { + return SERVICE_ACCOUNT_ACCESS_TOKEN; + } + + public String getIssuedTokenType() { + return ISSUED_TOKEN_TYPE; + } + + public Long getExpiresIn() { + return EXPIRES_IN; + } + + public String getSubjectToken() { + return SUBJECT_TOKEN; + } + + public String getMetadataUrl() { + return METADATA_SERVER_URL; + } + + public String getAwsCredentialsUrl() { + return AWS_CREDENTIALS_URL; + } + + public String getAwsRegionUrl() { + return AWS_REGION_URL; + } + + public String getStsUrl() { + return STS_URL; + } + + public String getServiceAccountImpersonationUrl() { + return SERVICE_ACCOUNT_IMPERSONATION_URL; + } + + public void setExpireTime(String expireTime) { + this.expireTime = expireTime; + } + + public void setMetadataServerContentType(String contentType) { + this.metadataServerContentType = contentType; + } +} diff --git a/oauth2_http/javatests/com/google/auth/oauth2/OAuthExceptionTest.java b/oauth2_http/javatests/com/google/auth/oauth2/OAuthExceptionTest.java new file mode 100644 index 000000000..f864f4791 --- /dev/null +++ b/oauth2_http/javatests/com/google/auth/oauth2/OAuthExceptionTest.java @@ -0,0 +1,87 @@ +/* + * 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.assertNull; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Tests for {@link OAuthException}. */ +@RunWith(JUnit4.class) +public final class OAuthExceptionTest { + + private static final String FULL_MESSAGE_FORMAT = "Error code %s: %s - %s"; + private static final String ERROR_DESCRIPTION_FORMAT = "Error code %s: %s"; + private static final String BASE_MESSAGE_FORMAT = "Error code %s"; + + @Test + public void getMessage_fullFormat() { + OAuthException e = new OAuthException("errorCode", "errorDescription", "errorUri"); + + assertEquals("errorCode", e.getErrorCode()); + assertEquals("errorDescription", e.getErrorDescription()); + assertEquals("errorUri", e.getErrorUri()); + + String expectedMessage = + String.format(FULL_MESSAGE_FORMAT, "errorCode", "errorDescription", "errorUri"); + assertEquals(expectedMessage, e.getMessage()); + } + + @Test + public void getMessage_descriptionFormat() { + OAuthException e = new OAuthException("errorCode", "errorDescription", /* errorUri= */ null); + + assertEquals("errorCode", e.getErrorCode()); + assertEquals("errorDescription", e.getErrorDescription()); + assertNull(e.getErrorUri()); + + String expectedMessage = + String.format(ERROR_DESCRIPTION_FORMAT, "errorCode", "errorDescription"); + assertEquals(expectedMessage, e.getMessage()); + } + + @Test + public void getMessage_baseFormat() { + OAuthException e = + new OAuthException("errorCode", /* errorDescription= */ null, /* errorUri= */ null); + + assertEquals("errorCode", e.getErrorCode()); + assertNull(e.getErrorDescription()); + assertNull(e.getErrorUri()); + + String expectedMessage = String.format(BASE_MESSAGE_FORMAT, "errorCode"); + assertEquals(expectedMessage, e.getMessage()); + } +} diff --git a/oauth2_http/javatests/com/google/auth/oauth2/StsRequestHandlerTest.java b/oauth2_http/javatests/com/google/auth/oauth2/StsRequestHandlerTest.java new file mode 100644 index 000000000..65d2bf90f --- /dev/null +++ b/oauth2_http/javatests/com/google/auth/oauth2/StsRequestHandlerTest.java @@ -0,0 +1,251 @@ +/* + * 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.assertNull; +import static org.junit.Assert.assertThrows; + +import com.google.api.client.http.HttpHeaders; +import com.google.api.client.testing.http.MockLowLevelHttpRequest; +import com.google.api.client.util.GenericData; +import com.google.auth.TestUtils; +import com.google.common.base.Joiner; +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; +import org.junit.function.ThrowingRunnable; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Tests for {@link StsRequestHandler}. */ +@RunWith(JUnit4.class) +public final class StsRequestHandlerTest { + + private static final String TOKEN_EXCHANGE_GRANT_TYPE = + "urn:ietf:params:oauth:grant-type:token-exchange"; + private static final String CLOUD_PLATFORM_SCOPE = + "https://www.googleapis.com/auth/cloud-platform"; + private static final String DEFAULT_REQUESTED_TOKEN_TYPE = + "urn:ietf:params:oauth:token-type:access_token"; + private static final String TOKEN_URL = "https://www.sts.google.com"; + + private MockExternalAccountCredentialsTransport transport; + + @Before + public void setup() { + transport = new MockExternalAccountCredentialsTransport(); + } + + @Test + public void exchangeToken() throws IOException { + StsTokenExchangeRequest stsTokenExchangeRequest = + StsTokenExchangeRequest.newBuilder("credential", "subjectTokenType") + .setScopes(Arrays.asList(CLOUD_PLATFORM_SCOPE)) + .build(); + + StsRequestHandler requestHandler = + StsRequestHandler.newBuilder( + TOKEN_URL, stsTokenExchangeRequest, transport.createRequestFactory()) + .build(); + + StsTokenExchangeResponse response = requestHandler.exchangeToken(); + + // Validate response. + assertEquals(transport.getAccessToken(), response.getAccessToken().getTokenValue()); + assertEquals(transport.getTokenType(), response.getTokenType()); + assertEquals(transport.getIssuedTokenType(), response.getIssuedTokenType()); + assertEquals(transport.getExpiresIn(), response.getExpiresInSeconds()); + + // Validate request content. + GenericData expectedRequestContent = + new GenericData() + .set("grant_type", TOKEN_EXCHANGE_GRANT_TYPE) + .set("scope", CLOUD_PLATFORM_SCOPE) + .set("requested_token_type", DEFAULT_REQUESTED_TOKEN_TYPE) + .set("subject_token_type", stsTokenExchangeRequest.getSubjectTokenType()) + .set("subject_token", stsTokenExchangeRequest.getSubjectToken()); + + MockLowLevelHttpRequest request = transport.getRequest(); + Map actualRequestContent = TestUtils.parseQuery(request.getContentAsString()); + assertEquals(expectedRequestContent.getUnknownKeys(), actualRequestContent); + } + + @Test + public void exchangeToken_withOptionalParams() throws IOException { + // Return optional params scope and the refresh_token. + transport.addScopeSequence(Arrays.asList("scope1", "scope2", "scope3")); + transport.addRefreshTokenSequence("refreshToken"); + + // Build the token exchange request. + StsTokenExchangeRequest stsTokenExchangeRequest = + StsTokenExchangeRequest.newBuilder("credential", "subjectTokenType") + .setAudience("audience") + .setResource("resource") + .setActingParty(new ActingParty("actorToken", "actorTokenType")) + .setRequestTokenType("requestedTokenType") + .setScopes(Arrays.asList("scope1", "scope2", "scope3")) + .build(); + + HttpHeaders httpHeaders = + new HttpHeaders() + .setContentType("application/x-www-form-urlencoded") + .setAcceptEncoding("gzip") + .set("custom_header_key", "custom_header_value"); + + StsRequestHandler requestHandler = + StsRequestHandler.newBuilder( + TOKEN_URL, stsTokenExchangeRequest, transport.createRequestFactory()) + .setHeaders(httpHeaders) + .setInternalOptions("internalOptions") + .build(); + + StsTokenExchangeResponse response = requestHandler.exchangeToken(); + + // Validate response. + assertEquals(transport.getAccessToken(), response.getAccessToken().getTokenValue()); + assertEquals(transport.getTokenType(), response.getTokenType()); + assertEquals(transport.getIssuedTokenType(), response.getIssuedTokenType()); + assertEquals(transport.getExpiresIn(), response.getExpiresInSeconds()); + assertEquals(Arrays.asList("scope1", "scope2", "scope3"), response.getScopes()); + assertEquals("refreshToken", response.getRefreshToken()); + + // Validate headers. + MockLowLevelHttpRequest request = transport.getRequest(); + Map> requestHeaders = request.getHeaders(); + assertEquals("application/x-www-form-urlencoded", requestHeaders.get("content-type").get(0)); + assertEquals("gzip", requestHeaders.get("accept-encoding").get(0)); + assertEquals("custom_header_value", requestHeaders.get("custom_header_key").get(0)); + + // Validate request content. + GenericData expectedRequestContent = + new GenericData() + .set("grant_type", TOKEN_EXCHANGE_GRANT_TYPE) + .set("scope", Joiner.on(' ').join(Arrays.asList("scope1", "scope2", "scope3"))) + .set("options", "internalOptions") + .set("subject_token_type", stsTokenExchangeRequest.getSubjectTokenType()) + .set("subject_token", stsTokenExchangeRequest.getSubjectToken()) + .set("requested_token_type", stsTokenExchangeRequest.getRequestedTokenType()) + .set("actor_token", stsTokenExchangeRequest.getActingParty().getActorToken()) + .set("actor_token_type", stsTokenExchangeRequest.getActingParty().getActorTokenType()) + .set("resource", stsTokenExchangeRequest.getResource()) + .set("audience", stsTokenExchangeRequest.getAudience()); + + Map actualRequestContent = TestUtils.parseQuery(request.getContentAsString()); + assertEquals(expectedRequestContent.getUnknownKeys(), actualRequestContent); + } + + @Test + public void exchangeToken_throwsException() throws IOException { + StsTokenExchangeRequest stsTokenExchangeRequest = + StsTokenExchangeRequest.newBuilder("credential", "subjectTokenType").build(); + + final StsRequestHandler requestHandler = + StsRequestHandler.newBuilder( + TOKEN_URL, stsTokenExchangeRequest, transport.createRequestFactory()) + .build(); + + transport.addResponseErrorSequence( + TestUtils.buildHttpResponseException( + "invalidRequest", /* errorDescription= */ null, /* errorUri= */ null)); + + OAuthException e = + assertThrows( + OAuthException.class, + new ThrowingRunnable() { + @Override + public void run() throws Throwable { + requestHandler.exchangeToken(); + } + }); + + assertEquals("invalidRequest", e.getErrorCode()); + assertNull(e.getErrorDescription()); + assertNull(e.getErrorUri()); + } + + @Test + public void exchangeToken_withOptionalParams_throwsException() throws IOException { + StsTokenExchangeRequest stsTokenExchangeRequest = + StsTokenExchangeRequest.newBuilder("credential", "subjectTokenType").build(); + + final StsRequestHandler requestHandler = + StsRequestHandler.newBuilder( + TOKEN_URL, stsTokenExchangeRequest, transport.createRequestFactory()) + .build(); + + transport.addResponseErrorSequence( + TestUtils.buildHttpResponseException("invalidRequest", "errorDescription", "errorUri")); + + OAuthException e = + assertThrows( + OAuthException.class, + new ThrowingRunnable() { + @Override + public void run() throws Throwable { + requestHandler.exchangeToken(); + } + }); + + assertEquals("invalidRequest", e.getErrorCode()); + assertEquals("errorDescription", e.getErrorDescription()); + assertEquals("errorUri", e.getErrorUri()); + } + + @Test + public void exchangeToken_ioException() { + StsTokenExchangeRequest stsTokenExchangeRequest = + StsTokenExchangeRequest.newBuilder("credential", "subjectTokenType").build(); + + final StsRequestHandler requestHandler = + StsRequestHandler.newBuilder( + TOKEN_URL, stsTokenExchangeRequest, transport.createRequestFactory()) + .build(); + + IOException e = new IOException(); + transport.addResponseErrorSequence(e); + + IOException thrownException = + assertThrows( + IOException.class, + new ThrowingRunnable() { + @Override + public void run() throws Throwable { + requestHandler.exchangeToken(); + } + }); + assertEquals(e, thrownException); + } +} diff --git a/oauth2_http/testresources/aws_security_credentials.json b/oauth2_http/testresources/aws_security_credentials.json new file mode 100644 index 000000000..76e7688a3 --- /dev/null +++ b/oauth2_http/testresources/aws_security_credentials.json @@ -0,0 +1,9 @@ +{ + "Code" : "Success", + "LastUpdated" : "2020-08-11T19:33:07Z", + "Type" : "AWS-HMAC", + "AccessKeyId" : "ASIARD4OQDT6A77FR3CL", + "SecretAccessKey" : "Y8AfSaucF37G4PpvfguKZ3/l7Id4uocLXxX0+VTx", + "Token" : "IQoJb3JpZ2luX2VjEIz//////////wEaCXVzLWVhc3QtMiJGMEQCIH7MHX/Oy/OB8OlLQa9GrqU1B914+iMikqWQW7vPCKlgAiA/Lsv8Jcafn14owfxXn95FURZNKaaphj0ykpmS+Ki+CSq0AwhlEAAaDDA3NzA3MTM5MTk5NiIMx9sAeP1ovlMTMKLjKpEDwuJQg41/QUKx0laTZYjPlQvjwSqS3OB9P1KAXPWSLkliVMMqaHqelvMF/WO/glv3KwuTfQsavRNs3v5pcSEm4SPO3l7mCs7KrQUHwGP0neZhIKxEXy+Ls//1C/Bqt53NL+LSbaGv6RPHaX82laz2qElphg95aVLdYgIFY6JWV5fzyjgnhz0DQmy62/Vi8pNcM2/VnxeCQ8CC8dRDSt52ry2v+nc77vstuI9xV5k8mPtnaPoJDRANh0bjwY5Sdwkbp+mGRUJBAQRlNgHUJusefXQgVKBCiyJY4w3Csd8Bgj9IyDV+Azuy1jQqfFZWgP68LSz5bURyIjlWDQunO82stZ0BgplKKAa/KJHBPCp8Qi6i99uy7qh76FQAqgVTsnDuU6fGpHDcsDSGoCls2HgZjZFPeOj8mmRhFk1Xqvkbjuz8V1cJk54d3gIJvQt8gD2D6yJQZecnuGWd5K2e2HohvCc8Fc9kBl1300nUJPV+k4tr/A5R/0QfEKOZL1/k5lf1g9CREnrM8LVkGxCgdYMxLQow1uTL+QU67AHRRSp5PhhGX4Rek+01vdYSnJCMaPhSEgcLqDlQkhk6MPsyT91QMXcWmyO+cAZwUPwnRamFepuP4K8k2KVXs/LIJHLELwAZ0ekyaS7CptgOqS7uaSTFG3U+vzFZLEnGvWQ7y9IPNQZ+Dffgh4p3vF4J68y9049sI6Sr5d5wbKkcbm8hdCDHZcv4lnqohquPirLiFQ3q7B17V9krMPu3mz1cg4Ekgcrn/E09NTsxAqD8NcZ7C7ECom9r+X3zkDOxaajW6hu3Az8hGlyylDaMiFfRbBJpTIlxp7jfa7CxikNgNtEKLH9iCzvuSg2vhA==", + "Expiration" : "2020-08-11T07:35:49Z" +} \ No newline at end of file