From aa7ede1d1c688ba437798f4204820c0506d5d969 Mon Sep 17 00:00:00 2001 From: Leo <39062083+lsirac@users.noreply.github.com> Date: Tue, 3 Aug 2021 09:44:16 -0700 Subject: [PATCH] feat: downscoping with credential access boundaries (#702) * feat: adds CAB rules classes (#687) * feat: adds CAB rules classes * fix: copyright * fix: revert pom * fix: review * fix: bad link * fix: more null and empty checks * fix: expand javadoc * fix: split null/empty checks * fix: use checkNotNull * feat: downscoping with credential access boundaries (#691) * feat: downscoping with credential access boundaries * fix: rename RefreshableOAuth2Credentials to OAuth2CredentialsWithRefresh * fix: review nits * test: adds integration tests for downscoping with credential access boundaries * fix: use source credential expiration when STS does not return expires_in * fix: require an expiration time to be passed in the AccessToken consumed by OAuth2CredentialsWithRefresh Co-authored-by: Elliotte Rusty Harold --- .../auth/oauth2/CredentialAccessBoundary.java | 345 ++++++++++++++++++ .../auth/oauth2/DownscopedCredentials.java | 193 ++++++++++ .../oauth2/OAuth2CredentialsWithRefresh.java | 107 ++++++ .../com/google/auth/oauth2/OAuth2Utils.java | 2 + .../google/auth/oauth2/StsRequestHandler.java | 15 +- .../auth/oauth2/StsTokenExchangeResponse.java | 29 +- .../oauth2/CredentialAccessBoundaryTest.java | 323 ++++++++++++++++ .../oauth2/DownscopedCredentialsTest.java | 244 +++++++++++++ .../google/auth/oauth2/ITDownscopingTest.java | 150 ++++++++ .../google/auth/oauth2/MockStsTransport.java | 159 ++++++++ .../OAuth2CredentialsWithRefreshTest.java | 122 +++++++ .../auth/oauth2/StsRequestHandlerTest.java | 33 +- scripts/downscoping-with-cab-setup.sh | 96 +++++ 13 files changed, 1797 insertions(+), 21 deletions(-) create mode 100644 oauth2_http/java/com/google/auth/oauth2/CredentialAccessBoundary.java create mode 100644 oauth2_http/java/com/google/auth/oauth2/DownscopedCredentials.java create mode 100644 oauth2_http/java/com/google/auth/oauth2/OAuth2CredentialsWithRefresh.java create mode 100644 oauth2_http/javatests/com/google/auth/oauth2/CredentialAccessBoundaryTest.java create mode 100644 oauth2_http/javatests/com/google/auth/oauth2/DownscopedCredentialsTest.java create mode 100644 oauth2_http/javatests/com/google/auth/oauth2/ITDownscopingTest.java create mode 100644 oauth2_http/javatests/com/google/auth/oauth2/MockStsTransport.java create mode 100644 oauth2_http/javatests/com/google/auth/oauth2/OAuth2CredentialsWithRefreshTest.java create mode 100755 scripts/downscoping-with-cab-setup.sh 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}