replacing an OAuth2 WebClient in a test
Asked Answered
L

1

3

I have a small Spring Boot 2.2 batch that writes to an OAuth2 REST API.

I have been able to configure the WebClient following https://medium.com/@asce4s/oauth2-with-spring-webclient-761d16f89cdd and it works as expected.

    @Configuration
    public class MyRemoteServiceClientOauth2Config {

        @Bean("myRemoteService")
        WebClient webClient(ReactiveClientRegistrationRepository clientRegistrations) {
            ServerOAuth2AuthorizedClientExchangeFilterFunction oauth =
                    new ServerOAuth2AuthorizedClientExchangeFilterFunction(
                            clientRegistrations,
                            new UnAuthenticatedServerOAuth2AuthorizedClientRepository());
            oauth.setDefaultClientRegistrationId("myRemoteService");

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

    }

However, now I would like to write an integration test for my batch, and I would like to avoid using the "real" authorization server to get a token : I don't want my test to fail if an external server is down. I want my test to be "autonomous".

The remote service I am calling is replaced by a mockserver fake one during my tests.

What is the best practice in that case ?

  • What works for me is to enable above config only outside of tests with @Profile("!test") and run my tests with @ActiveProfiles("test"). I also import a test specific config in my test :
    @Configuration
    @Profile("test")
    public class BatchTestConfiguration {

        @Bean("myRemoteService")
        public WebClient webClientForTest() {

            return WebClient.create();
        }

    }

But I feel having to add @Profile("!test") on my production config is not great..

  • is there an 'cleaner' way to replace the WebClient bean I am using, by one that will call my fake remote service without trying to get a token first ? I tried to put a @Primary on my webClientForTest bean, but it doesn't work : the production bean still gets enabled and I get an exception :

No qualifying bean of type 'org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository

which is the parameter type the production bean needs

  • do I need to start a fake authorization server as part of my test and configure the WebClient to get a dummy token from it ? Is there a library that provides this as out of the box as possible ?
Leanoraleant answered 14/1, 2020 at 15:1 Comment(3)
Were you able to find solution?Urita
Was this solved ?Napolitano
nope. I am still using "a fake authorization server as part of my test and configure the WebClient to get a dummy token from it "Leanoraleant
K
10

I was in the same situation as you and found a solution. First off, to see it in action, I have created a repository with a showcase implementation of everything that is explained below.

is there an 'cleaner' way to replace the WebClient bean I am using, by one that will call my fake remote service without trying to get a token first ?

I would not replace the WebClient bean in your test, but rather replace the ReactiveOAuth2AuthorizedClientManager bean with a mock. For this to work you have to slightly modify your MyRemoteServiceClientOauth2Config. Instead of using the now deprecated approach with an UnAuthenticatedServerOAuth2AuthorizedClientRepository you configure it this way (this is also more in line with the documented configuration on the Servlet-Stack):

@Configuration
public class MyRemoteServiceClientOauth2Config {

    @Bean
    public WebClient webClient(ReactiveOAuth2AuthorizedClientManager reactiveOAuth2AuthorizedClientManager) {
        ServerOAuth2AuthorizedClientExchangeFilterFunction oauth2ClientCredentialsFilter =
                new ServerOAuth2AuthorizedClientExchangeFilterFunction(reactiveOAuth2AuthorizedClientManager);
        oauth2ClientCredentialsFilter.setDefaultClientRegistrationId("myRemoteService");

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

    @Bean
    public ReactiveOAuth2AuthorizedClientManager reactiveOAuth2AuthorizedClientManager(ReactiveClientRegistrationRepository clientRegistrations,
                                                                                       ReactiveOAuth2AuthorizedClientService authorizedClients) {
        AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager authorizedClientManager =
                new AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager(clientRegistrations, authorizedClients);

        authorizedClientManager.setAuthorizedClientProvider(
                new ClientCredentialsReactiveOAuth2AuthorizedClientProvider());

        return authorizedClientManager;
    }
}

Then you can create a mock of ReactiveOAuth2AuthorizedClientManager that always returns a Mono of an OAuth2AuthorizedClient like this:

@TestComponent
@Primary
public class AlwaysAuthorizedOAuth2AuthorizedClientManager implements ReactiveOAuth2AuthorizedClientManager {

    @Value("${spring.security.oauth2.client.registration.myRemoteService.client-id}")
    String clientId;

    @Value("${spring.security.oauth2.client.registration.myRemoteService.client-secret}")
    String clientSecret;

    @Value("${spring.security.oauth2.client.provider.some-keycloak.token-uri}")
    String tokenUri;

    /**
     * {@inheritDoc}
     *
     * @return
     */
    @Override
    public Mono<OAuth2AuthorizedClient> authorize(final OAuth2AuthorizeRequest authorizeRequest) {
        return Mono.just(
                new OAuth2AuthorizedClient(
                        ClientRegistration
                                .withRegistrationId("myRemoteService")
                                .clientId(clientId)
                                .clientSecret(clientSecret)
                                .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
                                .tokenUri(tokenUri)
                                .build(),
                        "some-keycloak",
                        new OAuth2AccessToken(TokenType.BEARER,
                                "c29tZS10b2tlbg==",
                                Instant.now().minus(Duration.ofMinutes(1)),
                                Instant.now().plus(Duration.ofMinutes(4)))));
    }
}

And finally @Import that in your test:

@SpringBootTest
@Import(AlwaysAuthorizedOAuth2AuthorizedClientManager.class)
class YourIntegrationTestClass {

  // here is your test code

}

The corresponding src/test/resources/application.yml looks like this:

spring:
  security:
    oauth2:
      client:
        registration:
          myRemoteService:
            authorization-grant-type: client_credentials
            client-id: test-client
            client-secret: 6b30087f-65e2-4d89-a69e-08cb3c9f34d2 # bogus
            provider: some-keycloak
        provider:
          some-keycloak:
            token-uri: https://some.bogus/token/uri

Alternative

You could also just use the same mockserver you are already using to mock your REST-Resource, to also mock the Authorization server and respond to the token request. For this to work, you would configure the mockserver as the token-uri in the src/test/resources/application.yml or whatever you are using to provide properties to your test respectively.


Notes

Injecting the WebClient directly

The recommended way of providing a WebClient in your beans is by injecting WebClient.Builder, which gets preconfigured by Spring Boot. This also guarantees, that the WebClient in your test is configured exactly the same as in production. You can declare WebClientCustomizer beans to configure this builder further. This is the way it is implemented in my showcase repository mentioned above.

Bean overriding/prioritizing with @Primary on a @Bean inside a @Configuration or @TestConfiguration

I have tried that too and found that it does not always work the way one would expect, probably because of the order in which Spring loads and instantiates the bean definitions. For instance, the ReactiveOAuth2AuthorizedClientManager mock is only used if the @TestConfiguration is a static nested class inside the test class but not if it is @Imported. Having the static nested @TestConfiguration on an interface and implement that with the test class also does not work. So, to avoid putting that static nested class on every integration test I need it in, I rather opt for the @TestComponent approach presented here.

Other OAuth 2.0 Grant Types

I only tested my approach for the Client Credentials Grant Type, but I think it could also be adapted or expanded for other Grant Types as well.

Kesler answered 10/4, 2021 at 10:40 Comment(2)
Hey, Could you explain the alternative a bit? Thanks a lotSyllabify
Hi @Denson, for the alternative you would use the mockserver to respond to the token request with a bogus OAuth Token reply. For an example, see the implementation of TheRestClientImplIT in my answer here in section "Testing": https://mcmap.net/q/1453012/-why-spring-boot-webclient-oauth2-client_credentials-asks-for-a-new-token-for-each-requestKesler

© 2022 - 2024 — McMap. All rights reserved.