From 34f7f5d33eca11fd2d1df46a214f46718cc02d97 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Fri, 24 Aug 2018 12:37:41 -0400 Subject: [PATCH 1/3] Add DefaultAuthorizationCodeTokenResponseClient Fixes gh-5547 --- .../oauth2/client/OAuth2ClientConfigurer.java | 4 +- .../oauth2/client/OAuth2LoginConfigurer.java | 4 +- ...tAuthorizationCodeTokenResponseClient.java | 125 +++++++ .../http/OAuth2ErrorResponseErrorHandler.java | 53 +++ ...zationCodeGrantRequestEntityConverter.java | 110 ++++++ ...orizationCodeTokenResponseClientTests.java | 330 ++++++++++++++++++ .../OAuth2ErrorResponseErrorHandlerTests.java | 47 +++ ...nCodeGrantRequestEntityConverterTests.java | 104 ++++++ .../http/converter/HttpMessageConverters.java | 54 +++ ...cessTokenResponseHttpMessageConverter.java | 215 ++++++++++++ .../OAuth2ErrorHttpMessageConverter.java | 155 ++++++++ ...okenResponseHttpMessageConverterTests.java | 163 +++++++++ .../OAuth2ErrorHttpMessageConverterTests.java | 126 +++++++ 13 files changed, 1486 insertions(+), 4 deletions(-) create mode 100644 oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/DefaultAuthorizationCodeTokenResponseClient.java create mode 100644 oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/http/OAuth2ErrorResponseErrorHandler.java create mode 100644 oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/http/converter/OAuth2AuthorizationCodeGrantRequestEntityConverter.java create mode 100644 oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/DefaultAuthorizationCodeTokenResponseClientTests.java create mode 100644 oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/http/OAuth2ErrorResponseErrorHandlerTests.java create mode 100644 oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/http/converter/OAuth2AuthorizationCodeGrantRequestEntityConverterTests.java create mode 100644 oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/http/converter/HttpMessageConverters.java create mode 100644 oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/http/converter/OAuth2AccessTokenResponseHttpMessageConverter.java create mode 100644 oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/http/converter/OAuth2ErrorHttpMessageConverter.java create mode 100644 oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/http/converter/OAuth2AccessTokenResponseHttpMessageConverterTests.java create mode 100644 oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/http/converter/OAuth2ErrorHttpMessageConverterTests.java diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2ClientConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2ClientConfigurer.java index 837884eaecc..cdab08b2651 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2ClientConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2ClientConfigurer.java @@ -20,7 +20,7 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; import org.springframework.security.oauth2.client.authentication.OAuth2AuthorizationCodeAuthenticationProvider; -import org.springframework.security.oauth2.client.endpoint.NimbusAuthorizationCodeTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.DefaultAuthorizationCodeTokenResponseClient; import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; @@ -250,7 +250,7 @@ private OAuth2AccessTokenResponseClient get if (this.accessTokenResponseClient != null) { return this.accessTokenResponseClient; } - return new NimbusAuthorizationCodeTokenResponseClient(); + return new DefaultAuthorizationCodeTokenResponseClient(); } } diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurer.java index b84f4bb390b..cd26f479ac7 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurer.java @@ -29,7 +29,7 @@ import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; import org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationProvider; import org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationToken; -import org.springframework.security.oauth2.client.endpoint.NimbusAuthorizationCodeTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.DefaultAuthorizationCodeTokenResponseClient; import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; import org.springframework.security.oauth2.client.oidc.authentication.OidcAuthorizationCodeAuthenticationProvider; @@ -450,7 +450,7 @@ public void init(B http) throws Exception { OAuth2AccessTokenResponseClient accessTokenResponseClient = this.tokenEndpointConfig.accessTokenResponseClient; if (accessTokenResponseClient == null) { - accessTokenResponseClient = new NimbusAuthorizationCodeTokenResponseClient(); + accessTokenResponseClient = new DefaultAuthorizationCodeTokenResponseClient(); } OAuth2UserService oauth2UserService = this.userInfoEndpointConfig.userService; diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/DefaultAuthorizationCodeTokenResponseClient.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/DefaultAuthorizationCodeTokenResponseClient.java new file mode 100644 index 00000000000..1198b87cecb --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/DefaultAuthorizationCodeTokenResponseClient.java @@ -0,0 +1,125 @@ +/* + * Copyright 2002-2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.client.endpoint; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.http.RequestEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.FormHttpMessageConverter; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.security.oauth2.client.http.OAuth2ErrorResponseErrorHandler; +import org.springframework.security.oauth2.client.http.converter.OAuth2AuthorizationCodeGrantRequestEntityConverter; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; +import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.web.client.ResponseErrorHandler; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestOperations; +import org.springframework.web.client.RestTemplate; + +import java.util.Arrays; + +/** + * The default implementation of an {@link OAuth2AccessTokenResponseClient} + * for the {@link AuthorizationGrantType#AUTHORIZATION_CODE authorization_code} grant. + * This implementation uses a {@link RestOperations} when requesting + * an access token credential at the Authorization Server's Token Endpoint. + * + * @author Joe Grandja + * @since 5.1 + * @see OAuth2AccessTokenResponseClient + * @see OAuth2AuthorizationCodeGrantRequest + * @see OAuth2AccessTokenResponse + * @see Section 4.1.3 Access Token Request (Authorization Code Grant) + * @see Section 4.1.4 Access Token Response (Authorization Code Grant) + */ +public final class DefaultAuthorizationCodeTokenResponseClient implements OAuth2AccessTokenResponseClient { + private static final String INVALID_TOKEN_RESPONSE_ERROR_CODE = "invalid_token_response"; + + private Converter> requestEntityConverter = + new OAuth2AuthorizationCodeGrantRequestEntityConverter(); + + private RestOperations restOperations; + + public DefaultAuthorizationCodeTokenResponseClient() { + RestTemplate restTemplate = new RestTemplate(Arrays.asList( + new FormHttpMessageConverter(), new OAuth2AccessTokenResponseHttpMessageConverter())); + restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler()); + this.restOperations = restTemplate; + } + + @Override + public OAuth2AccessTokenResponse getTokenResponse(OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest) throws OAuth2AuthenticationException { + Assert.notNull(authorizationCodeGrantRequest, "authorizationCodeGrantRequest cannot be null"); + + RequestEntity request = this.requestEntityConverter.convert(authorizationCodeGrantRequest); + + ResponseEntity response; + try { + response = this.restOperations.exchange(request, OAuth2AccessTokenResponse.class); + } catch (RestClientException ex) { + OAuth2Error oauth2Error = new OAuth2Error(INVALID_TOKEN_RESPONSE_ERROR_CODE, + "An error occurred while attempting to retrieve the OAuth 2.0 Access Token Response: " + ex.getMessage(), null); + throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString(), ex); + } + + OAuth2AccessTokenResponse tokenResponse = response.getBody(); + + if (CollectionUtils.isEmpty(tokenResponse.getAccessToken().getScopes())) { + // As per spec, in Section 5.1 Successful Access Token Response + // https://tools.ietf.org/html/rfc6749#section-5.1 + // If AccessTokenResponse.scope is empty, then default to the scope + // originally requested by the client in the Token Request + tokenResponse = OAuth2AccessTokenResponse.withResponse(tokenResponse) + .scopes(authorizationCodeGrantRequest.getClientRegistration().getScopes()) + .build(); + } + + return tokenResponse; + } + + /** + * Sets the {@link Converter} used for converting the {@link OAuth2AuthorizationCodeGrantRequest} + * to a {@link RequestEntity} representation of the OAuth 2.0 Access Token Request. + * + * @param requestEntityConverter the {@link Converter} used for converting to a {@link RequestEntity} representation of the Access Token Request + */ + public void setRequestEntityConverter(Converter> requestEntityConverter) { + Assert.notNull(requestEntityConverter, "requestEntityConverter cannot be null"); + this.requestEntityConverter = requestEntityConverter; + } + + /** + * Sets the {@link RestOperations} used when requesting the OAuth 2.0 Access Token Response. + * + *

+ * NOTE: At a minimum, the supplied {@code restOperations} must be configured with the following: + *

    + *
  1. {@link HttpMessageConverter}'s - {@link FormHttpMessageConverter} and {@link OAuth2AccessTokenResponseHttpMessageConverter}
  2. + *
  3. {@link ResponseErrorHandler} - {@link OAuth2ErrorResponseErrorHandler}
  4. + *
+ * + * @param restOperations the {@link RestOperations} used when requesting the Access Token Response + */ + public void setRestOperations(RestOperations restOperations) { + Assert.notNull(restOperations, "restOperations cannot be null"); + this.restOperations = restOperations; + } +} diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/http/OAuth2ErrorResponseErrorHandler.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/http/OAuth2ErrorResponseErrorHandler.java new file mode 100644 index 00000000000..dca68594025 --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/http/OAuth2ErrorResponseErrorHandler.java @@ -0,0 +1,53 @@ +/* + * Copyright 2002-2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.client.http; + +import org.springframework.http.HttpStatus; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.http.converter.OAuth2ErrorHttpMessageConverter; +import org.springframework.web.client.DefaultResponseErrorHandler; +import org.springframework.web.client.ResponseErrorHandler; + +import java.io.IOException; + +/** + * A {@link ResponseErrorHandler} that handles an {@link OAuth2Error OAuth 2.0 Error}. + * + * @see ResponseErrorHandler + * @see OAuth2Error + * @author Joe Grandja + * @since 5.1 + */ +public class OAuth2ErrorResponseErrorHandler implements ResponseErrorHandler { + private final OAuth2ErrorHttpMessageConverter oauth2ErrorConverter = new OAuth2ErrorHttpMessageConverter(); + private final ResponseErrorHandler defaultErrorHandler = new DefaultResponseErrorHandler(); + + @Override + public boolean hasError(ClientHttpResponse response) throws IOException { + return this.defaultErrorHandler.hasError(response); + } + + @Override + public void handleError(ClientHttpResponse response) throws IOException { + if (HttpStatus.BAD_REQUEST.equals(response.getStatusCode())) { + OAuth2Error oauth2Error = this.oauth2ErrorConverter.read(OAuth2Error.class, response); + throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString()); + } + this.defaultErrorHandler.handleError(response); + } +} diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/http/converter/OAuth2AuthorizationCodeGrantRequestEntityConverter.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/http/converter/OAuth2AuthorizationCodeGrantRequestEntityConverter.java new file mode 100644 index 00000000000..14aa1f8ef2d --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/http/converter/OAuth2AuthorizationCodeGrantRequestEntityConverter.java @@ -0,0 +1,110 @@ +/* + * Copyright 2002-2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.client.http.converter; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.RequestEntity; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationExchange; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; +import java.util.Collections; + +import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED_VALUE; + +/** + * A {@link Converter} that converts the provided {@link OAuth2AuthorizationCodeGrantRequest} + * to a {@link RequestEntity} representation of an OAuth 2.0 Access Token Request + * for the Authorization Code Grant. + * + * @author Joe Grandja + * @since 5.1 + * @see Converter + * @see OAuth2AuthorizationCodeGrantRequest + * @see RequestEntity + */ +public class OAuth2AuthorizationCodeGrantRequestEntityConverter implements Converter> { + + /** + * Returns the {@link RequestEntity} used for the Access Token Request. + * + * @param authorizationCodeGrantRequest the authorization code grant request + * @return the {@link RequestEntity} used for the Access Token Request + */ + @Override + public RequestEntity convert(OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest) { + ClientRegistration clientRegistration = authorizationCodeGrantRequest.getClientRegistration(); + + HttpHeaders headers = this.buildHeaders(authorizationCodeGrantRequest); + MultiValueMap formParameters = this.buildFormParameters(authorizationCodeGrantRequest); + URI uri = UriComponentsBuilder.fromUriString(clientRegistration.getProviderDetails().getTokenUri()) + .build() + .toUri(); + + return new RequestEntity<>(formParameters, headers, HttpMethod.POST, uri); + } + + /** + * Returns the {@link HttpHeaders} used for the Access Token Request. + * + * @param authorizationCodeGrantRequest the authorization code grant request + * @return the {@link HttpHeaders} used for the Access Token Request + */ + private HttpHeaders buildHeaders(OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest) { + ClientRegistration clientRegistration = authorizationCodeGrantRequest.getClientRegistration(); + + HttpHeaders headers = new HttpHeaders(); + headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON_UTF8)); + final MediaType contentType = MediaType.valueOf(APPLICATION_FORM_URLENCODED_VALUE + ";charset=UTF-8"); + headers.setContentType(contentType); + if (ClientAuthenticationMethod.BASIC.equals(clientRegistration.getClientAuthenticationMethod())) { + headers.setBasicAuth(clientRegistration.getClientId(), clientRegistration.getClientSecret()); + } + + return headers; + } + + /** + * Returns a {@link MultiValueMap} of the form parameters used for the Access Token Request body. + * + * @param authorizationCodeGrantRequest the authorization code grant request + * @return a {@link MultiValueMap} of the form parameters used for the Access Token Request body + */ + private MultiValueMap buildFormParameters(OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest) { + ClientRegistration clientRegistration = authorizationCodeGrantRequest.getClientRegistration(); + OAuth2AuthorizationExchange authorizationExchange = authorizationCodeGrantRequest.getAuthorizationExchange(); + + MultiValueMap formParameters = new LinkedMultiValueMap<>(); + formParameters.add(OAuth2ParameterNames.GRANT_TYPE, authorizationCodeGrantRequest.getGrantType().getValue()); + formParameters.add(OAuth2ParameterNames.CODE, authorizationExchange.getAuthorizationResponse().getCode()); + formParameters.add(OAuth2ParameterNames.REDIRECT_URI, authorizationExchange.getAuthorizationRequest().getRedirectUri()); + if (ClientAuthenticationMethod.POST.equals(clientRegistration.getClientAuthenticationMethod())) { + formParameters.add(OAuth2ParameterNames.CLIENT_ID, clientRegistration.getClientId()); + formParameters.add(OAuth2ParameterNames.CLIENT_SECRET, clientRegistration.getClientSecret()); + } + + return formParameters; + } +} diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/DefaultAuthorizationCodeTokenResponseClientTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/DefaultAuthorizationCodeTokenResponseClientTests.java new file mode 100644 index 00000000000..a759571420d --- /dev/null +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/DefaultAuthorizationCodeTokenResponseClientTests.java @@ -0,0 +1,330 @@ +/* + * Copyright 2002-2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.client.endpoint; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationExchange; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponse; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Tests for {@link DefaultAuthorizationCodeTokenResponseClient}. + * + * @author Joe Grandja + */ +public class DefaultAuthorizationCodeTokenResponseClientTests { + private DefaultAuthorizationCodeTokenResponseClient tokenResponseClient = + new DefaultAuthorizationCodeTokenResponseClient(); + private ClientRegistration clientRegistration; + private MockWebServer server; + + @Before + public void setup() throws Exception { + this.server = new MockWebServer(); + this.server.start(); + String tokenUri = this.server.url("/oauth2/token").toString(); + this.clientRegistration = ClientRegistration.withRegistrationId("registration-1") + .clientId("client-1") + .clientSecret("secret") + .clientAuthenticationMethod(ClientAuthenticationMethod.BASIC) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUriTemplate("https://client.com/callback/client-1") + .scope("read", "write") + .authorizationUri("https://provider.com/oauth2/authorize") + .tokenUri(tokenUri) + .userInfoUri("https://provider.com/user") + .userNameAttributeName("id") + .clientName("client-1") + .build(); + } + + @After + public void cleanup() throws Exception { + this.server.shutdown(); + } + + @Test + public void setRequestEntityConverterWhenConverterIsNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> this.tokenResponseClient.setRequestEntityConverter(null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void setRestOperationsWhenRestOperationsIsNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> this.tokenResponseClient.setRestOperations(null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void getTokenResponseWhenRequestIsNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> this.tokenResponseClient.getTokenResponse(null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void getTokenResponseWhenSuccessResponseThenReturnAccessTokenResponse() throws Exception { + String accessTokenSuccessResponse = "{\n" + + " \"access_token\": \"access-token-1234\",\n" + + " \"token_type\": \"bearer\",\n" + + " \"expires_in\": \"3600\",\n" + + " \"scope\": \"read write\",\n" + + " \"refresh_token\": \"refresh-token-1234\",\n" + + " \"custom_parameter_1\": \"custom-value-1\",\n" + + " \"custom_parameter_2\": \"custom-value-2\"\n" + + "}\n"; + this.server.enqueue(jsonResponse(accessTokenSuccessResponse)); + + Instant expiresAtBefore = Instant.now().plusSeconds(3600); + + OAuth2AccessTokenResponse accessTokenResponse = + this.tokenResponseClient.getTokenResponse(this.authorizationCodeGrantRequest()); + + Instant expiresAtAfter = Instant.now().plusSeconds(3600); + + RecordedRequest recordedRequest = this.server.takeRequest(); + assertThat(recordedRequest.getMethod()).isEqualTo(HttpMethod.POST.toString()); + assertThat(recordedRequest.getHeader(HttpHeaders.ACCEPT)).isEqualTo(MediaType.APPLICATION_JSON_UTF8_VALUE); + assertThat(recordedRequest.getHeader(HttpHeaders.CONTENT_TYPE)).isEqualTo(MediaType.APPLICATION_FORM_URLENCODED_VALUE + ";charset=UTF-8"); + + String formParameters = recordedRequest.getBody().readUtf8(); + assertThat(formParameters).contains("grant_type=authorization_code"); + assertThat(formParameters).contains("code=code-1234"); + assertThat(formParameters).contains("redirect_uri=https%3A%2F%2Fclient.com%2Fcallback%2Fclient-1"); + + assertThat(accessTokenResponse.getAccessToken().getTokenValue()).isEqualTo("access-token-1234"); + assertThat(accessTokenResponse.getAccessToken().getTokenType()).isEqualTo(OAuth2AccessToken.TokenType.BEARER); + assertThat(accessTokenResponse.getAccessToken().getExpiresAt()).isBetween(expiresAtBefore, expiresAtAfter); + assertThat(accessTokenResponse.getAccessToken().getScopes()).containsExactly("read", "write"); + assertThat(accessTokenResponse.getRefreshToken().getTokenValue()).isEqualTo("refresh-token-1234"); + assertThat(accessTokenResponse.getAdditionalParameters().size()).isEqualTo(2); + assertThat(accessTokenResponse.getAdditionalParameters()).containsEntry("custom_parameter_1", "custom-value-1"); + assertThat(accessTokenResponse.getAdditionalParameters()).containsEntry("custom_parameter_2", "custom-value-2"); + } + + @Test + public void getTokenResponseWhenClientAuthenticationBasicThenAuthorizationHeaderIsSent() throws Exception { + String accessTokenSuccessResponse = "{\n" + + " \"access_token\": \"access-token-1234\",\n" + + " \"token_type\": \"bearer\",\n" + + " \"expires_in\": \"3600\"\n" + + "}\n"; + this.server.enqueue(jsonResponse(accessTokenSuccessResponse)); + + this.tokenResponseClient.getTokenResponse(this.authorizationCodeGrantRequest()); + + RecordedRequest recordedRequest = this.server.takeRequest(); + assertThat(recordedRequest.getHeader(HttpHeaders.AUTHORIZATION)).startsWith("Basic "); + } + + @Test + public void getTokenResponseWhenClientAuthenticationPostThenFormParametersAreSent() throws Exception { + String accessTokenSuccessResponse = "{\n" + + " \"access_token\": \"access-token-1234\",\n" + + " \"token_type\": \"bearer\",\n" + + " \"expires_in\": \"3600\"\n" + + "}\n"; + this.server.enqueue(jsonResponse(accessTokenSuccessResponse)); + + ClientRegistration clientRegistration = this.from(this.clientRegistration) + .clientAuthenticationMethod(ClientAuthenticationMethod.POST) + .build(); + + this.tokenResponseClient.getTokenResponse(this.authorizationCodeGrantRequest(clientRegistration)); + + RecordedRequest recordedRequest = this.server.takeRequest(); + assertThat(recordedRequest.getHeader(HttpHeaders.AUTHORIZATION)).isNull(); + + String formParameters = recordedRequest.getBody().readUtf8(); + assertThat(formParameters).contains("client_id=client-1"); + assertThat(formParameters).contains("client_secret=secret"); + } + + @Test + public void getTokenResponseWhenSuccessResponseAndNotBearerTokenTypeThenThrowOAuth2AuthenticationException() { + String accessTokenSuccessResponse = "{\n" + + " \"access_token\": \"access-token-1234\",\n" + + " \"token_type\": \"not-bearer\",\n" + + " \"expires_in\": \"3600\"\n" + + "}\n"; + this.server.enqueue(jsonResponse(accessTokenSuccessResponse)); + + assertThatThrownBy(() -> this.tokenResponseClient.getTokenResponse(this.authorizationCodeGrantRequest())) + .isInstanceOf(OAuth2AuthenticationException.class) + .hasMessageContaining("[invalid_token_response] An error occurred while attempting to retrieve the OAuth 2.0 Access Token Response") + .hasMessageContaining("tokenType cannot be null"); + } + + @Test + public void getTokenResponseWhenSuccessResponseAndMissingTokenTypeParameterThenThrowOAuth2AuthenticationException() { + String accessTokenSuccessResponse = "{\n" + + " \"access_token\": \"access-token-1234\"\n" + + "}\n"; + this.server.enqueue(jsonResponse(accessTokenSuccessResponse)); + + assertThatThrownBy(() -> this.tokenResponseClient.getTokenResponse(this.authorizationCodeGrantRequest())) + .isInstanceOf(OAuth2AuthenticationException.class) + .hasMessageContaining("[invalid_token_response] An error occurred while attempting to retrieve the OAuth 2.0 Access Token Response") + .hasMessageContaining("tokenType cannot be null"); + } + + @Test + public void getTokenResponseWhenSuccessResponseIncludesScopeThenAccessTokenHasResponseScope() { + String accessTokenSuccessResponse = "{\n" + + " \"access_token\": \"access-token-1234\",\n" + + " \"token_type\": \"bearer\",\n" + + " \"expires_in\": \"3600\",\n" + + " \"refresh_token\": \"refresh-token-1234\",\n" + + " \"scope\": \"read\"\n" + + "}\n"; + this.server.enqueue(jsonResponse(accessTokenSuccessResponse)); + + OAuth2AccessTokenResponse accessTokenResponse = + this.tokenResponseClient.getTokenResponse(this.authorizationCodeGrantRequest()); + + assertThat(accessTokenResponse.getAccessToken().getScopes()).containsExactly("read"); + } + + @Test + public void getTokenResponseWhenSuccessResponseDoesNotIncludeScopeThenAccessTokenHasDefaultScope() { + String accessTokenSuccessResponse = "{\n" + + " \"access_token\": \"access-token-1234\",\n" + + " \"token_type\": \"bearer\",\n" + + " \"expires_in\": \"3600\",\n" + + " \"refresh_token\": \"refresh-token-1234\"\n" + + "}\n"; + this.server.enqueue(jsonResponse(accessTokenSuccessResponse)); + + OAuth2AccessTokenResponse accessTokenResponse = + this.tokenResponseClient.getTokenResponse(this.authorizationCodeGrantRequest()); + + assertThat(accessTokenResponse.getAccessToken().getScopes()).containsExactly("read", "write"); + } + + @Test + public void getTokenResponseWhenTokenUriInvalidThenThrowOAuth2AuthenticationException() { + String invalidTokenUri = "http://invalid-provider.com/oauth2/token"; + ClientRegistration clientRegistration = this.from(this.clientRegistration) + .tokenUri(invalidTokenUri) + .build(); + + assertThatThrownBy(() -> this.tokenResponseClient.getTokenResponse(this.authorizationCodeGrantRequest(clientRegistration))) + .isInstanceOf(OAuth2AuthenticationException.class) + .hasMessageContaining("[invalid_token_response] An error occurred while attempting to retrieve the OAuth 2.0 Access Token Response"); + } + + @Test + public void getTokenResponseWhenMalformedResponseThenThrowOAuth2AuthenticationException() { + String accessTokenSuccessResponse = "{\n" + + " \"access_token\": \"access-token-1234\",\n" + + " \"token_type\": \"bearer\",\n" + + " \"expires_in\": \"3600\",\n" + + " \"scope\": \"read write\",\n" + + " \"refresh_token\": \"refresh-token-1234\",\n" + + " \"custom_parameter_1\": \"custom-value-1\",\n" + + " \"custom_parameter_2\": \"custom-value-2\"\n"; +// "}\n"; // Make the JSON invalid/malformed + this.server.enqueue(jsonResponse(accessTokenSuccessResponse)); + + assertThatThrownBy(() -> this.tokenResponseClient.getTokenResponse(this.authorizationCodeGrantRequest())) + .isInstanceOf(OAuth2AuthenticationException.class) + .hasMessageContaining("[invalid_token_response] An error occurred while attempting to retrieve the OAuth 2.0 Access Token Response"); + } + + @Test + public void getTokenResponseWhenErrorResponseThenThrowOAuth2AuthenticationException() { + String accessTokenErrorResponse = "{\n" + + " \"error\": \"unauthorized_client\"\n" + + "}\n"; + this.server.enqueue(jsonResponse(accessTokenErrorResponse).setResponseCode(400)); + + assertThatThrownBy(() -> this.tokenResponseClient.getTokenResponse(this.authorizationCodeGrantRequest())) + .isInstanceOf(OAuth2AuthenticationException.class) + .hasMessageContaining("[unauthorized_client]"); + } + + @Test + public void getTokenResponseWhenServerErrorResponseThenThrowOAuth2AuthenticationException() { + this.server.enqueue(new MockResponse().setResponseCode(500)); + + assertThatThrownBy(() -> this.tokenResponseClient.getTokenResponse(this.authorizationCodeGrantRequest())) + .isInstanceOf(OAuth2AuthenticationException.class) + .hasMessage("[invalid_token_response] An error occurred while attempting to retrieve the OAuth 2.0 Access Token Response: 500 Server Error"); + } + + private OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest() { + return this.authorizationCodeGrantRequest(this.clientRegistration); + } + + private OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest( + ClientRegistration clientRegistration) { + OAuth2AuthorizationRequest authorizationRequest = OAuth2AuthorizationRequest + .authorizationCode() + .clientId(clientRegistration.getClientId()) + .state("state-1234") + .authorizationUri(clientRegistration.getProviderDetails().getAuthorizationUri()) + .redirectUri(clientRegistration.getRedirectUriTemplate()) + .scopes(clientRegistration.getScopes()) + .build(); + OAuth2AuthorizationResponse authorizationResponse = OAuth2AuthorizationResponse + .success("code-1234") + .state("state-1234") + .redirectUri(clientRegistration.getRedirectUriTemplate()) + .build(); + OAuth2AuthorizationExchange authorizationExchange = + new OAuth2AuthorizationExchange(authorizationRequest, authorizationResponse); + return new OAuth2AuthorizationCodeGrantRequest(clientRegistration, authorizationExchange); + } + + private MockResponse jsonResponse(String json) { + return new MockResponse() + .setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .setBody(json); + } + + private ClientRegistration.Builder from(ClientRegistration registration) { + return ClientRegistration.withRegistrationId(registration.getRegistrationId()) + .clientId(registration.getClientId()) + .clientSecret(registration.getClientSecret()) + .clientAuthenticationMethod(registration.getClientAuthenticationMethod()) + .authorizationGrantType(registration.getAuthorizationGrantType()) + .redirectUriTemplate(registration.getRedirectUriTemplate()) + .scope(registration.getScopes()) + .authorizationUri(registration.getProviderDetails().getAuthorizationUri()) + .tokenUri(registration.getProviderDetails().getTokenUri()) + .userInfoUri(registration.getProviderDetails().getUserInfoEndpoint().getUri()) + .userNameAttributeName(registration.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName()) + .clientName(registration.getClientName()); + } +} diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/http/OAuth2ErrorResponseErrorHandlerTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/http/OAuth2ErrorResponseErrorHandlerTests.java new file mode 100644 index 00000000000..5dc310f3a59 --- /dev/null +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/http/OAuth2ErrorResponseErrorHandlerTests.java @@ -0,0 +1,47 @@ +/* + * Copyright 2002-2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.client.http; + +import org.junit.Test; +import org.springframework.http.HttpStatus; +import org.springframework.mock.http.client.MockClientHttpResponse; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Tests for {@link OAuth2ErrorResponseErrorHandler}. + * + * @author Joe Grandja + */ +public class OAuth2ErrorResponseErrorHandlerTests { + private OAuth2ErrorResponseErrorHandler errorHandler = new OAuth2ErrorResponseErrorHandler(); + + @Test + public void handleErrorWhenStatusBadRequestThenHandled() { + String errorResponse = "{\n" + + " \"error\": \"unauthorized_client\",\n" + + " \"error_description\": \"The client is not authorized\"\n" + + "}\n"; + + MockClientHttpResponse response = new MockClientHttpResponse( + errorResponse.getBytes(), HttpStatus.BAD_REQUEST); + + assertThatThrownBy(() -> this.errorHandler.handleError(response)) + .isInstanceOf(OAuth2AuthenticationException.class) + .hasMessage("[unauthorized_client] The client is not authorized"); + } +} diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/http/converter/OAuth2AuthorizationCodeGrantRequestEntityConverterTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/http/converter/OAuth2AuthorizationCodeGrantRequestEntityConverterTests.java new file mode 100644 index 00000000000..0c97e1e8ab0 --- /dev/null +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/http/converter/OAuth2AuthorizationCodeGrantRequestEntityConverterTests.java @@ -0,0 +1,104 @@ +/* + * Copyright 2002-2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.client.http.converter; + +import org.junit.Before; +import org.junit.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.RequestEntity; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationExchange; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponse; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.util.MultiValueMap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED_VALUE; + +/** + * Tests for {@link OAuth2AuthorizationCodeGrantRequestEntityConverter}. + * + * @author Joe Grandja + */ +public class OAuth2AuthorizationCodeGrantRequestEntityConverterTests { + private OAuth2AuthorizationCodeGrantRequestEntityConverter converter = new OAuth2AuthorizationCodeGrantRequestEntityConverter(); + private OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest; + + @Before + public void setup() { + ClientRegistration clientRegistration = ClientRegistration.withRegistrationId("registration-1") + .clientId("client-1") + .clientSecret("secret") + .clientAuthenticationMethod(ClientAuthenticationMethod.BASIC) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUriTemplate("https://client.com/callback/client-1") + .scope("read", "write") + .authorizationUri("https://provider.com/oauth2/authorize") + .tokenUri("https://provider.com/oauth2/token") + .userInfoUri("https://provider.com/user") + .userNameAttributeName("id") + .clientName("client-1") + .build(); + OAuth2AuthorizationRequest authorizationRequest = OAuth2AuthorizationRequest + .authorizationCode() + .clientId(clientRegistration.getClientId()) + .state("state-1234") + .authorizationUri(clientRegistration.getProviderDetails().getAuthorizationUri()) + .redirectUri(clientRegistration.getRedirectUriTemplate()) + .scopes(clientRegistration.getScopes()) + .build(); + OAuth2AuthorizationResponse authorizationResponse = OAuth2AuthorizationResponse + .success("code-1234") + .state("state-1234") + .redirectUri(clientRegistration.getRedirectUriTemplate()) + .build(); + OAuth2AuthorizationExchange authorizationExchange = + new OAuth2AuthorizationExchange(authorizationRequest, authorizationResponse); + this.authorizationCodeGrantRequest = new OAuth2AuthorizationCodeGrantRequest( + clientRegistration, authorizationExchange); + } + + @SuppressWarnings("unchecked") + @Test + public void convertWhenGrantRequestValidThenConverts() { + RequestEntity requestEntity = this.converter.convert(this.authorizationCodeGrantRequest); + + ClientRegistration clientRegistration = this.authorizationCodeGrantRequest.getClientRegistration(); + + assertThat(requestEntity.getMethod()).isEqualTo(HttpMethod.POST); + assertThat(requestEntity.getUrl().toASCIIString()).isEqualTo( + clientRegistration.getProviderDetails().getTokenUri()); + + HttpHeaders headers = requestEntity.getHeaders(); + assertThat(headers.getAccept()).contains(MediaType.APPLICATION_JSON_UTF8); + assertThat(headers.getContentType()).isEqualTo( + MediaType.valueOf(APPLICATION_FORM_URLENCODED_VALUE + ";charset=UTF-8")); + assertThat(headers.getFirst(HttpHeaders.AUTHORIZATION)).startsWith("Basic "); + + MultiValueMap formParameters = (MultiValueMap) requestEntity.getBody(); + assertThat(formParameters.getFirst(OAuth2ParameterNames.GRANT_TYPE)).isEqualTo( + AuthorizationGrantType.AUTHORIZATION_CODE.getValue()); + assertThat(formParameters.getFirst(OAuth2ParameterNames.CODE)).isEqualTo("code-1234"); + assertThat(formParameters.getFirst(OAuth2ParameterNames.REDIRECT_URI)).isEqualTo( + clientRegistration.getRedirectUriTemplate()); + } +} diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/http/converter/HttpMessageConverters.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/http/converter/HttpMessageConverters.java new file mode 100644 index 00000000000..1dbddc1441c --- /dev/null +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/http/converter/HttpMessageConverters.java @@ -0,0 +1,54 @@ +/* + * Copyright 2002-2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.core.http.converter; + +import org.springframework.http.converter.GenericHttpMessageConverter; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.json.GsonHttpMessageConverter; +import org.springframework.http.converter.json.JsonbHttpMessageConverter; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.util.ClassUtils; + +/** + * Utility methods for {@link HttpMessageConverter}'s. + * + * @author Joe Grandja + * @since 5.1 + */ +final class HttpMessageConverters { + private static final boolean jackson2Present; + private static final boolean gsonPresent; + private static final boolean jsonbPresent; + + static { + ClassLoader classLoader = HttpMessageConverters.class.getClassLoader(); + jackson2Present = ClassUtils.isPresent("com.fasterxml.jackson.databind.ObjectMapper", classLoader) && + ClassUtils.isPresent("com.fasterxml.jackson.core.JsonGenerator", classLoader); + gsonPresent = ClassUtils.isPresent("com.google.gson.Gson", classLoader); + jsonbPresent = ClassUtils.isPresent("javax.json.bind.Jsonb", classLoader); + } + + static GenericHttpMessageConverter getJsonMessageConverter() { + if (jackson2Present) { + return new MappingJackson2HttpMessageConverter(); + } else if (gsonPresent) { + return new GsonHttpMessageConverter(); + } else if (jsonbPresent) { + return new JsonbHttpMessageConverter(); + } + return null; + } +} diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/http/converter/OAuth2AccessTokenResponseHttpMessageConverter.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/http/converter/OAuth2AccessTokenResponseHttpMessageConverter.java new file mode 100644 index 00000000000..cee5a004e95 --- /dev/null +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/http/converter/OAuth2AccessTokenResponseHttpMessageConverter.java @@ -0,0 +1,215 @@ +/* + * Copyright 2002-2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.core.http.converter; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.convert.converter.Converter; +import org.springframework.http.HttpInputMessage; +import org.springframework.http.HttpOutputMessage; +import org.springframework.http.MediaType; +import org.springframework.http.converter.AbstractHttpMessageConverter; +import org.springframework.http.converter.GenericHttpMessageConverter; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.http.converter.HttpMessageNotWritableException; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * A {@link HttpMessageConverter} for an {@link OAuth2AccessTokenResponse OAuth 2.0 Access Token Response}. + * + * @see AbstractHttpMessageConverter + * @see OAuth2AccessTokenResponse + * @author Joe Grandja + * @since 5.1 + */ +public class OAuth2AccessTokenResponseHttpMessageConverter extends AbstractHttpMessageConverter { + private static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8; + + private static final ParameterizedTypeReference> PARAMETERIZED_RESPONSE_TYPE = + new ParameterizedTypeReference>() {}; + + private GenericHttpMessageConverter jsonMessageConverter = HttpMessageConverters.getJsonMessageConverter(); + + protected Converter, OAuth2AccessTokenResponse> tokenResponseConverter = + new OAuth2AccessTokenResponseConverter(); + + protected Converter> tokenResponseParametersConverter = + new OAuth2AccessTokenResponseParametersConverter(); + + public OAuth2AccessTokenResponseHttpMessageConverter() { + super(DEFAULT_CHARSET, MediaType.APPLICATION_JSON, new MediaType("application", "*+json")); + } + + @Override + protected boolean supports(Class clazz) { + return OAuth2AccessTokenResponse.class.isAssignableFrom(clazz); + } + + @Override + protected OAuth2AccessTokenResponse readInternal(Class clazz, HttpInputMessage inputMessage) + throws IOException, HttpMessageNotReadableException { + + try { + @SuppressWarnings("unchecked") + Map tokenResponseParameters = (Map) this.jsonMessageConverter.read( + PARAMETERIZED_RESPONSE_TYPE.getType(), null, inputMessage); + return this.tokenResponseConverter.convert(tokenResponseParameters); + } catch (Exception ex) { + throw new HttpMessageNotReadableException("An error occurred reading the OAuth 2.0 Access Token Response: " + + ex.getMessage(), ex, inputMessage); + } + } + + @Override + protected void writeInternal(OAuth2AccessTokenResponse tokenResponse, HttpOutputMessage outputMessage) + throws IOException, HttpMessageNotWritableException { + + try { + Map tokenResponseParameters = this.tokenResponseParametersConverter.convert(tokenResponse); + this.jsonMessageConverter.write( + tokenResponseParameters, PARAMETERIZED_RESPONSE_TYPE.getType(), MediaType.APPLICATION_JSON, outputMessage); + } catch (Exception ex) { + throw new HttpMessageNotWritableException("An error occurred writing the OAuth 2.0 Access Token Response: " + ex.getMessage(), ex); + } + } + + /** + * Sets the {@link Converter} used for converting the OAuth 2.0 Access Token Response parameters + * to an {@link OAuth2AccessTokenResponse}. + * + * @param tokenResponseConverter the {@link Converter} used for converting to an {@link OAuth2AccessTokenResponse} + */ + public final void setTokenResponseConverter(Converter, OAuth2AccessTokenResponse> tokenResponseConverter) { + Assert.notNull(tokenResponseConverter, "tokenResponseConverter cannot be null"); + this.tokenResponseConverter = tokenResponseConverter; + } + + /** + * Sets the {@link Converter} used for converting the {@link OAuth2AccessTokenResponse} + * to a {@code Map} representation of the OAuth 2.0 Access Token Response parameters. + * + * @param tokenResponseParametersConverter the {@link Converter} used for converting to a {@code Map} representation of the Access Token Response parameters + */ + public final void setTokenResponseParametersConverter(Converter> tokenResponseParametersConverter) { + Assert.notNull(tokenResponseParametersConverter, "tokenResponseParametersConverter cannot be null"); + this.tokenResponseParametersConverter = tokenResponseParametersConverter; + } + + /** + * A {@link Converter} that converts the provided + * OAuth 2.0 Access Token Response parameters to an {@link OAuth2AccessTokenResponse}. + */ + private static class OAuth2AccessTokenResponseConverter implements Converter, OAuth2AccessTokenResponse> { + private static final Set TOKEN_RESPONSE_PARAMETER_NAMES = Stream.of( + OAuth2ParameterNames.ACCESS_TOKEN, + OAuth2ParameterNames.TOKEN_TYPE, + OAuth2ParameterNames.EXPIRES_IN, + OAuth2ParameterNames.REFRESH_TOKEN, + OAuth2ParameterNames.SCOPE).collect(Collectors.toSet()); + + @Override + public OAuth2AccessTokenResponse convert(Map tokenResponseParameters) { + String accessToken = tokenResponseParameters.get(OAuth2ParameterNames.ACCESS_TOKEN); + + OAuth2AccessToken.TokenType accessTokenType = null; + if (OAuth2AccessToken.TokenType.BEARER.getValue().equalsIgnoreCase( + tokenResponseParameters.get(OAuth2ParameterNames.TOKEN_TYPE))) { + accessTokenType = OAuth2AccessToken.TokenType.BEARER; + } + + long expiresIn = 0; + if (tokenResponseParameters.containsKey(OAuth2ParameterNames.EXPIRES_IN)) { + try { + expiresIn = Long.valueOf(tokenResponseParameters.get(OAuth2ParameterNames.EXPIRES_IN)); + } catch (NumberFormatException ex) { } + } + + Set scopes = Collections.emptySet(); + if (tokenResponseParameters.containsKey(OAuth2ParameterNames.SCOPE)) { + String scope = tokenResponseParameters.get(OAuth2ParameterNames.SCOPE); + scopes = Arrays.stream(StringUtils.delimitedListToStringArray(scope, " ")).collect(Collectors.toSet()); + } + + String refreshToken = tokenResponseParameters.get(OAuth2ParameterNames.REFRESH_TOKEN); + + Map additionalParameters = new LinkedHashMap<>(); + tokenResponseParameters.entrySet().stream() + .filter(e -> !TOKEN_RESPONSE_PARAMETER_NAMES.contains(e.getKey())) + .forEach(e -> additionalParameters.put(e.getKey(), e.getValue())); + + return OAuth2AccessTokenResponse.withToken(accessToken) + .tokenType(accessTokenType) + .expiresIn(expiresIn) + .scopes(scopes) + .refreshToken(refreshToken) + .additionalParameters(additionalParameters) + .build(); + } + } + + /** + * A {@link Converter} that converts the provided {@link OAuth2AccessTokenResponse} + * to a {@code Map} representation of the OAuth 2.0 Access Token Response parameters. + */ + private static class OAuth2AccessTokenResponseParametersConverter implements Converter> { + + @Override + public Map convert(OAuth2AccessTokenResponse tokenResponse) { + Map parameters = new HashMap<>(); + + long expiresIn = -1; + if (tokenResponse.getAccessToken().getExpiresAt() != null) { + expiresIn = ChronoUnit.SECONDS.between(Instant.now(), tokenResponse.getAccessToken().getExpiresAt()); + } + + parameters.put(OAuth2ParameterNames.ACCESS_TOKEN, tokenResponse.getAccessToken().getTokenValue()); + parameters.put(OAuth2ParameterNames.TOKEN_TYPE, tokenResponse.getAccessToken().getTokenType().getValue()); + parameters.put(OAuth2ParameterNames.EXPIRES_IN, String.valueOf(expiresIn)); + if (!CollectionUtils.isEmpty(tokenResponse.getAccessToken().getScopes())) { + parameters.put(OAuth2ParameterNames.SCOPE, + StringUtils.collectionToDelimitedString(tokenResponse.getAccessToken().getScopes(), " ")); + } + if (tokenResponse.getRefreshToken() != null) { + parameters.put(OAuth2ParameterNames.REFRESH_TOKEN, tokenResponse.getRefreshToken().getTokenValue()); + } + if (!CollectionUtils.isEmpty(tokenResponse.getAdditionalParameters())) { + tokenResponse.getAdditionalParameters().entrySet().stream() + .forEach(e -> parameters.put(e.getKey(), e.getValue().toString())); + } + + return parameters; + } + } +} diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/http/converter/OAuth2ErrorHttpMessageConverter.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/http/converter/OAuth2ErrorHttpMessageConverter.java new file mode 100644 index 00000000000..9c622defa37 --- /dev/null +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/http/converter/OAuth2ErrorHttpMessageConverter.java @@ -0,0 +1,155 @@ +/* + * Copyright 2002-2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.core.http.converter; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.convert.converter.Converter; +import org.springframework.http.HttpInputMessage; +import org.springframework.http.HttpOutputMessage; +import org.springframework.http.MediaType; +import org.springframework.http.converter.AbstractHttpMessageConverter; +import org.springframework.http.converter.GenericHttpMessageConverter; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.http.converter.HttpMessageNotWritableException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +/** + * A {@link HttpMessageConverter} for an {@link OAuth2Error OAuth 2.0 Error}. + * + * @see AbstractHttpMessageConverter + * @see OAuth2Error + * @author Joe Grandja + * @since 5.1 + */ +public class OAuth2ErrorHttpMessageConverter extends AbstractHttpMessageConverter { + private static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8; + + private static final ParameterizedTypeReference> PARAMETERIZED_RESPONSE_TYPE = + new ParameterizedTypeReference>() {}; + + private GenericHttpMessageConverter jsonMessageConverter = HttpMessageConverters.getJsonMessageConverter(); + + protected Converter, OAuth2Error> errorConverter = new OAuth2ErrorConverter(); + + protected Converter> errorParametersConverter = new OAuth2ErrorParametersConverter(); + + public OAuth2ErrorHttpMessageConverter() { + super(DEFAULT_CHARSET, MediaType.APPLICATION_JSON, new MediaType("application", "*+json")); + } + + @Override + protected boolean supports(Class clazz) { + return OAuth2Error.class.isAssignableFrom(clazz); + } + + @Override + protected OAuth2Error readInternal(Class clazz, HttpInputMessage inputMessage) + throws IOException, HttpMessageNotReadableException { + + try { + @SuppressWarnings("unchecked") + Map errorParameters = (Map) this.jsonMessageConverter.read( + PARAMETERIZED_RESPONSE_TYPE.getType(), null, inputMessage); + return this.errorConverter.convert(errorParameters); + } catch (Exception ex) { + throw new HttpMessageNotReadableException("An error occurred reading the OAuth 2.0 Error: " + + ex.getMessage(), ex, inputMessage); + } + } + + @Override + protected void writeInternal(OAuth2Error oauth2Error, HttpOutputMessage outputMessage) + throws IOException, HttpMessageNotWritableException { + + try { + Map errorParameters = this.errorParametersConverter.convert(oauth2Error); + this.jsonMessageConverter.write( + errorParameters, PARAMETERIZED_RESPONSE_TYPE.getType(), MediaType.APPLICATION_JSON, outputMessage); + } catch (Exception ex) { + throw new HttpMessageNotWritableException("An error occurred writing the OAuth 2.0 Error: " + ex.getMessage(), ex); + } + } + + /** + * Sets the {@link Converter} used for converting the OAuth 2.0 Error parameters + * to an {@link OAuth2Error}. + * + * @param errorConverter the {@link Converter} used for converting to an {@link OAuth2Error} + */ + public final void setErrorConverter(Converter, OAuth2Error> errorConverter) { + Assert.notNull(errorConverter, "errorConverter cannot be null"); + this.errorConverter = errorConverter; + } + + /** + * Sets the {@link Converter} used for converting the {@link OAuth2Error} + * to a {@code Map} representation of the OAuth 2.0 Error parameters. + * + * @param errorParametersConverter the {@link Converter} used for converting to a {@code Map} representation of the Error parameters + */ + public final void setErrorParametersConverter(Converter> errorParametersConverter) { + Assert.notNull(errorParametersConverter, "errorParametersConverter cannot be null"); + this.errorParametersConverter = errorParametersConverter; + } + + /** + * A {@link Converter} that converts the provided + * OAuth 2.0 Error parameters to an {@link OAuth2Error}. + */ + private static class OAuth2ErrorConverter implements Converter, OAuth2Error> { + + @Override + public OAuth2Error convert(Map parameters) { + String errorCode = parameters.get(OAuth2ParameterNames.ERROR); + String errorDescription = parameters.get(OAuth2ParameterNames.ERROR_DESCRIPTION); + String errorUri = parameters.get(OAuth2ParameterNames.ERROR_URI); + + return new OAuth2Error(errorCode, errorDescription, errorUri); + } + } + + /** + * A {@link Converter} that converts the provided {@link OAuth2Error} + * to a {@code Map} representation of OAuth 2.0 Error parameters. + */ + private static class OAuth2ErrorParametersConverter implements Converter> { + + @Override + public Map convert(OAuth2Error oauth2Error) { + Map parameters = new HashMap<>(); + + parameters.put(OAuth2ParameterNames.ERROR, oauth2Error.getErrorCode()); + if (StringUtils.hasText(oauth2Error.getDescription())) { + parameters.put(OAuth2ParameterNames.ERROR_DESCRIPTION, oauth2Error.getDescription()); + } + if (StringUtils.hasText(oauth2Error.getUri())) { + parameters.put(OAuth2ParameterNames.ERROR_URI, oauth2Error.getUri()); + } + + return parameters; + } + } +} diff --git a/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/http/converter/OAuth2AccessTokenResponseHttpMessageConverterTests.java b/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/http/converter/OAuth2AccessTokenResponseHttpMessageConverterTests.java new file mode 100644 index 00000000000..674eee808c2 --- /dev/null +++ b/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/http/converter/OAuth2AccessTokenResponseHttpMessageConverterTests.java @@ -0,0 +1,163 @@ +/* + * Copyright 2002-2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.core.http.converter; + +import org.junit.Before; +import org.junit.Test; +import org.springframework.core.convert.converter.Converter; +import org.springframework.http.HttpStatus; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.http.converter.HttpMessageNotWritableException; +import org.springframework.mock.http.MockHttpOutputMessage; +import org.springframework.mock.http.client.MockClientHttpResponse; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; + +import java.time.Instant; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link OAuth2AccessTokenResponseHttpMessageConverter}. + * + * @author Joe Grandja + */ +public class OAuth2AccessTokenResponseHttpMessageConverterTests { + private OAuth2AccessTokenResponseHttpMessageConverter messageConverter; + + @Before + public void setup() { + this.messageConverter = new OAuth2AccessTokenResponseHttpMessageConverter(); + } + + @Test + public void supportsWhenOAuth2AccessTokenResponseThenTrue() { + assertThat(this.messageConverter.supports(OAuth2AccessTokenResponse.class)).isTrue(); + } + + @Test + public void setTokenResponseConverterWhenConverterIsNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> this.messageConverter.setTokenResponseConverter(null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void setTokenResponseParametersConverterWhenConverterIsNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> this.messageConverter.setTokenResponseParametersConverter(null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void readInternalWhenSuccessfulTokenResponseThenReadOAuth2AccessTokenResponse() throws Exception { + String tokenResponse = "{\n" + + " \"access_token\": \"access-token-1234\",\n" + + " \"token_type\": \"bearer\",\n" + + " \"expires_in\": \"3600\",\n" + + " \"scope\": \"read write\",\n" + + " \"refresh_token\": \"refresh-token-1234\",\n" + + " \"custom_parameter_1\": \"custom-value-1\",\n" + + " \"custom_parameter_2\": \"custom-value-2\"\n" + + "}\n"; + + MockClientHttpResponse response = new MockClientHttpResponse( + tokenResponse.getBytes(), HttpStatus.OK); + + OAuth2AccessTokenResponse accessTokenResponse = this.messageConverter.readInternal( + OAuth2AccessTokenResponse.class, response); + + assertThat(accessTokenResponse.getAccessToken().getTokenValue()).isEqualTo("access-token-1234"); + assertThat(accessTokenResponse.getAccessToken().getTokenType()).isEqualTo(OAuth2AccessToken.TokenType.BEARER); + assertThat(accessTokenResponse.getAccessToken().getExpiresAt()).isBeforeOrEqualTo(Instant.now().plusSeconds(3600)); + assertThat(accessTokenResponse.getAccessToken().getScopes()).containsExactly("read", "write"); + assertThat(accessTokenResponse.getRefreshToken().getTokenValue()).isEqualTo("refresh-token-1234"); + assertThat(accessTokenResponse.getAdditionalParameters()).containsExactly( + entry("custom_parameter_1", "custom-value-1"), entry("custom_parameter_2", "custom-value-2")); + + } + + @Test + public void readInternalWhenConversionFailsThenThrowHttpMessageNotReadableException() { + Converter tokenResponseConverter = mock(Converter.class); + when(tokenResponseConverter.convert(any())).thenThrow(RuntimeException.class); + this.messageConverter.setTokenResponseConverter(tokenResponseConverter); + + String tokenResponse = "{}"; + + MockClientHttpResponse response = new MockClientHttpResponse( + tokenResponse.getBytes(), HttpStatus.OK); + + assertThatThrownBy(() -> this.messageConverter.readInternal(OAuth2AccessTokenResponse.class, response)) + .isInstanceOf(HttpMessageNotReadableException.class) + .hasMessageContaining("An error occurred reading the OAuth 2.0 Access Token Response"); + } + + @Test + public void writeInternalWhenOAuth2AccessTokenResponseThenWriteTokenResponse() throws Exception { + Instant expiresAt = Instant.now().plusSeconds(3600); + Set scopes = new LinkedHashSet<>(Arrays.asList("read", "write")); + Map additionalParameters = new HashMap<>(); + additionalParameters.put("custom_parameter_1", "custom-value-1"); + additionalParameters.put("custom_parameter_2", "custom-value-2"); + + OAuth2AccessTokenResponse accessTokenResponse = OAuth2AccessTokenResponse + .withToken("access-token-1234") + .tokenType(OAuth2AccessToken.TokenType.BEARER) + .expiresIn(expiresAt.toEpochMilli()) + .scopes(scopes) + .refreshToken("refresh-token-1234") + .additionalParameters(additionalParameters) + .build(); + + MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); + this.messageConverter.writeInternal(accessTokenResponse, outputMessage); + String tokenResponse = outputMessage.getBodyAsString(); + + assertThat(tokenResponse).contains("\"access_token\":\"access-token-1234\""); + assertThat(tokenResponse).contains("\"token_type\":\"Bearer\""); + assertThat(tokenResponse).contains("\"expires_in\""); + assertThat(tokenResponse).contains("\"scope\":\"read write\""); + assertThat(tokenResponse).contains("\"refresh_token\":\"refresh-token-1234\""); + assertThat(tokenResponse).contains("\"custom_parameter_1\":\"custom-value-1\""); + assertThat(tokenResponse).contains("\"custom_parameter_2\":\"custom-value-2\""); + } + + @Test + public void writeInternalWhenConversionFailsThenThrowHttpMessageNotWritableException() { + Converter tokenResponseParametersConverter = mock(Converter.class); + when(tokenResponseParametersConverter.convert(any())).thenThrow(RuntimeException.class); + this.messageConverter.setTokenResponseParametersConverter(tokenResponseParametersConverter); + + OAuth2AccessTokenResponse accessTokenResponse = OAuth2AccessTokenResponse + .withToken("access-token-1234") + .tokenType(OAuth2AccessToken.TokenType.BEARER) + .expiresIn(Instant.now().plusSeconds(3600).toEpochMilli()) + .build(); + + MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); + + assertThatThrownBy(() -> this.messageConverter.writeInternal(accessTokenResponse, outputMessage)) + .isInstanceOf(HttpMessageNotWritableException.class) + .hasMessageContaining("An error occurred writing the OAuth 2.0 Access Token Response"); + } +} diff --git a/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/http/converter/OAuth2ErrorHttpMessageConverterTests.java b/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/http/converter/OAuth2ErrorHttpMessageConverterTests.java new file mode 100644 index 00000000000..8d58a72ca3b --- /dev/null +++ b/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/http/converter/OAuth2ErrorHttpMessageConverterTests.java @@ -0,0 +1,126 @@ +/* + * Copyright 2002-2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.core.http.converter; + +import org.junit.Before; +import org.junit.Test; +import org.springframework.core.convert.converter.Converter; +import org.springframework.http.HttpStatus; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.http.converter.HttpMessageNotWritableException; +import org.springframework.mock.http.MockHttpOutputMessage; +import org.springframework.mock.http.client.MockClientHttpResponse; +import org.springframework.security.oauth2.core.OAuth2Error; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link OAuth2ErrorHttpMessageConverter}. + * + * @author Joe Grandja + */ +public class OAuth2ErrorHttpMessageConverterTests { + private OAuth2ErrorHttpMessageConverter messageConverter; + + @Before + public void setup() { + this.messageConverter = new OAuth2ErrorHttpMessageConverter(); + } + + @Test + public void supportsWhenOAuth2ErrorThenTrue() { + assertThat(this.messageConverter.supports(OAuth2Error.class)).isTrue(); + } + + @Test + public void setErrorConverterWhenConverterIsNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> this.messageConverter.setErrorConverter(null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void setErrorParametersConverterWhenConverterIsNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> this.messageConverter.setErrorParametersConverter(null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void readInternalWhenErrorResponseThenReadOAuth2Error() throws Exception { + String errorResponse = "{\n" + + " \"error\": \"unauthorized_client\",\n" + + " \"error_description\": \"The client is not authorized\",\n" + + " \"error_uri\": \"https://tools.ietf.org/html/rfc6749#section-5.2\"\n" + + "}\n"; + + MockClientHttpResponse response = new MockClientHttpResponse( + errorResponse.getBytes(), HttpStatus.BAD_REQUEST); + + OAuth2Error oauth2Error = this.messageConverter.readInternal(OAuth2Error.class, response); + assertThat(oauth2Error.getErrorCode()).isEqualTo("unauthorized_client"); + assertThat(oauth2Error.getDescription()).isEqualTo("The client is not authorized"); + assertThat(oauth2Error.getUri()).isEqualTo("https://tools.ietf.org/html/rfc6749#section-5.2"); + } + + @Test + public void readInternalWhenConversionFailsThenThrowHttpMessageNotReadableException() { + Converter errorConverter = mock(Converter.class); + when(errorConverter.convert(any())).thenThrow(RuntimeException.class); + this.messageConverter.setErrorConverter(errorConverter); + + String errorResponse = "{}"; + + MockClientHttpResponse response = new MockClientHttpResponse( + errorResponse.getBytes(), HttpStatus.BAD_REQUEST); + + assertThatThrownBy(() -> this.messageConverter.readInternal(OAuth2Error.class, response)) + .isInstanceOf(HttpMessageNotReadableException.class) + .hasMessageContaining("An error occurred reading the OAuth 2.0 Error"); + } + + @Test + public void writeInternalWhenOAuth2ErrorThenWriteErrorResponse() throws Exception { + OAuth2Error oauth2Error = new OAuth2Error("unauthorized_client", + "The client is not authorized", "https://tools.ietf.org/html/rfc6749#section-5.2"); + + MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); + this.messageConverter.writeInternal(oauth2Error, outputMessage); + String errorResponse = outputMessage.getBodyAsString(); + + assertThat(errorResponse).contains("\"error\":\"unauthorized_client\""); + assertThat(errorResponse).contains("\"error_description\":\"The client is not authorized\""); + assertThat(errorResponse).contains("\"error_uri\":\"https://tools.ietf.org/html/rfc6749#section-5.2\""); + } + + @Test + public void writeInternalWhenConversionFailsThenThrowHttpMessageNotWritableException() { + Converter errorParametersConverter = mock(Converter.class); + when(errorParametersConverter.convert(any())).thenThrow(RuntimeException.class); + this.messageConverter.setErrorParametersConverter(errorParametersConverter); + + OAuth2Error oauth2Error = new OAuth2Error("unauthorized_client", + "The client is not authorized", "https://tools.ietf.org/html/rfc6749#section-5.2"); + + MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); + + assertThatThrownBy(() -> this.messageConverter.writeInternal(oauth2Error, outputMessage)) + .isInstanceOf(HttpMessageNotWritableException.class) + .hasMessageContaining("An error occurred writing the OAuth 2.0 Error"); + } +} From 43472230b8a1d9db5b7f2d69c5952379ce0f425a Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Mon, 27 Aug 2018 09:29:47 -0400 Subject: [PATCH 2/3] Fix package tangle --- .../endpoint/DefaultAuthorizationCodeTokenResponseClient.java | 1 - .../OAuth2AuthorizationCodeGrantRequestEntityConverter.java | 2 +- ...OAuth2AuthorizationCodeGrantRequestEntityConverterTests.java | 1 + 3 files changed, 2 insertions(+), 2 deletions(-) rename oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/{http/converter => endpoint}/OAuth2AuthorizationCodeGrantRequestEntityConverter.java (98%) diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/DefaultAuthorizationCodeTokenResponseClient.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/DefaultAuthorizationCodeTokenResponseClient.java index 1198b87cecb..2f37cb5e772 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/DefaultAuthorizationCodeTokenResponseClient.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/DefaultAuthorizationCodeTokenResponseClient.java @@ -21,7 +21,6 @@ import org.springframework.http.converter.FormHttpMessageConverter; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.security.oauth2.client.http.OAuth2ErrorResponseErrorHandler; -import org.springframework.security.oauth2.client.http.converter.OAuth2AuthorizationCodeGrantRequestEntityConverter; import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.OAuth2Error; diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/http/converter/OAuth2AuthorizationCodeGrantRequestEntityConverter.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/OAuth2AuthorizationCodeGrantRequestEntityConverter.java similarity index 98% rename from oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/http/converter/OAuth2AuthorizationCodeGrantRequestEntityConverter.java rename to oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/OAuth2AuthorizationCodeGrantRequestEntityConverter.java index 14aa1f8ef2d..db65cd9e951 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/http/converter/OAuth2AuthorizationCodeGrantRequestEntityConverter.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/OAuth2AuthorizationCodeGrantRequestEntityConverter.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.security.oauth2.client.http.converter; +package org.springframework.security.oauth2.client.endpoint; import org.springframework.core.convert.converter.Converter; import org.springframework.http.HttpHeaders; diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/http/converter/OAuth2AuthorizationCodeGrantRequestEntityConverterTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/http/converter/OAuth2AuthorizationCodeGrantRequestEntityConverterTests.java index 0c97e1e8ab0..2b88869c250 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/http/converter/OAuth2AuthorizationCodeGrantRequestEntityConverterTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/http/converter/OAuth2AuthorizationCodeGrantRequestEntityConverterTests.java @@ -22,6 +22,7 @@ import org.springframework.http.MediaType; import org.springframework.http.RequestEntity; import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequestEntityConverter; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.ClientAuthenticationMethod; From 800c8de0bb62db81008b11d18acc09666324f168 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Mon, 27 Aug 2018 10:47:37 -0400 Subject: [PATCH 3/3] Move test --- ...uth2AuthorizationCodeGrantRequestEntityConverterTests.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) rename oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/{http/converter => endpoint}/OAuth2AuthorizationCodeGrantRequestEntityConverterTests.java (94%) diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/http/converter/OAuth2AuthorizationCodeGrantRequestEntityConverterTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/OAuth2AuthorizationCodeGrantRequestEntityConverterTests.java similarity index 94% rename from oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/http/converter/OAuth2AuthorizationCodeGrantRequestEntityConverterTests.java rename to oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/OAuth2AuthorizationCodeGrantRequestEntityConverterTests.java index 2b88869c250..6874096c6f6 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/http/converter/OAuth2AuthorizationCodeGrantRequestEntityConverterTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/OAuth2AuthorizationCodeGrantRequestEntityConverterTests.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.security.oauth2.client.http.converter; +package org.springframework.security.oauth2.client.endpoint; import org.junit.Before; import org.junit.Test; @@ -21,8 +21,6 @@ import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.http.RequestEntity; -import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; -import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequestEntityConverter; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.ClientAuthenticationMethod;