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 index a1ed924307..20de672a70 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 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. @@ -15,6 +15,11 @@ */ 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; @@ -22,8 +27,6 @@ import org.springframework.http.RequestEntity; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.core.ClientAuthenticationMethod; -import java.util.Collections; - import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED_VALUE; /** @@ -44,11 +47,23 @@ final class OAuth2AuthorizationGrantRequestEntityUtils { HttpHeaders headers = new HttpHeaders(); headers.addAll(DEFAULT_TOKEN_REQUEST_HEADERS); if (ClientAuthenticationMethod.BASIC.equals(clientRegistration.getClientAuthenticationMethod())) { - headers.setBasicAuth(clientRegistration.getClientId(), clientRegistration.getClientSecret()); + 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)); diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/WebClientReactiveAuthorizationCodeTokenResponseClient.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/WebClientReactiveAuthorizationCodeTokenResponseClient.java index 76d49cc367..11833b0129 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/WebClientReactiveAuthorizationCodeTokenResponseClient.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/WebClientReactiveAuthorizationCodeTokenResponseClient.java @@ -15,6 +15,12 @@ */ package org.springframework.security.oauth2.client.endpoint; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +import reactor.core.publisher.Mono; + import org.springframework.http.MediaType; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.core.AuthorizationGrantType; @@ -24,10 +30,9 @@ import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationExch import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponse; import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; import org.springframework.security.oauth2.core.endpoint.PkceParameterNames; +import org.springframework.util.Assert; import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.client.WebClient; -import org.springframework.util.Assert; -import reactor.core.publisher.Mono; import static org.springframework.security.oauth2.core.web.reactive.function.OAuth2BodyExtractors.oauth2AccessTokenResponse; @@ -74,7 +79,9 @@ public class WebClientReactiveAuthorizationCodeTokenResponseClient implements Re .accept(MediaType.APPLICATION_JSON) .headers(headers -> { if (ClientAuthenticationMethod.BASIC.equals(clientRegistration.getClientAuthenticationMethod())) { - headers.setBasicAuth(clientRegistration.getClientId(), clientRegistration.getClientSecret()); + String clientId = encodeClientCredential(clientRegistration.getClientId()); + String clientSecret = encodeClientCredential(clientRegistration.getClientSecret()); + headers.setBasicAuth(clientId, clientSecret); } }) .body(body) @@ -91,6 +98,16 @@ public class WebClientReactiveAuthorizationCodeTokenResponseClient implements Re }); } + 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 BodyInserters.FormInserter body(OAuth2AuthorizationExchange authorizationExchange, ClientRegistration clientRegistration) { OAuth2AuthorizationResponse authorizationResponse = authorizationExchange.getAuthorizationResponse(); BodyInserters.FormInserter body = BodyInserters diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/WebClientReactiveClientCredentialsTokenResponseClient.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/WebClientReactiveClientCredentialsTokenResponseClient.java index 6acfd38547..af7fa64478 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/WebClientReactiveClientCredentialsTokenResponseClient.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/WebClientReactiveClientCredentialsTokenResponseClient.java @@ -15,6 +15,14 @@ */ package org.springframework.security.oauth2.client.endpoint; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Set; +import java.util.function.Consumer; + +import reactor.core.publisher.Mono; + import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DataBufferUtils; import org.springframework.http.HttpHeaders; @@ -30,10 +38,6 @@ import org.springframework.util.StringUtils; import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClientResponseException; -import reactor.core.publisher.Mono; - -import java.util.Set; -import java.util.function.Consumer; import static org.springframework.security.oauth2.core.web.reactive.function.OAuth2BodyExtractors.oauth2AccessTokenResponse; @@ -98,11 +102,23 @@ public class WebClientReactiveClientCredentialsTokenResponseClient implements Re return headers -> { headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); if (ClientAuthenticationMethod.BASIC.equals(clientRegistration.getClientAuthenticationMethod())) { - headers.setBasicAuth(clientRegistration.getClientId(), clientRegistration.getClientSecret()); + String clientId = encodeClientCredential(clientRegistration.getClientId()); + String clientSecret = encodeClientCredential(clientRegistration.getClientSecret()); + headers.setBasicAuth(clientId, clientSecret); } }; } + 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 BodyInserters.FormInserter body(OAuth2ClientCredentialsGrantRequest authorizationGrantRequest) { ClientRegistration clientRegistration = authorizationGrantRequest.getClientRegistration(); BodyInserters.FormInserter body = BodyInserters diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/WebClientReactivePasswordTokenResponseClient.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/WebClientReactivePasswordTokenResponseClient.java index 41fe121694..957c9d5508 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/WebClientReactivePasswordTokenResponseClient.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/WebClientReactivePasswordTokenResponseClient.java @@ -15,6 +15,14 @@ */ 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.function.Consumer; + +import reactor.core.publisher.Mono; + import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DataBufferUtils; import org.springframework.http.HttpHeaders; @@ -32,10 +40,6 @@ import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.client.WebClient; -import reactor.core.publisher.Mono; - -import java.util.Collections; -import java.util.function.Consumer; import static org.springframework.security.oauth2.core.web.reactive.function.OAuth2BodyExtractors.oauth2AccessTokenResponse; @@ -100,11 +104,23 @@ public final class WebClientReactivePasswordTokenResponseClient implements React headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); if (ClientAuthenticationMethod.BASIC.equals(clientRegistration.getClientAuthenticationMethod())) { - headers.setBasicAuth(clientRegistration.getClientId(), clientRegistration.getClientSecret()); + String clientId = encodeClientCredential(clientRegistration.getClientId()); + String clientSecret = encodeClientCredential(clientRegistration.getClientSecret()); + headers.setBasicAuth(clientId, clientSecret); } }; } + 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 BodyInserters.FormInserter tokenRequestBody(OAuth2PasswordGrantRequest passwordGrantRequest) { ClientRegistration clientRegistration = passwordGrantRequest.getClientRegistration(); BodyInserters.FormInserter body = BodyInserters.fromFormData( diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/WebClientReactiveRefreshTokenTokenResponseClient.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/WebClientReactiveRefreshTokenTokenResponseClient.java index 6d6daa83d5..abb200a9c1 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/WebClientReactiveRefreshTokenTokenResponseClient.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/WebClientReactiveRefreshTokenTokenResponseClient.java @@ -15,6 +15,14 @@ */ 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.function.Consumer; + +import reactor.core.publisher.Mono; + import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DataBufferUtils; import org.springframework.http.HttpHeaders; @@ -32,10 +40,6 @@ import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.client.WebClient; -import reactor.core.publisher.Mono; - -import java.util.Collections; -import java.util.function.Consumer; import static org.springframework.security.oauth2.core.web.reactive.function.OAuth2BodyExtractors.oauth2AccessTokenResponse; @@ -88,11 +92,23 @@ public final class WebClientReactiveRefreshTokenTokenResponseClient implements R headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); if (ClientAuthenticationMethod.BASIC.equals(clientRegistration.getClientAuthenticationMethod())) { - headers.setBasicAuth(clientRegistration.getClientId(), clientRegistration.getClientSecret()); + String clientId = encodeClientCredential(clientRegistration.getClientId()); + String clientSecret = encodeClientCredential(clientRegistration.getClientSecret()); + headers.setBasicAuth(clientId, clientSecret); } }; } + 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 BodyInserters.FormInserter tokenRequestBody(OAuth2RefreshTokenGrantRequest refreshTokenGrantRequest) { ClientRegistration clientRegistration = refreshTokenGrantRequest.getClientRegistration(); BodyInserters.FormInserter body = BodyInserters.fromFormData( diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/OAuth2ClientCredentialsGrantRequestEntityConverterTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/OAuth2ClientCredentialsGrantRequestEntityConverterTests.java index 28233c9a16..0f24312af5 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/OAuth2ClientCredentialsGrantRequestEntityConverterTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/OAuth2ClientCredentialsGrantRequestEntityConverterTests.java @@ -15,13 +15,20 @@ */ package org.springframework.security.oauth2.client.endpoint; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + 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.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.TestClientRegistrations; import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.ClientAuthenticationMethod; import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; @@ -74,4 +81,37 @@ public class OAuth2ClientCredentialsGrantRequestEntityConverterTests { AuthorizationGrantType.CLIENT_CREDENTIALS.getValue()); assertThat(formParameters.getFirst(OAuth2ParameterNames.SCOPE)).isEqualTo("read write"); } + + // gh-9610 + @SuppressWarnings("unchecked") + @Test + public void convertWhenSpecialCharactersThenConvertsWithEncodedClientCredentials() + throws UnsupportedEncodingException { + String clientCredentialWithAnsiKeyboardSpecialCharacters = "~!@#$%^&*()_+{}|:\"<>?`-=[]\\;',./ "; + // @formatter:off + ClientRegistration clientRegistration = TestClientRegistrations.clientCredentials() + .clientId(clientCredentialWithAnsiKeyboardSpecialCharacters) + .clientSecret(clientCredentialWithAnsiKeyboardSpecialCharacters) + .build(); + // @formatter:on + OAuth2ClientCredentialsGrantRequest clientCredentialsGrantRequest = new OAuth2ClientCredentialsGrantRequest( + clientRegistration); + RequestEntity requestEntity = this.converter.convert(clientCredentialsGrantRequest); + 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")); + String urlEncodedClientCredential = URLEncoder.encode(clientCredentialWithAnsiKeyboardSpecialCharacters, + StandardCharsets.UTF_8.toString()); + String clientCredentials = Base64.getEncoder().encodeToString( + (urlEncodedClientCredential + ":" + urlEncodedClientCredential).getBytes(StandardCharsets.UTF_8)); + assertThat(headers.getFirst(HttpHeaders.AUTHORIZATION)).isEqualTo("Basic " + clientCredentials); + MultiValueMap formParameters = (MultiValueMap) requestEntity.getBody(); + assertThat(formParameters.getFirst(OAuth2ParameterNames.GRANT_TYPE)) + .isEqualTo(AuthorizationGrantType.CLIENT_CREDENTIALS.getValue()); + assertThat(formParameters.getFirst(OAuth2ParameterNames.SCOPE)).contains(clientRegistration.getScopes()); + } } diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/WebClientReactiveClientCredentialsTokenResponseClientTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/WebClientReactiveClientCredentialsTokenResponseClientTests.java index c4d92d629c..430efd6927 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/WebClientReactiveClientCredentialsTokenResponseClientTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/WebClientReactiveClientCredentialsTokenResponseClientTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 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. @@ -16,12 +16,17 @@ package org.springframework.security.oauth2.client.endpoint; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + 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.MediaType; import org.springframework.security.oauth2.client.registration.ClientRegistration; @@ -82,6 +87,35 @@ public class WebClientReactiveClientCredentialsTokenResponseClientTests { assertThat(body).isEqualTo("grant_type=client_credentials&scope=read%3Auser"); } + // gh-9610 + @Test + public void getTokenResponseWhenSpecialCharactersThenSuccessWithEncodedClientCredentials() throws Exception { + // @formatter:off + enqueueJson("{\n" + + " \"access_token\":\"MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3\",\n" + + " \"token_type\":\"bearer\",\n" + + " \"expires_in\":3600,\n" + + " \"refresh_token\":\"IwOGYzYTlmM2YxOTQ5MGE3YmNmMDFkNTVk\",\n" + + " \"scope\":\"create\"\n" + + "}"); + // @formatter:on + String clientCredentialWithAnsiKeyboardSpecialCharacters = "~!@#$%^&*()_+{}|:\"<>?`-=[]\\;',./ "; + OAuth2ClientCredentialsGrantRequest request = new OAuth2ClientCredentialsGrantRequest( + this.clientRegistration.clientId(clientCredentialWithAnsiKeyboardSpecialCharacters) + .clientSecret(clientCredentialWithAnsiKeyboardSpecialCharacters).build()); + OAuth2AccessTokenResponse response = this.client.getTokenResponse(request).block(); + RecordedRequest actualRequest = this.server.takeRequest(); + String body = actualRequest.getBody().readUtf8(); + assertThat(response.getAccessToken()).isNotNull(); + String urlEncodedClientCredentialecret = URLEncoder.encode(clientCredentialWithAnsiKeyboardSpecialCharacters, + StandardCharsets.UTF_8.toString()); + String clientCredentials = Base64.getEncoder() + .encodeToString((urlEncodedClientCredentialecret + ":" + urlEncodedClientCredentialecret) + .getBytes(StandardCharsets.UTF_8)); + assertThat(actualRequest.getHeader(HttpHeaders.AUTHORIZATION)).isEqualTo("Basic " + clientCredentials); + assertThat(body).isEqualTo("grant_type=client_credentials&scope=read%3Auser"); + } + @Test public void getTokenResponseWhenPostThenSuccess() throws Exception { ClientRegistration registration = this.clientRegistration