Dealing with Spring's reactive WebClient

 Introduction

 

Being a Java developer usually means that we will interact with Spring Framework in some way. Today I'd like to focus on my personal struggles with making the brand new, reactive WebClient utility work the way I need and want to.

 

New doesn't always mean simple

But wait... shouldn't the brand new API be dead simple to use? WebClient has a builder, so shouldn't it be as simple as this?:

WebClient.builder().build(); 

Well, yes. As long as you are using it to call API/Services that are available on the Internet. This use case will work just fine:

    @Test
    public void webClientTest() {
        WebClient webClient = WebClient.builder().build();
        String content = webClient.get()
                .uri("https://www.google.com/")
                .retrieve()
                .bodyToMono(String.class)
                .block();
        System.out.println(content);
    }

 

Unfortunately if you try to use any URL that is not on the Internet (for example an URL of a Service in your or your company's internal network - in this particular example it would be an address of my Media Server), then you will be faced with an error:

org.springframework.web.reactive.function.client.WebClientRequestException: failed to resolve 'osmc' after 2 queries ; nested exception is java.net.UnknownHostException: failed to resolve 'osmc' after 2 queries 

	at org.springframework.web.reactive.function.client.ExchangeFunctions$DefaultExchangeFunction.lambda$wrapException$9(ExchangeFunctions.java:141)
	Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException: 
Error has been observed at the following site(s):
	|_ checkpoint ⇢ Request to GET http://osmc/ [DefaultWebClient]

 

Why is that? Well, for some reason the default setup of WebClient ignores your host machine's DNS settings. I had a very hard time finding the solution to this, so I'm happy to share it with you. 

In order to make this work, we need to create a custom Netty-based HttpClient and provide it with a custom DNS Resolver:

    @Test
    public void webClientCustomResolverTest() {
        WebClient webClient = WebClient.builder()
                .clientConnector(new ReactorClientHttpConnector(
                                HttpClient.create()
                                        .resolver(DefaultAddressResolverGroup.INSTANCE)
                        )
                ).build();
        String content = webClient.get()
                .uri("http://osmc/")
                .retrieve()
                .bodyToMono(String.class)
                .block();
        System.out.println(content);
    }

 

After applying this change everything works!


Insecurities

How about testing an internally (or externally for that matter) hosted API that uses self-signed SSL Certificates on the test regions? Attempting to connect with any such Service will result in an SSL validation error:

org.springframework.web.reactive.function.client.WebClientRequestException: PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target; nested exception is javax.net.ssl.SSLHandshakeException: PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target

	at org.springframework.web.reactive.function.client.ExchangeFunctions$DefaultExchangeFunction.lambda$wrapException$9(ExchangeFunctions.java:141)
	Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException: 
Error has been observed at the following site(s):
	|_ checkpoint ⇢ Request to GET https://self-signed.badssl.com/ [DefaultWebClient]


Ok, so how to force WebClient to accept self signed Certificates? It's actually pretty simple:

    @Test
    public void webClientCustomResolverSelfSignedTest() throws SSLException {
        SslContext sslContext = SslContextBuilder
                .forClient()
                .trustManager(InsecureTrustManagerFactory.INSTANCE)
                .build();
        WebClient webClient = WebClient.builder()
                .clientConnector(new ReactorClientHttpConnector(
                                HttpClient.create()
                                        .secure(t -> t.sslContext(sslContext))
                                        .resolver(DefaultAddressResolverGroup.INSTANCE)
                        )
                ).build();
        String content = webClient.get()
                .uri("https://self-signed.badssl.com/")
                .retrieve()
                .bodyToMono(String.class)
                .block();
        System.out.println(content);
    }


Now everything works. Please remember to use this feature cautiously and responsibly! SSL Certificates validation is there for a reason and should not be disabled lightly.


OAuth Nightmare

Things start to get dirty and ugly when we need to authenticate ourselves against an OAuth Server. To make things more readable, I will use the JDK11+ 'var' keyword from now on.

If we are using an internal (non-Internet) OAuth Server, we will need to provide our Custom DNS Resolver into the AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager (woah, what a mouthful). How shall we do that? Well, it's not going to be pretty...:

private  WebClient createOAuth2WebClient(String accessTokenUri, String clientId, String clientSecret) {
        var registration = ClientRegistration
                .withRegistrationId("RegistrationId")
                .tokenUri(accessTokenUri)
                .clientId(clientId)
                .clientSecret(clientSecret)
                .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
                .build();
        var repo = new InMemoryReactiveClientRegistrationRepository(registration);
        var authorizedClientService = new InMemoryReactiveOAuth2AuthorizedClientService(repo);
        var authorizedClientManager = new AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager(repo, authorizedClientService);
        authorizedClientManager.setAuthorizedClientProvider(createAuthorizedClientProvider());
        var oauth = new ServerOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
        oauth.setDefaultOAuth2AuthorizedClient(true);
        oauth.setDefaultClientRegistrationId("RegistrationId");
        return WebClient.builder()
                .clientConnector(new ReactorClientHttpConnector(createClient()))
                .filter(oauth)
                .build();
    }

    private ReactiveOAuth2AuthorizedClientProvider createAuthorizedClientProvider() {
        var clientCredentialsTokenResponseClient = new WebClientReactiveClientCredentialsTokenResponseClient();
        clientCredentialsTokenResponseClient.setWebClient(createOAuthAuthorizationWebClient());
        return ReactiveOAuth2AuthorizedClientProviderBuilder.builder()
                .clientCredentials(builder -> builder.accessTokenResponseClient(clientCredentialsTokenResponseClient))
                .build();
    }

    private WebClient createOAuthAuthorizationWebClient() {
        return WebClient.builder()
                .clientConnector(new ReactorClientHttpConnector(createClient()))
                .build();
    }


    private HttpClient createClient() {
        return HttpClient.create().resolver(DefaultAddressResolverGroup.INSTANCE);
    }

 

Ugh. Well, at least it works. But wait... Are you happen to use ApiGee as your OAuth provider? Well then you're on for an additional surprise! According to the official documentation:

Attempting to connect to ApiGee Service will result with an ugly Exception coming deep from the insides of the Nimbus (default OAuth client used by WebClient/Spring Security) implementation:

if (! tokenType.equals(AccessTokenType.BEARER))
			throw new ParseException("Token type must be Bearer");

Can this be easily fixed on the client side? Well, I wouldn't call it an easy solution. It will require some changes in the createOAuthAuthorizationWebClient()method. Why there? Because there is no way to re-configure the com.nimbusds.oauth2.sdk.token.AccessTokenType.BEARER definition that is used to validate the OAuth Server response. The only solution I was able to come up with is to intercept the OAuth2 Server response and correct it so that it adheres to the RFC OAuth behavior:

private WebClient createOAuthAuthorizationWebClient() {
        DataBufferFactory dataBufferFactory = new DefaultDataBufferFactory();
        return WebClient.builder()
                .clientConnector(new ReactorClientHttpConnector(createClient()))
                .filter(ExchangeFilterFunction.ofResponseProcessor(clientResponse ->
                        Mono.just(
                                clientResponse.mutate()
                                        .body(dataBufferFlux ->
                                                Flux.from(
                                                        clientResponse
                                                                .body(BodyExtractors.toMono(String.class))
                                                                .map(data -> dataBufferFactory
                                                                        .wrap(
                                                                                data
                                                                                        .replace("BearerToken", "Bearer")
                                                                                        .getBytes()
                                                                        )
                                                                )
                                                )
                                        )
                                        .build()
                        )
                ))
                .build();
    }


Is it ugly? It most certainly is. But it gets the job done. If anyone reading this post knows a better solution, I'm dying to learn it.

OK, to sum up...

In this (very first technical) post, I've shared with you some experiences about using WebClient:

  • to connect to Resources residing on an internal network
  • to connect to Resources that use self-signed SSL Certificates
  • to authenticate against an OAuth Server that is available on an internal network
  • to authenticate against an ApiGee Non-RFC-compliant OAuth Servers

 Hopefully it will make your life a little easier now :)

 

Comments

  1. I am currently facing an error while trying to use SSL and OAuth via WebClient.
    Pretty much the issue mentioned here:
    https://stackoverflow.com/questions/68431602/java-spring-security-webclient-threw-sslhandshakeexception-when-used-with-server
    Do you have a solution or a way to debug this?

    ReplyDelete

Post a Comment