Search code examples
javaspringspring-securityoauth-2.0

AuthorizedClientServiceOAuth2AuthorizedClientManager: accessToken cannot be null


In order to

  • authenticate with a 3rd party API using OAuth2, and
  • use a proxy for this,

in a regular Spring Boot Web-App Microservice (Java 21), I've been using the combination of

  • a custom ClientHttpRequestInterceptor,
  • some custom config beans,
  • and a custom OAuth2AuthorizedClientManager,

and this has worked ok...

But now, DefaultClientCredentialsTokenResponseClient has been deprecated by Spring, and I can't get its replacement, RestClientClientCredentialsTokenResponseClient to work.

Here's the NEW version of the RequestInterceptor:

@Component
@RequiredArgsConstructor
@Slf4j
public class ThirdpartyOAuthRequestInterceptor implements ClientHttpRequestInterceptor {

    private final @Qualifier(THIRDPARTY_AUTH_CLIENT_MGR) AuthorizedClientServiceOAuth2AuthorizedClientManager thirdpartyAuthorizedClientManager;

    @Override
    public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {

        OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest
                .withClientRegistrationId(
            ThirdpartyConfiguration.THIRDPARTY_REGISTATION_ID)
                .principal("OUR SERVICE").build();

        OAuth2AuthorizedClient authorizedClient = thirdpartyAuthorizedClientManager.authorize(authorizeRequest);

        OAuth2AccessToken accessToken = Objects.requireNonNull(authorizedClient).getAccessToken();
        request.getHeaders().add(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken.getTokenValue());

        return execution.execute(request, body);
    }

}

And here's the NEW version of the config beans, containing a AuthorizedClientServiceOAuth2AuthorizedClientManager that uses RestTemplate:

@Configuration
@RequiredArgsConstructor
@Slf4j
public class ThirdpartyConfiguration {

    private final ThirdpartyConfigurationProperties properties;

    public static final String THIRDPARTY_AUTH_CLIENT_MGR = "thirdpartyAuthorizedClientManager";
    public static final String THIRDPARTY_REGISTATION_ID = "thirdparty";

    @Bean
    ClientRegistration thirdpartyClientRegistration(ThirdpartyConfigurationProperties config) {
        return ClientRegistration
                .withRegistrationId(THIRDPARTY_REGISTATION_ID)
                .tokenUri(config.getTokenEndpoint().toString())
                .clientId(config.getClientId())
                .clientSecret(config.getClientSecret())
                .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
                .build();
    }

    @Bean
    public ClientRegistrationRepository clientRegistrationRepository(ClientRegistration thirdpartyClientRegistration) {
        return new InMemoryClientRegistrationRepository(thirdpartyClientRegistration);
    }

    @Bean(THIRDPARTY_AUTH_CLIENT_MGR)
    public AuthorizedClientServiceOAuth2AuthorizedClientManager thirdpartyAuthorizedClientManager(
            final ClientRegistrationRepository clientRegistrationRepository,
            final OAuth2AuthorizedClientService authorizedClientService) {

        final var tokenResponseClient = new RestClientClientCredentialsTokenResponseClient();

        RestClient restClient = RestClient.builder(buildRestTemplate(properties))
                .baseUrl(String.valueOf(properties.getTokenEndpoint()))
                .build();
        tokenResponseClient.setRestClient(restClient);

        final var authorizedClientProvider = new ClientCredentialsOAuth2AuthorizedClientProvider();
        authorizedClientProvider.setAccessTokenResponseClient(tokenResponseClient);

        final var authClientManager =
                new AuthorizedClientServiceOAuth2AuthorizedClientManager(
                        clientRegistrationRepository, authorizedClientService);
        authClientManager.setAuthorizedClientProvider(authorizedClientProvider);

        return authClientManager;
    }

    private RestTemplate buildRestTemplate(ThirdpartyConfigurationProperties config) {

        HttpHost proxy = new HttpHost(config.getProxyHostname(), config.getProxyPort());
        var httpClient = HttpClientBuilder.create()
                .setRoutePlanner(new DefaultProxyRoutePlanner(proxy))
                .build();
        var proxyRequestFactory = new HttpComponentsClientHttpRequestFactory(httpClient);

        var restTemplate = new RestTemplate();
        restTemplate.setRequestFactory(new BufferingClientHttpRequestFactory(proxyRequestFactory));
        var interceptors = restTemplate.getInterceptors();
        interceptors.add(new LoggingInterceptor());
        restTemplate.setInterceptors(interceptors);

        return restTemplate;
    }

    public static class LoggingInterceptor implements ClientHttpRequestInterceptor {

        @Override
        public ClientHttpResponse intercept(
                HttpRequest req, byte[] reqBody, ClientHttpRequestExecution ex) throws IOException {
            LOG.info("Request body: {}", new String(reqBody, StandardCharsets.UTF_8));
            ClientHttpResponse response = ex.execute(req, reqBody);
            InputStreamReader isr = new InputStreamReader(
                    response.getBody(), StandardCharsets.UTF_8);
            String body = new BufferedReader(isr).lines()
                    .collect(Collectors.joining("\n"));
            LOG.info("Response body: {}", body);

            return response;
        }
    }
}

The error message is:

ERROR 1 --- [nio-8080-exec-1] o.a.c.c.C.[.[.[.[dispatcherServlet]      : Servlet.service() for servlet [dispatcherServlet] in context with path [/eed] threw exception [Request processing failed: java.lang.IllegalArgumentException: accessToken cannot be null] with root cause

java.lang.IllegalArgumentException: accessToken cannot be null
        at org.springframework.util.Assert.notNull(Assert.java:181) ~[spring-core-6.2.2.jar:6.2.2]
        at org.springframework.security.oauth2.client.OAuth2AuthorizedClient.<init>(OAuth2AuthorizedClient.java:78) ~[spring-security-oauth2-client-6.4.2.jar:6.4.2]
        at org.springframework.security.oauth2.client.OAuth2AuthorizedClient.<init>(OAuth2AuthorizedClient.java:64) ~[spring-security-oauth2-client-6.4.2.jar:6.4.2]
        at org.springframework.security.oauth2.client.ClientCredentialsOAuth2AuthorizedClientProvider.authorize(ClientCredentialsOAuth2AuthorizedClientProvider.java:87) ~[spring-security-oauth2-client-6.4.2.jar:6.4.2]
        at org.springframework.security.oauth2.client.AuthorizedClientServiceOAuth2AuthorizedClientManager.authorize(AuthorizedClientServiceOAuth2AuthorizedClientManager.java:144) ~[spring-security-oauth2-client-6.4.2.jar:6.4.2]
        at this.is.ours.thirdparty.configuration.ThirdpartyOAuthRequestInterceptor.intercept(ThirdpartyOAuthRequestInterceptor.java:38) ~[classes/:1.0.576]

But the logs show that the response looks fine:

Response body: {"access_token":"eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJLTWtORUZudjNYYWhLSEk5YmFvc29XYS1rQWmVzb3VyY2VfYWNjZXNzIjp7IndlcnQxNC1hcGkiOnsicm9sZXMiOlsiYWNjZXNzIiwidzE0LWFwaTp2MTpidWlsZGluZ19wcmVmaWxsOnJlcG9ydDplbmVyZ3lfcGVyZm9ybWFuY2VfY2VydGlmaWNhdGVfZHJhZnZmB5PA","expires_in":300,"refresh_expires_in":0,"token_type":"Bearer","not-before-policy":1662484858,"scope":""}

What could be going wrong?


Solution

  • If the authorized client manager has to go through an HTTP proxy to reach the token endpoint during client credentials flow, I guess that the RestClient using the token also has to go through that proxy.

    Also, the authorized client providers relying on RestTemplate are marked as deprecated in favor of their equivalents relying on RestClient.

    So, I'll expose solutions with two RestClient beans:

    • tokenClient which will be used internally by the authentication manager to call the authorization server token endpoint
    • biduleClient which will use the token to authorize requests to an external resource server (probably from the same domain as the authorization server)

    In the two solutions below, I use the following Spring Boot configuration for OAuth2:

    spring:
      security:
        oauth2:
          client:
            provider:
              external:
                issuer-uri: ${issuer-uri}
            registration:
              external-m2m:
                provider: external
                authorization-grant-type: client_credentials
                client-id: ${client-id}
                client-secret: ${client-secret}
                scope: openid
    

    I also rely on the HTTP_PROXY and NO_PROXY environment variables to be set.

    Easy Solution

    I wrote starter to ease the configuration of:

    com:
      c4-soft:
        springaddons:
          rest:
            client:
              bidule-client:
                base-url: ${bidule-api-base-url}
                authorization:
                  oauth2:
                    oauth2-registration-id: external-m2m
              token-client:
                expose-builder: true
    
    @Bean
    RestClient tokenClient(RestClient.Builder tokenClientBuilder) {
      return tokenClientBuilder.messageConverters((messageConverters) -> {
        messageConverters.clear();
        messageConverters.add(new FormHttpMessageConverter());
        messageConverters.add(new OAuth2AccessTokenResponseHttpMessageConverter());
      }).defaultStatusHandler(new OAuth2ErrorResponseErrorHandler()).build();
    }
    
    @Bean
    OAuth2AuthorizedClientProvider oauth2AuthorizedClientProvider(
        SpringAddonsOidcProperties addonsProperties,
        InMemoryClientRegistrationRepository clientRegistrationRepository,
        RestClient tokenClient) {
      return new PerRegistrationOAuth2AuthorizedClientProvider(clientRegistrationRepository,
          addonsProperties, Map.of("external-m2m", tokenClient));
    }
    
    // when using spring-addons-starter-oidc to configure an app with oauth2Login, 
    // this bean declaration is not needed (the custom OAuth2AuthorizedClientProvider is detected and used)
    @Bean
    OAuth2AuthorizedClientManager authorizedClientManager(OAuth2AuthorizedClientProvider oauth2AuthorizedClientProvider) {
      authorizedClientManager.setAuthorizedClientProvider(oauth2AuthorizedClientProvider);
      return authorizedClientManager;
    }
    

    There are application properties to

    • switch the ClientHttpRequestFactory implementation (more on that below)
    • override some proxy settings.

    With Just "Official" Starters

    Proxy configuration is not done directly on the RestClient. It is provided to a ClientHttpRequestFactory and we have a choice of implementations:

    • SimpleClientHttpRequestFactory is pretty old and does not support PATCH requests
    • JdkClientHttpRequestFactory is available from the JDK, but can cause issues with some Microsoft middleware
    • JettyClientHttpRequestFactory requires to add org.eclipse.jetty:jetty-client to the classpath
    • HttpComponentsClientHttpRequestFactory requires to add org.apache.httpcomponents.client5:httpclient5 to the classpath

    Proxy configuration differs with each implementation, so refer to the documentation of the one you choose for details.

    // Over simplified config for just proxy.
    // Production conf would include timeouts settings, reading from properties
    ClientHttpRequestFactory clientHttpRequestFactoryWithProxy(URI httpProxy) {
      final var proxyAddress = new InetSocketAddress(httpProxy.getHost(), httpProxy.getPort());
    
      return new JdkClientHttpRequestFactory(
          HttpClient.newBuilder().proxy(ProxySelector.of(proxyAddress)).build());
    }
    
    RestClient.Builder restClientBuilderWithProxy(URI httpProxy) {
      return RestClient.builder().requestFactory(clientHttpRequestFactoryWithProxy(httpProxy));
    }
    
    @Bean
    RestClient tokenClient(@Value("${http_proxy}") URI httpProxy) {
      return restClientBuilderWithProxy(httpProxy).messageConverters((messageConverters) -> {
        messageConverters.clear();
        messageConverters.add(new FormHttpMessageConverter());
        messageConverters.add(new OAuth2AccessTokenResponseHttpMessageConverter());
      }).defaultStatusHandler(new OAuth2ErrorResponseErrorHandler()).build();
    }
    
    @Bean
    RestClient biduleClient(OAuth2AuthorizedClientManager authorizedClientManager,
        OAuth2AuthorizedClientRepository authorizedClientRepository,
        @Value("${http_proxy}") URI httpProxy,
        @Value("${bidule-api-base-url}") String biduleApiBaseUrl) {
      final var interceptor = new OAuth2ClientHttpRequestInterceptor(authorizedClientManager);
      interceptor.setClientRegistrationIdResolver((HttpRequest request) -> "external-m2m");
      interceptor.setAuthorizationFailureHandler(
          OAuth2ClientHttpRequestInterceptor.authorizationFailureHandler(authorizedClientRepository));
      return restClientBuilderWithProxy(httpProxy).requestInterceptor(interceptor)
          .baseUrl(biduleApiBaseUrl).build();
    }
    
    @Bean
    OAuth2AuthorizedClientManager authorizedClientManager(
        ClientRegistrationRepository clientRegistrationRepository,
        OAuth2AuthorizedClientRepository authorizedClientRepository, RestClient tokenClient) {
      final var authorizedClientManager = new DefaultOAuth2AuthorizedClientManager(
          clientRegistrationRepository, authorizedClientRepository);
      // In many apps, a single AuthorizedClientProvider won't be enough
      authorizedClientManager.setAuthorizedClientProvider(
          OAuth2AuthorizedClientProviderBuilder.builder().clientCredentials(clientCredentials -> {
            final var accessTokenResponseClient =
                new RestClientClientCredentialsTokenResponseClient();
            accessTokenResponseClient.setRestClient(tokenClient);
            clientCredentials.accessTokenResponseClient(accessTokenResponseClient);
          }).build());
      return authorizedClientManager;
    }
    

    In addition to being much more verbose, this solution is likely to need quite some enhancements before going to prod:

    • timeouts are not configured
    • only client credentials flow is supported by the authorized client manager. In most apps, the manager will use different provider(s) depending on the registration, and the providers configuration is likely to change from a registration to another. For instance, not all the registrations with client credentials might need to go through a proxy, or timeouts might not be the same for internal and external requests.
    • only one RestClient is available for talking to token endpoints. If the app uses an "internal" issuer to talk to local micro services (in addition to the "external" issuer used to authorize requests going through the proxy), there will be quite some Java code to add.
    • if the number of registrations or RestClients increases, things might get messy.