Describe the bug

When customizing a AbstractWebClientReactiveOAuth2AccessTokenResponseClient implementation with a converter via the new setParametersConverter added in 5.6.x the default converted populateTokenRequestParameters which added the grant_type is removed and the resulting token request body is missing the grant_type parameter

To Reproduce Steps to reproduce the behavior.

WebClientReactiveClientCredentialsTokenResponseClient client = new WebClientReactiveClientCredentialsTokenResponseClient();
        client.setParametersConverter(new NimbusJwtClientAuthenticationParametersConverter<>(jwkResolver));

Expected behavior

when I use setParametersConverter the default parameter of the AccessTokenResponseClient should be modified by the converter not replaced entirely.

Sample

I know this stuff is all pretty fresh, so if there is an easier way to do this let me know

        <!-- Spring Security -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-oauth2-client</artifactId>
        </dependency>
        <dependency>
            <groupId>com.nimbusds</groupId>
            <artifactId>oauth2-oidc-sdk</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-test</artifactId>
            <scope>test</scope>
        </dependency>
        <!-- manually update https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-oauth2-client/2.5.6 client dependencies to 5.6.0-->
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-config</artifactId>
            <version>5.6.0</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-core</artifactId>
            <version>5.6.0</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-oauth2-jose</artifactId>
            <version>5.6.0</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-oauth2-client</artifactId>
            <version>5.6.0</version>
        </dependency>

configure two clients with the same issuer

spring:
  application:
    name: placeholder
  security:
    oauth2:
      client:
        registration:
          ciam:
            client-id: client-id
            client-secret: client-secret
            authorization-grant-type: client_credentials
            scope: "scope"
          ciam-jwt:
            client-id: client-id
            client-authentication-method: private_key_jwt
            authorization-grant-type: client_credentials
            scope: "scope"
        provider:
          ciam:
            issuer-uri: # isser
          ciam-jwt:
            issuer-uri: # isser

Make your own ReactiveOAuth2AuthorizedClientManager and add a converter to WebClientReactiveClientCredentialsTokenResponseClient to support PRIVATE_KEY_JWT

@Bean
ReactiveOAuth2AuthorizedClientManager applicationReactiveOAuth2AuthorizedClientManager(
        ReactiveClientRegistrationRepository clientRegistrationRepository,
        ServerOAuth2AuthorizedClientRepository authorizedClientRepository
) throws UnrecoverableKeyException, NoSuchAlgorithmException, KeyStoreException {

    RSAPublicKey publicKey = // publicKey 
    PrivateKey privateKey = // privateKey 

    Function<ClientRegistration, JWK> jwkResolver = client -> {
        if (client.getClientAuthenticationMethod().equals(ClientAuthenticationMethod.PRIVATE_KEY_JWT)) {
            // Assuming RSA key type
            return new RSAKey.Builder(publicKey)
                    .privateKey(privateKey)
                    .keyID(UUID.randomUUID().toString())
                    .build();
        }
        return null;
    };

    WebClientReactiveClientCredentialsTokenResponseClient client = new WebClientReactiveClientCredentialsTokenResponseClient();
    client.setParametersConverter(new NimbusJwtClientAuthenticationParametersConverter<>(jwkResolver));

    DefaultReactiveOAuth2AuthorizedClientManager manager =
            new DefaultReactiveOAuth2AuthorizedClientManager(clientRegistrationRepository, authorizedClientRepository);

    ReactiveOAuth2AuthorizedClientProvider clientProvider = ReactiveOAuth2AuthorizedClientProviderBuilder.builder()
            .clientCredentials(b -> b.accessTokenResponseClient(client).build())
            .build();

    manager.setAuthorizedClientProvider(clientProvider);

    return manager;
}

use ReactiveOAuth2AuthorizedClientManager directly to get token

@Log4j2
@RestController
@RequestMapping("/test")
@AllArgsConstructor
@PreAuthorize("hasAuthority('SCOPE_openid')")
public class EchoController {

    private final ReactiveOAuth2AuthorizedClientManager reactiveOAuth2AuthorizedClientManager;

    @ResponseBody
    @GetMapping(path = "/client-credentials", produces = MediaType.APPLICATION_JSON_VALUE)
    public Mono<OAuth2AccessToken> clientCredentials(@RegisteredOAuth2AuthorizedClient("ciam") OAuth2AuthorizedClient authorizedClient){
        return Mono.justOrEmpty(authorizedClient).map(OAuth2AuthorizedClient::getAccessToken);
    }

    @ResponseBody
    @GetMapping(path = "/private-key-jwt-service", produces = MediaType.APPLICATION_JSON_VALUE)
    public Mono<OAuth2AccessToken> privateKeyJwtService(BearerTokenAuthentication authentication){
        return reactiveOAuth2AuthorizedClientManager.authorize(OAuth2AuthorizeRequest
                .withClientRegistrationId("ciam-jwt")
                .principal(authentication)
                .build()
        ).map(OAuth2AuthorizedClient::getAccessToken);
    }

}

Tangentially, I have no idea how to get the modified ReactiveOAuth2AuthorizedClientManager bean registered with the default Oauth2 client security chain so it work with @RegisteredOAuth2AuthorizedClient and the WebClient

Comment From: iamlothian

The issue seems to be related to these methods

       private Converter<T, MultiValueMap<String, String>> parametersConverter = this::populateTokenRequestParameters;

    /**
     * Combine the results of {@code parametersConverter} and
     * {@link #populateTokenRequestBody}.
     *
     * <p>
     * This method pre-populates the body with some standard properties, and then
     * delegates to
     * {@link #populateTokenRequestBody(AbstractOAuth2AuthorizationGrantRequest, BodyInserters.FormInserter)}
     * for subclasses to further populate the body before returning.
     * </p>
     * @param grantRequest the grant request
     * @return the body for the token request.
     */
    private BodyInserters.FormInserter<String> createTokenRequestBody(T grantRequest) {
        MultiValueMap<String, String> parameters = getParametersConverter().convert(grantRequest);
        return populateTokenRequestBody(grantRequest, BodyInserters.fromFormData(parameters));
    }

    /**
     * Returns the {@link Converter} used for converting the
     * {@link AbstractOAuth2AuthorizationGrantRequest} instance to a {@link MultiValueMap}
     * used in the OAuth 2.0 Access Token Request body.
     * @return the {@link Converter} used for converting the
     * {@link AbstractOAuth2AuthorizationGrantRequest} to {@link MultiValueMap}
     */
    final Converter<T, MultiValueMap<String, String>> getParametersConverter() {
        return this.parametersConverter;
    }

    /**
     * Sets the {@link Converter} used for converting the
     * {@link AbstractOAuth2AuthorizationGrantRequest} instance to a {@link MultiValueMap}
     * used in the OAuth 2.0 Access Token Request body.
     * @param parametersConverter the {@link Converter} used for converting the
     * {@link AbstractOAuth2AuthorizationGrantRequest} to {@link MultiValueMap}
     * @since 5.6
     */
    public final void setParametersConverter(Converter<T, MultiValueMap<String, String>> parametersConverter) {
        Assert.notNull(parametersConverter, "parametersConverter cannot be null");
        this.parametersConverter = parametersConverter;
    }

in createTokenRequestBody we should always call populateTokenRequestParameters first then merge the result with the converter, as the relationship between the token request client and converter seems to be additive rather replace the body parameters.

Comment From: iamlothian

the work around for my specific case is to wrap the nimbus converter and add the missing grant_type back

public class WrappedNimbusJwtClientAuthenticationParametersConverter<T extends AbstractOAuth2AuthorizationGrantRequest>
        implements Converter<T, MultiValueMap<String, String>> {

    final NimbusJwtClientAuthenticationParametersConverter<T> delegate;

    public WrappedNimbusJwtClientAuthenticationParametersConverter(Function<ClientRegistration, JWK > jwkResolver) {
        delegate = new NimbusJwtClientAuthenticationParametersConverter<>(jwkResolver);
    }

    @Override
    public MultiValueMap<String, String> convert(T grantRequest) {
        MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
        parameters.add(OAuth2ParameterNames.GRANT_TYPE, grantRequest.getGrantType().getValue());
        MultiValueMap<String, String> converted = delegate.convert(grantRequest);
        if (converted != null)
            parameters.addAll(converted);
        return parameters;
    }
}

Comment From: sjohnr

Hi @iamlothian, thanks for your interest in the project and trying out the 5.6 release!

This test demonstrates how to set up the WebClientReactiveClientCredentialsTokenResponseClient (or any AbstractWebClientReactiveOAuth2AccessTokenResponseClient) with the NimbusJwtClientAuthenticationParametersConverter. The short answer is you would want to use addParametersConverter instead of setParametersConverter.

See Customizing the Access Token Request in the reference docs for more information. I'm going to close this for now, as I believe this should solve your use case. Please let me know if I've misunderstood anything, and we can reopen and discuss further.

Update: Also, see Authenticate using private_key_jwt in the reference docs.

Comment From: iamlothian

Thanks @sjohnr, how did I miss that method.

https://docs.spring.io/spring-security/reference/reactive/oauth2/client/index.html

The docs should how to modify a new WebClientReactiveClientCredentialsTokenResponseClient and add it to a manager bean, but I'm interested in how to get a modified client used as part of the default authorization chain eg added to a ReactiveOAuth2AuthorizedClientProvider provider and used in by a ReactiveOAuth2AuthorizedClientManager, as all instances of the Manager seem to be new instances created in the constructors of ServerOAuthAuthorizedClientExchangeFilterFunction or OAuth2AuthorizedClientArgumentResolver, so they don't pick up the bean. So when trying to use @RegisteredOAuth2AuthorizedClient or the WebClient with a ServerOAuthAuthorizedClientExchangeFilterFunction it won't use my private_key_jwt enabled client.

I have my own client filter that uses my manager bean and caches the token in the filter for a service client currently. Even when there is an authorized user context we want to use the service authorized session for downstream WebClient calls rather than propagating the user's token.

Any thoughts on this?

Comment From: iamlothian

Found the WebClient filter answer here.

And this make the @RegisteredOAuth2AuthorizedClient use the bean manager.

@Configuration
public class WebFluxConfig implements WebFluxConfigurer {

    @Autowired
    ReactiveOAuth2AuthorizedClientManager reactiveOAuth2AuthorizedClientManager;

    @Override
    public void configureArgumentResolvers(ArgumentResolverConfigurer configurer) {
        configurer.addCustomResolver(new OAuth2AuthorizedClientArgumentResolver(reactiveOAuth2AuthorizedClientManager));
    }
}

All though order of config matters as the default OAuth2AuthorizedClientArgumentResolver is still added by OAuth2ClientWebFluxSecurityConfiguration