OAuth2 client with extra parameters in body (Audience)

Viewed 840

Seems that with Auth0, when in a M2M flow, we need to pass the audience parameter in the authorization request, and the the token will be issued for such audience

curl --request POST \
  --url https://domain.eu.auth0.com/oauth/token \
  --header 'content-type: application/json' \
  --data '{"client_id":"xxxxx","client_secret":"xxxxx","audience":"my-api-audience","grant_type":"client_credentials"}'

otherwise, an error is thrown

403 Forbidden: "{"error":"access_denied","error_description":"No audience parameter was provided, and no default audience has been configured"}"

I try to implement a Client Credentials flow with Spring Boot using the new Spring Security 5 approach with webflux using WebClient.

https://github.com/spring-projects/spring-security-samples/tree/main/servlet/spring-boot/java/oauth2/webclient

Spring doesn't provide a way to add custom parameters to the Auth requests so as by this post

https://github.com/spring-projects/spring-security/issues/6569

I have to implement a custom converter.

Everything seems to be injected fine on startup but the converted is never invoked when accessing the client's endpoint localhost/api/explicit so I keep stuck with the audience problem.

WebClientConfig.java

@Configuration
public class WebClientConfig {
    @Value("${resource-uri}")
    String resourceUri;

    @Value("${wallet-audience}")
    String audience;

       @Bean
        WebClient webClient(OAuth2AuthorizedClientManager authorizedClientManager) {

            var oauth2 = new ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);

            return WebClient.builder()
                    .filter(oauth2)
                    // TRIED BOTH
                    //.apply(oauth2.oauth2Configuration())
                    .build();
        }

        @Bean
        OAuth2AuthorizedClientManager authorizedClientManager(ClientRegistrationRepository clientRegistrationRepository, OAuth2AuthorizedClientRepository authorizedClientRepository) {

            Converter<OAuth2ClientCredentialsGrantRequest, RequestEntity<?>> customRequestEntityConverter = new Auth0ClientCredentialsGrantRequestEntityConverter(audience);

            // @formatter:off
            var authorizedClientProvider =
                    OAuth2AuthorizedClientProviderBuilder.builder()
                                                         .refreshToken()
                                                         .clientCredentials(clientCredentialsGrantBuilder -> {
                                                             var clientCredentialsTokenResponseClient = new DefaultClientCredentialsTokenResponseClient();
                                                             clientCredentialsTokenResponseClient.setRequestEntityConverter(customRequestEntityConverter);
                                                         })
                                                         .build();
            // @formatter:on

            var authorizedClientManager = new DefaultOAuth2AuthorizedClientManager(clientRegistrationRepository, authorizedClientRepository);
            authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);

            return authorizedClientManager;
        }

Auth0ClientCredentialsGrantRequestEntityConverter.java

thanks to https://www.aheritier.net/spring-boot-app-client-of-an-auth0-protected-service-jwt/

import org.springframework.core.convert.converter.Converter;
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.endpoint.OAuth2ClientCredentialsGrantRequest;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.util.CollectionUtils;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;
import org.springframework.web.util.UriComponentsBuilder;

import java.util.Collections;

public final class Auth0ClientCredentialsGrantRequestEntityConverter implements Converter<OAuth2ClientCredentialsGrantRequest, RequestEntity<?>> {

    private static final HttpHeaders DEFAULT_TOKEN_REQUEST_HEADERS = getDefaultTokenRequestHeaders();

    private final String audience;

    /**
     * @param audience The audience to pass to Auth0
     */
    public Auth0ClientCredentialsGrantRequestEntityConverter(String audience) {
        this.audience = audience;
    }

    /**
     * Returns the {@link RequestEntity} used for the Access Token Request.
     *
     * @param clientCredentialsGrantRequest the client credentials grant request
     * @return the {@link RequestEntity} used for the Access Token Request
     */
    @Override
    public RequestEntity<?> convert(OAuth2ClientCredentialsGrantRequest clientCredentialsGrantRequest) {
        var clientRegistration = clientCredentialsGrantRequest.getClientRegistration();
        var headers = getTokenRequestHeaders(clientRegistration);
        var formParameters = this.buildFormParameters(clientCredentialsGrantRequest);
        var uri = UriComponentsBuilder.fromUriString(clientRegistration.getProviderDetails().getTokenUri())
                                      .build()
                                      .toUri();
        return new RequestEntity<>(formParameters, headers, HttpMethod.POST, uri);
    }

    /**
     * Returns a {@link MultiValueMap} of the form parameters used for the Access Token
     * Request body.
     *
     * @param clientCredentialsGrantRequest the client credentials grant request
     * @return a {@link MultiValueMap} of the form parameters used for the Access Token
     * Request body
     */
    private MultiValueMap<String, String> buildFormParameters(OAuth2ClientCredentialsGrantRequest clientCredentialsGrantRequest) {
        var clientRegistration = clientCredentialsGrantRequest.getClientRegistration();
        MultiValueMap<String, String> formParameters = new LinkedMultiValueMap<>();
        formParameters.add(OAuth2ParameterNames.GRANT_TYPE, clientCredentialsGrantRequest.getGrantType().getValue());

        if (!CollectionUtils.isEmpty(clientRegistration.getScopes())) {
            formParameters.add(OAuth2ParameterNames.SCOPE,
                    StringUtils.collectionToDelimitedString(clientRegistration.getScopes(), " "));
        }

        if (ClientAuthenticationMethod.POST.equals(clientRegistration.getClientAuthenticationMethod())) {
            formParameters.add(OAuth2ParameterNames.CLIENT_ID, clientRegistration.getClientId());
            formParameters.add(OAuth2ParameterNames.CLIENT_SECRET, clientRegistration.getClientSecret());
        }

        formParameters.add("audience", this.audience);
        return formParameters;
    }

    private static HttpHeaders getTokenRequestHeaders(ClientRegistration clientRegistration) {
        var headers = new HttpHeaders();
        headers.addAll(DEFAULT_TOKEN_REQUEST_HEADERS);
        if (ClientAuthenticationMethod.BASIC.equals(clientRegistration.getClientAuthenticationMethod())) {
            headers.setBasicAuth(clientRegistration.getClientId(), clientRegistration.getClientSecret());
        }
        return headers;
    }

    private static HttpHeaders getDefaultTokenRequestHeaders() {
        var headers = new HttpHeaders();
        final var contentType = MediaType.valueOf(MediaType.APPLICATION_FORM_URLENCODED_VALUE + ";charset=UTF-8");

        headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
        headers.setContentType(contentType);

        return headers;
    }

}

Controller.java

@RestController public class PrivateController {

private final WebClient webClient;

public PrivateController(WebClient webClient) {
    this.webClient = webClient;
}

@GetMapping("/explicit")
String explicit(Model model, @RegisteredOAuth2AuthorizedClient("wallet") OAuth2AuthorizedClient authorizedClient) {

    String body = this.webClient
            .get()
            .attributes(oauth2AuthorizedClient(authorizedClient))
            .retrieve()
            .bodyToMono(String.class)
            .block();

    model.addAttribute("body", body);
    return "response";
}

}

application.properties

spring.security.oauth2.client.registration.wallet.client-id                = 
spring.security.oauth2.client.registration.wallet.client-secret            =
spring.security.oauth2.client.registration.wallet.scope[]                  = read:transaction,write:transaction
spring.security.oauth2.client.registration.wallet.authorization-grant-type = client_credentials

spring.security.oauth2.client.provider.wallet.issuer-uri                   = https://domain.eu.auth0.com/

resource-uri                                                               = http://localhost:8081/api/wallet
wallet-audience                                                            = https://wallet
2 Answers

The argument of clientCredentials() is a builder Consumer. This means that the function you provide takes a builder as an argument that you then need to use for futher configuration, i.e. configure it to use your newly created client. You are doing nothing with the builder in your code, hence whatever you create in the function is just a local variable that is never used.

var authorizedClientProvider =
       OAuth2AuthorizedClientProviderBuilder.builder()
                 .refreshToken()
                 .clientCredentials(clientCredentialsGrantBuilder -> {
                             var clientCredentialsTokenResponseClient = new DefaultClientCredentialsTokenResponseClient();
                             clientCredentialsTokenResponseClient.setRequestEntityConverter(customRequestEntityConverter); 
                             clientCredentialsGrantBuilder.accessTokenResponseClient(clientCredentialsTokenResponseClient);
                  })
                 .build();

Note the clientCredentialsGrantBuilder.accessTokenResponseClient() line.

I believe you need to use ServerOAuth2AuthorizedClientExchangeFilterFunction instead of ServletOAuth2AuthorizedClientExchangeFilterFunction for the WebClient configuration.

Servletxxxxx only works in blocking environment if I remember correctly but most have a Serverxxxxx alternative for non-blocking..

Related