Skip to content

Commit f5c1a73

Browse files
author
Steve Riesenberg
committed
Add parameters converter support to AbstractWebClientReactiveOAuth2AccessTokenResponseClient
This adds support for configuring NimbusJwtClientAuthenticationParametersConverter to any AbstractWebClientReactiveOAuth2AccessTokenResponseClient as an additional parameters converter, which in turns adds reactive support for jwt client authentication. Closes gh-10146
1 parent 9b24f66 commit f5c1a73

File tree

6 files changed

+690
-5
lines changed

6 files changed

+690
-5
lines changed

oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/AbstractWebClientReactiveOAuth2AccessTokenResponseClient.java

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@
3535
import org.springframework.security.oauth2.core.web.reactive.function.OAuth2BodyExtractors;
3636
import org.springframework.util.Assert;
3737
import org.springframework.util.CollectionUtils;
38+
import org.springframework.util.LinkedMultiValueMap;
39+
import org.springframework.util.MultiValueMap;
3840
import org.springframework.util.StringUtils;
3941
import org.springframework.web.reactive.function.BodyExtractor;
4042
import org.springframework.web.reactive.function.BodyInserters;
@@ -70,6 +72,8 @@ public abstract class AbstractWebClientReactiveOAuth2AccessTokenResponseClient<T
7072

7173
private Converter<T, HttpHeaders> headersConverter = this::populateTokenRequestHeaders;
7274

75+
private Converter<T, MultiValueMap<String, String>> parametersConverter = this::populateTokenRequestParameters;
76+
7377
private BodyExtractor<Mono<OAuth2AccessTokenResponse>, ReactiveHttpInputMessage> bodyExtractor = OAuth2BodyExtractors
7478
.oauth2AccessTokenResponse();
7579

@@ -132,7 +136,19 @@ private static String encodeClientCredential(String clientCredential) {
132136
}
133137

134138
/**
135-
* Creates and returns the body for the token request.
139+
* Populates default parameters for the token request.
140+
* @param grantRequest the grant request
141+
* @return the parameters populated for the token request.
142+
*/
143+
private MultiValueMap<String, String> populateTokenRequestParameters(T grantRequest) {
144+
MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
145+
parameters.add(OAuth2ParameterNames.GRANT_TYPE, grantRequest.getGrantType().getValue());
146+
return parameters;
147+
}
148+
149+
/**
150+
* Combine the results of {@code parametersConverter} and
151+
* {@link #populateTokenRequestBody}.
136152
*
137153
* <p>
138154
* This method pre-populates the body with some standard properties, and then
@@ -144,9 +160,8 @@ private static String encodeClientCredential(String clientCredential) {
144160
* @return the body for the token request.
145161
*/
146162
private BodyInserters.FormInserter<String> createTokenRequestBody(T grantRequest) {
147-
BodyInserters.FormInserter<String> body = BodyInserters.fromFormData(OAuth2ParameterNames.GRANT_TYPE,
148-
grantRequest.getGrantType().getValue());
149-
return populateTokenRequestBody(grantRequest, body);
163+
MultiValueMap<String, String> parameters = getParametersConverter().convert(grantRequest);
164+
return populateTokenRequestBody(grantRequest, BodyInserters.fromFormData(parameters));
150165
}
151166

152167
/**
@@ -296,6 +311,56 @@ public final void addHeadersConverter(Converter<T, HttpHeaders> headersConverter
296311
};
297312
}
298313

314+
/**
315+
* Returns the {@link Converter} used for converting the
316+
* {@link AbstractOAuth2AuthorizationGrantRequest} instance to a {@link MultiValueMap}
317+
* used in the OAuth 2.0 Access Token Request body.
318+
* @return the {@link Converter} used for converting the
319+
* {@link AbstractOAuth2AuthorizationGrantRequest} to {@link MultiValueMap}
320+
*/
321+
final Converter<T, MultiValueMap<String, String>> getParametersConverter() {
322+
return this.parametersConverter;
323+
}
324+
325+
/**
326+
* Sets the {@link Converter} used for converting the
327+
* {@link AbstractOAuth2AuthorizationGrantRequest} instance to a {@link MultiValueMap}
328+
* used in the OAuth 2.0 Access Token Request body.
329+
* @param parametersConverter the {@link Converter} used for converting the
330+
* {@link AbstractOAuth2AuthorizationGrantRequest} to {@link MultiValueMap}
331+
* @since 5.6
332+
*/
333+
public final void setParametersConverter(Converter<T, MultiValueMap<String, String>> parametersConverter) {
334+
Assert.notNull(parametersConverter, "parametersConverter cannot be null");
335+
this.parametersConverter = parametersConverter;
336+
}
337+
338+
/**
339+
* Add (compose) the provided {@code parametersConverter} to the current
340+
* {@link Converter} used for converting the
341+
* {@link AbstractOAuth2AuthorizationGrantRequest} instance to a {@link MultiValueMap}
342+
* used in the OAuth 2.0 Access Token Request body.
343+
* @param parametersConverter the {@link Converter} to add (compose) to the current
344+
* {@link Converter} used for converting the
345+
* {@link AbstractOAuth2AuthorizationGrantRequest} to a {@link MultiValueMap}
346+
* @since 5.6
347+
*/
348+
public final void addParametersConverter(Converter<T, MultiValueMap<String, String>> parametersConverter) {
349+
Assert.notNull(parametersConverter, "parametersConverter cannot be null");
350+
Converter<T, MultiValueMap<String, String>> currentParametersConverter = this.parametersConverter;
351+
this.parametersConverter = (authorizationGrantRequest) -> {
352+
MultiValueMap<String, String> parameters = currentParametersConverter.convert(authorizationGrantRequest);
353+
if (parameters == null) {
354+
parameters = new LinkedMultiValueMap<>();
355+
}
356+
MultiValueMap<String, String> parametersToAdd = parametersConverter.convert(authorizationGrantRequest);
357+
if (parametersToAdd != null) {
358+
parameters.addAll(parametersToAdd);
359+
}
360+
return parameters;
361+
};
362+
}
363+
299364
/**
300365
* Sets the {@link BodyExtractor} that will be used to decode the
301366
* {@link OAuth2AccessTokenResponse}

oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/WebClientReactiveAuthorizationCodeTokenResponseClientTests.java

Lines changed: 144 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,16 @@
1616

1717
package org.springframework.security.oauth2.client.endpoint;
1818

19+
import java.nio.charset.StandardCharsets;
1920
import java.time.Instant;
2021
import java.util.Collections;
2122
import java.util.HashMap;
2223
import java.util.Map;
24+
import java.util.function.Function;
2325

26+
import javax.crypto.spec.SecretKeySpec;
27+
28+
import com.nimbusds.jose.jwk.JWK;
2429
import okhttp3.mockwebserver.MockResponse;
2530
import okhttp3.mockwebserver.MockWebServer;
2631
import okhttp3.mockwebserver.RecordedRequest;
@@ -36,6 +41,7 @@
3641
import org.springframework.http.ReactiveHttpInputMessage;
3742
import org.springframework.security.oauth2.client.registration.ClientRegistration;
3843
import org.springframework.security.oauth2.client.registration.TestClientRegistrations;
44+
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
3945
import org.springframework.security.oauth2.core.OAuth2AccessToken;
4046
import org.springframework.security.oauth2.core.OAuth2AuthorizationException;
4147
import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
@@ -44,6 +50,10 @@
4450
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponse;
4551
import org.springframework.security.oauth2.core.endpoint.PkceParameterNames;
4652
import org.springframework.security.oauth2.core.endpoint.TestOAuth2AccessTokenResponses;
53+
import org.springframework.security.oauth2.jose.TestJwks;
54+
import org.springframework.security.oauth2.jose.TestKeys;
55+
import org.springframework.util.LinkedMultiValueMap;
56+
import org.springframework.util.MultiValueMap;
4757
import org.springframework.web.reactive.function.BodyExtractor;
4858
import org.springframework.web.reactive.function.client.WebClient;
4959

@@ -112,6 +122,75 @@ public void getTokenResponseWhenSuccessResponseThenReturnAccessTokenResponse() t
112122
assertThat(accessTokenResponse.getAdditionalParameters()).containsEntry("custom_parameter_2", "custom-value-2");
113123
}
114124

125+
@Test
126+
public void getTokenResponseWhenAuthenticationClientSecretJwtThenFormParametersAreSent() throws Exception {
127+
// @formatter:off
128+
String accessTokenSuccessResponse = "{\n"
129+
+ " \"access_token\": \"access-token-1234\",\n"
130+
+ " \"token_type\": \"bearer\",\n"
131+
+ " \"expires_in\": \"3600\"\n"
132+
+ "}\n";
133+
// @formatter:on
134+
this.server.enqueue(jsonResponse(accessTokenSuccessResponse));
135+
136+
// @formatter:off
137+
ClientRegistration clientRegistration = this.clientRegistration
138+
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_JWT)
139+
.clientSecret(TestKeys.DEFAULT_ENCODED_SECRET_KEY)
140+
.build();
141+
// @formatter:on
142+
143+
// Configure Jwt client authentication converter
144+
SecretKeySpec secretKey = new SecretKeySpec(
145+
clientRegistration.getClientSecret().getBytes(StandardCharsets.UTF_8), "HmacSHA256");
146+
JWK jwk = TestJwks.jwk(secretKey).build();
147+
Function<ClientRegistration, JWK> jwkResolver = (registration) -> jwk;
148+
configureJwtClientAuthenticationConverter(jwkResolver);
149+
150+
this.tokenResponseClient.getTokenResponse(authorizationCodeGrantRequest(clientRegistration)).block();
151+
RecordedRequest actualRequest = this.server.takeRequest();
152+
assertThat(actualRequest.getHeader(HttpHeaders.AUTHORIZATION)).isNull();
153+
assertThat(actualRequest.getBody().readUtf8()).contains("grant_type=authorization_code",
154+
"client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer",
155+
"client_assertion=");
156+
}
157+
158+
@Test
159+
public void getTokenResponseWhenAuthenticationPrivateKeyJwtThenFormParametersAreSent() throws Exception {
160+
// @formatter:off
161+
String accessTokenSuccessResponse = "{\n"
162+
+ " \"access_token\": \"access-token-1234\",\n"
163+
+ " \"token_type\": \"bearer\",\n"
164+
+ " \"expires_in\": \"3600\"\n"
165+
+ "}\n";
166+
// @formatter:on
167+
this.server.enqueue(jsonResponse(accessTokenSuccessResponse));
168+
169+
// @formatter:off
170+
ClientRegistration clientRegistration = this.clientRegistration
171+
.clientAuthenticationMethod(ClientAuthenticationMethod.PRIVATE_KEY_JWT)
172+
.build();
173+
// @formatter:on
174+
175+
// Configure Jwt client authentication converter
176+
JWK jwk = TestJwks.DEFAULT_RSA_JWK;
177+
Function<ClientRegistration, JWK> jwkResolver = (registration) -> jwk;
178+
configureJwtClientAuthenticationConverter(jwkResolver);
179+
180+
this.tokenResponseClient.getTokenResponse(authorizationCodeGrantRequest(clientRegistration)).block();
181+
RecordedRequest actualRequest = this.server.takeRequest();
182+
assertThat(actualRequest.getHeader(HttpHeaders.AUTHORIZATION)).isNull();
183+
assertThat(actualRequest.getBody().readUtf8()).contains("grant_type=authorization_code",
184+
"client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer",
185+
"client_assertion=");
186+
}
187+
188+
private void configureJwtClientAuthenticationConverter(Function<ClientRegistration, JWK> jwkResolver) {
189+
NimbusJwtClientAuthenticationParametersConverter<OAuth2AuthorizationCodeGrantRequest> jwtClientAuthenticationConverter = new NimbusJwtClientAuthenticationParametersConverter<>(
190+
jwkResolver);
191+
this.tokenResponseClient.addParametersConverter(jwtClientAuthenticationConverter);
192+
}
193+
115194
// @Test
116195
// public void
117196
// getTokenResponseWhenRedirectUriMalformedThenThrowIllegalArgumentException() throws
@@ -261,7 +340,10 @@ public void getTokenResponseWhenSuccessResponseDoesNotIncludeScopeThenReturnAcce
261340
}
262341

263342
private OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest() {
264-
ClientRegistration registration = this.clientRegistration.build();
343+
return authorizationCodeGrantRequest(this.clientRegistration.build());
344+
}
345+
346+
private OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest(ClientRegistration registration) {
265347
OAuth2AuthorizationRequest authorizationRequest = OAuth2AuthorizationRequest.authorizationCode()
266348
.clientId(registration.getClientId()).state("state")
267349
.authorizationUri(registration.getProviderDetails().getAuthorizationUri())
@@ -414,6 +496,67 @@ public void convertWhenHeadersConverterSetThenCalled() throws Exception {
414496
.isEqualTo("Basic Y2xpZW50LWlkOmNsaWVudC1zZWNyZXQ=");
415497
}
416498

499+
@Test
500+
public void setParametersConverterWhenNullThenThrowIllegalArgumentException() {
501+
assertThatIllegalArgumentException().isThrownBy(() -> this.tokenResponseClient.setParametersConverter(null))
502+
.withMessage("parametersConverter cannot be null");
503+
}
504+
505+
@Test
506+
public void addParametersConverterWhenNullThenThrowIllegalArgumentException() {
507+
assertThatIllegalArgumentException().isThrownBy(() -> this.tokenResponseClient.addParametersConverter(null))
508+
.withMessage("parametersConverter cannot be null");
509+
}
510+
511+
@Test
512+
public void convertWhenParametersConverterAddedThenCalled() throws Exception {
513+
OAuth2AuthorizationCodeGrantRequest request = authorizationCodeGrantRequest();
514+
Converter<OAuth2AuthorizationCodeGrantRequest, MultiValueMap<String, String>> addedParametersConverter = mock(
515+
Converter.class);
516+
MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
517+
parameters.add("custom-parameter-name", "custom-parameter-value");
518+
given(addedParametersConverter.convert(request)).willReturn(parameters);
519+
this.tokenResponseClient.addParametersConverter(addedParametersConverter);
520+
// @formatter:off
521+
String accessTokenSuccessResponse = "{\n"
522+
+ " \"access_token\":\"MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3\",\n"
523+
+ " \"token_type\":\"bearer\",\n"
524+
+ " \"expires_in\":3600,\n"
525+
+ " \"refresh_token\":\"IwOGYzYTlmM2YxOTQ5MGE3YmNmMDFkNTVk\"\n"
526+
+ "}";
527+
// @formatter:on
528+
this.server.enqueue(jsonResponse(accessTokenSuccessResponse));
529+
this.tokenResponseClient.getTokenResponse(request).block();
530+
verify(addedParametersConverter).convert(request);
531+
RecordedRequest actualRequest = this.server.takeRequest();
532+
assertThat(actualRequest.getBody().readUtf8()).contains("grant_type=authorization_code",
533+
"custom-parameter-name=custom-parameter-value");
534+
}
535+
536+
@Test
537+
public void convertWhenParametersConverterSetThenCalled() throws Exception {
538+
OAuth2AuthorizationCodeGrantRequest request = authorizationCodeGrantRequest();
539+
Converter<OAuth2AuthorizationCodeGrantRequest, MultiValueMap<String, String>> parametersConverter = mock(
540+
Converter.class);
541+
MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
542+
parameters.add("custom-parameter-name", "custom-parameter-value");
543+
given(parametersConverter.convert(request)).willReturn(parameters);
544+
this.tokenResponseClient.setParametersConverter(parametersConverter);
545+
// @formatter:off
546+
String accessTokenSuccessResponse = "{\n"
547+
+ " \"access_token\":\"MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3\",\n"
548+
+ " \"token_type\":\"bearer\",\n"
549+
+ " \"expires_in\":3600,\n"
550+
+ " \"refresh_token\":\"IwOGYzYTlmM2YxOTQ5MGE3YmNmMDFkNTVk\"\n"
551+
+ "}";
552+
// @formatter:on
553+
this.server.enqueue(jsonResponse(accessTokenSuccessResponse));
554+
this.tokenResponseClient.getTokenResponse(request).block();
555+
verify(parametersConverter).convert(request);
556+
RecordedRequest actualRequest = this.server.takeRequest();
557+
assertThat(actualRequest.getBody().readUtf8()).contains("custom-parameter-name=custom-parameter-value");
558+
}
559+
417560
// gh-10260
418561
@Test
419562
public void getTokenResponseWhenSuccessCustomResponseThenReturnAccessTokenResponse() {

0 commit comments

Comments
 (0)