Setup Spring WebClient filter from an OAuth2 refresh_token

Viewed 33

I have to consume an external API which uses OAuth2 for security. They do not support the grant type "client_credentials", but instead they give out a long-lived refresh_token that we can inject into the Spring application without it expiring.

However, I cannot find any information on how to setup an OAuth2 filter for the WebClient use the given refresh_token to get an access_token.

Below is some code to get a starting point and show where what I have tried.



    @Value("application.external-api.refresh_token")
    private String refresh_token;

    @Bean
    public WebClient externalApiWebClient(
            ReactiveClientRegistrationRepository clientRegistrations,
            ServerOAuth2AuthorizedClientRepository authorizedClients
    ) {
        ServerOAuth2AuthorizedClientExchangeFilterFunction oauth =
                new ServerOAuth2AuthorizedClientExchangeFilterFunction(clientRegistrations, authorizedClients);
        oauth.setDefaultClientRegistrationId("external-api");

        // TODO: Somehow get the refresh_token into the OAuth filter

        return WebClient.builder()
                .baseUrl("https://api.external-api.com")
                .filter(oauth)
                .build();
    }
spring:
  security:
    oauth2:
      client:
        registration:
          external-api:
            # client-secret: <refresh_token>
            authorization-grant-type: refresh_token

        provider:
          external-api:
            token-uri: https://api.external-api.com/oauth2/token

Anyone needed to do something similar and have a solution for this problem?

1 Answers

Yes, I have done something like this. The context I had was to (attempt to) integrate with the Google Smart Device Management ecosystem. I'm not sure I recommend it, but I was at least successful in using refresh tokens for connecting to the API.

There are two ways I can think of for accomplishing this:

  1. Implement the ReactiveOAuth2AuthorizedClientService class yourself
  2. Use in-memory implementations and load a refreshToken on startup

Both approaches are similar, because in either case we don't have an accessToken and must give the framework an expired token, so it knows to load a new one on first use.

I went with the second option, though in hindsight the first option could be nicer.

Here is a class that ties all the concepts together and allows you to create a WebClient for either request-based access or for a background service that is not responding to user requests (e.g. a scheduled batch job):

@Component
public class WebClientFactory {

    private final ReactiveClientRegistrationRepository clientRegistrationRepository;

    private final ServerOAuth2AuthorizedClientRepository authorizedClientRepository;

    private final ReactiveOAuth2AuthorizedClientService authorizedClientService;

    private final ReactiveOAuth2AuthorizedClientProvider authorizedClientProvider;

    private final String baseUrl;

    public WebClientFactory(
            ReactiveClientRegistrationRepository clientRegistrationRepository,
            ServerOAuth2AuthorizedClientRepository authorizedClientRepository,
            ReactiveOAuth2AuthorizedClientService authorizedClientService,
            ReactiveOAuth2AuthorizedClientProvider authorizedClientProvider,
            @Value("${my.base-url}") String baseUrl) {
        this.clientRegistrationRepository = clientRegistrationRepository;
        this.authorizedClientRepository = authorizedClientRepository;
        this.authorizedClientService = authorizedClientService;
        this.authorizedClientProvider = authorizedClientProvider;
        this.baseUrl = baseUrl;
    }

    public Mono<Void> saveRefreshToken(String registrationId, String principalName, String token) {
        var now = Instant.now();
        var accessToken = new OAuth2AccessToken(
            OAuth2AccessToken.TokenType.BEARER, "none", now.minusSeconds(2), now.minusSeconds(1));
        var refreshToken = new OAuth2RefreshToken(token, now);
        var principal = new BearerTokenAuthenticationToken(principalName);
        principal.setAuthenticated(true);

        return this.clientRegistrationRepository.findByRegistrationId(registrationId)
            .switchIfEmpty(Mono.error(new InternalAuthenticationServiceException("Unable to find registrationId " + registrationId)))
            .map((clientRegistration) -> new OAuth2AuthorizedClient(clientRegistration, principalName, accessToken, refreshToken))
            .flatMap((authorizedClient) -> this.authorizedClientService.saveAuthorizedClient(authorizedClient, principal));
    }

    public WebClient createRequestClient() {
        var authorizedClientManager = new DefaultReactiveOAuth2AuthorizedClientManager(
            this.clientRegistrationRepository, this.authorizedClientRepository);
        authorizedClientManager.setAuthorizedClientProvider(this.authorizedClientProvider);

        return createWebClient(authorizedClientManager);
    }

    public WebClient createBackgroundClient() {
        var authorizedClientManager = new AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager(
            this.clientRegistrationRepository, this.authorizedClientService);
        authorizedClientManager.setAuthorizedClientProvider(this.authorizedClientProvider);

        return createWebClient(authorizedClientManager);
    }

    private WebClient createWebClient(ReactiveOAuth2AuthorizedClientManager authorizedClientManager) {
        var oauth2 = new ServerOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
        return WebClient.builder()
            .filter(oauth2)
            .baseUrl(this.baseUrl)
            .build();
    }

}

Note: I'm re-using the BearerTokenAuthenticationToken from spring-security-oauth2-resource-server for simplicity, as some kind of principal is required to uniquely identify the refresh token.

Spring Boot will provide most of the injected beans for you (defaulting to in-memory), but you will need to provide ReactiveOAuth2AuthorizedClientProvider:

    @Bean
    public ReactiveOAuth2AuthorizedClientProvider authorizedClientProvider() {
        return ReactiveOAuth2AuthorizedClientProviderBuilder.builder()
            .refreshToken()
            .build();
    }

You'll need to set up the same BearerTokenAuthenticationToken as a principal yourself through the ReactiveSecurityContextHolder if using the WebClient in a background service.

See the docs for more info.

Related