diff --git a/oauth2_http/java/com/google/auth/oauth2/CredentialAccessBoundary.java b/oauth2_http/java/com/google/auth/oauth2/CredentialAccessBoundary.java
new file mode 100644
index 000000000..92cacc258
--- /dev/null
+++ b/oauth2_http/java/com/google/auth/oauth2/CredentialAccessBoundary.java
@@ -0,0 +1,345 @@
+/*
+ * 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.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.api.client.json.GenericJson;
+import java.util.ArrayList;
+import java.util.List;
+import javax.annotation.Nullable;
+
+/**
+ * Defines an upper bound of permissions available for a GCP credential via {@link
+ * AccessBoundaryRule}s.
+ *
+ *
See for more
+ * information.
+ */
+public final class CredentialAccessBoundary {
+
+ private static final int RULES_SIZE_LIMIT = 10;
+
+ private final List accessBoundaryRules;
+
+ CredentialAccessBoundary(List accessBoundaryRules) {
+ checkNotNull(accessBoundaryRules);
+ checkArgument(
+ !accessBoundaryRules.isEmpty(), "At least one access boundary rule must be provided.");
+ checkArgument(
+ accessBoundaryRules.size() < RULES_SIZE_LIMIT,
+ String.format(
+ "The provided list has more than %s access boundary rules.", RULES_SIZE_LIMIT));
+ this.accessBoundaryRules = accessBoundaryRules;
+ }
+
+ /**
+ * Internal method that returns the JSON string representation of the credential access boundary.
+ */
+ String toJson() {
+ List rules = new ArrayList<>();
+ for (AccessBoundaryRule rule : accessBoundaryRules) {
+ GenericJson ruleJson = new GenericJson();
+ ruleJson.setFactory(OAuth2Utils.JSON_FACTORY);
+
+ ruleJson.put("availableResource", rule.getAvailableResource());
+ ruleJson.put("availablePermissions", rule.getAvailablePermissions());
+
+ AccessBoundaryRule.AvailabilityCondition availabilityCondition =
+ rule.getAvailabilityCondition();
+ if (availabilityCondition != null) {
+ GenericJson availabilityConditionJson = new GenericJson();
+ availabilityConditionJson.setFactory(OAuth2Utils.JSON_FACTORY);
+
+ availabilityConditionJson.put("expression", availabilityCondition.getExpression());
+ if (availabilityCondition.getTitle() != null) {
+ availabilityConditionJson.put("title", availabilityCondition.getTitle());
+ }
+ if (availabilityCondition.getDescription() != null) {
+ availabilityConditionJson.put("description", availabilityCondition.getDescription());
+ }
+
+ ruleJson.put("availabilityCondition", availabilityConditionJson);
+ }
+ rules.add(ruleJson);
+ }
+ GenericJson accessBoundaryRulesJson = new GenericJson();
+ accessBoundaryRulesJson.setFactory(OAuth2Utils.JSON_FACTORY);
+ accessBoundaryRulesJson.put("accessBoundaryRules", rules);
+
+ GenericJson json = new GenericJson();
+ json.setFactory(OAuth2Utils.JSON_FACTORY);
+ json.put("accessBoundary", accessBoundaryRulesJson);
+ return json.toString();
+ }
+
+ public List getAccessBoundaryRules() {
+ return accessBoundaryRules;
+ }
+
+ public static Builder newBuilder() {
+ return new Builder();
+ }
+
+ public static class Builder {
+ private List accessBoundaryRules;
+
+ private Builder() {}
+
+ /**
+ * Sets the list of {@link AccessBoundaryRule}'s.
+ *
+ * This list must not exceed 10 rules.
+ */
+ public Builder setRules(List rule) {
+ accessBoundaryRules = new ArrayList<>(checkNotNull(rule));
+ return this;
+ }
+
+ public CredentialAccessBoundary.Builder addRule(AccessBoundaryRule rule) {
+ if (accessBoundaryRules == null) {
+ accessBoundaryRules = new ArrayList<>();
+ }
+ accessBoundaryRules.add(checkNotNull(rule));
+ return this;
+ }
+
+ public CredentialAccessBoundary build() {
+ return new CredentialAccessBoundary(accessBoundaryRules);
+ }
+ }
+
+ /**
+ * Defines an upper bound of permissions on a particular resource.
+ *
+ * The following snippet shows an AccessBoundaryRule that applies to the Cloud Storage bucket
+ * bucket-one to set the upper bound of permissions to those defined by the
+ * roles/storage.objectViewer role.
+ *
+ *
+ * AccessBoundaryRule rule = AccessBoundaryRule.newBuilder()
+ * .setAvailableResource("//storage.googleapis.com/projects/_/buckets/bucket-one")
+ * .addAvailablePermission("inRole:roles/storage.objectViewer")
+ * .build();
+ *
+ */
+ public static final class AccessBoundaryRule {
+
+ private final String availableResource;
+ private final List availablePermissions;
+
+ @Nullable private final AvailabilityCondition availabilityCondition;
+
+ AccessBoundaryRule(
+ String availableResource,
+ List availablePermissions,
+ @Nullable AvailabilityCondition availabilityCondition) {
+ this.availableResource = checkNotNull(availableResource);
+ this.availablePermissions = new ArrayList<>(checkNotNull(availablePermissions));
+ this.availabilityCondition = availabilityCondition;
+
+ checkArgument(!availableResource.isEmpty(), "The provided availableResource is empty.");
+ checkArgument(
+ !availablePermissions.isEmpty(), "The list of provided availablePermissions is empty.");
+ for (String permission : availablePermissions) {
+ if (permission == null) {
+ throw new IllegalArgumentException("One of the provided available permissions is null.");
+ }
+ if (permission.isEmpty()) {
+ throw new IllegalArgumentException("One of the provided available permissions is empty.");
+ }
+ }
+ }
+
+ public String getAvailableResource() {
+ return availableResource;
+ }
+
+ public List getAvailablePermissions() {
+ return availablePermissions;
+ }
+
+ @Nullable
+ public AvailabilityCondition getAvailabilityCondition() {
+ return availabilityCondition;
+ }
+
+ public static Builder newBuilder() {
+ return new Builder();
+ }
+
+ public static class Builder {
+ private String availableResource;
+ private List availablePermissions;
+
+ @Nullable private AvailabilityCondition availabilityCondition;
+
+ private Builder() {}
+
+ /**
+ * Sets the available resource, which is the full resource name of the GCP resource to allow
+ * access to.
+ *
+ * For example: "//storage.googleapis.com/projects/_/buckets/example".
+ */
+ public Builder setAvailableResource(String availableResource) {
+ this.availableResource = availableResource;
+ return this;
+ }
+
+ /**
+ * Sets the list of permissions that can be used on the resource. This should be a list of IAM
+ * roles prefixed by inRole.
+ *
+ *
For example: {"inRole:roles/storage.objectViewer"}.
+ */
+ public Builder setAvailablePermissions(List availablePermissions) {
+ this.availablePermissions = new ArrayList<>(checkNotNull(availablePermissions));
+ return this;
+ }
+
+ /**
+ * Adds a permission that can be used on the resource. This should be an IAM role prefixed by
+ * inRole.
+ *
+ * For example: "inRole:roles/storage.objectViewer".
+ */
+ public Builder addAvailablePermission(String availablePermission) {
+ if (availablePermissions == null) {
+ availablePermissions = new ArrayList<>();
+ }
+ availablePermissions.add(availablePermission);
+ return this;
+ }
+
+ /**
+ * Sets the availability condition which is an IAM condition that defines constraints to apply
+ * to the token expressed in CEL format.
+ */
+ public Builder setAvailabilityCondition(AvailabilityCondition availabilityCondition) {
+ this.availabilityCondition = availabilityCondition;
+ return this;
+ }
+
+ public AccessBoundaryRule build() {
+ return new AccessBoundaryRule(
+ availableResource, availablePermissions, availabilityCondition);
+ }
+ }
+
+ /**
+ * An optional condition that can be used as part of a {@link AccessBoundaryRule} to further
+ * restrict permissions.
+ *
+ *
For example, you can define an AvailabilityCondition that applies to a set of Cloud
+ * Storage objects whose names start with auth:
+ *
+ *
+ * AvailabilityCondition availabilityCondition = AvailabilityCondition.newBuilder()
+ * .setExpression("resource.name.startsWith('projects/_/buckets/bucket-123/objects/auth')")
+ * .build();
+ *
+ */
+ public static final class AvailabilityCondition {
+ private final String expression;
+
+ @Nullable private final String title;
+ @Nullable private final String description;
+
+ AvailabilityCondition(
+ String expression, @Nullable String title, @Nullable String description) {
+ this.expression = checkNotNull(expression);
+ this.title = title;
+ this.description = description;
+
+ checkArgument(!expression.isEmpty(), "The provided expression is empty.");
+ }
+
+ public String getExpression() {
+ return expression;
+ }
+
+ @Nullable
+ public String getTitle() {
+ return title;
+ }
+
+ @Nullable
+ public String getDescription() {
+ return description;
+ }
+
+ public static Builder newBuilder() {
+ return new Builder();
+ }
+
+ public static final class Builder {
+ private String expression;
+
+ @Nullable private String title;
+ @Nullable private String description;
+
+ private Builder() {}
+
+ /**
+ * Sets the required expression which must be defined in Common Expression Language (CEL)
+ * format.
+ *
+ * This expression specifies the Cloud Storage object where permissions are available.
+ * See for more
+ * information.
+ */
+ public Builder setExpression(String expression) {
+ this.expression = expression;
+ return this;
+ }
+
+ /** Sets the optional title that identifies the purpose of the condition. */
+ public Builder setTitle(String title) {
+ this.title = title;
+ return this;
+ }
+
+ /** Sets the description that details the purpose of the condition. */
+ public Builder setDescription(String description) {
+ this.description = description;
+ return this;
+ }
+
+ public AvailabilityCondition build() {
+ return new AvailabilityCondition(expression, title, description);
+ }
+ }
+ }
+ }
+}
diff --git a/oauth2_http/java/com/google/auth/oauth2/DownscopedCredentials.java b/oauth2_http/java/com/google/auth/oauth2/DownscopedCredentials.java
new file mode 100644
index 000000000..1928f2539
--- /dev/null
+++ b/oauth2_http/java/com/google/auth/oauth2/DownscopedCredentials.java
@@ -0,0 +1,193 @@
+/*
+ * 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.MoreObjects.firstNonNull;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.auth.http.HttpTransportFactory;
+import com.google.common.annotations.VisibleForTesting;
+import java.io.IOException;
+import java.util.Arrays;
+
+/**
+ * DownscopedCredentials enables the ability to downscope, or restrict, the Identity and Access
+ * Management (IAM) permissions that a short-lived credential can use for Cloud Storage.
+ *
+ *
To downscope permissions you must define a {@link CredentialAccessBoundary} which specifies
+ * the upper bound of permissions that the credential can access. You must also provide a source
+ * credential which will be used to acquire the downscoped credential.
+ *
+ *
See for more
+ * information.
+ *
+ *
Usage:
+ *
+ *
+ * GoogleCredentials sourceCredentials = GoogleCredentials.getApplicationDefault();
+ *
+ * CredentialAccessBoundary.AccessBoundaryRule rule =
+ * CredentialAccessBoundary.AccessBoundaryRule.newBuilder()
+ * .setAvailableResource(
+ * "//storage.googleapis.com/projects/_/buckets/bucket")
+ * .addAvailablePermission("inRole:roles/storage.objectViewer")
+ * .build();
+ *
+ * DownscopedCredentials downscopedCredentials =
+ * DownscopedCredentials.newBuilder()
+ * .setSourceCredential(credentials)
+ * .setCredentialAccessBoundary(
+ * CredentialAccessBoundary.newBuilder().addRule(rule).build())
+ * .build();
+ *
+ * AccessToken accessToken = downscopedCredentials.refreshAccessToken();
+ *
+ * OAuth2Credentials credentials = OAuth2Credentials.create(accessToken);
+ *
+ * Storage storage =
+ * StorageOptions.newBuilder().setCredentials(credentials).build().getService();
+ *
+ * Blob blob = storage.get(BlobId.of("bucket", "object"));
+ * System.out.printf("Blob %s retrieved.", blob.getBlobId());
+ *
+ *
+ * Note that {@link OAuth2CredentialsWithRefresh} can instead be used to consume the downscoped
+ * token, allowing for automatic token refreshes by providing a {@link
+ * OAuth2CredentialsWithRefresh.OAuth2RefreshHandler}.
+ */
+public final class DownscopedCredentials extends OAuth2Credentials {
+
+ private static final String TOKEN_EXCHANGE_ENDPOINT = "https://sts.googleapis.com/v1/token";
+
+ private static final String CLOUD_PLATFORM_SCOPE =
+ "https://www.googleapis.com/auth/cloud-platform";
+
+ private final GoogleCredentials sourceCredential;
+ private final CredentialAccessBoundary credentialAccessBoundary;
+ private final transient HttpTransportFactory transportFactory;
+
+ private DownscopedCredentials(
+ GoogleCredentials sourceCredential,
+ CredentialAccessBoundary credentialAccessBoundary,
+ HttpTransportFactory transportFactory) {
+ this.transportFactory =
+ firstNonNull(
+ transportFactory,
+ getFromServiceLoader(HttpTransportFactory.class, OAuth2Utils.HTTP_TRANSPORT_FACTORY));
+ this.sourceCredential =
+ checkNotNull(sourceCredential.createScoped(Arrays.asList(CLOUD_PLATFORM_SCOPE)));
+ this.credentialAccessBoundary = checkNotNull(credentialAccessBoundary);
+ }
+
+ @Override
+ public AccessToken refreshAccessToken() throws IOException {
+ try {
+ this.sourceCredential.refreshIfExpired();
+ } catch (IOException e) {
+ throw new IOException("Unable to refresh the provided source credential.", e);
+ }
+
+ StsTokenExchangeRequest request =
+ StsTokenExchangeRequest.newBuilder(
+ sourceCredential.getAccessToken().getTokenValue(),
+ OAuth2Utils.TOKEN_TYPE_ACCESS_TOKEN)
+ .setRequestTokenType(OAuth2Utils.TOKEN_TYPE_ACCESS_TOKEN)
+ .build();
+
+ StsRequestHandler handler =
+ StsRequestHandler.newBuilder(
+ TOKEN_EXCHANGE_ENDPOINT, request, transportFactory.create().createRequestFactory())
+ .setInternalOptions(credentialAccessBoundary.toJson())
+ .build();
+
+ AccessToken downscopedAccessToken = handler.exchangeToken().getAccessToken();
+
+ // The STS endpoint will only return the expiration time for the downscoped token if the
+ // original access token represents a service account.
+ // The downscoped token's expiration time will always match the source credential expiration.
+ // When no expires_in is returned, we can copy the source credential's expiration time.
+ if (downscopedAccessToken.getExpirationTime() == null) {
+ AccessToken sourceAccessToken = this.sourceCredential.getAccessToken();
+ if (sourceAccessToken.getExpirationTime() != null) {
+ return new AccessToken(
+ downscopedAccessToken.getTokenValue(), sourceAccessToken.getExpirationTime());
+ }
+ }
+ return downscopedAccessToken;
+ }
+
+ public GoogleCredentials getSourceCredentials() {
+ return sourceCredential;
+ }
+
+ public CredentialAccessBoundary getCredentialAccessBoundary() {
+ return credentialAccessBoundary;
+ }
+
+ @VisibleForTesting
+ HttpTransportFactory getTransportFactory() {
+ return transportFactory;
+ }
+
+ public static Builder newBuilder() {
+ return new Builder();
+ }
+
+ public static class Builder extends OAuth2Credentials.Builder {
+
+ private GoogleCredentials sourceCredential;
+ private CredentialAccessBoundary credentialAccessBoundary;
+ private HttpTransportFactory transportFactory;
+
+ private Builder() {}
+
+ public Builder setSourceCredential(GoogleCredentials sourceCredential) {
+ this.sourceCredential = sourceCredential;
+ return this;
+ }
+
+ public Builder setCredentialAccessBoundary(CredentialAccessBoundary credentialAccessBoundary) {
+ this.credentialAccessBoundary = credentialAccessBoundary;
+ return this;
+ }
+
+ public Builder setHttpTransportFactory(HttpTransportFactory transportFactory) {
+ this.transportFactory = transportFactory;
+ return this;
+ }
+
+ public DownscopedCredentials build() {
+ return new DownscopedCredentials(
+ sourceCredential, credentialAccessBoundary, transportFactory);
+ }
+ }
+}
diff --git a/oauth2_http/java/com/google/auth/oauth2/OAuth2CredentialsWithRefresh.java b/oauth2_http/java/com/google/auth/oauth2/OAuth2CredentialsWithRefresh.java
new file mode 100644
index 000000000..0306fc43f
--- /dev/null
+++ b/oauth2_http/java/com/google/auth/oauth2/OAuth2CredentialsWithRefresh.java
@@ -0,0 +1,107 @@
+/*
+ * 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;
+
+/**
+ * A refreshable alternative to {@link OAuth2Credentials}.
+ *
+ * To enable automatic token refreshes, you must provide an {@link OAuth2RefreshHandler}.
+ */
+public class OAuth2CredentialsWithRefresh extends OAuth2Credentials {
+
+ /** Interface for the refresh handler. */
+ public interface OAuth2RefreshHandler {
+ AccessToken refreshAccessToken() throws IOException;
+ }
+
+ private final OAuth2RefreshHandler refreshHandler;
+
+ protected OAuth2CredentialsWithRefresh(
+ AccessToken accessToken, OAuth2RefreshHandler refreshHandler) {
+ super(accessToken);
+
+ // If no expirationTime is provided, the token will never be refreshed.
+ if (accessToken != null && accessToken.getExpirationTime() == null) {
+ throw new IllegalArgumentException(
+ "The provided access token must contain the expiration time.");
+ }
+
+ this.refreshHandler = checkNotNull(refreshHandler);
+ }
+
+ /** Refreshes the access token using the provided {@link OAuth2RefreshHandler}. */
+ @Override
+ public AccessToken refreshAccessToken() throws IOException {
+ // Delegate refresh to the provided refresh handler.
+ return refreshHandler.refreshAccessToken();
+ }
+
+ /** Returns the provided {@link OAuth2RefreshHandler}. */
+ public OAuth2RefreshHandler getRefreshHandler() {
+ return refreshHandler;
+ }
+
+ public static Builder newBuilder() {
+ return new Builder();
+ }
+
+ public static class Builder extends OAuth2Credentials.Builder {
+
+ private OAuth2RefreshHandler refreshHandler;
+
+ private Builder() {}
+
+ /**
+ * Sets the {@link AccessToken} to be consumed. It must contain an expiration time otherwise an
+ * {@link IllegalArgumentException} will be thrown.
+ */
+ @Override
+ public Builder setAccessToken(AccessToken token) {
+ super.setAccessToken(token);
+ return this;
+ }
+
+ /** Sets the {@link OAuth2RefreshHandler} to be used for token refreshes. */
+ public Builder setRefreshHandler(OAuth2RefreshHandler handler) {
+ this.refreshHandler = handler;
+ return this;
+ }
+
+ public OAuth2CredentialsWithRefresh build() {
+ return new OAuth2CredentialsWithRefresh(getAccessToken(), refreshHandler);
+ }
+ }
+}
diff --git a/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java b/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java
index 54e6bb941..9ba5ce8d7 100644
--- a/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java
+++ b/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java
@@ -57,6 +57,8 @@
class OAuth2Utils {
static final String SIGNATURE_ALGORITHM = "SHA256withRSA";
+ static final String TOKEN_TYPE_ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token";
+
static final URI TOKEN_SERVER_URI = URI.create("https://oauth2.googleapis.com/token");
static final URI TOKEN_REVOKE_URI = URI.create("https://oauth2.googleapis.com/revoke");
static final URI USER_AUTH_URI = URI.create("https://accounts.google.com/o/oauth2/auth");
diff --git a/oauth2_http/java/com/google/auth/oauth2/StsRequestHandler.java b/oauth2_http/java/com/google/auth/oauth2/StsRequestHandler.java
index a6a14fcbf..15e9611b1 100644
--- a/oauth2_http/java/com/google/auth/oauth2/StsRequestHandler.java
+++ b/oauth2_http/java/com/google/auth/oauth2/StsRequestHandler.java
@@ -53,8 +53,6 @@
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;
@@ -140,7 +138,9 @@ private GenericData buildTokenRequest() {
// 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;
+ request.hasRequestedTokenType()
+ ? request.getRequestedTokenType()
+ : OAuth2Utils.TOKEN_TYPE_ACCESS_TOKEN;
tokenRequest.set("requested_token_type", requestTokenType);
// Add other optional params, if possible.
@@ -168,13 +168,14 @@ private StsTokenExchangeResponse buildResponse(GenericData responseData) throws
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);
+ StsTokenExchangeResponse.newBuilder(accessToken, issuedTokenType, tokenType);
+ if (responseData.containsKey("expires_in")) {
+ builder.setExpiresInSeconds(
+ OAuth2Utils.validateLong(responseData, "expires_in", PARSE_ERROR_PREFIX));
+ }
if (responseData.containsKey("refresh_token")) {
builder.setRefreshToken(
OAuth2Utils.validateString(responseData, "refresh_token", PARSE_ERROR_PREFIX));
diff --git a/oauth2_http/java/com/google/auth/oauth2/StsTokenExchangeResponse.java b/oauth2_http/java/com/google/auth/oauth2/StsTokenExchangeResponse.java
index a16f5a329..ef0382689 100644
--- a/oauth2_http/java/com/google/auth/oauth2/StsTokenExchangeResponse.java
+++ b/oauth2_http/java/com/google/auth/oauth2/StsTokenExchangeResponse.java
@@ -46,8 +46,8 @@ final class StsTokenExchangeResponse {
private final AccessToken accessToken;
private final String issuedTokenType;
private final String tokenType;
- private final Long expiresInSeconds;
+ @Nullable private final Long expiresInSeconds;
@Nullable private final String refreshToken;
@Nullable private final List scopes;
@@ -55,22 +55,25 @@ private StsTokenExchangeResponse(
String accessToken,
String issuedTokenType,
String tokenType,
- Long expiresInSeconds,
+ @Nullable 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.expiresInSeconds = expiresInSeconds;
+ Long expiresAtMilliseconds =
+ expiresInSeconds == null ? null : System.currentTimeMillis() + expiresInSeconds * 1000L;
+ Date date = expiresAtMilliseconds == null ? null : new Date(expiresAtMilliseconds);
+ this.accessToken = new AccessToken(accessToken, date);
+
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 static Builder newBuilder(String accessToken, String issuedTokenType, String tokenType) {
+ return new Builder(accessToken, issuedTokenType, tokenType);
}
public AccessToken getAccessToken() {
@@ -85,6 +88,7 @@ public String getTokenType() {
return tokenType;
}
+ @Nullable
public Long getExpiresInSeconds() {
return expiresInSeconds;
}
@@ -106,17 +110,20 @@ public static class Builder {
private final String accessToken;
private final String issuedTokenType;
private final String tokenType;
- private final Long expiresInSeconds;
+ @Nullable private Long expiresInSeconds;
@Nullable private String refreshToken;
@Nullable private List scopes;
- private Builder(
- String accessToken, String issuedTokenType, String tokenType, Long expiresInSeconds) {
+ private Builder(String accessToken, String issuedTokenType, String tokenType) {
this.accessToken = accessToken;
this.issuedTokenType = issuedTokenType;
this.tokenType = tokenType;
+ }
+
+ public StsTokenExchangeResponse.Builder setExpiresInSeconds(long expiresInSeconds) {
this.expiresInSeconds = expiresInSeconds;
+ return this;
}
public StsTokenExchangeResponse.Builder setRefreshToken(String refreshToken) {
diff --git a/oauth2_http/javatests/com/google/auth/oauth2/CredentialAccessBoundaryTest.java b/oauth2_http/javatests/com/google/auth/oauth2/CredentialAccessBoundaryTest.java
new file mode 100644
index 000000000..ac042e065
--- /dev/null
+++ b/oauth2_http/javatests/com/google/auth/oauth2/CredentialAccessBoundaryTest.java
@@ -0,0 +1,323 @@
+/*
+ * 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.fail;
+
+import com.google.auth.oauth2.CredentialAccessBoundary.AccessBoundaryRule;
+import com.google.auth.oauth2.CredentialAccessBoundary.AccessBoundaryRule.AvailabilityCondition;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Tests for {@link CredentialAccessBoundary} and encompassing classes. */
+@RunWith(JUnit4.class)
+public class CredentialAccessBoundaryTest {
+
+ @Test
+ public void credentialAccessBoundary() {
+ AvailabilityCondition availabilityCondition =
+ AvailabilityCondition.newBuilder().setExpression("expression").build();
+
+ AccessBoundaryRule firstRule =
+ AccessBoundaryRule.newBuilder()
+ .setAvailableResource("firstResource")
+ .addAvailablePermission("firstPermission")
+ .setAvailabilityCondition(availabilityCondition)
+ .build();
+
+ AccessBoundaryRule secondRule =
+ AccessBoundaryRule.newBuilder()
+ .setAvailableResource("secondResource")
+ .addAvailablePermission("secondPermission")
+ .build();
+
+ CredentialAccessBoundary credentialAccessBoundary =
+ CredentialAccessBoundary.newBuilder()
+ .setRules(Arrays.asList(firstRule, secondRule))
+ .build();
+
+ assertEquals(2, credentialAccessBoundary.getAccessBoundaryRules().size());
+
+ AccessBoundaryRule first = credentialAccessBoundary.getAccessBoundaryRules().get(0);
+ assertEquals(firstRule, first);
+ assertEquals("firstResource", first.getAvailableResource());
+ assertEquals(1, first.getAvailablePermissions().size());
+ assertEquals("firstPermission", first.getAvailablePermissions().get(0));
+ assertEquals(availabilityCondition, first.getAvailabilityCondition());
+ assertEquals("expression", first.getAvailabilityCondition().getExpression());
+ assertNull(first.getAvailabilityCondition().getTitle());
+ assertNull(first.getAvailabilityCondition().getDescription());
+
+ AccessBoundaryRule second = credentialAccessBoundary.getAccessBoundaryRules().get(1);
+ assertEquals(secondRule, second);
+ assertEquals("secondResource", second.getAvailableResource());
+ assertEquals(1, second.getAvailablePermissions().size());
+ assertEquals("secondPermission", second.getAvailablePermissions().get(0));
+ assertNull(second.getAvailabilityCondition());
+ }
+
+ @Test
+ public void credentialAccessBoundary_nullRules_throws() {
+ try {
+ CredentialAccessBoundary.newBuilder().build();
+ fail("Should fail.");
+ } catch (NullPointerException e) {
+ // Expected.
+ }
+ }
+
+ @Test
+ public void credentialAccessBoundary_withoutRules_throws() {
+ try {
+ CredentialAccessBoundary.newBuilder().setRules(new ArrayList()).build();
+ fail("Should fail.");
+ } catch (IllegalArgumentException e) {
+ assertEquals("At least one access boundary rule must be provided.", e.getMessage());
+ }
+ }
+
+ @Test
+ public void credentialAccessBoundary_ruleCountExceeded_throws() {
+ AccessBoundaryRule rule =
+ AccessBoundaryRule.newBuilder()
+ .setAvailableResource("resource")
+ .addAvailablePermission("permission")
+ .build();
+
+ CredentialAccessBoundary.Builder builder = CredentialAccessBoundary.newBuilder();
+ for (int i = 0; i <= 10; i++) {
+ builder.addRule(rule);
+ }
+
+ try {
+ builder.build();
+ fail("Should fail.");
+ } catch (IllegalArgumentException e) {
+ assertEquals("The provided list has more than 10 access boundary rules.", e.getMessage());
+ }
+ }
+
+ @Test
+ public void credentialAccessBoundary_toJson() {
+ AvailabilityCondition availabilityCondition =
+ AvailabilityCondition.newBuilder()
+ .setExpression("expression")
+ .setTitle("title")
+ .setDescription("description")
+ .build();
+
+ AccessBoundaryRule firstRule =
+ AccessBoundaryRule.newBuilder()
+ .setAvailableResource("firstResource")
+ .addAvailablePermission("firstPermission")
+ .setAvailabilityCondition(availabilityCondition)
+ .build();
+
+ AccessBoundaryRule secondRule =
+ AccessBoundaryRule.newBuilder()
+ .setAvailableResource("secondResource")
+ .setAvailablePermissions(Arrays.asList("firstPermission", "secondPermission"))
+ .build();
+
+ CredentialAccessBoundary credentialAccessBoundary =
+ CredentialAccessBoundary.newBuilder()
+ .setRules(Arrays.asList(firstRule, secondRule))
+ .build();
+
+ String expectedJson =
+ "{\"accessBoundary\":{\"accessBoundaryRules\":"
+ + "[{\"availableResource\":\"firstResource\","
+ + "\"availablePermissions\":[\"firstPermission\"],"
+ + "\"availabilityCondition\":{\"expression\":\"expression\","
+ + "\"title\":\"title\",\"description\":\"description\"}},"
+ + "{\"availableResource\":\"secondResource\","
+ + "\"availablePermissions\":[\"firstPermission\","
+ + "\"secondPermission\"]}]}}";
+ assertEquals(expectedJson, credentialAccessBoundary.toJson());
+ }
+
+ @Test
+ public void accessBoundaryRule_allFields() {
+ AvailabilityCondition availabilityCondition =
+ AvailabilityCondition.newBuilder().setExpression("expression").build();
+
+ AccessBoundaryRule rule =
+ AccessBoundaryRule.newBuilder()
+ .setAvailableResource("resource")
+ .addAvailablePermission("firstPermission")
+ .addAvailablePermission("secondPermission")
+ .setAvailabilityCondition(availabilityCondition)
+ .build();
+
+ assertEquals("resource", rule.getAvailableResource());
+ assertEquals(2, rule.getAvailablePermissions().size());
+ assertEquals("firstPermission", rule.getAvailablePermissions().get(0));
+ assertEquals("secondPermission", rule.getAvailablePermissions().get(1));
+ assertEquals(availabilityCondition, rule.getAvailabilityCondition());
+ }
+
+ @Test
+ public void accessBoundaryRule_requiredFields() {
+ AccessBoundaryRule rule =
+ AccessBoundaryRule.newBuilder()
+ .setAvailableResource("resource")
+ .setAvailablePermissions(Collections.singletonList("firstPermission"))
+ .build();
+
+ assertEquals("resource", rule.getAvailableResource());
+ assertEquals(1, rule.getAvailablePermissions().size());
+ assertEquals("firstPermission", rule.getAvailablePermissions().get(0));
+ assertNull(rule.getAvailabilityCondition());
+ }
+
+ @Test
+ public void accessBoundaryRule_withEmptyAvailableResource_throws() {
+ try {
+ AccessBoundaryRule.newBuilder()
+ .setAvailableResource("")
+ .addAvailablePermission("permission")
+ .build();
+ fail("Should fail.");
+ } catch (IllegalArgumentException e) {
+ assertEquals("The provided availableResource is empty.", e.getMessage());
+ }
+ }
+
+ @Test
+ public void accessBoundaryRule_withoutAvailableResource_throws() {
+ try {
+ AccessBoundaryRule.newBuilder().addAvailablePermission("permission").build();
+ fail("Should fail.");
+ } catch (NullPointerException e) {
+ // Expected.
+ }
+ }
+
+ @Test
+ public void accessBoundaryRule_withoutAvailablePermissions_throws() {
+ try {
+ AccessBoundaryRule.newBuilder().setAvailableResource("resource").build();
+ fail("Should fail.");
+ } catch (NullPointerException e) {
+ // Expected.
+ }
+ }
+
+ @Test
+ public void accessBoundaryRule_withEmptyAvailablePermissions_throws() {
+ try {
+ AccessBoundaryRule.newBuilder()
+ .setAvailableResource("resource")
+ .setAvailablePermissions(new ArrayList())
+ .build();
+ fail("Should fail.");
+ } catch (IllegalArgumentException e) {
+ assertEquals("The list of provided availablePermissions is empty.", e.getMessage());
+ }
+ }
+
+ @Test
+ public void accessBoundaryRule_withNullAvailablePermissions_throws() {
+ try {
+ AccessBoundaryRule.newBuilder()
+ .setAvailableResource("resource")
+ .addAvailablePermission(null)
+ .build();
+ fail("Should fail.");
+ } catch (IllegalArgumentException e) {
+ assertEquals("One of the provided available permissions is null.", e.getMessage());
+ }
+ }
+
+ @Test
+ public void accessBoundaryRule_withEmptyAvailablePermission_throws() {
+ try {
+ AccessBoundaryRule.newBuilder()
+ .setAvailableResource("resource")
+ .addAvailablePermission("")
+ .build();
+ fail("Should fail.");
+ } catch (IllegalArgumentException e) {
+ assertEquals("One of the provided available permissions is empty.", e.getMessage());
+ }
+ }
+
+ @Test
+ public void availabilityCondition_allFields() {
+ AvailabilityCondition availabilityCondition =
+ AvailabilityCondition.newBuilder()
+ .setExpression("expression")
+ .setTitle("title")
+ .setDescription("description")
+ .build();
+
+ assertEquals("expression", availabilityCondition.getExpression());
+ assertEquals("title", availabilityCondition.getTitle());
+ assertEquals("description", availabilityCondition.getDescription());
+ }
+
+ @Test
+ public void availabilityCondition_expressionOnly() {
+ AvailabilityCondition availabilityCondition =
+ AvailabilityCondition.newBuilder().setExpression("expression").build();
+
+ assertEquals("expression", availabilityCondition.getExpression());
+ assertNull(availabilityCondition.getTitle());
+ assertNull(availabilityCondition.getDescription());
+ }
+
+ @Test
+ public void availabilityCondition_nullExpression_throws() {
+ try {
+ AvailabilityCondition.newBuilder().setExpression(null).build();
+ fail("Should fail.");
+ } catch (NullPointerException e) {
+ // Expected.
+ }
+ }
+
+ @Test
+ public void availabilityCondition_emptyExpression_throws() {
+ try {
+ AvailabilityCondition.newBuilder().setExpression("").build();
+ fail("Should fail.");
+ } catch (IllegalArgumentException e) {
+ assertEquals("The provided expression is empty.", e.getMessage());
+ }
+ }
+}
diff --git a/oauth2_http/javatests/com/google/auth/oauth2/DownscopedCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/DownscopedCredentialsTest.java
new file mode 100644
index 000000000..c9f98604b
--- /dev/null
+++ b/oauth2_http/javatests/com/google/auth/oauth2/DownscopedCredentialsTest.java
@@ -0,0 +1,244 @@
+/*
+ * 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.fail;
+
+import com.google.api.client.http.HttpTransport;
+import com.google.auth.TestUtils;
+import com.google.auth.http.HttpTransportFactory;
+import java.io.IOException;
+import java.util.Date;
+import java.util.Map;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Tests for {@link DownscopedCredentials}. */
+@RunWith(JUnit4.class)
+public class DownscopedCredentialsTest {
+
+ private static final String SA_PRIVATE_KEY_PKCS8 =
+ "-----BEGIN PRIVATE KEY-----\n"
+ + "MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBALX0PQoe1igW12i"
+ + "kv1bN/r9lN749y2ijmbc/mFHPyS3hNTyOCjDvBbXYbDhQJzWVUikh4mvGBA07qTj79Xc3yBDfKP2IeyYQIFe0t0"
+ + "zkd7R9Zdn98Y2rIQC47aAbDfubtkU1U72t4zL11kHvoa0/RuFZjncvlr42X7be7lYh4p3NAgMBAAECgYASk5wDw"
+ + "4Az2ZkmeuN6Fk/y9H+Lcb2pskJIXjrL533vrDWGOC48LrsThMQPv8cxBky8HFSEklPpkfTF95tpD43iVwJRB/Gr"
+ + "CtGTw65IfJ4/tI09h6zGc4yqvIo1cHX/LQ+SxKLGyir/dQM925rGt/VojxY5ryJR7GLbCzxPnJm/oQJBANwOCO6"
+ + "D2hy1LQYJhXh7O+RLtA/tSnT1xyMQsGT+uUCMiKS2bSKx2wxo9k7h3OegNJIu1q6nZ6AbxDK8H3+d0dUCQQDTrP"
+ + "SXagBxzp8PecbaCHjzNRSQE2in81qYnrAFNB4o3DpHyMMY6s5ALLeHKscEWnqP8Ur6X4PvzZecCWU9BKAZAkAut"
+ + "LPknAuxSCsUOvUfS1i87ex77Ot+w6POp34pEX+UWb+u5iFn2cQacDTHLV1LtE80L8jVLSbrbrlH43H0DjU5AkEA"
+ + "gidhycxS86dxpEljnOMCw8CKoUBd5I880IUahEiUltk7OLJYS/Ts1wbn3kPOVX3wyJs8WBDtBkFrDHW2ezth2QJ"
+ + "ADj3e1YhMVdjJW5jqwlD/VNddGjgzyunmiZg0uOXsHXbytYmsA545S8KRQFaJKFXYYFo2kOjqOiC1T2cAzMDjCQ"
+ + "==\n-----END PRIVATE KEY-----\n";
+
+ private static final CredentialAccessBoundary CREDENTIAL_ACCESS_BOUNDARY =
+ CredentialAccessBoundary.newBuilder()
+ .addRule(
+ CredentialAccessBoundary.AccessBoundaryRule.newBuilder()
+ .setAvailableResource("//storage.googleapis.com/projects/_/buckets/bucket")
+ .addAvailablePermission("inRole:roles/storage.objectViewer")
+ .build())
+ .build();
+
+ static class MockStsTransportFactory implements HttpTransportFactory {
+
+ MockStsTransport transport = new MockStsTransport();
+
+ @Override
+ public HttpTransport create() {
+ return transport;
+ }
+ }
+
+ @Test
+ public void refreshAccessToken() throws IOException {
+ MockStsTransportFactory transportFactory = new MockStsTransportFactory();
+
+ GoogleCredentials sourceCredentials =
+ getServiceAccountSourceCredentials(/* canRefresh= */ true);
+
+ DownscopedCredentials downscopedCredentials =
+ DownscopedCredentials.newBuilder()
+ .setSourceCredential(sourceCredentials)
+ .setCredentialAccessBoundary(CREDENTIAL_ACCESS_BOUNDARY)
+ .setHttpTransportFactory(transportFactory)
+ .build();
+
+ AccessToken accessToken = downscopedCredentials.refreshAccessToken();
+
+ assertEquals(transportFactory.transport.getAccessToken(), accessToken.getTokenValue());
+
+ // Validate CAB specific params.
+ Map query =
+ TestUtils.parseQuery(transportFactory.transport.getRequest().getContentAsString());
+ assertNotNull(query.get("options"));
+ assertEquals(CREDENTIAL_ACCESS_BOUNDARY.toJson(), query.get("options"));
+ assertEquals(
+ "urn:ietf:params:oauth:token-type:access_token", query.get("requested_token_type"));
+ }
+
+ @Test
+ public void refreshAccessToken_userCredentials_expectExpiresInCopied() throws IOException {
+ // STS only returns expires_in if the source access token belongs to a service account.
+ // For other source credential types, we can copy the source credentials expiration as
+ // the generated downscoped token will always have the same expiration time as the source
+ // credentials.
+
+ MockStsTransportFactory transportFactory = new MockStsTransportFactory();
+ transportFactory.transport.setReturnExpiresIn(false);
+
+ GoogleCredentials sourceCredentials = getUserSourceCredentials();
+
+ DownscopedCredentials downscopedCredentials =
+ DownscopedCredentials.newBuilder()
+ .setSourceCredential(sourceCredentials)
+ .setCredentialAccessBoundary(CREDENTIAL_ACCESS_BOUNDARY)
+ .setHttpTransportFactory(transportFactory)
+ .build();
+
+ AccessToken accessToken = downscopedCredentials.refreshAccessToken();
+
+ assertEquals(transportFactory.transport.getAccessToken(), accessToken.getTokenValue());
+
+ // Validate that the expires_in has been copied from the source credential.
+ assertEquals(
+ sourceCredentials.getAccessToken().getExpirationTime(), accessToken.getExpirationTime());
+ }
+
+ @Test
+ public void refreshAccessToken_cantRefreshSourceCredentials_throws() throws IOException {
+ MockStsTransportFactory transportFactory = new MockStsTransportFactory();
+
+ GoogleCredentials sourceCredentials =
+ getServiceAccountSourceCredentials(/* canRefresh= */ false);
+
+ DownscopedCredentials downscopedCredentials =
+ DownscopedCredentials.newBuilder()
+ .setSourceCredential(sourceCredentials)
+ .setCredentialAccessBoundary(CREDENTIAL_ACCESS_BOUNDARY)
+ .setHttpTransportFactory(transportFactory)
+ .build();
+
+ try {
+ downscopedCredentials.refreshAccessToken();
+ fail("Should fail as the source credential should not be able to be refreshed.");
+ } catch (IOException e) {
+ assertEquals("Unable to refresh the provided source credential.", e.getMessage());
+ }
+ }
+
+ @Test
+ public void builder_noSourceCredential_throws() {
+ try {
+ DownscopedCredentials.newBuilder()
+ .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY)
+ .setCredentialAccessBoundary(CREDENTIAL_ACCESS_BOUNDARY)
+ .build();
+ fail("Should fail as the source credential is null.");
+ } catch (NullPointerException e) {
+ // Expected.
+ }
+ }
+
+ @Test
+ public void builder_noCredentialAccessBoundary_throws() throws IOException {
+ try {
+ DownscopedCredentials.newBuilder()
+ .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY)
+ .setSourceCredential(getServiceAccountSourceCredentials(/* canRefresh= */ true))
+ .build();
+ fail("Should fail as no access boundary was provided.");
+ } catch (NullPointerException e) {
+ // Expected.
+ }
+ }
+
+ @Test
+ public void builder_noTransport_defaults() throws IOException {
+ GoogleCredentials sourceCredentials =
+ getServiceAccountSourceCredentials(/* canRefresh= */ true);
+ DownscopedCredentials credentials =
+ DownscopedCredentials.newBuilder()
+ .setSourceCredential(sourceCredentials)
+ .setCredentialAccessBoundary(CREDENTIAL_ACCESS_BOUNDARY)
+ .build();
+
+ GoogleCredentials scopedSourceCredentials =
+ sourceCredentials.createScoped("https://www.googleapis.com/auth/cloud-platform");
+ assertEquals(scopedSourceCredentials, credentials.getSourceCredentials());
+ assertEquals(CREDENTIAL_ACCESS_BOUNDARY, credentials.getCredentialAccessBoundary());
+ assertEquals(OAuth2Utils.HTTP_TRANSPORT_FACTORY, credentials.getTransportFactory());
+ }
+
+ private static GoogleCredentials getServiceAccountSourceCredentials(boolean canRefresh)
+ throws IOException {
+ GoogleCredentialsTest.MockTokenServerTransportFactory transportFactory =
+ new GoogleCredentialsTest.MockTokenServerTransportFactory();
+
+ String email = "service-account@google.com";
+
+ ServiceAccountCredentials sourceCredentials =
+ ServiceAccountCredentials.newBuilder()
+ .setClientEmail(email)
+ .setPrivateKey(ServiceAccountCredentials.privateKeyFromPkcs8(SA_PRIVATE_KEY_PKCS8))
+ .setPrivateKeyId("privateKeyId")
+ .setProjectId("projectId")
+ .setHttpTransportFactory(transportFactory)
+ .build();
+
+ transportFactory.transport.addServiceAccount(email, "accessToken");
+
+ if (!canRefresh) {
+ transportFactory.transport.setError(new IOException());
+ }
+
+ return sourceCredentials;
+ }
+
+ private static GoogleCredentials getUserSourceCredentials() {
+ GoogleCredentialsTest.MockTokenServerTransportFactory transportFactory =
+ new GoogleCredentialsTest.MockTokenServerTransportFactory();
+ transportFactory.transport.addClient("clientId", "clientSecret");
+ transportFactory.transport.addRefreshToken("refreshToken", "accessToken");
+ AccessToken accessToken = new AccessToken("accessToken", new Date());
+ return UserCredentials.newBuilder()
+ .setClientId("clientId")
+ .setClientSecret("clientSecret")
+ .setRefreshToken("refreshToken")
+ .setAccessToken(accessToken)
+ .setHttpTransportFactory(transportFactory)
+ .build();
+ }
+}
diff --git a/oauth2_http/javatests/com/google/auth/oauth2/ITDownscopingTest.java b/oauth2_http/javatests/com/google/auth/oauth2/ITDownscopingTest.java
new file mode 100644
index 000000000..bd00f42c4
--- /dev/null
+++ b/oauth2_http/javatests/com/google/auth/oauth2/ITDownscopingTest.java
@@ -0,0 +1,150 @@
+/*
+ * 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.assertTrue;
+import static org.junit.Assert.fail;
+
+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.http.HttpResponseException;
+import com.google.api.client.http.javanet.NetHttpTransport;
+import com.google.api.client.json.JsonObjectParser;
+import com.google.api.client.json.gson.GsonFactory;
+import com.google.auth.Credentials;
+import com.google.auth.http.HttpCredentialsAdapter;
+import java.io.IOException;
+import org.junit.Test;
+
+/**
+ * Integration tests for Downscoping with Credential Access Boundaries via {@link
+ * DownscopedCredentials}.
+ *
+ * The only requirements for this test suite to run is to set the environment variable
+ * GOOGLE_APPLICATION_CREDENTIALS to point to the same service account configured in the setup
+ * script (downscoping-with-cab-setup.sh).
+ */
+public final class ITDownscopingTest {
+
+ // Output copied from the setup script (downscoping-with-cab-setup.sh).
+ private static final String GCS_BUCKET_NAME = "cab-int-bucket-cbi3qrv5";
+ private static final String GCS_OBJECT_NAME_WITH_PERMISSION = "cab-first-cbi3qrv5.txt";
+ private static final String GCS_OBJECT_NAME_WITHOUT_PERMISSION = "cab-second-cbi3qrv5.txt";
+
+ // This Credential Access Boundary enables the objectViewer permission to the specified object in
+ // the specified bucket.
+ private static final CredentialAccessBoundary CREDENTIAL_ACCESS_BOUNDARY =
+ CredentialAccessBoundary.newBuilder()
+ .addRule(
+ CredentialAccessBoundary.AccessBoundaryRule.newBuilder()
+ .setAvailableResource(
+ String.format(
+ "//storage.googleapis.com/projects/_/buckets/%s", GCS_BUCKET_NAME))
+ .addAvailablePermission("inRole:roles/storage.objectViewer")
+ .setAvailabilityCondition(
+ CredentialAccessBoundary.AccessBoundaryRule.AvailabilityCondition.newBuilder()
+ .setExpression(
+ String.format(
+ "resource.name.startsWith('projects/_/buckets/%s/objects/%s')",
+ GCS_BUCKET_NAME, GCS_OBJECT_NAME_WITH_PERMISSION))
+ .build())
+ .build())
+ .build();
+
+ /**
+ * A downscoped credential is obtained from a service account credential with permissions to
+ * access an object in the GCS bucket configured. We should only have access to retrieve this
+ * object.
+ *
+ *
We confirm this by: 1. Validating that we can successfully retrieve this object with the
+ * downscoped token. 2. Validating that we do not have permission to retrieve a different object
+ * in the same bucket.
+ */
+ @Test
+ public void downscoping_serviceAccountSourceWithRefresh() throws IOException {
+ OAuth2CredentialsWithRefresh.OAuth2RefreshHandler refreshHandler =
+ new OAuth2CredentialsWithRefresh.OAuth2RefreshHandler() {
+ @Override
+ public AccessToken refreshAccessToken() throws IOException {
+
+ ServiceAccountCredentials credentials =
+ (ServiceAccountCredentials)
+ GoogleCredentials.getApplicationDefault()
+ .createScoped("https://www.googleapis.com/auth/cloud-platform");
+
+ DownscopedCredentials downscopedCredentials =
+ DownscopedCredentials.newBuilder()
+ .setSourceCredential(credentials)
+ .setCredentialAccessBoundary(CREDENTIAL_ACCESS_BOUNDARY)
+ .build();
+
+ return downscopedCredentials.refreshAccessToken();
+ }
+ };
+
+ OAuth2CredentialsWithRefresh credentials =
+ OAuth2CredentialsWithRefresh.newBuilder().setRefreshHandler(refreshHandler).build();
+
+ // Attempt to retrieve the object that the downscoped token has access to.
+ retrieveObjectFromGcs(credentials, GCS_OBJECT_NAME_WITH_PERMISSION);
+
+ // Attempt to retrieve the object that the downscoped token does not have access to. This should
+ // fail.
+ try {
+ retrieveObjectFromGcs(credentials, GCS_OBJECT_NAME_WITHOUT_PERMISSION);
+ fail("Call to GCS should have failed.");
+ } catch (HttpResponseException e) {
+ assertEquals(403, e.getStatusCode());
+ }
+ }
+
+ private void retrieveObjectFromGcs(Credentials credentials, String objectName)
+ throws IOException {
+ String url =
+ String.format(
+ "https://storage.googleapis.com/storage/v1/b/%s/o/%s", GCS_BUCKET_NAME, objectName);
+
+ HttpCredentialsAdapter credentialsAdapter = new HttpCredentialsAdapter(credentials);
+ HttpRequestFactory requestFactory =
+ new NetHttpTransport().createRequestFactory(credentialsAdapter);
+ HttpRequest request = requestFactory.buildGetRequest(new GenericUrl(url));
+
+ JsonObjectParser parser = new JsonObjectParser(GsonFactory.getDefaultInstance());
+ request.setParser(parser);
+
+ HttpResponse response = request.execute();
+ assertTrue(response.isSuccessStatusCode());
+ }
+}
diff --git a/oauth2_http/javatests/com/google/auth/oauth2/MockStsTransport.java b/oauth2_http/javatests/com/google/auth/oauth2/MockStsTransport.java
new file mode 100644
index 000000000..1695c8450
--- /dev/null
+++ b/oauth2_http/javatests/com/google/auth/oauth2/MockStsTransport.java
@@ -0,0 +1,159 @@
+/*
+ * 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 com.google.api.client.http.HttpStatusCodes;
+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.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.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Queue;
+
+/** Transport that mocks a basic STS endpoint. */
+public final class MockStsTransport extends MockHttpTransport {
+
+ private static final String EXPECTED_GRANT_TYPE =
+ "urn:ietf:params:oauth:grant-type:token-exchange";
+ private static final String ISSUED_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token";
+ private static final String STS_URL = "https://sts.googleapis.com/v1/token";
+ private static final String ACCESS_TOKEN = "accessToken";
+ private static final String TOKEN_TYPE = "Bearer";
+ private static final Long EXPIRES_IN = 3600L;
+
+ private final Queue responseErrorSequence = new ArrayDeque<>();
+ private final Queue> scopeSequence = new ArrayDeque<>();
+ private final Queue refreshTokenSequence = new ArrayDeque<>();
+
+ private boolean returnExpiresIn = true;
+ private MockLowLevelHttpRequest request;
+
+ public void addResponseErrorSequence(IOException... errors) {
+ Collections.addAll(responseErrorSequence, errors);
+ }
+
+ 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 {
+ if (!STS_URL.equals(url)) {
+ return makeErrorResponse();
+ }
+
+ if (!responseErrorSequence.isEmpty()) {
+ throw responseErrorSequence.poll();
+ }
+
+ 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(new GsonFactory());
+ response.put("token_type", TOKEN_TYPE);
+ response.put("access_token", ACCESS_TOKEN);
+ response.put("issued_token_type", ISSUED_TOKEN_TYPE);
+
+ if (returnExpiresIn) {
+ response.put("expires_in", EXPIRES_IN);
+ }
+ 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());
+ }
+ };
+ return this.request;
+ }
+
+ private MockLowLevelHttpResponse makeErrorResponse() {
+ MockLowLevelHttpResponse errorResponse = new MockLowLevelHttpResponse();
+ errorResponse.setStatusCode(HttpStatusCodes.STATUS_CODE_BAD_REQUEST);
+ errorResponse.setContentType(Json.MEDIA_TYPE);
+ errorResponse.setContent("{\"error\":\"error\"}");
+ return errorResponse;
+ }
+
+ public MockLowLevelHttpRequest getRequest() {
+ return request;
+ }
+
+ public String getAccessToken() {
+ return ACCESS_TOKEN;
+ }
+
+ public String getTokenType() {
+ return TOKEN_TYPE;
+ }
+
+ public String getIssuedTokenType() {
+ return ISSUED_TOKEN_TYPE;
+ }
+
+ public Long getExpiresIn() {
+ return EXPIRES_IN;
+ }
+
+ public void setReturnExpiresIn(boolean returnExpiresIn) {
+ this.returnExpiresIn = returnExpiresIn;
+ }
+}
diff --git a/oauth2_http/javatests/com/google/auth/oauth2/OAuth2CredentialsWithRefreshTest.java b/oauth2_http/javatests/com/google/auth/oauth2/OAuth2CredentialsWithRefreshTest.java
new file mode 100644
index 000000000..2acd41ed9
--- /dev/null
+++ b/oauth2_http/javatests/com/google/auth/oauth2/OAuth2CredentialsWithRefreshTest.java
@@ -0,0 +1,122 @@
+/*
+ * 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.fail;
+
+import java.io.IOException;
+import java.util.Date;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Tests for {@link OAuth2CredentialsWithRefresh}. */
+@RunWith(JUnit4.class)
+public class OAuth2CredentialsWithRefreshTest {
+
+ private static final AccessToken ACCESS_TOKEN = new AccessToken("accessToken", new Date());
+
+ @Test
+ public void builder() {
+ OAuth2CredentialsWithRefresh.OAuth2RefreshHandler refreshHandler =
+ new OAuth2CredentialsWithRefresh.OAuth2RefreshHandler() {
+ @Override
+ public AccessToken refreshAccessToken() {
+ return null;
+ }
+ };
+ OAuth2CredentialsWithRefresh credential =
+ OAuth2CredentialsWithRefresh.newBuilder()
+ .setAccessToken(ACCESS_TOKEN)
+ .setRefreshHandler(refreshHandler)
+ .build();
+
+ assertEquals(ACCESS_TOKEN, credential.getAccessToken());
+ assertEquals(refreshHandler, credential.getRefreshHandler());
+ }
+
+ @Test
+ public void builder_noAccessToken() {
+ OAuth2CredentialsWithRefresh.newBuilder()
+ .setRefreshHandler(
+ new OAuth2CredentialsWithRefresh.OAuth2RefreshHandler() {
+ @Override
+ public AccessToken refreshAccessToken() {
+ return null;
+ }
+ })
+ .build();
+ }
+
+ @Test
+ public void builder_noRefreshHandler_throws() {
+ try {
+ OAuth2CredentialsWithRefresh.newBuilder().setAccessToken(ACCESS_TOKEN).build();
+ fail("Should fail as a refresh handler must be provided.");
+ } catch (NullPointerException e) {
+ // Expected.
+ }
+ }
+
+ @Test
+ public void builder_noExpirationTimeInAccessToken_throws() {
+ try {
+ OAuth2CredentialsWithRefresh.newBuilder()
+ .setAccessToken(new AccessToken("accessToken", null))
+ .build();
+ fail("Should fail as a refresh handler must be provided.");
+ } catch (IllegalArgumentException e) {
+ // Expected.
+ }
+ }
+
+ @Test
+ public void refreshAccessToken_delegateToRefreshHandler() throws IOException {
+ final AccessToken refreshedToken = new AccessToken("refreshedAccessToken", new Date());
+ OAuth2CredentialsWithRefresh credentials =
+ OAuth2CredentialsWithRefresh.newBuilder()
+ .setAccessToken(ACCESS_TOKEN)
+ .setRefreshHandler(
+ new OAuth2CredentialsWithRefresh.OAuth2RefreshHandler() {
+ @Override
+ public AccessToken refreshAccessToken() {
+ return refreshedToken;
+ }
+ })
+ .build();
+
+ AccessToken accessToken = credentials.refreshAccessToken();
+
+ assertEquals(refreshedToken, accessToken);
+ }
+}
diff --git a/oauth2_http/javatests/com/google/auth/oauth2/StsRequestHandlerTest.java b/oauth2_http/javatests/com/google/auth/oauth2/StsRequestHandlerTest.java
index 65d2bf90f..dd0cc7ce9 100644
--- a/oauth2_http/javatests/com/google/auth/oauth2/StsRequestHandlerTest.java
+++ b/oauth2_http/javatests/com/google/auth/oauth2/StsRequestHandlerTest.java
@@ -60,13 +60,13 @@ public final class StsRequestHandlerTest {
"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 static final String TOKEN_URL = "https://sts.googleapis.com/v1/token";
- private MockExternalAccountCredentialsTransport transport;
+ private MockStsTransport transport;
@Before
public void setup() {
- transport = new MockExternalAccountCredentialsTransport();
+ transport = new MockStsTransport();
}
@Test
@@ -248,4 +248,31 @@ public void run() throws Throwable {
});
assertEquals(e, thrownException);
}
+
+ @Test
+ public void exchangeToken_noExpiresInReturned() throws IOException {
+ // Don't return expires in. This happens in the CAB flow when the subject token does not belong
+ // to a service account.
+ transport.setReturnExpiresIn(/* returnExpiresIn= */ false);
+
+ 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());
+ assertNull(response.getAccessToken().getExpirationTime());
+
+ assertEquals(transport.getTokenType(), response.getTokenType());
+ assertEquals(transport.getIssuedTokenType(), response.getIssuedTokenType());
+ assertNull(response.getExpiresInSeconds());
+ }
}
diff --git a/scripts/downscoping-with-cab-setup.sh b/scripts/downscoping-with-cab-setup.sh
new file mode 100755
index 000000000..e2f847d94
--- /dev/null
+++ b/scripts/downscoping-with-cab-setup.sh
@@ -0,0 +1,96 @@
+#!/bin/bash
+
+# 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.
+
+# This script is used to generate the project configurations needed to
+# end-to-end test Downscoping with Credential Access Boundaries in the Auth
+# library.
+#
+# In order to run this script, you need to fill in the project_id and
+# service_account_email variables.
+#
+# This script needs to be run once. It will do the following:
+# 1. Sets the current project to the one specified.
+# 2. Creates a GCS bucket in the specified project.
+# 3. Gives the specified service account the objectAdmin role for this bucket.
+# 4. Creates two text files to be uploaded to the created bucket.
+# 5. Uploads both text files.
+# 6. Prints out the identifiers (bucket ID, first object ID, second object ID)
+# to be used in the accompanying tests.
+# 7. Deletes the created text files in the current directory.
+#
+# The same service account used for this setup script should be used for
+# the integration tests.
+#
+# It is safe to run the setup script again. A new bucket is created along with
+# new objects. If run multiple times, it is advisable to delete
+# unused buckets.
+
+suffix=""
+
+function generate_random_string () {
+ local valid_chars=abcdefghijklmnopqrstuvwxyz0123456789
+ for i in {1..8} ; do
+ suffix+="${valid_chars:RANDOM%${#valid_chars}:1}"
+ done
+}
+
+generate_random_string
+
+bucket_id="cab-int-bucket-"${suffix}
+first_object="cab-first-"${suffix}.txt
+second_object="cab-second-"${suffix}.txt
+
+# Fill in.
+project_id=""
+service_account_email=""
+
+gcloud config set project ${project_id}
+
+# Create the GCS bucket.
+gsutil mb -b on -l us-east1 gs://${bucket_id}
+
+# Give the specified service account the objectAdmin role for this bucket.
+gsutil iam ch serviceAccount:${service_account_email}:objectAdmin gs://${bucket_id}
+
+# Create both objects.
+echo "first" >> ${first_object}
+echo "second" >> ${second_object}
+
+# Upload the created objects to the bucket.
+gsutil cp ${first_object} gs://${bucket_id}
+gsutil cp ${second_object} gs://${bucket_id}
+
+echo "Bucket ID: "${bucket_id}
+echo "First object ID: "${first_object}
+echo "Second object ID: "${second_object}
+
+# Cleanup.
+rm ${first_object}
+rm ${second_object}