How to configure Spring WebClient to reuse access tokens

ghz 9months ago ⋅ 84 views

I'm trying to use the spring WebClient to access some OAuth2 secured REST services. It works but it seems that it does not reuse the access token between requests. I started with password grant and refresh token and the refresh token was used to get a new access token with every request. Then I tried to use client credentials, since it seemed that not everything is supported with password grant type - but the behavior was the same, it requested a new access token on every request also the token is valid for 5 minutes. To make sure it is not some strange configuration in my application or an old bug, I created a new empty spring boot app with the current version 3.2.4 and the minimal dependencies:

  • org.springframework.boot:spring-boot-starter-oauth2-client
  • org.springframework.boot:spring-boot-starter-web
  • org.springframework:spring-webflux
  • org.springframework.boot:spring-boot-starter-test

I create the WebClient like that:

  @Bean
  WebClient webClient(
          WebClient.Builder builder,
          ClientRegistrationRepository clientRegistrationRepository,
          OAuth2AuthorizedClientRepository authorizedClientRepository ) {
    ServletOAuth2AuthorizedClientExchangeFilterFunction oauth =
            new ServletOAuth2AuthorizedClientExchangeFilterFunction( clientRegistrationRepository, authorizedClientRepository );
    oauth.setDefaultClientRegistrationId( "khtest" );
    return builder.filter( oauth ).build();
  }

Then I use the in injected WebClient like this:

    String result = webClient.get()
            .uri( "http://172.28.80.1:8080/auth/admin/realms/AI/groups" )
            .retrieve()
            .bodyToMono( String.class )
            .block();

If I call this two times after another, I get the following logs:

start first REST call
2024-03-28T10:32:18.716+01:00 DEBUG 50808 --- [demo] [    Test worker] o.s.http.codec.FormHttpMessageWriter     : [97b84a4] Writing {grant_type=[client_credentials]}
2024-03-28T10:32:18.734+01:00 DEBUG 50808 --- [demo] [    Test worker] o.s.w.r.f.client.ExchangeFunctions       : [97b84a4] HTTP POST http://172.28.80.1:8080/auth/realms/AI/protocol/openid-connect/token
2024-03-28T10:32:18.802+01:00 DEBUG 50808 --- [demo] [onPool-worker-1] o.s.w.r.f.client.ExchangeFunctions       : [97b84a4] [6f5d4358] Response 200 OK
2024-03-28T10:32:18.840+01:00 DEBUG 50808 --- [demo] [onPool-worker-1] o.s.http.codec.json.Jackson2JsonDecoder  : [97b84a4] [6f5d4358] Decoded [{access_token=eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI2RGdhSXNRR0REVm44Tk9USlk2cElSRnNHMH (truncated)...]
2024-03-28T10:32:18.848+01:00 DEBUG 50808 --- [demo] [onPool-worker-1] o.s.w.r.f.client.ExchangeFunctions       : [97b84a4] Cancel signal (to close connection)
2024-03-28T10:32:18.852+01:00 DEBUG 50808 --- [demo] [onPool-worker-1] o.s.http.codec.FormHttpMessageWriter     : [1cc1a157] Writing {grant_type=[client_credentials]}
2024-03-28T10:32:18.853+01:00 DEBUG 50808 --- [demo] [onPool-worker-1] o.s.w.r.f.client.ExchangeFunctions       : [1cc1a157] HTTP POST http://172.28.80.1:8080/auth/realms/AI/protocol/openid-connect/token
2024-03-28T10:32:18.889+01:00 DEBUG 50808 --- [demo] [onPool-worker-1] o.s.w.r.f.client.ExchangeFunctions       : [1cc1a157] [1daa2410] Response 200 OK
2024-03-28T10:32:18.891+01:00 DEBUG 50808 --- [demo] [onPool-worker-1] o.s.http.codec.json.Jackson2JsonDecoder  : [1cc1a157] [1daa2410] Decoded [{access_token=eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI2RGdhSXNRR0REVm44Tk9USlk2cElSRnNHMH (truncated)...]
2024-03-28T10:32:18.891+01:00 DEBUG 50808 --- [demo] [onPool-worker-1] o.s.w.r.f.client.ExchangeFunctions       : [1cc1a157] Cancel signal (to close connection)
2024-03-28T10:32:18.892+01:00 DEBUG 50808 --- [demo] [onPool-worker-1] o.s.w.r.f.client.ExchangeFunctions       : [74a74070] HTTP GET http://172.28.80.1:8080/auth/admin/realms/AI/groups
2024-03-28T10:32:18.899+01:00 DEBUG 50808 --- [demo] [onPool-worker-1] o.s.w.r.f.client.ExchangeFunctions       : [74a74070] [7085d850] Response 200 OK
2024-03-28T10:32:18.901+01:00 DEBUG 50808 --- [demo] [onPool-worker-1] org.springframework.web.HttpLogging      : [74a74070] [7085d850] Decoded "[{"id":"c129933b-0270-43ac-85bf-050f7d7ca35a","name":"ai-keycloak-admin","path":"/ai-keycloak-admin" (truncated)..."
[{"id":"c129933b-0270-43ac-85bf-050f7d7ca35a","name":"ai-keycloak-admin","path":"/ai-keycloak-admin","subGroupCount":0,"subGroups":[],"access":{"view":true,"viewMembers":true,"manageMembers":true,"manage":true,"manageMembership":true}},{"id":"c5d34304-95a0-4a33-808b-9224c207eeed","name":"business","path":"/business","subGroupCount":0,"subGroups":[],"access":{"view":true,"viewMembers":true,"manageMembers":true,"manage":true,"manageMembership":true}},{"id":"c70e3556-e620-4a9e-86d3-220540930669","name":"business-readonly","path":"/business-readonly","subGroupCount":0,"subGroups":[],"access":{"view":true,"viewMembers":true,"manageMembers":true,"manage":true,"manageMembership":true}},{"id":"6af3b23a-706c-4d42-9c80-37648e186c5e","name":"management","path":"/management","subGroupCount":0,"subGroups":[],"access":{"view":true,"viewMembers":true,"manageMembers":true,"manage":true,"manageMembership":true}}]
finished first REST call
start second REST call immediately, access token should still be valid
2024-03-28T10:32:18.906+01:00 DEBUG 50808 --- [demo] [    Test worker] o.s.http.codec.FormHttpMessageWriter     : [12bcf7c6] Writing {grant_type=[client_credentials]}
2024-03-28T10:32:18.906+01:00 DEBUG 50808 --- [demo] [    Test worker] o.s.w.r.f.client.ExchangeFunctions       : [12bcf7c6] HTTP POST http://172.28.80.1:8080/auth/realms/AI/protocol/openid-connect/token
2024-03-28T10:32:18.918+01:00 DEBUG 50808 --- [demo] [onPool-worker-1] o.s.w.r.f.client.ExchangeFunctions       : [12bcf7c6] [2a2aae5b] Response 200 OK
2024-03-28T10:32:18.919+01:00 DEBUG 50808 --- [demo] [onPool-worker-1] o.s.http.codec.json.Jackson2JsonDecoder  : [12bcf7c6] [2a2aae5b] Decoded [{access_token=eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI2RGdhSXNRR0REVm44Tk9USlk2cElSRnNHMH (truncated)...]
2024-03-28T10:32:18.919+01:00 DEBUG 50808 --- [demo] [onPool-worker-1] o.s.w.r.f.client.ExchangeFunctions       : [12bcf7c6] Cancel signal (to close connection)
2024-03-28T10:32:18.922+01:00 DEBUG 50808 --- [demo] [onPool-worker-1] o.s.http.codec.FormHttpMessageWriter     : [6b46d949] Writing {grant_type=[client_credentials]}
2024-03-28T10:32:18.923+01:00 DEBUG 50808 --- [demo] [onPool-worker-1] o.s.w.r.f.client.ExchangeFunctions       : [6b46d949] HTTP POST http://172.28.80.1:8080/auth/realms/AI/protocol/openid-connect/token
2024-03-28T10:32:18.939+01:00 DEBUG 50808 --- [demo] [onPool-worker-1] o.s.w.r.f.client.ExchangeFunctions       : [6b46d949] [451c14d8] Response 200 OK
2024-03-28T10:32:18.940+01:00 DEBUG 50808 --- [demo] [onPool-worker-1] o.s.http.codec.json.Jackson2JsonDecoder  : [6b46d949] [451c14d8] Decoded [{access_token=eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI2RGdhSXNRR0REVm44Tk9USlk2cElSRnNHMH (truncated)...]
2024-03-28T10:32:18.941+01:00 DEBUG 50808 --- [demo] [onPool-worker-1] o.s.w.r.f.client.ExchangeFunctions       : [6b46d949] Cancel signal (to close connection)
2024-03-28T10:32:18.941+01:00 DEBUG 50808 --- [demo] [onPool-worker-1] o.s.w.r.f.client.ExchangeFunctions       : [3a116ca6] HTTP GET http://172.28.80.1:8080/auth/admin/realms/AI/groups
2024-03-28T10:32:18.949+01:00 DEBUG 50808 --- [demo] [onPool-worker-1] o.s.w.r.f.client.ExchangeFunctions       : [3a116ca6] [2131e65a] Response 200 OK
2024-03-28T10:32:18.950+01:00 DEBUG 50808 --- [demo] [onPool-worker-1] org.springframework.web.HttpLogging      : [3a116ca6] [2131e65a] Decoded "[{"id":"c129933b-0270-43ac-85bf-050f7d7ca35a","name":"ai-keycloak-admin","path":"/ai-keycloak-admin" (truncated)..."
[{"id":"c129933b-0270-43ac-85bf-050f7d7ca35a","name":"ai-keycloak-admin","path":"/ai-keycloak-admin","subGroupCount":0,"subGroups":[],"access":{"view":true,"viewMembers":true,"manageMembers":true,"manage":true,"manageMembership":true}},{"id":"c5d34304-95a0-4a33-808b-9224c207eeed","name":"business","path":"/business","subGroupCount":0,"subGroups":[],"access":{"view":true,"viewMembers":true,"manageMembers":true,"manage":true,"manageMembership":true}},{"id":"c70e3556-e620-4a9e-86d3-220540930669","name":"business-readonly","path":"/business-readonly","subGroupCount":0,"subGroups":[],"access":{"view":true,"viewMembers":true,"manageMembers":true,"manage":true,"manageMembership":true}},{"id":"6af3b23a-706c-4d42-9c80-37648e186c5e","name":"management","path":"/management","subGroupCount":0,"subGroups":[],"access":{"view":true,"viewMembers":true,"manageMembers":true,"manage":true,"manageMembership":true}}]
finished second REST call

It looks like the application somehow makes not only one but two token requests for each REST call.

Looking at ServletOAuth2AuthorizedClientExchangeFilterFunction.filter, I don't really get how it should work. It checks for an entry for OAUTH2_AUTHORIZED_CLIENT_ATTR_NAME in the requests attribute map but it seems that it is never filled, so it always calls authorizeClient but even if it was filled it would call reauthorizeClient, what looks like it will also result in a token request. I would have expected some check if the access token in the OAuth2AuthorizedClient was still valid, but I haven't found something like that. I also looked at the reactive variant (ServerOAuth2AuthorizedClientExchangeFilterFunction), but I got the same behavior there - and I'm not totally sure using the reactive version in a servlet application is a good idea.

I feel like I'm missing something here, any help is appreciated, thank you.

Answers

The behavior you are observing is due to the default behavior of ServletOAuth2AuthorizedClientExchangeFilterFunction, which by design requests a new access token for each request. This behavior is suitable for certain scenarios where you need to ensure that you always have a fresh token, but it may not be desirable if you want to reuse the same access token for multiple requests within its validity period.

To achieve token reuse and avoid unnecessary token requests, you can customize the OAuth2AuthorizedClientManager to cache and reuse access tokens. Here's how you can do it:

  1. Customize OAuth2AuthorizedClientManager: Implement a custom OAuth2AuthorizedClientManager that caches authorized clients and reuses access tokens.

    @Bean
    public OAuth2AuthorizedClientManager authorizedClientManager(
            ClientRegistrationRepository clientRegistrationRepository,
            OAuth2AuthorizedClientRepository authorizedClientRepository) {
    
        OAuth2AuthorizedClientProvider authorizedClientProvider =
                OAuth2AuthorizedClientProviderBuilder.builder()
                        .clientCredentials()
                        .build();
    
        DefaultOAuth2AuthorizedClientManager authorizedClientManager =
                new DefaultOAuth2AuthorizedClientManager(
                        clientRegistrationRepository, authorizedClientRepository);
    
        authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);
    
        // Custom token cache implementation
        authorizedClientManager.setContextAttributesMapper(contextAttributesMapper());
    
        return authorizedClientManager;
    }
    
    private Function<ServletWebRequest, Map<String, Object>> contextAttributesMapper() {
        return request -> {
            HttpServletRequest servletRequest = request.getNativeRequest(HttpServletRequest.class);
            HttpServletResponse servletResponse = request.getNativeResponse(HttpServletResponse.class);
            Map<String, Object> contextAttributes = new HashMap<>();
            contextAttributes.put(HttpSession.class.getName(), servletRequest.getSession(false));
            contextAttributes.put(HttpServletRequest.class.getName(), servletRequest);
            contextAttributes.put(HttpServletResponse.class.getName(), servletResponse);
            return contextAttributes;
        };
    }
    
  2. Configure WebClient with Custom OAuth2AuthorizedClientManager: Inject the custom OAuth2AuthorizedClientManager into ServletOAuth2AuthorizedClientExchangeFilterFunction.

    @Bean
    public WebClient webClient(WebClient.Builder webClientBuilder,
                               OAuth2AuthorizedClientManager authorizedClientManager) {
        ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2Client =
                new ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
        oauth2Client.setDefaultClientRegistrationId("khtest");
    
        return webClientBuilder
                .filter(oauth2Client)
                .build();
    }
    

With this setup, the OAuth2AuthorizedClientManager will cache authorized clients and reuse access tokens as long as they are valid. If an access token expires or is revoked, the OAuth2AuthorizedClientManager will automatically request a new token.

Make sure to handle token expiration and token refresh logic properly in case the access token expires before the request is made. You can catch OAuth2AuthorizationException and implement token refresh logic to get a new access token before retrying the request.