The main reason why your current impl is not working is that you don't configure your AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager with your custom ReactiveOAuth2AuthorizedClientProvider. But there are several other things to consider.
I think that the clientCredentialsAccessTokenResponseClient bean you declare is never used.
About WebClient usage to call the internal REST API and external Entra ID token endpoint:
- The requests authorization will be different.
- The proxy configuration might be different as the endpoints are on different networks.
- The base URLs are different.
In this situation, using different WebClient beans, each with a specific configuration makes things easier and clearer. It is also wise to give them meaningful names to avoid confusion with the instance built with the default Builder.
You should use the WebClient.Builder bean exposed by Spring Boot as a base for your own instances to benefit from the defaults set by the Spring teams, and you you should clone() it to avoid side effects between instances.
Now two different solutions with this shared configuration:
tenant-id: change-me entra-issuer-id: https://login.microsoftonline.com/${tenant-id} internal-rest-client-base-url: http://localhost:8081 spring: security: oauth2: client: provider: sso: issuer-uri: ${entra-issuer-id} registration: internal-api-programatic-client: provider: sso authorization-grant-type: client_credentials client-id: change-me client-secret: change-me scope: openid
With spring-addons
I created two starters:
com: c4-soft: springaddons: rest: client: entra-client: base-url: ${entra-issuer-id} http: # proxy properties are needed only if HTTP_PROXY and NO_PROXY environment variables are not set or if don't include credentials proxy: username: change-me password: change-me internal-rest-client: base-url: ${internal-rest-client-base-url} authorization: oauth2: # a reference to the registration in the shared conf above oauth2-registration-id: internal-api-programatic-client
@Configuration public class RestConfiguration { /** * Expose a ReactiveOAuth2AccessTokenResponseClient bean with the customized WebClient above. * It is picked by spring-addons-starter-oidc to replace the default response client in the default client-credentials provider, * and configure the authentication manager with it. * * ⚠️ entraClient is a WebClient bean auto configured according to the entra-client properties above. */ @Bean ReactiveOAuth2AccessTokenResponseClient<OAuth2ClientCredentialsGrantRequest> clientCredentialsTokenResponseClient( WebClient entraClient) { final var client = new WebClientReactiveClientCredentialsTokenResponseClient(); client.setWebClient(entraClient); return client; } }
Done!
You can inject WebClient internalRestClient wherever you need it. It is configured with a base URL, and will authorize its requests with tokens it gets using the client credentials flow. It does this with another WebClient bean named entraClient, configured to go through an HTTP proxy described with the HTTP_PROXY and NO_PROXY environment variables.
With just the "official" starters
@Configuration public class RestConfiguration { /** * This should be improved to extract hard-coded values to properties, * or better, parse the HTTP_PROXY and NO_PROXY environment variables. * But this solution is already too verbose. For something synthetic, use my starters... */ private ClientHttpConnector connectorWithProxy() { final var client = HttpClient.create(); client.proxy(proxy -> proxy.type(ProxyProvider.Proxy.HTTP).host("proxy.corporate.com") .port(8080).username("change-me").password(username -> "change-me").nonProxyHosts("")); return new ReactorClientHttpConnector(client); } @Bean WebClient entraClient(WebClient.Builder defaultBuilder, @Value("${entra-issuer-id}") String baseUrl) { return defaultBuilder // use Spring Boot's default Builder .clone() // clone it to prevent side effects on other instances .baseUrl(baseUrl) .clientConnector(connectorWithProxy()) .build(); } /** * ReactiveOAuth2AccessTokenResponseClient with the customized WebClient above. */ private ReactiveOAuth2AccessTokenResponseClient<OAuth2ClientCredentialsGrantRequest> clientCredentialsTokenResponseClient( WebClient entraClient) { final var client = new WebClientReactiveClientCredentialsTokenResponseClient(); client.setWebClient(entraClient); return client; } private ReactiveOAuth2AuthorizedClientProvider oauth2AuthorizedClientProvider(WebClient entraClient) { final var clientCredentialsProvider = new ClientCredentialsReactiveOAuth2AuthorizedClientProvider(); clientCredentialsProvider.setAccessTokenResponseClient(clientCredentialsTokenResponseClient(entraClient)); // If using other OAuth2 flows within other OAuth2 client registrations, add it to the constructor below (it accepts varargs) return new DelegatingReactiveOAuth2AuthorizedClientProvider(clientCredentialsProvider); } /** * ⚠️ As we declare 2 WebClient beans in this conf, names are important (the 3rd parameter is resolved to the WebClient defined above by its name). */ @Bean ReactiveOAuth2AuthorizedClientManager authorizedClientManager( ReactiveClientRegistrationRepository clientRegistrationRepository, ServerOAuth2AuthorizedClientRepository authorizedClientRepository, WebClient entraClient) { final var authorizedClientManager = new DefaultReactiveOAuth2AuthorizedClientManager( clientRegistrationRepository, authorizedClientRepository); // ⚠️ This part is missing in the question code authorizedClientManager.setAuthorizedClientProvider(oauth2AuthorizedClientProvider(entraClient)); return authorizedClientManager; } /** * This assumes that the consumed REST API is on the same network and that no proxy conf is required. * If it's not the case, configure the clientConnector with connectorWithProxy() here too. */ @Bean WebClient internalRestClient(WebClient.Builder defaultBuilder, @Value("${internal-rest-client-base-url}") String baseUrl, ReactiveOAuth2AuthorizedClientManager authorizedClientManager) { final var authorization = new ServerOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager); authorization.setDefaultClientRegistrationId("internal-api-programatic-client"); return defaultBuilder // use Spring Boot's default Builder .clone() // clone it to prevent side effects on other instances .baseUrl(baseUrl) .filter(authorization) // authorize all requests from this client .build(); } }
Side notes
The shared configuration above requires that Entra ID is configured to comply with OIDC, which is not the case by default. I wrote a guide to enable this compatibility there: Why can't I get things working easily with Microsoft authorization servers?
If writing a new app, you should think twice before using WebFlux & WebClient. Since virtual threads are out (Java 21), there is no real performance advantage, and servlets & RestClient are way easier to read and debug.
If switching to RestClient, be careful when using Microsoft's middleware: I was reported that the h2c header set by the JDK's default HTTP client (built with JdkClientHttpRequestFactory) can cause problems with MS (not the 1st time that they do not support a standard...). You might need to switch to an alternative implementation like HttpComponentsClientHttpRequestFactory or JettyClientHttpRequestFactory which require additional dependencies on the classpath. With spring-addons-starter-rest, once the right jar is added to the pom/gradle file, switching to Appache's or Jetty's impl can be done in properties (and proxy configuration follows). Also, you won't need the same message converters for your RestClient instances: HTTP form for the token endpoint, and Jakson for the REST payloads.