In order to
in a regular Spring Boot Web-App Microservice (Java 21), I've been using the combination of
ClientHttpRequestInterceptor
,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?
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 endpointbiduleClient
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.
I wrote starter to ease the configuration of:
oauth2Login
and oauth2ResourceServer
RestClient
(or WebClient
) with requests authorization (Basic
, Bearer
, and static API key) and proxy settings (reading from HTTP_PROXY
and NO_PROXY
env variables or application properties)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
ClientHttpRequestFactory
implementation (more on that below)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
requestsJdkClientHttpRequestFactory
is available from the JDK, but can cause issues with some Microsoft middlewareJettyClientHttpRequestFactory
requires to add org.eclipse.jetty:jetty-client
to the classpathHttpComponentsClientHttpRequestFactory
requires to add org.apache.httpcomponents.client5:httpclient5
to the classpathProxy 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: