spring-addons icon indicating copy to clipboard operation
spring-addons copied to clipboard

Use AuthorizedClientServiceOAuth2AuthorizedClientManager for client_credentials

Open xehpuk opened this issue 5 months ago • 6 comments

I currently have two client registrations with a rest client each:

spring:
  security:
    oauth2:
      client:
        provider:
          keycloak:
            issuer-uri: ${keycloak-configuration.issuer-uri}
        registration:
          keycloak-authorization-code:
            provider: keycloak
            authorization-grant-type: authorization_code
            client-id: ${keycloak-configuration.client-id}
            client-secret: ${keycloak-configuration.client-secret}
            scope:
              - openid
          keycloak-client-credentials:
            provider: keycloak
            authorization-grant-type: client_credentials
            client-id: ${keycloak-configuration.client-id}
            client-secret: ${keycloak-configuration.client-secret}
com:
  c4-soft:
    springaddons:
      rest:
        client:
          foo-client:
            base-url: ${foo-service.base-url}
            authorization:
              oauth2:
                oauth2-registration-id: keycloak-authorization-code
          bar-client:
            base-url: ${bar-service.base-url}
            authorization:
              oauth2:
                oauth2-registration-id: keycloak-client-credentials
            expose-builder: true

For my bar-client to work, I have to use the Builder to change the OAuth2AuthorizedClientManager from DefaultOAuth2AuthorizedClientManager to AuthorizedClientServiceOAuth2AuthorizedClientManager (it's used in a background thread). But then I lose the ClientRegistrationIdResolver you create in the RestClientBuilderFactoryBean, so the two options I currently see are:

@Bean
fun barClient(
    barClientBuilder: RestClient.Builder,
    clientRegistrationRepository: ClientRegistrationRepository,
    authorizedClientService: OAuth2AuthorizedClientService,
    oauth2AuthorizedClientProvider: OAuth2AuthorizedClientProvider,
): RestClient = barClientBuilder.requestInterceptors {
    it.clear()
    it.add(
        OAuth2ClientHttpRequestInterceptor(
            AuthorizedClientServiceOAuth2AuthorizedClientManager(clientRegistrationRepository, authorizedClientService).apply {
                setAuthorizedClientProvider(oauth2AuthorizedClientProvider)
            }
        ).apply {
            // option 1
            setClientRegistrationIdResolver {
                "keycloak-client-credentials"
            }
        })
}.requestInitializer { request ->
    // option 2
    RequestAttributeClientRegistrationIdResolver.clientRegistrationId("keycloak-client-credentials").accept(request.attributes)
}.build()
  1. Overwrite the ClientRegistrationIdResolver in the OAuth2ClientHttpRequestInterceptor with a custom one.
  2. Add a ClientHttpRequestInitializer to let the default RequestAttributeClientRegistrationIdResolver get the registration ID from the request attributes.

With both options, I have to reconfigure the registration ID, even though it's already configured in the application properties. Is there a better option I'm missing?

Either way, if the AuthorizedClientServiceOAuth2AuthorizedClientManager was the default for client_credentials clients, it would work out of the box.

xehpuk avatar Aug 14 '25 22:08 xehpuk

spring-addons-starter-rest does not instantiate a DefaultOAuth2AuthorizedClientManager. It uses whatever OAuth2AuthorizedClientManager @Bean is in the context.

spring-addons-starter-oidc instantiates a DefaultOAuth2AuthorizedClientManager only if no other OAuth2AuthorizedClientManager @Bean is defined.

So, exposing your AuthorizedClientServiceOAuth2AuthorizedClientManager instance as a @Bean should be enough in any case (with or without spring-addons-starter-oidc).

ch4mpy avatar Aug 15 '25 00:08 ch4mpy

Yes, the DefaultOAuth2AuthorizedClientManager comes from spring-addons-starter-oidc.

I thought I needed the DefaultOAuth2AuthorizedClientManager for the foo-client (and the AuthorizedClientServiceOAuth2AuthorizedClientManager for the bar-client). Then what's the point of the DefaultOAuth2AuthorizedClientManager at all when AuthorizedClientServiceOAuth2AuthorizedClientManager can handle both cases?
The documentation says:

(When operating within the context of a HttpServletRequest, use DefaultOAuth2AuthorizedClientManager instead.)

What if I wanted to have different client managers, be it just to follow the "official guide"?

xehpuk avatar Aug 15 '25 01:08 xehpuk

@xehpuk, please accept my apologies for two things:

  • the delay: I was very busy on other subjects recently and couldn't dig deeper into your problem
  • I hadn't realized that your use case requires different authorized client managers for the two registrations because one is used with the context of the HttpServletRequest, but not the other.

May I ask why the keycloak-client-credentials is used in a separate thread? (why is the response not important for answering the request triggering its usage?)

In any case, I can't think of a mean to satisfy this use case without using named beans: registering two OAuth2AuthorizedClientManager beans with different names, and using qualifiers when injecting the authorized client manager to the RestClient configurer. This could break backward compatibility for quite some existing apps: we would have to be very careful with the name of the bean when registering a custom OAuth2AuthorizedClientManager, and would need a new property to specify for each RestClient the name of the OAuth2AuthorizedClientManager to use.

So, I fear that the best option for you is to expose the RestClient builder and override the authorized client manager, like you do already. However, there could be some options to reduce the technical debt by injecting the SpringAddonsRestProperties and retrieving the registration ID to use from there. I'll try to prototype around that by the end of the week.

ch4mpy avatar Aug 21 '25 19:08 ch4mpy

The other client just informs another service that something happened. No user authentication or response needed in this case.

xehpuk avatar Aug 22 '25 16:08 xehpuk

If you don't need the response, you could consider using the WebClient and fire-and-forget the child request from the HttpServletRequest thread (subcribe() to it). There is an addons property to expose a WebClient (or its builder) instead of the default RestClient in servlets.

If you're using generated @HttpClient proxies, the factory works equally with WebClient and RestClient.

ch4mpy avatar Aug 22 '25 17:08 ch4mpy

@xehpuk did you give a try to the WebClient and fire-and-forget the child request from the HttpServletRequest thread?

ch4mpy avatar Aug 25 '25 17:08 ch4mpy