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 81ed9599a47..af81f36aebb 100644 --- a/docs/modules/ROOT/pages/servlet/oauth2/client/client-authentication.adoc +++ b/docs/modules/ROOT/pages/servlet/oauth2/client/client-authentication.adoc @@ -92,9 +92,6 @@ 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 e94f342e028..60e29fe1686 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 @@ -42,8 +42,7 @@ abstract class AbstractOAuth2AuthorizationGrantRequestEntityConverter implements Converter> { - private Converter headersConverter = DefaultOAuth2TokenRequestHeadersConverter - .historicalConverter(); + private Converter headersConverter = DefaultOAuth2TokenRequestHeadersConverter.withCharsetUtf8(); private Converter> parametersConverter = this::createParameters; 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 index 3a232ceab44..92d5d5e1ec6 100644 --- 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 @@ -16,6 +16,10 @@ package org.springframework.security.oauth2.client.endpoint; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.List; + import org.springframework.core.convert.converter.Converter; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; @@ -23,87 +27,82 @@ 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 AbstractOAuth2AuthorizationGrantRequest} to the {@link HttpHeaders} of a * {@link RequestEntity} representation of an OAuth 2.0 Access Token Request for the * specific Authorization Grant. * * @author Peter Eastham - * @author Joe Grandja - * @see AbstractOAuth2AuthorizationGrantRequestEntityConverter + * @author Steve Riesenberg * @since 6.3 + * @see AbstractOAuth2AuthorizationGrantRequestEntityConverter */ public final class DefaultOAuth2TokenRequestHeadersConverter implements Converter { - private MediaType accept = MediaType.APPLICATION_JSON; + private static final MediaType APPLICATION_JSON_UTF8 = new MediaType(MediaType.APPLICATION_JSON, + StandardCharsets.UTF_8); + + private static final MediaType APPLICATION_FORM_URLENCODED_UTF8 = new MediaType( + MediaType.APPLICATION_FORM_URLENCODED, StandardCharsets.UTF_8); + + private List accept = List.of(MediaType.APPLICATION_JSON); private MediaType contentType = MediaType.APPLICATION_FORM_URLENCODED; - private boolean encodeClientCredentialsIfRequired = true; + private boolean encodeClientCredentials = true; /** - * Populates the headers for the token request. - * @param grantRequest the grant request + * Populates the default headers for the token request. + * @param grantRequest the authorization 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); + headers.setAccept(this.accept); + headers.setContentType(this.contentType); ClientRegistration clientRegistration = grantRequest.getClientRegistration(); if (ClientAuthenticationMethod.CLIENT_SECRET_BASIC.equals(clientRegistration.getClientAuthenticationMethod())) { - String clientId = encodeClientCredential(clientRegistration.getClientId()); - String clientSecret = encodeClientCredential(clientRegistration.getClientSecret()); + String clientId = encodeClientCredentialIfRequired(clientRegistration.getClientId()); + String clientSecret = encodeClientCredentialIfRequired(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); + private String encodeClientCredentialIfRequired(String clientCredential) { + if (!this.encodeClientCredentials) { + return clientCredential; } - 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; + return URLEncoder.encode(clientCredential, StandardCharsets.UTF_8); } /** - * MediaType to set for the Accept header. Default is application/json - * @param accept MediaType to use for the Accept header + * Sets whether the client credentials of the {@code Authorization} header will be + * encoded using the {@code application/x-www-form-urlencoded} encoding algorithm + * according to RFC 6749. Default is {@code true}. + * @param encodeClientCredentials whether the client credentials will be encoded + * @see 2.3.1 Client + * Password */ - private void setAccept(MediaType accept) { - this.accept = accept; + public void setEncodeClientCredentials(boolean encodeClientCredentials) { + this.encodeClientCredentials = encodeClientCredentials; } /** - * 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 + * Creates a {@link DefaultOAuth2TokenRequestHeadersConverter} that populates default + * {@link HttpHeaders} that includes {@code charset=UTF-8} on both the {@code Accept} + * and {@code Content-Type} headers to provide backwards compatibility for + * {@link AbstractOAuth2AuthorizationGrantRequestEntityConverter}. + * @return the default headers converter */ - private void setContentType(MediaType contentType) { - this.contentType = contentType; - } - - static DefaultOAuth2TokenRequestHeadersConverter historicalConverter() { + static DefaultOAuth2TokenRequestHeadersConverter withCharsetUtf8() { DefaultOAuth2TokenRequestHeadersConverter converter = new DefaultOAuth2TokenRequestHeadersConverter<>(); - converter.setAccept(MediaType.APPLICATION_JSON_UTF8); - converter.setContentType(MediaType.valueOf(MediaType.APPLICATION_FORM_URLENCODED_VALUE + ";charset=UTF-8")); + converter.accept = List.of(APPLICATION_JSON_UTF8); + converter.contentType = APPLICATION_FORM_URLENCODED_UTF8; return converter; } diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/DefaultOAuth2TokenRequestHeadersConverterTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/DefaultOAuth2TokenRequestHeadersConverterTests.java new file mode 100644 index 00000000000..2ba7888ecdf --- /dev/null +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/DefaultOAuth2TokenRequestHeadersConverterTests.java @@ -0,0 +1,119 @@ +/* + * 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 java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.TestClientRegistrations; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link DefaultOAuth2TokenRequestHeadersConverter}. + * + * @author Steve Riesenberg + */ +public class DefaultOAuth2TokenRequestHeadersConverterTests { + + private static final MediaType APPLICATION_JSON_UTF8 = new MediaType(MediaType.APPLICATION_JSON, + StandardCharsets.UTF_8); + + private static final MediaType APPLICATION_FORM_URLENCODED_UTF8 = new MediaType( + MediaType.APPLICATION_FORM_URLENCODED, StandardCharsets.UTF_8); + + private DefaultOAuth2TokenRequestHeadersConverter converter; + + @BeforeEach + public void setUp() { + this.converter = new DefaultOAuth2TokenRequestHeadersConverter<>(); + } + + @Test + public void convertWhenEncodeClientCredentialsTrueThenConvertsWithUrlEncoding() { + // @formatter:off + ClientRegistration clientRegistration = TestClientRegistrations.clientCredentials() + .clientId("clientId") + .clientSecret("clientSecret=") + .build(); + // @formatter:on + OAuth2ClientCredentialsGrantRequest grantRequest = new OAuth2ClientCredentialsGrantRequest(clientRegistration); + HttpHeaders defaultHeaders = this.converter.convert(grantRequest); + assertThat(defaultHeaders.getAccept()).containsExactly(MediaType.APPLICATION_JSON); + assertThat(defaultHeaders.getContentType()).isEqualTo(MediaType.APPLICATION_FORM_URLENCODED); + assertThat(defaultHeaders.getFirst(HttpHeaders.AUTHORIZATION)) + .isEqualTo("Basic Y2xpZW50SWQ6Y2xpZW50U2VjcmV0JTNE"); + } + + @Test + public void convertWhenEncodeClientCredentialsFalseThenConvertsWithoutUrlEncoding() { + this.converter.setEncodeClientCredentials(false); + // @formatter:off + ClientRegistration clientRegistration = TestClientRegistrations.clientCredentials() + .clientId("clientId") + .clientSecret("clientSecret=") + .build(); + // @formatter:on + OAuth2ClientCredentialsGrantRequest grantRequest = new OAuth2ClientCredentialsGrantRequest(clientRegistration); + HttpHeaders defaultHeaders = this.converter.convert(grantRequest); + assertThat(defaultHeaders.getAccept()).containsExactly(MediaType.APPLICATION_JSON); + assertThat(defaultHeaders.getContentType()).isEqualTo(MediaType.APPLICATION_FORM_URLENCODED); + assertThat(defaultHeaders.getFirst(HttpHeaders.AUTHORIZATION)) + .isEqualTo("Basic Y2xpZW50SWQ6Y2xpZW50U2VjcmV0PQ=="); + } + + @Test + public void convertWhenWithCharsetUtf8AndEncodeClientCredentialsTrueThenConvertsWithUrlEncoding() { + this.converter = DefaultOAuth2TokenRequestHeadersConverter.withCharsetUtf8(); + // @formatter:off + ClientRegistration clientRegistration = TestClientRegistrations.clientCredentials() + .clientId("clientId") + .clientSecret("clientSecret=") + .build(); + // @formatter:on + OAuth2ClientCredentialsGrantRequest grantRequest = new OAuth2ClientCredentialsGrantRequest(clientRegistration); + HttpHeaders defaultHeaders = this.converter.convert(grantRequest); + assertThat(defaultHeaders.getAccept()).containsExactly(APPLICATION_JSON_UTF8); + assertThat(defaultHeaders.getContentType()).isEqualTo(APPLICATION_FORM_URLENCODED_UTF8); + assertThat(defaultHeaders.getFirst(HttpHeaders.AUTHORIZATION)) + .isEqualTo("Basic Y2xpZW50SWQ6Y2xpZW50U2VjcmV0JTNE"); + } + + @Test + public void convertWhenWithCharsetUtf8EncodeClientCredentialsFalseThenConvertsWithoutUrlEncoding() { + this.converter = DefaultOAuth2TokenRequestHeadersConverter.withCharsetUtf8(); + this.converter.setEncodeClientCredentials(false); + // @formatter:off + ClientRegistration clientRegistration = TestClientRegistrations.clientCredentials() + .clientId("clientId") + .clientSecret("clientSecret=") + .build(); + // @formatter:on + OAuth2ClientCredentialsGrantRequest grantRequest = new OAuth2ClientCredentialsGrantRequest(clientRegistration); + HttpHeaders defaultHeaders = this.converter.convert(grantRequest); + assertThat(defaultHeaders.getAccept()).containsExactly(APPLICATION_JSON_UTF8); + assertThat(defaultHeaders.getContentType()).isEqualTo(APPLICATION_FORM_URLENCODED_UTF8); + assertThat(defaultHeaders.getFirst(HttpHeaders.AUTHORIZATION)) + .isEqualTo("Basic Y2xpZW50SWQ6Y2xpZW50U2VjcmV0PQ=="); + } + +} diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/OAuth2AuthorizationCodeGrantRequestEntityConverterTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/OAuth2AuthorizationCodeGrantRequestEntityConverterTests.java index 3c722fb0509..9c4fc4d88f8 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/OAuth2AuthorizationCodeGrantRequestEntityConverterTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/OAuth2AuthorizationCodeGrantRequestEntityConverterTests.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. @@ -122,7 +122,12 @@ public void convertWhenParametersConverterSetThenCalled() { @SuppressWarnings("unchecked") @Test public void convertWhenGrantRequestValidThenConverts() { - ClientRegistration clientRegistration = TestClientRegistrations.clientRegistration().build(); + // @formatter:off + ClientRegistration clientRegistration = TestClientRegistrations.clientRegistration() + .clientId("clientId") + .clientSecret("clientSecret=") + .build(); + // @formatter:on OAuth2AuthorizationExchange authorizationExchange = TestOAuth2AuthorizationExchanges.success(); OAuth2AuthorizationRequest authorizationRequest = authorizationExchange.getAuthorizationRequest(); OAuth2AuthorizationResponse authorizationResponse = authorizationExchange.getAuthorizationResponse(); @@ -136,7 +141,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.AUTHORIZATION_CODE.getValue()); 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 e8f11f91a52..d884559f733 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-2024 the original author or authors. + * Copyright 2002-2021 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,10 +110,7 @@ public void convertWhenParametersConverterSetThenCalled() { @SuppressWarnings("unchecked") @Test public void convertWhenGrantRequestValidThenConverts() { - ClientRegistration clientRegistration = TestClientRegistrations.password() - .clientId("clientId") - .clientSecret("clientSecret=") - .build(); + ClientRegistration clientRegistration = TestClientRegistrations.password().build(); OAuth2PasswordGrantRequest passwordGrantRequest = new OAuth2PasswordGrantRequest(clientRegistration, "user1", "password"); RequestEntity requestEntity = this.converter.convert(passwordGrantRequest); @@ -124,7 +121,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)).isEqualTo("Basic Y2xpZW50SWQ6Y2xpZW50U2VjcmV0JTNE"); + assertThat(headers.getFirst(HttpHeaders.AUTHORIZATION)).startsWith("Basic "); MultiValueMap formParameters = (MultiValueMap) requestEntity.getBody(); assertThat(formParameters.getFirst(OAuth2ParameterNames.GRANT_TYPE)) .isEqualTo(AuthorizationGrantType.PASSWORD.getValue()); @@ -133,34 +130,4 @@ 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()); - } - }