OAuth2 client with extra parameters in body (Audience)
Asked Answered
C

4

7

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
Contactor answered 24/5, 2022 at 11:59 Comment(1)
Here's another example of how this can be solved github.com/spring-projects/spring-security/issues/11783Warila
I
3

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.

Implied answered 31/5, 2022 at 10:55 Comment(1)
Even if @grekier pointed out an issue, this actually made the deal. Thank youContactor
A
4

As several classes got deprecated since the last answer, I am posting my solution in Spring 2.7.13 / 3.1.1

@Bean("apiGatewayWebClient")
public WebClient apiGatewayWebClient(ReactiveClientRegistrationRepository clientRegistrations) {
    InMemoryReactiveOAuth2AuthorizedClientService clientService =
            new InMemoryReactiveOAuth2AuthorizedClientService(clientRegistrations);
    AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager authorizedClientManager =
            new AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager(clientRegistrations, clientService);
    authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider());

    ServerOAuth2AuthorizedClientExchangeFilterFunction oauth =
            new ServerOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
    oauth.setDefaultClientRegistrationId("adfs");

    return WebClient.builder()
            .filter(oauth)
            .build();
}

private ReactiveOAuth2AuthorizedClientProvider authorizedClientProvider() {
    WebClientReactiveClientCredentialsTokenResponseClient responseClient =
            new WebClientReactiveClientCredentialsTokenResponseClient();

    // Spring does not provide a configuration only way to add 'resource' to the message body
    // See https://docs.spring.io/spring-security/reference/servlet/oauth2/client/authorization-grants.html#_customizing_the_access_token_request_2
    responseClient.addParametersConverter(
            source -> {
                LinkedMultiValueMap<String, String> map = new LinkedMultiValueMap<>();
                map.add("audience", "myAudience");
                return map;
            }
    );

    return ReactiveOAuth2AuthorizedClientProviderBuilder.builder()
            .clientCredentials(clientCredentialsGrantBuilder ->
                    clientCredentialsGrantBuilder.accessTokenResponseClient(responseClient))
            .build();
}

With application.yml

spring:
security:
    oauth2:
        client:
            registration:
                adfs:
                    client-id: 'client:id'
                    client-secret: '???????'
                    authorization-grant-type: 'client_credentials'
            provider:
                adfs:
                    token-uri: 'https://domain.eu.auth0.com/oauth/token'
Adkison answered 10/7, 2023 at 10:21 Comment(2)
where is the ReactiveClientRegistrationRepository coming from ?Unhorse
@VincentF it is from dependency spring-security-oauth2-clientAdkison
I
3

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.

Implied answered 31/5, 2022 at 10:55 Comment(1)
Even if @grekier pointed out an issue, this actually made the deal. Thank youContactor
L
2

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..

Limestone answered 25/5, 2022 at 8:36 Comment(0)
I
0

I maintain a Spring Boot starter which enables to define in application properties any additional parameter to authorization_code requests:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <!-- For a reactive application, use spring-boot-starter-webflux instead -->
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>

<dependency>
    <groupId>com.c4-soft.springaddons</groupId>
    <artifactId>spring-addons-starter-oidc</artifactId>
</dependency>
auth0-issuer: https://domain.eu.auth0.com/
auth0-client-id: change-me
auth0-client-secret: change-me
auth0-aud: http://localhost:8080

spring:
  security:
    oauth2:
      client:
        provider:
          auth0:
            issuer-uri: ${auth0-issuer}
        registration:
          auth0-authorization-code:
            authorization-grant-type: authorization_code
            client-id: ${auth0-client-id}
            client-secret: ${auth0-client-secret}
            provider: auth0
            scope: openid,profile,email,offline_access
com:
  c4-soft:
    springaddons:
      oidc:
        ops:
        - iss: ${auth0-issuer}
          # You can replace it to a JSON path to any claim, even a nested private claim like $['https://c4-soft.com']['name']
          username-claim: $.sub
          authorities:
          - path: roles
          - path: permissions
          - path: $['https://c4-soft.com']['authorities']
        client:
          security-matchers:
          - /**
          permit-all:
          - /login/**
          - /oauth2/**
          - /
          # By default, Auth0 does not follow strictly the OpenID RP-Initiated Logout spec and needs specific configuration
          oauth2-logout:
            auth0-authorization-code:
              uri: ${auth0-issuer}v2/logout
              client-id-request-param: client_id
              post-logout-uri-request-param: returnTo
          # Auth0 requires an "audience" parameter in authorization-code request to deliver JWTs
          authorization-request-params:
            auth0-authorization-code:
            - name: audience
              value: ${auth0-aud}

And that's it, no Java conf required.

The tutorials section of the repo linked above, include an Auth0 configuration README with instructions to create an "action" adding user details to access tokens.

Inaccuracy answered 10/7, 2023 at 21:40 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.