diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/AbstractRestClientOAuth2AccessTokenResponseClient.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/AbstractRestClientOAuth2AccessTokenResponseClient.java index c538757faa..8d7f700a63 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/AbstractRestClientOAuth2AccessTokenResponseClient.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/AbstractRestClientOAuth2AccessTokenResponseClient.java @@ -20,7 +20,9 @@ import org.springframework.core.convert.converter.Converter; import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; import org.springframework.http.converter.FormHttpMessageConverter; +import org.springframework.http.converter.StringHttpMessageConverter; import org.springframework.security.oauth2.client.http.OAuth2ErrorResponseErrorHandler; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.core.ClientAuthenticationMethod; @@ -66,6 +68,7 @@ public abstract class AbstractRestClientOAuth2AccessTokenResponseClient { messageConverters.clear(); messageConverters.add(new FormHttpMessageConverter()); + messageConverters.add(new StringHttpMessageConverter()); messageConverters.add(new OAuth2AccessTokenResponseHttpMessageConverter()); }) .defaultStatusHandler(new OAuth2ErrorResponseErrorHandler()) @@ -134,6 +137,22 @@ private RequestHeadersSpec populateRequest(T grantRequest) { } this.parametersCustomizer.accept(parameters); + // Special handling for CLIENT_SECRET_POST to preserve Base64 padding + ClientRegistration clientRegistration = grantRequest.getClientRegistration(); + if (ClientAuthenticationMethod.CLIENT_SECRET_POST.equals(clientRegistration.getClientAuthenticationMethod())) { + String formData = OAuth2ClientCredentialsGrantRequestEntityUtils.encodeFormData(parameters); + return this.restClient.post() + .uri(clientRegistration.getProviderDetails().getTokenUri()) + .headers((headers) -> { + HttpHeaders headersToAdd = this.headersConverter.convert(grantRequest); + if (headersToAdd != null) { + headers.addAll(headersToAdd); + } + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + }) + .body(formData); + } + return this.restClient.post() .uri(grantRequest.getClientRegistration().getProviderDetails().getTokenUri()) .headers((headers) -> { diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/OAuth2ClientCredentialsGrantRequestEntityUtils.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/OAuth2ClientCredentialsGrantRequestEntityUtils.java new file mode 100644 index 0000000000..1a5200f344 --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/OAuth2ClientCredentialsGrantRequestEntityUtils.java @@ -0,0 +1,76 @@ +/* + * Copyright 2025 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.StringJoiner; + +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; + +/** + * Utility methods for the OAuth 2.0 Client Credentials Grant. + * + * @author Hyunjoon Kim + * @since 6.5 + */ +final class OAuth2ClientCredentialsGrantRequestEntityUtils { + + private OAuth2ClientCredentialsGrantRequestEntityUtils() { + } + + static String encodeFormData(MultiValueMap parameters) { + StringJoiner result = new StringJoiner("&"); + parameters.forEach((key, values) -> { + for (String value : values) { + result.add(encodeFormParameter(key, value)); + } + }); + return result.toString(); + } + + private static String encodeFormParameter(String name, String value) { + if (!StringUtils.hasText(value)) { + return urlEncode(name); + } + + // Special handling for client_secret to preserve Base64 padding + if (OAuth2ParameterNames.CLIENT_SECRET.equals(name) && value.endsWith("=")) { + // For client secrets ending with '=', don't encode the padding character + int lastEqualIndex = value.lastIndexOf('='); + String beforePadding = value.substring(0, lastEqualIndex); + String padding = value.substring(lastEqualIndex); + return urlEncode(name) + "=" + urlEncode(beforePadding) + padding; + } + + return urlEncode(name) + "=" + urlEncode(value); + } + + private static String urlEncode(String value) { + try { + return URLEncoder.encode(value, StandardCharsets.UTF_8.name()); + } + catch (UnsupportedEncodingException ex) { + // Should never happen with UTF-8 + throw new IllegalArgumentException(ex); + } + } + +} \ No newline at end of file