From 06bf21a6ce9478a35907bd6681e53a0e86ffc83f Mon Sep 17 00:00:00 2001 From: Leo <39062083+lsirac@users.noreply.github.com> Date: Fri, 20 Jan 2023 22:10:46 -0800 Subject: [PATCH] feat: adds external account authorized user credentials (#1129) * feat: adds external account authorized user credentials * fix: positioning * fix: serialVersionUID * fix: toString() test * fix: tests --- ...ernalAccountAuthorizedUserCredentials.java | 513 ++++++++ .../google/auth/oauth2/GoogleCredentials.java | 4 + .../google/auth/oauth2/OAuthException.java | 21 + .../google/auth/oauth2/StsRequestHandler.java | 12 +- ...lAccountAuthorizedUserCredentialsTest.java | 1124 +++++++++++++++++ .../auth/oauth2/GoogleCredentialsTest.java | 16 + .../google/auth/oauth2/MockStsTransport.java | 46 +- .../auth/oauth2/OAuthExceptionTest.java | 48 + 8 files changed, 1756 insertions(+), 28 deletions(-) create mode 100644 oauth2_http/java/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentials.java create mode 100644 oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentialsTest.java diff --git a/oauth2_http/java/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentials.java new file mode 100644 index 000000000..3304eae75 --- /dev/null +++ b/oauth2_http/java/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentials.java @@ -0,0 +1,513 @@ +/* + * Copyright 2022 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.oauth2; + +import static com.google.auth.oauth2.OAuth2Utils.JSON_FACTORY; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.api.client.http.GenericUrl; +import com.google.api.client.http.HttpHeaders; +import com.google.api.client.http.HttpRequest; +import com.google.api.client.http.HttpResponse; +import com.google.api.client.http.HttpResponseException; +import com.google.api.client.http.UrlEncodedContent; +import com.google.api.client.json.GenericJson; +import com.google.api.client.json.JsonObjectParser; +import com.google.api.client.util.GenericData; +import com.google.api.client.util.Preconditions; +import com.google.auth.http.HttpTransportFactory; +import com.google.common.base.MoreObjects; +import com.google.common.io.BaseEncoding; +import java.io.IOException; +import java.io.InputStream; +import java.io.ObjectInputStream; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.Map; +import java.util.Objects; +import javax.annotation.Nullable; + +/** + * OAuth2 credentials sourced using external identities through Workforce Identity Federation. + * + *

Obtaining the initial access and refresh token can be done through the Google Cloud CLI. + * + *

+ * Example credentials file:
+ * {
+ *   "type": "external_account_authorized_user",
+ *   "audience": "//iam.googleapis.com/locations/global/workforcePools/$WORKFORCE_POOL_ID/providers/$PROVIDER_ID",
+ *   "refresh_token": "refreshToken",
+ *   "token_url": "https://sts.googleapis.com/v1/oauthtoken",
+ *   "token_info_url": "https://sts.googleapis.com/v1/introspect",
+ *   "client_id": "clientId",
+ *   "client_secret": "clientSecret"
+ * }
+ * 
+ */ +public class ExternalAccountAuthorizedUserCredentials extends GoogleCredentials { + + private static final String PARSE_ERROR_PREFIX = "Error parsing token refresh response. "; + + private static final long serialVersionUID = -2181779590486283287L; + + static final String EXTERNAL_ACCOUNT_AUTHORIZED_USER_FILE_TYPE = + "external_account_authorized_user"; + + private final String transportFactoryClassName; + private final String audience; + private final String tokenUrl; + private final String tokenInfoUrl; + private final String revokeUrl; + private final String clientId; + private final String clientSecret; + + private String refreshToken; + + private transient HttpTransportFactory transportFactory; + + /** + * Internal constructor. + * + * @param builder A builder for {@link ExternalAccountAuthorizedUserCredentials}. See {@link + * ExternalAccountAuthorizedUserCredentials.Builder} + */ + private ExternalAccountAuthorizedUserCredentials(Builder builder) { + super(builder); + this.transportFactory = + MoreObjects.firstNonNull( + builder.transportFactory, + getFromServiceLoader(HttpTransportFactory.class, OAuth2Utils.HTTP_TRANSPORT_FACTORY)); + this.transportFactoryClassName = this.transportFactory.getClass().getName(); + this.audience = builder.audience; + this.refreshToken = builder.refreshToken; + this.tokenUrl = builder.tokenUrl; + this.tokenInfoUrl = builder.tokenInfoUrl; + this.revokeUrl = builder.revokeUrl; + this.clientId = builder.clientId; + this.clientSecret = builder.clientSecret; + + Preconditions.checkState( + getAccessToken() != null || canRefresh(), + "ExternalAccountAuthorizedUserCredentials must be initialized with " + + "an access token or fields to enable refresh: " + + "('refresh_token', 'token_url', 'client_id', 'client_secret')."); + } + + /** + * Returns external account authorized user credentials defined by a JSON file stream. + * + * @param credentialsStream the stream with the credential definition + * @return the credential defined by the credentialsStream + * @throws IOException if the credential cannot be created from the stream + */ + public static ExternalAccountAuthorizedUserCredentials fromStream(InputStream credentialsStream) + throws IOException { + checkNotNull(credentialsStream); + return fromStream(credentialsStream, OAuth2Utils.HTTP_TRANSPORT_FACTORY); + } + + /** + * Returns external account authorized user credentials defined by a JSON file stream. + * + * @param credentialsStream the stream with the credential definition + * @param transportFactory the HTTP transport factory used to create the transport to get access + * tokens + * @return the credential defined by the credentialsStream + * @throws IOException if the credential cannot be created from the stream + */ + public static ExternalAccountAuthorizedUserCredentials fromStream( + InputStream credentialsStream, HttpTransportFactory transportFactory) throws IOException { + checkNotNull(credentialsStream); + checkNotNull(transportFactory); + + JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY); + GenericJson fileContents = + parser.parseAndClose(credentialsStream, StandardCharsets.UTF_8, GenericJson.class); + try { + return fromJson(fileContents, transportFactory); + } catch (ClassCastException | IllegalArgumentException e) { + throw new CredentialFormatException("Invalid input stream provided.", e); + } + } + + @Override + public AccessToken refreshAccessToken() throws IOException { + if (!canRefresh()) { + throw new IllegalStateException( + "Unable to refresh ExternalAccountAuthorizedUserCredentials. All of 'refresh_token'," + + "'token_url', 'client_id', 'client_secret' are required to refresh."); + } + + HttpResponse response; + try { + HttpRequest httpRequest = buildRefreshRequest(); + response = httpRequest.execute(); + } catch (HttpResponseException e) { + throw OAuthException.createFromHttpResponseException(e); + } + + // Parse response. + GenericData responseData = response.parseAs(GenericData.class); + response.disconnect(); + + // Required fields. + String accessToken = + OAuth2Utils.validateString(responseData, /* key= */ "access_token", PARSE_ERROR_PREFIX); + int expiresInSeconds = + OAuth2Utils.validateInt32(responseData, /* key= */ "expires_in", PARSE_ERROR_PREFIX); + Date expiresAtMilliseconds = new Date(clock.currentTimeMillis() + expiresInSeconds * 1000L); + + // Set the new refresh token if returned. + String refreshToken = + OAuth2Utils.validateOptionalString( + responseData, /* key= */ "refresh_token", PARSE_ERROR_PREFIX); + if (refreshToken != null && refreshToken.trim().length() > 0) { + this.refreshToken = refreshToken; + } + + return AccessToken.newBuilder() + .setExpirationTime(expiresAtMilliseconds) + .setTokenValue(accessToken) + .build(); + } + + @Nullable + public String getAudience() { + return audience; + } + + @Nullable + public String getClientId() { + return clientId; + } + + @Nullable + public String getClientSecret() { + return clientSecret; + } + + @Nullable + public String getRevokeUrl() { + return revokeUrl; + } + + @Nullable + public String getTokenUrl() { + return tokenUrl; + } + + @Nullable + public String getTokenInfoUrl() { + return tokenInfoUrl; + } + + @Nullable + public String getRefreshToken() { + return refreshToken; + } + + public static Builder newBuilder() { + return new Builder(); + } + + @Override + public int hashCode() { + return Objects.hash( + super.hashCode(), + clientId, + clientSecret, + refreshToken, + tokenUrl, + tokenInfoUrl, + revokeUrl, + audience, + transportFactoryClassName, + quotaProjectId); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("requestMetadata", getRequestMetadataInternal()) + .add("temporaryAccess", getAccessToken()) + .add("clientId", clientId) + .add("clientSecret", clientSecret) + .add("refreshToken", refreshToken) + .add("tokenUrl", tokenUrl) + .add("tokenInfoUrl", tokenInfoUrl) + .add("revokeUrl", revokeUrl) + .add("audience", audience) + .add("transportFactoryClassName", transportFactoryClassName) + .add("quotaProjectId", quotaProjectId) + .toString(); + } + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof ExternalAccountAuthorizedUserCredentials)) { + return false; + } + ExternalAccountAuthorizedUserCredentials credentials = + (ExternalAccountAuthorizedUserCredentials) obj; + return super.equals(credentials) + && Objects.equals(this.clientId, credentials.clientId) + && Objects.equals(this.clientSecret, credentials.clientSecret) + && Objects.equals(this.refreshToken, credentials.refreshToken) + && Objects.equals(this.tokenUrl, credentials.tokenUrl) + && Objects.equals(this.tokenInfoUrl, credentials.tokenInfoUrl) + && Objects.equals(this.revokeUrl, credentials.revokeUrl) + && Objects.equals(this.audience, credentials.audience) + && Objects.equals(this.transportFactoryClassName, credentials.transportFactoryClassName) + && Objects.equals(this.quotaProjectId, credentials.quotaProjectId); + } + + public Builder toBuilder() { + return new Builder(this); + } + + /** + * Returns external account authorized user credentials defined by JSON contents using the format + * supported by the Cloud SDK. + * + * @param json a map from the JSON representing the credentials + * @param transportFactory HTTP transport factory, creates the transport used to get access tokens + * @return the external account authorized user credentials defined by the JSON + */ + static ExternalAccountAuthorizedUserCredentials fromJson( + Map json, HttpTransportFactory transportFactory) throws IOException { + String audience = (String) json.get("audience"); + String refreshToken = (String) json.get("refresh_token"); + String tokenUrl = (String) json.get("token_url"); + String tokenInfoUrl = (String) json.get("token_info_url"); + String revokeUrl = (String) json.get("revoke_url"); + String clientId = (String) json.get("client_id"); + String clientSecret = (String) json.get("client_secret"); + String quotaProjectId = (String) json.get("quota_project_id"); + + return ExternalAccountAuthorizedUserCredentials.newBuilder() + .setAudience(audience) + .setRefreshToken(refreshToken) + .setTokenUrl(tokenUrl) + .setTokenInfoUrl(tokenInfoUrl) + .setRevokeUrl(revokeUrl) + .setClientId(clientId) + .setClientSecret(clientSecret) + .setRefreshToken(refreshToken) + .setHttpTransportFactory(transportFactory) + .setQuotaProjectId(quotaProjectId) + .build(); + } + + private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException { + input.defaultReadObject(); + transportFactory = newInstance(transportFactoryClassName); + } + + private boolean canRefresh() { + return refreshToken != null + && refreshToken.trim().length() > 0 + && tokenUrl != null + && tokenUrl.trim().length() > 0 + && clientId != null + && clientId.trim().length() > 0 + && clientSecret != null + && clientSecret.trim().length() > 0; + } + + private HttpRequest buildRefreshRequest() throws IOException { + GenericData tokenRequest = new GenericData(); + tokenRequest.set("grant_type", "refresh_token"); + tokenRequest.set("refresh_token", refreshToken); + + HttpRequest request = + transportFactory + .create() + .createRequestFactory() + .buildPostRequest(new GenericUrl(tokenUrl), new UrlEncodedContent(tokenRequest)); + + request.setParser(new JsonObjectParser(JSON_FACTORY)); + + HttpHeaders requestHeaders = request.getHeaders(); + requestHeaders.setAuthorization( + String.format( + "Basic %s", + BaseEncoding.base64() + .encode( + String.format("%s:%s", clientId, clientSecret) + .getBytes(StandardCharsets.UTF_8)))); + + return request; + } + + /** Builder for {@link ExternalAccountAuthorizedUserCredentials}. */ + public static class Builder extends GoogleCredentials.Builder { + + private HttpTransportFactory transportFactory; + private String audience; + private String refreshToken; + private String tokenUrl; + private String tokenInfoUrl; + private String revokeUrl; + private String clientId; + private String clientSecret; + + protected Builder() {} + + protected Builder(ExternalAccountAuthorizedUserCredentials credentials) { + super(credentials); + this.transportFactory = credentials.transportFactory; + this.audience = credentials.audience; + this.refreshToken = credentials.refreshToken; + this.tokenUrl = credentials.tokenUrl; + this.tokenInfoUrl = credentials.tokenInfoUrl; + this.revokeUrl = credentials.revokeUrl; + this.clientId = credentials.clientId; + this.clientSecret = credentials.clientSecret; + } + + /** + * Sets the HTTP transport factory. + * + * @param transportFactory the {@code HttpTransportFactory} to set + * @return this {@code Builder} object + */ + public Builder setHttpTransportFactory(HttpTransportFactory transportFactory) { + this.transportFactory = transportFactory; + return this; + } + + /** + * Sets the optional audience, which is usually the fully specified resource name of the + * workforce pool provider. + * + * @param audience the audience to set + * @return this {@code Builder} object + */ + public Builder setAudience(String audience) { + this.audience = audience; + return this; + } + + /** + * Sets the token exchange endpoint. + * + * @param tokenUrl the token exchange url to set + * @return this {@code Builder} object + */ + public Builder setTokenUrl(String tokenUrl) { + this.tokenUrl = tokenUrl; + return this; + } + + /** + * Sets the token introspection endpoint used to retrieve account related information. + * + * @param tokenInfoUrl the token info url to set + * @return this {@code Builder} object + */ + public Builder setTokenInfoUrl(String tokenInfoUrl) { + this.tokenInfoUrl = tokenInfoUrl; + return this; + } + + /** + * Sets the token revocation endpoint. + * + * @param revokeUrl the revoke url to set + * @return this {@code Builder} object + */ + public Builder setRevokeUrl(String revokeUrl) { + this.revokeUrl = revokeUrl; + return this; + } + + /** + * Sets the OAuth 2.0 refresh token. + * + * @param refreshToken the refresh token + * @return this {@code Builder} object + */ + public Builder setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + return this; + } + + /** + * Sets the OAuth 2.0 client ID. + * + * @param clientId the client ID + * @return this {@code Builder} object + */ + public Builder setClientId(String clientId) { + this.clientId = clientId; + return this; + } + + /** + * Sets the OAuth 2.0 client secret. + * + * @param clientSecret the client secret + * @return this {@code Builder} object + */ + public Builder setClientSecret(String clientSecret) { + this.clientSecret = clientSecret; + return this; + } + + /** + * Sets the optional project used for quota and billing purposes. + * + * @param quotaProjectId the quota and billing project id to set + * @return this {@code Builder} object + */ + public Builder setQuotaProjectId(String quotaProjectId) { + super.setQuotaProjectId(quotaProjectId); + return this; + } + + /** + * Sets the optional access token. + * + * @param accessToken the access token + * @return this {@code Builder} object + */ + public Builder setAccessToken(AccessToken accessToken) { + super.setAccessToken(accessToken); + return this; + } + + public ExternalAccountAuthorizedUserCredentials build() { + return new ExternalAccountAuthorizedUserCredentials(this); + } + } +} diff --git a/oauth2_http/java/com/google/auth/oauth2/GoogleCredentials.java b/oauth2_http/java/com/google/auth/oauth2/GoogleCredentials.java index 9fc61c29d..d12632a32 100644 --- a/oauth2_http/java/com/google/auth/oauth2/GoogleCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/GoogleCredentials.java @@ -181,6 +181,10 @@ public static GoogleCredentials fromStream( if (ExternalAccountCredentials.EXTERNAL_ACCOUNT_FILE_TYPE.equals(fileType)) { return ExternalAccountCredentials.fromJson(fileContents, transportFactory); } + if (ExternalAccountAuthorizedUserCredentials.EXTERNAL_ACCOUNT_AUTHORIZED_USER_FILE_TYPE.equals( + fileType)) { + return ExternalAccountAuthorizedUserCredentials.fromJson(fileContents, transportFactory); + } if ("impersonated_service_account".equals(fileType)) { return ImpersonatedCredentials.fromJson(fileContents, transportFactory); } diff --git a/oauth2_http/java/com/google/auth/oauth2/OAuthException.java b/oauth2_http/java/com/google/auth/oauth2/OAuthException.java index a920b93d0..011456e24 100644 --- a/oauth2_http/java/com/google/auth/oauth2/OAuthException.java +++ b/oauth2_http/java/com/google/auth/oauth2/OAuthException.java @@ -33,6 +33,10 @@ import static com.google.common.base.Preconditions.checkNotNull; +import com.google.api.client.http.HttpResponseException; +import com.google.api.client.json.GenericJson; +import com.google.api.client.json.JsonParser; +import java.io.IOException; import javax.annotation.Nullable; /** @@ -77,4 +81,21 @@ String getErrorDescription() { String getErrorUri() { return errorUri; } + + static OAuthException createFromHttpResponseException(HttpResponseException e) + throws IOException { + JsonParser parser = OAuth2Utils.JSON_FACTORY.createJsonParser((e).getContent()); + GenericJson errorResponse = parser.parseAndClose(GenericJson.class); + + String errorCode = (String) errorResponse.get("error"); + String errorDescription = null; + String errorUri = null; + if (errorResponse.containsKey("error_description")) { + errorDescription = (String) errorResponse.get("error_description"); + } + if (errorResponse.containsKey("error_uri")) { + errorUri = (String) errorResponse.get("error_uri"); + } + return new OAuthException(errorCode, errorDescription, errorUri); + } } diff --git a/oauth2_http/java/com/google/auth/oauth2/StsRequestHandler.java b/oauth2_http/java/com/google/auth/oauth2/StsRequestHandler.java index 15e9611b1..3f93ad86e 100644 --- a/oauth2_http/java/com/google/auth/oauth2/StsRequestHandler.java +++ b/oauth2_http/java/com/google/auth/oauth2/StsRequestHandler.java @@ -107,17 +107,7 @@ public StsTokenExchangeResponse exchangeToken() throws IOException { GenericData responseData = response.parseAs(GenericData.class); return buildResponse(responseData); } catch (HttpResponseException e) { - GenericJson errorResponse = parseJson((e).getContent()); - String errorCode = (String) errorResponse.get("error"); - String errorDescription = null; - String errorUri = null; - if (errorResponse.containsKey("error_description")) { - errorDescription = (String) errorResponse.get("error_description"); - } - if (errorResponse.containsKey("error_uri")) { - errorUri = (String) errorResponse.get("error_uri"); - } - throw new OAuthException(errorCode, errorDescription, errorUri); + throw OAuthException.createFromHttpResponseException(e); } } diff --git a/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentialsTest.java new file mode 100644 index 000000000..2fb89dfdc --- /dev/null +++ b/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentialsTest.java @@ -0,0 +1,1124 @@ +/* + * Copyright 2022 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.oauth2; + +import static com.google.auth.oauth2.ExternalAccountAuthorizedUserCredentials.EXTERNAL_ACCOUNT_AUTHORIZED_USER_FILE_TYPE; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import com.google.api.client.http.HttpTransport; +import com.google.api.client.json.GenericJson; +import com.google.api.client.testing.http.MockLowLevelHttpRequest; +import com.google.api.client.util.Clock; +import com.google.auth.TestUtils; +import com.google.auth.http.AuthHttpConstants; +import com.google.auth.http.HttpTransportFactory; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.io.BaseEncoding; +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Test case for {@link ExternalAccountAuthorizedUserCredentials}. */ +@RunWith(JUnit4.class) +public class ExternalAccountAuthorizedUserCredentialsTest extends BaseSerializationTest { + + private static final String AUDIENCE = + "//iam.googleapis.com/locations/global/workforcePools/$WORKFORCE_POOL_ID/providers/$PROVIDER_ID"; + private static final String CLIENT_SECRET = "jakuaL9YyieakhECKL2SwZcu"; + private static final String CLIENT_ID = "ya29.1.AADtN_UtlxN3PuGAxrN2XQnZTVRvDyVWnYq4I6dws"; + private static final String REFRESH_TOKEN = "1/Tl6awhpFjkMkSJoj1xsli0H2eL5YsMgU_NKPY2TyGWY"; + private static final String ACCESS_TOKEN = "1/MkSJoj1xsli0AccessToken_NKPY2"; + private static final String TOKEN_URL = "https://sts.googleapis.com/v1/oauthtoken"; + private static final String TOKEN_INFO_URL = "https://sts.googleapis.com/v1/introspect"; + private static final String REVOKE_URL = "https://sts.googleapis.com/v1/revoke"; + private static final String QUOTA_PROJECT = "sample-quota-project-id"; + private static final String BASIC_AUTH = + String.format( + "Basic %s", + BaseEncoding.base64() + .encode( + String.format("%s:%s", CLIENT_ID, CLIENT_SECRET) + .getBytes(StandardCharsets.UTF_8))); + + private static final Collection SCOPES = Collections.singletonList("dummy.scope"); + private static final URI CALL_URI = URI.create("http://googleapis.com/testapi/v1/foo"); + + private MockExternalAccountAuthorizedUserCredentialsTransportFactory transportFactory; + + static class MockExternalAccountAuthorizedUserCredentialsTransportFactory + implements HttpTransportFactory { + + MockStsTransport transport = new MockStsTransport(); + + @Override + public HttpTransport create() { + return transport; + } + } + + @Before + public void setup() { + transportFactory = new MockExternalAccountAuthorizedUserCredentialsTransportFactory(); + } + + @Test + public void builder_allFields() { + ExternalAccountAuthorizedUserCredentials credentials = + ExternalAccountAuthorizedUserCredentials.newBuilder() + .setAudience(AUDIENCE) + .setClientId(CLIENT_ID) + .setClientSecret(CLIENT_SECRET) + .setRefreshToken(REFRESH_TOKEN) + .setTokenUrl(TOKEN_URL) + .setTokenInfoUrl(TOKEN_INFO_URL) + .setRevokeUrl(REVOKE_URL) + .setAccessToken(new AccessToken(ACCESS_TOKEN, /* expirationTime= */ null)) + .setQuotaProjectId(QUOTA_PROJECT) + .build(); + + assertEquals(AUDIENCE, credentials.getAudience()); + assertEquals(CLIENT_ID, credentials.getClientId()); + assertEquals(CLIENT_SECRET, credentials.getClientSecret()); + assertEquals(REFRESH_TOKEN, credentials.getRefreshToken()); + assertEquals(TOKEN_URL, credentials.getTokenUrl()); + assertEquals(TOKEN_INFO_URL, credentials.getTokenInfoUrl()); + assertEquals(REVOKE_URL, credentials.getRevokeUrl()); + assertEquals(ACCESS_TOKEN, credentials.getAccessToken().getTokenValue()); + assertEquals(QUOTA_PROJECT, credentials.getQuotaProjectId()); + } + + @Test + public void builder_minimumRequiredFieldsForRefresh() { + ExternalAccountAuthorizedUserCredentials credentials = + ExternalAccountAuthorizedUserCredentials.newBuilder() + .setClientId(CLIENT_ID) + .setClientSecret(CLIENT_SECRET) + .setRefreshToken(REFRESH_TOKEN) + .setTokenUrl(TOKEN_URL) + .build(); + + assertEquals(CLIENT_ID, credentials.getClientId()); + assertEquals(CLIENT_SECRET, credentials.getClientSecret()); + assertEquals(REFRESH_TOKEN, credentials.getRefreshToken()); + assertEquals(TOKEN_URL, credentials.getTokenUrl()); + assertNull(credentials.getAudience()); + assertNull(credentials.getTokenInfoUrl()); + assertNull(credentials.getRevokeUrl()); + assertNull(credentials.getAccessToken()); + assertNull(credentials.getQuotaProjectId()); + } + + @Test + public void builder_accessTokenOnly() { + ExternalAccountAuthorizedUserCredentials credentials = + ExternalAccountAuthorizedUserCredentials.newBuilder() + .setAccessToken(AccessToken.newBuilder().setTokenValue(ACCESS_TOKEN).build()) + .build(); + + assertEquals(ACCESS_TOKEN, credentials.getAccessToken().getTokenValue()); + assertNull(credentials.getAudience()); + assertNull(credentials.getTokenUrl()); + assertNull(credentials.getTokenInfoUrl()); + assertNull(credentials.getRevokeUrl()); + assertNull(credentials.getClientId()); + assertNull(credentials.getClientSecret()); + assertNull(credentials.getRefreshToken()); + assertNull(credentials.getQuotaProjectId()); + } + + @Test + public void builder_credentialConstructor() { + ExternalAccountAuthorizedUserCredentials credentials = + ExternalAccountAuthorizedUserCredentials.newBuilder() + .setAudience(AUDIENCE) + .setClientId(CLIENT_ID) + .setClientSecret(CLIENT_SECRET) + .setRefreshToken(REFRESH_TOKEN) + .setTokenUrl(TOKEN_URL) + .setTokenInfoUrl(TOKEN_INFO_URL) + .setRevokeUrl(REVOKE_URL) + .setQuotaProjectId(QUOTA_PROJECT) + .build(); + + ExternalAccountAuthorizedUserCredentials otherCredentials = credentials.toBuilder().build(); + + assertEquals(AUDIENCE, otherCredentials.getAudience()); + assertEquals(CLIENT_ID, otherCredentials.getClientId()); + assertEquals(CLIENT_SECRET, otherCredentials.getClientSecret()); + assertEquals(REFRESH_TOKEN, otherCredentials.getRefreshToken()); + assertEquals(TOKEN_URL, otherCredentials.getTokenUrl()); + assertEquals(TOKEN_INFO_URL, otherCredentials.getTokenInfoUrl()); + assertEquals(REVOKE_URL, otherCredentials.getRevokeUrl()); + assertEquals(QUOTA_PROJECT, otherCredentials.getQuotaProjectId()); + } + + @Test + public void builder_accessTokenWithMissingRefreshFields() { + ExternalAccountAuthorizedUserCredentials credentials = + ExternalAccountAuthorizedUserCredentials.newBuilder() + .setAccessToken(AccessToken.newBuilder().setTokenValue(ACCESS_TOKEN).build()) + .setRefreshToken(REFRESH_TOKEN) + .setTokenUrl(TOKEN_URL) + .setClientId(CLIENT_ID) + .build(); + + assertEquals(ACCESS_TOKEN, credentials.getAccessToken().getTokenValue()); + assertEquals(REFRESH_TOKEN, credentials.getRefreshToken()); + assertEquals(TOKEN_URL, credentials.getTokenUrl()); + assertEquals(CLIENT_ID, credentials.getClientId()); + assertNull(credentials.getAudience()); + assertNull(credentials.getTokenInfoUrl()); + assertNull(credentials.getRevokeUrl()); + assertNull(credentials.getClientSecret()); + assertNull(credentials.getQuotaProjectId()); + } + + @Test + public void builder_accessAndRefreshTokenNull_throws() { + try { + ExternalAccountAuthorizedUserCredentials.newBuilder().build(); + fail("Should not be able to continue without exception."); + } catch (IllegalStateException exception) { + assertEquals( + "ExternalAccountAuthorizedUserCredentials must be initialized with " + + "an access token or fields to enable refresh: " + + "('refresh_token', 'token_url', 'client_id', 'client_secret').", + exception.getMessage()); + } + } + + @Test + public void builder_missingTokenUrl_throws() { + try { + ExternalAccountAuthorizedUserCredentials.newBuilder() + .setRefreshToken(REFRESH_TOKEN) + .setClientId(CLIENT_ID) + .setClientSecret(CLIENT_SECRET) + .build(); + fail("Should not be able to continue without exception."); + } catch (IllegalStateException exception) { + assertEquals( + "ExternalAccountAuthorizedUserCredentials must be initialized with " + + "an access token or fields to enable refresh: " + + "('refresh_token', 'token_url', 'client_id', 'client_secret').", + exception.getMessage()); + } + } + + @Test + public void builder_missingClientId_throws() { + try { + ExternalAccountAuthorizedUserCredentials.newBuilder() + .setRefreshToken(REFRESH_TOKEN) + .setTokenUrl(TOKEN_URL) + .setClientSecret(CLIENT_SECRET) + .build(); + fail("Should not be able to continue without exception."); + } catch (IllegalStateException exception) { + assertEquals( + "ExternalAccountAuthorizedUserCredentials must be initialized with " + + "an access token or fields to enable refresh: " + + "('refresh_token', 'token_url', 'client_id', 'client_secret').", + exception.getMessage()); + } + } + + @Test + public void builder_missingClientSecret_throws() { + try { + ExternalAccountAuthorizedUserCredentials.newBuilder() + .setRefreshToken(REFRESH_TOKEN) + .setTokenUrl(TOKEN_URL) + .setClientId(CLIENT_ID) + .build(); + fail("Should not be able to continue without exception."); + } catch (IllegalStateException exception) { + assertEquals( + "ExternalAccountAuthorizedUserCredentials must be initialized with " + + "an access token or fields to enable refresh: " + + "('refresh_token', 'token_url', 'client_id', 'client_secret').", + exception.getMessage()); + } + } + + @Test + public void toBuilder() { + ExternalAccountAuthorizedUserCredentials credentials = + ExternalAccountAuthorizedUserCredentials.newBuilder() + .setAudience(AUDIENCE) + .setClientId(CLIENT_ID) + .setClientSecret(CLIENT_SECRET) + .setRefreshToken(REFRESH_TOKEN) + .setTokenUrl(TOKEN_URL) + .setTokenInfoUrl(TOKEN_INFO_URL) + .setRevokeUrl(REVOKE_URL) + .setAccessToken(new AccessToken(ACCESS_TOKEN, new Date())) + .setQuotaProjectId(QUOTA_PROJECT) + .build(); + + ExternalAccountAuthorizedUserCredentials secondCredentials = credentials.toBuilder().build(); + + assertEquals(credentials, secondCredentials); + } + + @Test + public void fromJson_allFields() throws IOException { + ExternalAccountAuthorizedUserCredentials credentials = + ExternalAccountAuthorizedUserCredentials.fromJson( + buildJsonCredentials(), OAuth2Utils.HTTP_TRANSPORT_FACTORY); + + assertEquals(AUDIENCE, credentials.getAudience()); + assertEquals(CLIENT_ID, credentials.getClientId()); + assertEquals(CLIENT_SECRET, credentials.getClientSecret()); + assertEquals(REFRESH_TOKEN, credentials.getRefreshToken()); + assertEquals(TOKEN_URL, credentials.getTokenUrl()); + assertEquals(TOKEN_INFO_URL, credentials.getTokenInfoUrl()); + assertEquals(REVOKE_URL, credentials.getRevokeUrl()); + assertEquals(QUOTA_PROJECT, credentials.getQuotaProjectId()); + } + + @Test + public void fromJson_minimumRequiredFieldsForRefresh() throws IOException { + GenericJson json = new GenericJson(); + json.put("client_id", CLIENT_ID); + json.put("client_secret", CLIENT_SECRET); + json.put("refresh_token", REFRESH_TOKEN); + json.put("token_url", TOKEN_URL); + + ExternalAccountAuthorizedUserCredentials credentials = + ExternalAccountAuthorizedUserCredentials.fromJson(json, OAuth2Utils.HTTP_TRANSPORT_FACTORY); + + assertEquals(CLIENT_ID, credentials.getClientId()); + assertEquals(CLIENT_SECRET, credentials.getClientSecret()); + assertEquals(REFRESH_TOKEN, credentials.getRefreshToken()); + assertEquals(TOKEN_URL, credentials.getTokenUrl()); + assertNull(credentials.getAudience()); + assertNull(credentials.getTokenInfoUrl()); + assertNull(credentials.getRevokeUrl()); + assertNull(credentials.getAccessToken()); + assertNull(credentials.getQuotaProjectId()); + } + + @Test + public void fromJson_accessTokenOnly_notSupported() throws IOException { + GenericJson json = new GenericJson(); + json.put("access_token", ACCESS_TOKEN); + + try { + ExternalAccountAuthorizedUserCredentials.fromJson(json, OAuth2Utils.HTTP_TRANSPORT_FACTORY); + fail("Should not be able to continue without exception."); + } catch (IllegalStateException exception) { + assertEquals( + "ExternalAccountAuthorizedUserCredentials must be initialized with " + + "an access token or fields to enable refresh: " + + "('refresh_token', 'token_url', 'client_id', 'client_secret').", + exception.getMessage()); + } + } + + @Test + public void fromJson_missingRefreshToken_throws() throws IOException { + try { + GenericJson json = buildJsonCredentials(); + json.remove("refresh_token"); + ExternalAccountAuthorizedUserCredentials.fromJson(json, OAuth2Utils.HTTP_TRANSPORT_FACTORY); + fail("Should not be able to continue without exception."); + } catch (IllegalStateException exception) { + assertEquals( + "ExternalAccountAuthorizedUserCredentials must be initialized with " + + "an access token or fields to enable refresh: " + + "('refresh_token', 'token_url', 'client_id', 'client_secret').", + exception.getMessage()); + } + } + + @Test + public void fromJson_missingTokenUrl_throws() throws IOException { + try { + GenericJson json = buildJsonCredentials(); + json.remove("token_url"); + ExternalAccountAuthorizedUserCredentials.fromJson(json, OAuth2Utils.HTTP_TRANSPORT_FACTORY); + fail("Should not be able to continue without exception."); + } catch (IllegalStateException exception) { + assertEquals( + "ExternalAccountAuthorizedUserCredentials must be initialized with " + + "an access token or fields to enable refresh: " + + "('refresh_token', 'token_url', 'client_id', 'client_secret').", + exception.getMessage()); + } + } + + @Test + public void fromJson_missingClientId_throws() throws IOException { + try { + GenericJson json = buildJsonCredentials(); + json.remove("client_id"); + ExternalAccountAuthorizedUserCredentials.fromJson(json, OAuth2Utils.HTTP_TRANSPORT_FACTORY); + fail("Should not be able to continue without exception."); + } catch (IllegalStateException exception) { + assertEquals( + "ExternalAccountAuthorizedUserCredentials must be initialized with " + + "an access token or fields to enable refresh: " + + "('refresh_token', 'token_url', 'client_id', 'client_secret').", + exception.getMessage()); + } + } + + @Test + public void fromJson_missingClientSecret_throws() throws IOException { + try { + GenericJson json = buildJsonCredentials(); + json.remove("client_secret"); + ExternalAccountAuthorizedUserCredentials.fromJson(json, OAuth2Utils.HTTP_TRANSPORT_FACTORY); + fail("Should not be able to continue without exception."); + } catch (IllegalStateException exception) { + assertEquals( + "ExternalAccountAuthorizedUserCredentials must be initialized with " + + "an access token or fields to enable refresh: " + + "('refresh_token', 'token_url', 'client_id', 'client_secret').", + exception.getMessage()); + } + } + + @Test + public void fromStream_allFields() throws IOException { + GenericJson json = buildJsonCredentials(); + + ExternalAccountAuthorizedUserCredentials credentials = + ExternalAccountAuthorizedUserCredentials.fromStream(TestUtils.jsonToInputStream(json)); + + assertEquals(AUDIENCE, credentials.getAudience()); + assertEquals(CLIENT_ID, credentials.getClientId()); + assertEquals(CLIENT_SECRET, credentials.getClientSecret()); + assertEquals(REFRESH_TOKEN, credentials.getRefreshToken()); + assertEquals(TOKEN_URL, credentials.getTokenUrl()); + assertEquals(TOKEN_INFO_URL, credentials.getTokenInfoUrl()); + assertEquals(REVOKE_URL, credentials.getRevokeUrl()); + assertEquals(QUOTA_PROJECT, credentials.getQuotaProjectId()); + } + + @Test + public void fromStream_minimumRequiredFieldsForRefresh() throws IOException { + GenericJson json = new GenericJson(); + json.put("client_id", CLIENT_ID); + json.put("client_secret", CLIENT_SECRET); + json.put("refresh_token", REFRESH_TOKEN); + json.put("token_url", TOKEN_URL); + + ExternalAccountAuthorizedUserCredentials credentials = + ExternalAccountAuthorizedUserCredentials.fromStream(TestUtils.jsonToInputStream(json)); + + assertEquals(CLIENT_ID, credentials.getClientId()); + assertEquals(CLIENT_SECRET, credentials.getClientSecret()); + assertEquals(REFRESH_TOKEN, credentials.getRefreshToken()); + assertEquals(TOKEN_URL, credentials.getTokenUrl()); + assertNull(credentials.getAudience()); + assertNull(credentials.getTokenInfoUrl()); + assertNull(credentials.getRevokeUrl()); + assertNull(credentials.getAccessToken()); + assertNull(credentials.getQuotaProjectId()); + } + + @Test + public void fromStream_accessTokenOnly_notSupported() throws IOException { + GenericJson json = new GenericJson(); + json.put("access_token", ACCESS_TOKEN); + try { + ExternalAccountAuthorizedUserCredentials.fromStream(TestUtils.jsonToInputStream(json)); + fail("Should not be able to continue without exception."); + } catch (IllegalStateException exception) { + assertEquals( + "ExternalAccountAuthorizedUserCredentials must be initialized with " + + "an access token or fields to enable refresh: " + + "('refresh_token', 'token_url', 'client_id', 'client_secret').", + exception.getMessage()); + } + } + + @Test + public void fromStream_missingRefreshToken_throws() throws IOException { + try { + GenericJson json = buildJsonCredentials(); + json.remove("refresh_token"); + ExternalAccountAuthorizedUserCredentials.fromStream(TestUtils.jsonToInputStream(json)); + fail("Should not be able to continue without exception."); + } catch (IllegalStateException exception) { + assertEquals( + "ExternalAccountAuthorizedUserCredentials must be initialized with " + + "an access token or fields to enable refresh: " + + "('refresh_token', 'token_url', 'client_id', 'client_secret').", + exception.getMessage()); + } + } + + @Test + public void fromStream_missingTokenUrl_throws() throws IOException { + try { + GenericJson json = buildJsonCredentials(); + json.remove("token_url"); + ExternalAccountAuthorizedUserCredentials.fromStream(TestUtils.jsonToInputStream(json)); + fail("Should not be able to continue without exception."); + } catch (IllegalStateException exception) { + assertEquals( + "ExternalAccountAuthorizedUserCredentials must be initialized with " + + "an access token or fields to enable refresh: " + + "('refresh_token', 'token_url', 'client_id', 'client_secret').", + exception.getMessage()); + } + } + + @Test + public void fromStream_missingClientId_throws() throws IOException { + try { + GenericJson json = buildJsonCredentials(); + json.remove("client_id"); + ExternalAccountAuthorizedUserCredentials.fromStream(TestUtils.jsonToInputStream(json)); + fail("Should not be able to continue without exception."); + } catch (IllegalStateException exception) { + assertEquals( + "ExternalAccountAuthorizedUserCredentials must be initialized with " + + "an access token or fields to enable refresh: " + + "('refresh_token', 'token_url', 'client_id', 'client_secret').", + exception.getMessage()); + } + } + + @Test + public void fromStream_missingClientSecret_throws() throws IOException { + try { + GenericJson json = buildJsonCredentials(); + json.remove("client_secret"); + ExternalAccountAuthorizedUserCredentials.fromStream(TestUtils.jsonToInputStream(json)); + fail("Should not be able to continue without exception."); + } catch (IllegalStateException exception) { + assertEquals( + "ExternalAccountAuthorizedUserCredentials must be initialized with " + + "an access token or fields to enable refresh: " + + "('refresh_token', 'token_url', 'client_id', 'client_secret').", + exception.getMessage()); + } + } + + @Test + public void fromStream_invalidInputStream_throws() throws IOException { + try { + GenericJson json = buildJsonCredentials(); + json.put("audience", new HashMap<>()); + ExternalAccountAuthorizedUserCredentials.fromStream(TestUtils.jsonToInputStream(json)); + fail("Should not be able to continue without exception."); + } catch (CredentialFormatException e) { + assertEquals("Invalid input stream provided.", e.getMessage()); + } + } + + @Test + public void createScoped_noChange() { + ExternalAccountAuthorizedUserCredentials externalAccountAuthorizedUserCredentials = + ExternalAccountAuthorizedUserCredentials.newBuilder() + .setTokenUrl(TOKEN_URL) + .setClientId(CLIENT_ID) + .setClientSecret(CLIENT_SECRET) + .setRefreshToken(REFRESH_TOKEN) + .build(); + assertSame( + externalAccountAuthorizedUserCredentials, + externalAccountAuthorizedUserCredentials.createScoped(SCOPES)); + } + + @Test + public void createScopedRequired_false() { + ExternalAccountAuthorizedUserCredentials externalAccountAuthorizedUserCredentials = + ExternalAccountAuthorizedUserCredentials.newBuilder() + .setTokenUrl(TOKEN_URL) + .setClientId(CLIENT_ID) + .setClientSecret(CLIENT_SECRET) + .setRefreshToken(REFRESH_TOKEN) + .build(); + assertFalse(externalAccountAuthorizedUserCredentials.createScopedRequired()); + } + + @Test + public void getRequestMetadata() throws IOException { + GoogleCredentials credentials = + ExternalAccountAuthorizedUserCredentials.fromJson(buildJsonCredentials(), transportFactory); + + Map> metadata = credentials.getRequestMetadata(CALL_URI); + + TestUtils.assertContainsBearerToken(metadata, transportFactory.transport.getAccessToken()); + validateAuthHeader(transportFactory.transport.getRequest()); + } + + @Test + public void getRequestMetadata_withQuotaProjectId() throws IOException { + GoogleCredentials credentials = + ExternalAccountAuthorizedUserCredentials.fromJson(buildJsonCredentials(), transportFactory); + + Map> metadata = credentials.getRequestMetadata(CALL_URI); + + assertTrue(metadata.containsKey(GoogleCredentials.QUOTA_PROJECT_ID_HEADER_KEY)); + assertEquals( + metadata.get(GoogleCredentials.QUOTA_PROJECT_ID_HEADER_KEY), + Collections.singletonList(QUOTA_PROJECT)); + + validateAuthHeader(transportFactory.transport.getRequest()); + } + + @Test + public void getRequestMetadata_withAccessToken() throws IOException { + ExternalAccountAuthorizedUserCredentials credentials = + ExternalAccountAuthorizedUserCredentials.newBuilder() + .setHttpTransportFactory(transportFactory) + .setAccessToken(new AccessToken(ACCESS_TOKEN, /* expirationTime= */ null)) + .build(); + + Map> metadata = credentials.getRequestMetadata(CALL_URI); + + TestUtils.assertContainsBearerToken(metadata, ACCESS_TOKEN); + } + + @Test + public void refreshAccessToken() throws IOException { + ExternalAccountAuthorizedUserCredentials credentials = + ExternalAccountAuthorizedUserCredentials.fromJson(buildJsonCredentials(), transportFactory); + + AccessToken accessToken = credentials.refreshAccessToken(); + + assertEquals(transportFactory.transport.getAccessToken(), accessToken.getTokenValue()); + validateAuthHeader(transportFactory.transport.getRequest()); + } + + @Test + public void refreshAccessToken_withRefreshTokenRotation() throws IOException { + ExternalAccountAuthorizedUserCredentials credentials = + ExternalAccountAuthorizedUserCredentials.fromJson(buildJsonCredentials(), transportFactory); + + transportFactory.transport.addRefreshTokenSequence("aNewRefreshToken"); + + AccessToken accessToken = credentials.refreshAccessToken(); + + assertEquals(transportFactory.transport.getAccessToken(), accessToken.getTokenValue()); + // Validate new refresh token was set. + assertEquals("aNewRefreshToken", credentials.getRefreshToken()); + assertNotEquals(REFRESH_TOKEN, credentials.getRefreshToken()); + validateAuthHeader(transportFactory.transport.getRequest()); + } + + @Test + public void refreshAccessToken_genericAuthError_throws() throws IOException { + transportFactory.transport.addResponseErrorSequence( + TestUtils.buildHttpResponseException( + "invalid_request", "Invalid request.", /* errorUri= */ null)); + + ExternalAccountAuthorizedUserCredentials credentials = + ExternalAccountAuthorizedUserCredentials.fromJson(buildJsonCredentials(), transportFactory); + + try { + credentials.refreshAccessToken(); + fail(""); + } catch (OAuthException e) { + assertEquals("invalid_request", e.getErrorCode()); + assertEquals("Invalid request.", e.getErrorDescription()); + } + } + + @Test(expected = IOException.class) + public void refreshAccessToken_genericIOError_throws() throws IOException { + transportFactory.transport.addResponseErrorSequence(new IOException("")); + + ExternalAccountAuthorizedUserCredentials credentials = + ExternalAccountAuthorizedUserCredentials.fromJson(buildJsonCredentials(), transportFactory); + + credentials.refreshAccessToken(); + } + + @Test(expected = IllegalStateException.class) + public void refreshAccessToken_missingRefreshFields_throws() throws IOException { + ExternalAccountAuthorizedUserCredentials credentials = + ExternalAccountAuthorizedUserCredentials.newBuilder() + .setClientId(CLIENT_ID) + .setClientSecret(CLIENT_SECRET) + .setTokenUrl(TOKEN_URL) + .setAccessToken(new AccessToken(ACCESS_TOKEN, /* expirationTime= */ null)) + .setHttpTransportFactory(transportFactory) + .build(); + + credentials.refreshAccessToken(); + } + + @Test + public void hashCode_sameCredentials() { + ExternalAccountAuthorizedUserCredentials credentials = + ExternalAccountAuthorizedUserCredentials.newBuilder() + .setAudience(AUDIENCE) + .setClientId(CLIENT_ID) + .setClientSecret(CLIENT_SECRET) + .setRefreshToken(REFRESH_TOKEN) + .setTokenUrl(TOKEN_URL) + .setTokenInfoUrl(TOKEN_INFO_URL) + .setRevokeUrl(REVOKE_URL) + .setAccessToken(new AccessToken(ACCESS_TOKEN, /* expirationTime= */ null)) + .setQuotaProjectId(QUOTA_PROJECT) + .build(); + + ExternalAccountAuthorizedUserCredentials secondCredentials = + ExternalAccountAuthorizedUserCredentials.newBuilder() + .setAudience(AUDIENCE) + .setClientId(CLIENT_ID) + .setClientSecret(CLIENT_SECRET) + .setRefreshToken(REFRESH_TOKEN) + .setTokenUrl(TOKEN_URL) + .setTokenInfoUrl(TOKEN_INFO_URL) + .setRevokeUrl(REVOKE_URL) + .setAccessToken(new AccessToken(ACCESS_TOKEN, /* expirationTime= */ null)) + .setQuotaProjectId(QUOTA_PROJECT) + .build(); + + assertEquals(credentials, secondCredentials); + assertEquals(secondCredentials, credentials); + assertEquals(credentials.hashCode(), secondCredentials.hashCode()); + } + + @Test + public void hashCode_differentCredentials() { + ExternalAccountAuthorizedUserCredentials credentials = + ExternalAccountAuthorizedUserCredentials.newBuilder() + .setAudience(AUDIENCE) + .setClientId(CLIENT_ID) + .setClientSecret(CLIENT_SECRET) + .setRefreshToken(REFRESH_TOKEN) + .setTokenUrl(TOKEN_URL) + .setTokenInfoUrl(TOKEN_INFO_URL) + .setRevokeUrl(REVOKE_URL) + .build(); + + // Second credentials have an access token set. + ExternalAccountAuthorizedUserCredentials secondCredentials = + ExternalAccountAuthorizedUserCredentials.newBuilder() + .setAudience(AUDIENCE) + .setClientId(CLIENT_ID) + .setClientSecret(CLIENT_SECRET) + .setRefreshToken(REFRESH_TOKEN) + .setTokenUrl(TOKEN_URL) + .setTokenInfoUrl(TOKEN_INFO_URL) + .setRevokeUrl(REVOKE_URL) + .setAccessToken(new AccessToken(ACCESS_TOKEN, /* expirationTime= */ null)) + .setQuotaProjectId(QUOTA_PROJECT) + .build(); + + assertNotEquals(secondCredentials, credentials); + assertNotEquals(credentials, secondCredentials); + assertNotEquals(credentials.hashCode(), secondCredentials.hashCode()); + } + + @Test + public void hashCode_differentCredentialsWithCredentialsFile() throws IOException { + // Optional fields that can be specified in the credentials file. + List fields = Arrays.asList("audience", "revoke_url", "quota_project_id"); + + // Credential initialized with all fields. + ExternalAccountAuthorizedUserCredentials credentials = + ExternalAccountAuthorizedUserCredentials.fromJson(buildJsonCredentials(), transportFactory); + + for (String field : fields) { + // Build credential with one of these fields missing. + GenericJson json = buildJsonCredentials(); + json.remove(field); + + ExternalAccountAuthorizedUserCredentials secondCredentials = + ExternalAccountAuthorizedUserCredentials.fromJson(json, transportFactory); + assertNotEquals(secondCredentials, credentials); + assertNotEquals(credentials, secondCredentials); + assertNotEquals(credentials.hashCode(), secondCredentials.hashCode()); + } + } + + @Test + public void equals_differentCredentials() throws IOException { + UserCredentials userCredentials = + UserCredentials.newBuilder() + .setClientId(CLIENT_ID) + .setClientSecret(CLIENT_SECRET) + .setRefreshToken(REFRESH_TOKEN) + .setHttpTransportFactory(transportFactory) + .setTokenServerUri(URI.create(TOKEN_URL)) + .setQuotaProjectId(QUOTA_PROJECT) + .build(); + + ExternalAccountAuthorizedUserCredentials credentials = + ExternalAccountAuthorizedUserCredentials.fromJson(buildJsonCredentials(), transportFactory); + + assertNotEquals(userCredentials, credentials); + assertNotEquals(credentials, userCredentials); + } + + @Test + public void equals_differentAudience() { + ExternalAccountAuthorizedUserCredentials credentials = + ExternalAccountAuthorizedUserCredentials.newBuilder() + .setAudience(AUDIENCE) + .setClientId(CLIENT_ID) + .setClientSecret(CLIENT_SECRET) + .setRefreshToken(REFRESH_TOKEN) + .setTokenUrl(TOKEN_URL) + .setTokenInfoUrl(TOKEN_INFO_URL) + .setRevokeUrl(REVOKE_URL) + .setAccessToken(new AccessToken(ACCESS_TOKEN, /* expirationTime= */ null)) + .setQuotaProjectId(QUOTA_PROJECT) + .setHttpTransportFactory(transportFactory) + .build(); + + ExternalAccountAuthorizedUserCredentials secondCredentials = + credentials.toBuilder().setAudience("different").build(); + + assertNotEquals(secondCredentials, credentials); + assertNotEquals(credentials, secondCredentials); + assertNotEquals(credentials.hashCode(), secondCredentials.hashCode()); + } + + @Test + public void equals_differentClientId() { + ExternalAccountAuthorizedUserCredentials credentials = + ExternalAccountAuthorizedUserCredentials.newBuilder() + .setAudience(AUDIENCE) + .setClientId(CLIENT_ID) + .setClientSecret(CLIENT_SECRET) + .setRefreshToken(REFRESH_TOKEN) + .setTokenUrl(TOKEN_URL) + .setTokenInfoUrl(TOKEN_INFO_URL) + .setRevokeUrl(REVOKE_URL) + .setAccessToken(new AccessToken(ACCESS_TOKEN, /* expirationTime= */ null)) + .setQuotaProjectId(QUOTA_PROJECT) + .setHttpTransportFactory(transportFactory) + .build(); + + ExternalAccountAuthorizedUserCredentials secondCredentials = + credentials.toBuilder().setClientId("different").build(); + + assertNotEquals(secondCredentials, credentials); + assertNotEquals(credentials, secondCredentials); + assertNotEquals(credentials.hashCode(), secondCredentials.hashCode()); + } + + @Test + public void equals_differentClientSecret() { + ExternalAccountAuthorizedUserCredentials credentials = + ExternalAccountAuthorizedUserCredentials.newBuilder() + .setAudience(AUDIENCE) + .setClientId(CLIENT_ID) + .setClientSecret(CLIENT_SECRET) + .setRefreshToken(REFRESH_TOKEN) + .setTokenUrl(TOKEN_URL) + .setTokenInfoUrl(TOKEN_INFO_URL) + .setRevokeUrl(REVOKE_URL) + .setAccessToken(new AccessToken(ACCESS_TOKEN, /* expirationTime= */ null)) + .setQuotaProjectId(QUOTA_PROJECT) + .setHttpTransportFactory(transportFactory) + .build(); + + ExternalAccountAuthorizedUserCredentials secondCredentials = + credentials.toBuilder().setClientSecret("different").build(); + + assertNotEquals(secondCredentials, credentials); + assertNotEquals(credentials, secondCredentials); + assertNotEquals(credentials.hashCode(), secondCredentials.hashCode()); + } + + @Test + public void equals_differentRefreshToken() { + ExternalAccountAuthorizedUserCredentials credentials = + ExternalAccountAuthorizedUserCredentials.newBuilder() + .setAudience(AUDIENCE) + .setClientId(CLIENT_ID) + .setClientSecret(CLIENT_SECRET) + .setRefreshToken(REFRESH_TOKEN) + .setTokenUrl(TOKEN_URL) + .setTokenInfoUrl(TOKEN_INFO_URL) + .setRevokeUrl(REVOKE_URL) + .setAccessToken(new AccessToken(ACCESS_TOKEN, /* expirationTime= */ null)) + .setQuotaProjectId(QUOTA_PROJECT) + .setHttpTransportFactory(transportFactory) + .build(); + + ExternalAccountAuthorizedUserCredentials secondCredentials = + credentials.toBuilder().setRefreshToken("different").build(); + + assertNotEquals(secondCredentials, credentials); + assertNotEquals(credentials, secondCredentials); + assertNotEquals(credentials.hashCode(), secondCredentials.hashCode()); + } + + @Test + public void equals_differentTokenUrl() { + ExternalAccountAuthorizedUserCredentials credentials = + ExternalAccountAuthorizedUserCredentials.newBuilder() + .setAudience(AUDIENCE) + .setClientId(CLIENT_ID) + .setClientSecret(CLIENT_SECRET) + .setRefreshToken(REFRESH_TOKEN) + .setTokenUrl(TOKEN_URL) + .setTokenInfoUrl(TOKEN_INFO_URL) + .setRevokeUrl(REVOKE_URL) + .setAccessToken(new AccessToken(ACCESS_TOKEN, /* expirationTime= */ null)) + .setQuotaProjectId(QUOTA_PROJECT) + .setHttpTransportFactory(transportFactory) + .build(); + + ExternalAccountAuthorizedUserCredentials secondCredentials = + credentials.toBuilder().setTokenUrl("different").build(); + + assertNotEquals(secondCredentials, credentials); + assertNotEquals(credentials, secondCredentials); + assertNotEquals(credentials.hashCode(), secondCredentials.hashCode()); + } + + @Test + public void equals_differentTokenInfoUrl() { + ExternalAccountAuthorizedUserCredentials credentials = + ExternalAccountAuthorizedUserCredentials.newBuilder() + .setAudience(AUDIENCE) + .setClientId(CLIENT_ID) + .setClientSecret(CLIENT_SECRET) + .setRefreshToken(REFRESH_TOKEN) + .setTokenUrl(TOKEN_URL) + .setTokenInfoUrl(TOKEN_INFO_URL) + .setRevokeUrl(REVOKE_URL) + .setAccessToken(new AccessToken(ACCESS_TOKEN, /* expirationTime= */ null)) + .setQuotaProjectId(QUOTA_PROJECT) + .setHttpTransportFactory(transportFactory) + .build(); + + ExternalAccountAuthorizedUserCredentials secondCredentials = + credentials.toBuilder().setTokenInfoUrl("different").build(); + + assertNotEquals(secondCredentials, credentials); + assertNotEquals(credentials, secondCredentials); + assertNotEquals(credentials.hashCode(), secondCredentials.hashCode()); + } + + @Test + public void equals_differentRevokeUrl() { + ExternalAccountAuthorizedUserCredentials credentials = + ExternalAccountAuthorizedUserCredentials.newBuilder() + .setAudience(AUDIENCE) + .setClientId(CLIENT_ID) + .setClientSecret(CLIENT_SECRET) + .setRefreshToken(REFRESH_TOKEN) + .setTokenUrl(TOKEN_URL) + .setTokenInfoUrl(TOKEN_INFO_URL) + .setRevokeUrl(REVOKE_URL) + .setAccessToken(new AccessToken(ACCESS_TOKEN, /* expirationTime= */ null)) + .setQuotaProjectId(QUOTA_PROJECT) + .setHttpTransportFactory(transportFactory) + .build(); + + ExternalAccountAuthorizedUserCredentials secondCredentials = + credentials.toBuilder().setRevokeUrl("different").build(); + + assertNotEquals(secondCredentials, credentials); + assertNotEquals(credentials, secondCredentials); + assertNotEquals(credentials.hashCode(), secondCredentials.hashCode()); + } + + @Test + public void equals_differentAccessToken() { + ExternalAccountAuthorizedUserCredentials credentials = + ExternalAccountAuthorizedUserCredentials.newBuilder() + .setAudience(AUDIENCE) + .setClientId(CLIENT_ID) + .setClientSecret(CLIENT_SECRET) + .setRefreshToken(REFRESH_TOKEN) + .setTokenUrl(TOKEN_URL) + .setTokenInfoUrl(TOKEN_INFO_URL) + .setRevokeUrl(REVOKE_URL) + .setAccessToken(new AccessToken(ACCESS_TOKEN, new Date())) + .setQuotaProjectId(QUOTA_PROJECT) + .setHttpTransportFactory(transportFactory) + .build(); + + ExternalAccountAuthorizedUserCredentials secondCredentials = + credentials.toBuilder().setAccessToken(new AccessToken("different", new Date())).build(); + + assertNotEquals(secondCredentials, credentials); + assertNotEquals(credentials, secondCredentials); + assertNotEquals(credentials.hashCode(), secondCredentials.hashCode()); + } + + @Test + public void equals_differentQuotaProjectId() { + ExternalAccountAuthorizedUserCredentials credentials = + ExternalAccountAuthorizedUserCredentials.newBuilder() + .setAudience(AUDIENCE) + .setClientId(CLIENT_ID) + .setClientSecret(CLIENT_SECRET) + .setRefreshToken(REFRESH_TOKEN) + .setTokenUrl(TOKEN_URL) + .setTokenInfoUrl(TOKEN_INFO_URL) + .setRevokeUrl(REVOKE_URL) + .setAccessToken(new AccessToken(ACCESS_TOKEN, /* expirationTime= */ null)) + .setQuotaProjectId(QUOTA_PROJECT) + .setHttpTransportFactory(transportFactory) + .build(); + + ExternalAccountAuthorizedUserCredentials secondCredentials = + credentials.toBuilder().setQuotaProjectId("different").build(); + + assertNotEquals(secondCredentials, credentials); + assertNotEquals(credentials, secondCredentials); + assertNotEquals(credentials.hashCode(), secondCredentials.hashCode()); + } + + @Test + public void equals_differentTransportFactory() { + ExternalAccountAuthorizedUserCredentials credentials = + ExternalAccountAuthorizedUserCredentials.newBuilder() + .setAudience(AUDIENCE) + .setClientId(CLIENT_ID) + .setClientSecret(CLIENT_SECRET) + .setRefreshToken(REFRESH_TOKEN) + .setTokenUrl(TOKEN_URL) + .setTokenInfoUrl(TOKEN_INFO_URL) + .setRevokeUrl(REVOKE_URL) + .setAccessToken(new AccessToken(ACCESS_TOKEN, /* expirationTime= */ null)) + .setQuotaProjectId(QUOTA_PROJECT) + .setHttpTransportFactory(transportFactory) + .build(); + + ExternalAccountAuthorizedUserCredentials secondCredentials = + credentials.toBuilder().setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY).build(); + + assertNotEquals(secondCredentials, credentials); + assertNotEquals(credentials, secondCredentials); + assertNotEquals(credentials.hashCode(), secondCredentials.hashCode()); + } + + @Test + public void toString_expectedFormat() { + AccessToken accessToken = new AccessToken(ACCESS_TOKEN, new Date()); + ExternalAccountAuthorizedUserCredentials credentials = + ExternalAccountAuthorizedUserCredentials.newBuilder() + .setAudience(AUDIENCE) + .setClientId(CLIENT_ID) + .setClientSecret(CLIENT_SECRET) + .setRefreshToken(REFRESH_TOKEN) + .setTokenUrl(TOKEN_URL) + .setTokenInfoUrl(TOKEN_INFO_URL) + .setRevokeUrl(REVOKE_URL) + .setAccessToken(accessToken) + .setQuotaProjectId(QUOTA_PROJECT) + .setHttpTransportFactory(transportFactory) + .build(); + + String expectedToString = + String.format( + "ExternalAccountAuthorizedUserCredentials{requestMetadata=%s, temporaryAccess=%s, " + + "clientId=%s, clientSecret=%s, refreshToken=%s, tokenUrl=%s, tokenInfoUrl=%s, " + + "revokeUrl=%s, audience=%s, transportFactoryClassName=%s, quotaProjectId=%s}", + ImmutableMap.of( + AuthHttpConstants.AUTHORIZATION, + ImmutableList.of(OAuth2Utils.BEARER_PREFIX + accessToken.getTokenValue())), + accessToken, + CLIENT_ID, + CLIENT_SECRET, + REFRESH_TOKEN, + TOKEN_URL, + TOKEN_INFO_URL, + REVOKE_URL, + AUDIENCE, + transportFactory.getClass().getName(), + QUOTA_PROJECT); + + assertEquals(expectedToString, credentials.toString()); + } + + @Test + public void serialize() throws IOException, ClassNotFoundException { + ExternalAccountAuthorizedUserCredentials credentials = + ExternalAccountAuthorizedUserCredentials.newBuilder() + .setAudience(AUDIENCE) + .setClientId(CLIENT_ID) + .setClientSecret(CLIENT_SECRET) + .setRefreshToken(REFRESH_TOKEN) + .setTokenUrl(TOKEN_URL) + .setTokenInfoUrl(TOKEN_INFO_URL) + .setRevokeUrl(REVOKE_URL) + .setAccessToken(new AccessToken(ACCESS_TOKEN, /* expirationTime= */ null)) + .setQuotaProjectId(QUOTA_PROJECT) + .build(); + + ExternalAccountAuthorizedUserCredentials deserializedCredentials = + serializeAndDeserialize(credentials); + assertEquals(credentials, deserializedCredentials); + assertEquals(credentials.hashCode(), deserializedCredentials.hashCode()); + assertEquals(credentials.toString(), deserializedCredentials.toString()); + assertSame(deserializedCredentials.clock, Clock.SYSTEM); + } + + static GenericJson buildJsonCredentials() { + GenericJson json = new GenericJson(); + json.put("type", EXTERNAL_ACCOUNT_AUTHORIZED_USER_FILE_TYPE); + json.put("audience", AUDIENCE); + json.put("refresh_token", REFRESH_TOKEN); + json.put("client_id", CLIENT_ID); + json.put("client_secret", CLIENT_SECRET); + json.put("token_url", TOKEN_URL); + json.put("token_info_url", TOKEN_INFO_URL); + json.put("revoke_url", REVOKE_URL); + json.put("quota_project_id", QUOTA_PROJECT); + return json; + } + + private static void validateAuthHeader(MockLowLevelHttpRequest request) { + Map> headers = request.getHeaders(); + List authHeader = headers.get("authorization"); + + assertEquals(BASIC_AUTH, authHeader.iterator().next()); + assertEquals(1, authHeader.size()); + } +} diff --git a/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java index 2c311c481..295fdc663 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java @@ -42,6 +42,7 @@ import com.google.api.client.testing.http.MockHttpTransport; import com.google.auth.TestUtils; import com.google.auth.http.HttpTransportFactory; +import com.google.auth.oauth2.ExternalAccountAuthorizedUserCredentialsTest.MockExternalAccountAuthorizedUserCredentialsTransportFactory; import com.google.auth.oauth2.IdentityPoolCredentialsTest.MockExternalAccountCredentialsTransportFactory; import com.google.auth.oauth2.ImpersonatedCredentialsTest.MockIAMCredentialsServiceTransportFactory; import com.google.common.collect.ImmutableList; @@ -468,6 +469,21 @@ public void fromStream_pluggableAuthCredentials_providesToken() throws IOExcepti TestUtils.assertContainsBearerToken(metadata, transportFactory.transport.getAccessToken()); } + @Test + public void fromStream_externalAccountAuthorizedUserCredentials_providesToken() + throws IOException { + MockExternalAccountAuthorizedUserCredentialsTransportFactory transportFactory = + new MockExternalAccountAuthorizedUserCredentialsTransportFactory(); + InputStream stream = + TestUtils.jsonToInputStream( + ExternalAccountAuthorizedUserCredentialsTest.buildJsonCredentials()); + + GoogleCredentials credentials = GoogleCredentials.fromStream(stream, transportFactory); + + Map> metadata = credentials.getRequestMetadata(CALL_URI); + TestUtils.assertContainsBearerToken(metadata, transportFactory.transport.getAccessToken()); + } + @Test public void fromStream_Impersonation_providesToken_WithQuotaProject() throws IOException { MockTokenServerTransportFactory transportFactoryForSource = diff --git a/oauth2_http/javatests/com/google/auth/oauth2/MockStsTransport.java b/oauth2_http/javatests/com/google/auth/oauth2/MockStsTransport.java index 1695c8450..8251be13c 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/MockStsTransport.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/MockStsTransport.java @@ -59,6 +59,7 @@ public final class MockStsTransport extends MockHttpTransport { "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 STS_OAUTHTOKEN_URL = "https://sts.googleapis.com/v1/oauthtoken"; private static final String ACCESS_TOKEN = "accessToken"; private static final String TOKEN_TYPE = "Bearer"; private static final Long EXPIRES_IN = 3600L; @@ -88,7 +89,7 @@ public LowLevelHttpRequest buildRequest(final String method, final String url) { new MockLowLevelHttpRequest(url) { @Override public LowLevelHttpResponse execute() throws IOException { - if (!STS_URL.equals(url)) { + if (!STS_URL.equals(url) && !STS_OAUTHTOKEN_URL.equals(url)) { return makeErrorResponse(); } @@ -96,27 +97,38 @@ public LowLevelHttpResponse execute() throws IOException { 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) { + Map query = TestUtils.parseQuery(getContentAsString()); + if (STS_URL.equals(url)) { + assertEquals(EXPECTED_GRANT_TYPE, query.get("grant_type")); + assertNotNull(query.get("subject_token_type")); + assertNotNull(query.get("subject_token")); + + 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())); + } + } else { + assertEquals("refresh_token", query.get("grant_type")); + + response.put("access_token", ACCESS_TOKEN); 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())); - } + if (!refreshTokenSequence.isEmpty()) { + response.put("refresh_token", refreshTokenSequence.poll()); + } + } return new MockLowLevelHttpResponse() .setContentType(Json.MEDIA_TYPE) .setContent(response.toPrettyString()); diff --git a/oauth2_http/javatests/com/google/auth/oauth2/OAuthExceptionTest.java b/oauth2_http/javatests/com/google/auth/oauth2/OAuthExceptionTest.java index f864f4791..84e522a73 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/OAuthExceptionTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/OAuthExceptionTest.java @@ -34,6 +34,8 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; +import com.google.auth.TestUtils; +import java.io.IOException; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; @@ -84,4 +86,50 @@ public void getMessage_baseFormat() { String expectedMessage = String.format(BASE_MESSAGE_FORMAT, "errorCode"); assertEquals(expectedMessage, e.getMessage()); } + + @Test + public void createFromHttpResponseException() throws IOException { + OAuthException e = + OAuthException.createFromHttpResponseException( + TestUtils.buildHttpResponseException("errorCode", "errorDescription", "errorUri")); + + assertEquals("errorCode", e.getErrorCode()); + assertEquals("errorDescription", e.getErrorDescription()); + assertEquals("errorUri", e.getErrorUri()); + + String expectedMessage = + String.format(FULL_MESSAGE_FORMAT, "errorCode", "errorDescription", "errorUri"); + assertEquals(expectedMessage, e.getMessage()); + } + + @Test + public void createFromHttpResponseException_descriptionFormat() throws IOException { + OAuthException e = + OAuthException.createFromHttpResponseException( + TestUtils.buildHttpResponseException( + "errorCode", "errorDescription", /* errorUri= */ null)); + + assertEquals("errorCode", e.getErrorCode()); + assertEquals("errorDescription", e.getErrorDescription()); + assertNull(e.getErrorUri()); + + String expectedMessage = + String.format(ERROR_DESCRIPTION_FORMAT, "errorCode", "errorDescription"); + assertEquals(expectedMessage, e.getMessage()); + } + + @Test + public void createFromHttpResponseException_baseFormat() throws IOException { + OAuthException e = + OAuthException.createFromHttpResponseException( + TestUtils.buildHttpResponseException( + "errorCode", /* errorDescription= */ null, /* errorUri= */ null)); + + assertEquals("errorCode", e.getErrorCode()); + assertNull(e.getErrorDescription()); + assertNull(e.getErrorUri()); + + String expectedMessage = String.format(BASE_MESSAGE_FORMAT, "errorCode"); + assertEquals(expectedMessage, e.getMessage()); + } }