diff --git a/docs/modules/ROOT/pages/servlet/oauth2/client/client-authentication.adoc b/docs/modules/ROOT/pages/servlet/oauth2/client/client-authentication.adoc index ed5c82ca034..81ed9599a47 100644 --- a/docs/modules/ROOT/pages/servlet/oauth2/client/client-authentication.adoc +++ b/docs/modules/ROOT/pages/servlet/oauth2/client/client-authentication.adoc @@ -92,7 +92,9 @@ val tokenResponseClient = DefaultAuthorizationCodeTokenResponseClient() tokenResponseClient.setRequestEntityConverter(requestEntityConverter) ---- ====== - +[NOTE] +If you're using the `client-authentication-method: client_secret_basic` and you need to skip URL encoding, +create a new `DefaultOAuth2TokenRequestHeadersConverter` and set it in the Request Entity Converter above. === Authenticate using `client_secret_jwt` diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/AbstractOAuth2AuthorizationGrantRequestEntityConverter.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/AbstractOAuth2AuthorizationGrantRequestEntityConverter.java index 1c853d6c3c7..e94f342e028 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/AbstractOAuth2AuthorizationGrantRequestEntityConverter.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/AbstractOAuth2AuthorizationGrantRequestEntityConverter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 the original author or authors. + * Copyright 2002-2024 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. @@ -42,11 +42,8 @@ abstract class AbstractOAuth2AuthorizationGrantRequestEntityConverter implements Converter> { - // @formatter:off - private Converter headersConverter = - (authorizationGrantRequest) -> OAuth2AuthorizationGrantRequestEntityUtils - .getTokenRequestHeaders(authorizationGrantRequest.getClientRegistration()); - // @formatter:on + private Converter headersConverter = DefaultOAuth2TokenRequestHeadersConverter + .historicalConverter(); private Converter> parametersConverter = this::createParameters; diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/AbstractWebClientReactiveOAuth2AccessTokenResponseClient.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/AbstractWebClientReactiveOAuth2AccessTokenResponseClient.java index 1ce02634315..14d403d9880 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/AbstractWebClientReactiveOAuth2AccessTokenResponseClient.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/AbstractWebClientReactiveOAuth2AccessTokenResponseClient.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 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. @@ -16,9 +16,6 @@ package org.springframework.security.oauth2.client.endpoint; -import java.io.UnsupportedEncodingException; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; import java.util.Collections; import java.util.Set; @@ -26,7 +23,6 @@ import org.springframework.core.convert.converter.Converter; import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; import org.springframework.http.ReactiveHttpInputMessage; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.core.ClientAuthenticationMethod; @@ -65,6 +61,7 @@ * @see WebClientReactiveClientCredentialsTokenResponseClient * @see WebClientReactivePasswordTokenResponseClient * @see WebClientReactiveRefreshTokenTokenResponseClient + * @see DefaultOAuth2TokenRequestHeadersConverter */ public abstract class AbstractWebClientReactiveOAuth2AccessTokenResponseClient implements ReactiveOAuth2AccessTokenResponseClient { @@ -73,7 +70,7 @@ public abstract class AbstractWebClientReactiveOAuth2AccessTokenResponseClient> requestEntityConverter = this::validatingPopulateRequest; - private Converter headersConverter = this::populateTokenRequestHeaders; + private Converter headersConverter = new DefaultOAuth2TokenRequestHeadersConverter<>(); private Converter> parametersConverter = this::populateTokenRequestParameters; @@ -131,34 +128,6 @@ private RequestHeadersSpec populateRequest(T grantRequest) { .body(createTokenRequestBody(grantRequest)); } - /** - * Populates the headers for the token request. - * @param grantRequest the grant request - * @return the headers populated for the token request - */ - private HttpHeaders populateTokenRequestHeaders(T grantRequest) { - HttpHeaders headers = new HttpHeaders(); - ClientRegistration clientRegistration = clientRegistration(grantRequest); - headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); - headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); - if (ClientAuthenticationMethod.CLIENT_SECRET_BASIC.equals(clientRegistration.getClientAuthenticationMethod())) { - String clientId = encodeClientCredential(clientRegistration.getClientId()); - String clientSecret = encodeClientCredential(clientRegistration.getClientSecret()); - headers.setBasicAuth(clientId, clientSecret); - } - return headers; - } - - private static String encodeClientCredential(String clientCredential) { - try { - return URLEncoder.encode(clientCredential, StandardCharsets.UTF_8.toString()); - } - catch (UnsupportedEncodingException ex) { - // Will not happen since UTF-8 is a standard charset - throw new IllegalArgumentException(ex); - } - } - /** * Populates default parameters for the token request. * @param grantRequest the grant request diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/DefaultOAuth2TokenRequestHeadersConverter.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/DefaultOAuth2TokenRequestHeadersConverter.java new file mode 100644 index 00000000000..3a232ceab44 --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/DefaultOAuth2TokenRequestHeadersConverter.java @@ -0,0 +1,110 @@ +/* + * Copyright 2002-2024 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 + * + * https://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.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.RequestEntity; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Collections; + +/** + * Default {@link Converter} used to convert an + * {@link AbstractOAuth2AuthorizationGrantRequest} to the {@link HttpHeaders} of aKk + * {@link RequestEntity} representation of an OAuth 2.0 Access Token Request for the + * specific Authorization Grant. + * + * @author Peter Eastham + * @author Joe Grandja + * @see AbstractOAuth2AuthorizationGrantRequestEntityConverter + * @since 6.3 + */ +public final class DefaultOAuth2TokenRequestHeadersConverter + implements Converter { + + private MediaType accept = MediaType.APPLICATION_JSON; + + private MediaType contentType = MediaType.APPLICATION_FORM_URLENCODED; + + private boolean encodeClientCredentialsIfRequired = true; + + /** + * Populates the headers for the token request. + * @param grantRequest the grant request + * @return the headers populated for the token request + */ + @Override + public HttpHeaders convert(T grantRequest) { + HttpHeaders headers = new HttpHeaders(); + headers.setAccept(Collections.singletonList(accept)); + headers.setContentType(contentType); + ClientRegistration clientRegistration = grantRequest.getClientRegistration(); + if (ClientAuthenticationMethod.CLIENT_SECRET_BASIC.equals(clientRegistration.getClientAuthenticationMethod())) { + String clientId = encodeClientCredential(clientRegistration.getClientId()); + String clientSecret = encodeClientCredential(clientRegistration.getClientSecret()); + headers.setBasicAuth(clientId, clientSecret); + } + return headers; + } + + private String encodeClientCredential(String clientCredential) { + String encodedCredential = clientCredential; + if (this.encodeClientCredentialsIfRequired) { + encodedCredential = URLEncoder.encode(clientCredential, StandardCharsets.UTF_8); + } + return encodedCredential; + } + + /** + * Sets the behavior for if this URL Encoding the Client Credentials during the + * conversion. + * @param encodeClientCredentialsIfRequired if false, no URL encoding will happen + */ + public void setEncodeClientCredentials(boolean encodeClientCredentialsIfRequired) { + this.encodeClientCredentialsIfRequired = encodeClientCredentialsIfRequired; + } + + /** + * MediaType to set for the Accept header. Default is application/json + * @param accept MediaType to use for the Accept header + */ + private void setAccept(MediaType accept) { + this.accept = accept; + } + + /** + * MediaType to set for the Content Type header. Default is + * application/x-www-form-urlencoded + * @param contentType MediaType to use for the Content Type header + */ + private void setContentType(MediaType contentType) { + this.contentType = contentType; + } + + static DefaultOAuth2TokenRequestHeadersConverter historicalConverter() { + DefaultOAuth2TokenRequestHeadersConverter converter = new DefaultOAuth2TokenRequestHeadersConverter<>(); + converter.setAccept(MediaType.APPLICATION_JSON_UTF8); + converter.setContentType(MediaType.valueOf(MediaType.APPLICATION_FORM_URLENCODED_VALUE + ";charset=UTF-8")); + return converter; + } + +} diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/OAuth2AuthorizationGrantRequestEntityUtils.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/OAuth2AuthorizationGrantRequestEntityUtils.java deleted file mode 100644 index ba82a9466c8..00000000000 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/OAuth2AuthorizationGrantRequestEntityUtils.java +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright 2002-2022 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 - * - * https://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 java.io.UnsupportedEncodingException; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; -import java.util.Collections; - -import org.springframework.core.convert.converter.Converter; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.http.RequestEntity; -import org.springframework.security.oauth2.client.registration.ClientRegistration; -import org.springframework.security.oauth2.core.ClientAuthenticationMethod; - -/** - * Utility methods used by the {@link Converter}'s that convert from an implementation of - * an {@link AbstractOAuth2AuthorizationGrantRequest} to a {@link RequestEntity} - * representation of an OAuth 2.0 Access Token Request for the specific Authorization - * Grant. - * - * @author Joe Grandja - * @since 5.1 - * @see OAuth2AuthorizationCodeGrantRequestEntityConverter - * @see OAuth2ClientCredentialsGrantRequestEntityConverter - */ -final class OAuth2AuthorizationGrantRequestEntityUtils { - - private static HttpHeaders DEFAULT_TOKEN_REQUEST_HEADERS = getDefaultTokenRequestHeaders(); - - private OAuth2AuthorizationGrantRequestEntityUtils() { - } - - static HttpHeaders getTokenRequestHeaders(ClientRegistration clientRegistration) { - HttpHeaders headers = new HttpHeaders(); - headers.addAll(DEFAULT_TOKEN_REQUEST_HEADERS); - if (ClientAuthenticationMethod.CLIENT_SECRET_BASIC.equals(clientRegistration.getClientAuthenticationMethod())) { - String clientId = encodeClientCredential(clientRegistration.getClientId()); - String clientSecret = encodeClientCredential(clientRegistration.getClientSecret()); - headers.setBasicAuth(clientId, clientSecret); - } - return headers; - } - - private static String encodeClientCredential(String clientCredential) { - try { - return URLEncoder.encode(clientCredential, StandardCharsets.UTF_8.toString()); - } - catch (UnsupportedEncodingException ex) { - // Will not happen since UTF-8 is a standard charset - throw new IllegalArgumentException(ex); - } - } - - private static HttpHeaders getDefaultTokenRequestHeaders() { - HttpHeaders headers = new HttpHeaders(); - headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON_UTF8)); - final MediaType contentType = MediaType.valueOf(MediaType.APPLICATION_FORM_URLENCODED_VALUE + ";charset=UTF-8"); - headers.setContentType(contentType); - return headers; - } - -} diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/OAuth2PasswordGrantRequestEntityConverterTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/OAuth2PasswordGrantRequestEntityConverterTests.java index d884559f733..e8f11f91a52 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/OAuth2PasswordGrantRequestEntityConverterTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/OAuth2PasswordGrantRequestEntityConverterTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 the original author or authors. + * Copyright 2002-2024 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. @@ -110,7 +110,10 @@ public void convertWhenParametersConverterSetThenCalled() { @SuppressWarnings("unchecked") @Test public void convertWhenGrantRequestValidThenConverts() { - ClientRegistration clientRegistration = TestClientRegistrations.password().build(); + ClientRegistration clientRegistration = TestClientRegistrations.password() + .clientId("clientId") + .clientSecret("clientSecret=") + .build(); OAuth2PasswordGrantRequest passwordGrantRequest = new OAuth2PasswordGrantRequest(clientRegistration, "user1", "password"); RequestEntity requestEntity = this.converter.convert(passwordGrantRequest); @@ -121,7 +124,7 @@ public void convertWhenGrantRequestValidThenConverts() { assertThat(headers.getAccept()).contains(MediaType.APPLICATION_JSON_UTF8); assertThat(headers.getContentType()) .isEqualTo(MediaType.valueOf(MediaType.APPLICATION_FORM_URLENCODED_VALUE + ";charset=UTF-8")); - assertThat(headers.getFirst(HttpHeaders.AUTHORIZATION)).startsWith("Basic "); + assertThat(headers.getFirst(HttpHeaders.AUTHORIZATION)).isEqualTo("Basic Y2xpZW50SWQ6Y2xpZW50U2VjcmV0JTNE"); MultiValueMap formParameters = (MultiValueMap) requestEntity.getBody(); assertThat(formParameters.getFirst(OAuth2ParameterNames.GRANT_TYPE)) .isEqualTo(AuthorizationGrantType.PASSWORD.getValue()); @@ -130,4 +133,34 @@ public void convertWhenGrantRequestValidThenConverts() { assertThat(formParameters.getFirst(OAuth2ParameterNames.SCOPE)).contains(clientRegistration.getScopes()); } + @SuppressWarnings("unchecked") + @Test + public void convertWhenGrantRequestValidThenConvertsWithoutUrlEncoding() { + ClientRegistration clientRegistration = TestClientRegistrations.password() + .clientId("clientId") + .clientSecret("clientSecret=") + .build(); + OAuth2PasswordGrantRequest passwordGrantRequest = new OAuth2PasswordGrantRequest(clientRegistration, "user1", + "password="); + DefaultOAuth2TokenRequestHeadersConverter headersConverter = DefaultOAuth2TokenRequestHeadersConverter + .historicalConverter(); + headersConverter.setEncodeClientCredentials(false); + this.converter.setHeadersConverter(headersConverter); + RequestEntity requestEntity = this.converter.convert(passwordGrantRequest); + 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(MediaType.APPLICATION_FORM_URLENCODED_VALUE + ";charset=UTF-8")); + assertThat(headers.getFirst(HttpHeaders.AUTHORIZATION)).isEqualTo("Basic Y2xpZW50SWQ6Y2xpZW50U2VjcmV0PQ=="); + MultiValueMap formParameters = (MultiValueMap) requestEntity.getBody(); + assertThat(formParameters.getFirst(OAuth2ParameterNames.GRANT_TYPE)) + .isEqualTo(AuthorizationGrantType.PASSWORD.getValue()); + assertThat(formParameters.getFirst(OAuth2ParameterNames.USERNAME)).isEqualTo("user1"); + assertThat(formParameters.getFirst(OAuth2ParameterNames.PASSWORD)).isEqualTo("password="); + assertThat(formParameters.getFirst(OAuth2ParameterNames.SCOPE)).contains(clientRegistration.getScopes()); + } + }