spring-security

Spring-security-oauth2-client to authenticate ourselves with a downstream service


I'm not even sure I'm knocking on the right door so bear with me on this. I have a service, that's being called by another service (upstream) and calls a third service (downstream). My service provides auth mech X for the upstream, but should call downstream with oauth2 due to a change on that service.

Let's say I have an HttpInterface like below:

@HttpExchange("/api")
public interface DownstreamClient {
    @GetExchange("/quotes/{id}")
    QouteResponse getQuoteById(
            @PathVariable("id") String id, 
            @RequestParam("type") String type);
}

For this I have properties defined in my app-{env}.yaml profile as:

downstream:
    location: "https://downstream.domain.com"
    oauth2:
        registration-id: "..."
        client-id: "..."
        client-name: "..."
        client-secret:
            key: "..."
            value: "..."
        redirectUri: "..."
        authorizationUri: "..."
        tokenUri: "..."
        scope: "..."

From this, I attempted to build a bean of type DownstreamClient that's backed by a WebClient that's able to authenticate the service itself with downstream while not touching the SecurityContextHolder that contains the Authentication for "upstream" and is required for that purpose. Naively, I tried to use spring-security-oauth2-client using ServerOAuth2AuthorizedClientExchangeFilterFunction as the description seems relevant to my usecase. I started from there, basically doing:

@Configuration
public class DownstreamClientConfiguration {
    @Bean
    DownstreamClient downstreamClient(WebClient downstreamClient) {
        return HttpServiceProxyFactory
                .builderFor(WebClientAdapter.create(downstreamClient))
                .build()
                .createClient(DownstreamClient.class);
    }

    @Bean
    WebClient downstreamClient(DownstreamProperties downstreamProps) {
        ClientRegistration clientRegistration = ClientRegistration.withRegId....;
        ReactiveClientRegistrationRepository clientRegistrationRepository = 
                new InMemoryReactiveClientRegistrationRepository(clientRegistration);
        ReactiveOAuth2AuthorizedClientService authorizedClientService = 
                new ReactiveOAuth2AuthorizedClientService(clientRegistrationRepository);
        ReactiveOAuth2AuthorizedClientManager authorizedClientManager =
                new ReactiveOAuth2AuthorizedClientManager(
                        clientRegistrationRepository, authorizedClientService);
        ServerOAuth2AuthorizedClientExchangeFilterFunction oauth2Filter = 
                new ServerOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
        oauth2Filter.setDefaultClientRegistrationId(downstreamProps.oauth2().registrationId());
        return WebClient.builder().filter(oauth2Filter).baseUrl(downstreamProps.location()).build();
    }
}

Well something along these lines I guess seemed to be sufficient. Then, I learnt that every single time I received "unauthorized_client" back from the authorization server so I digged and digged and I found couple crucial looking piece of information that was not so clear from the spring.io documentation of this topic. Namely that I need to call:

.attribute(oauth2AuthorizedClient(authorizedClient))

where authorizedClient based on my current very limited knowledge on this topic comes from me adding a controller argument:

@RegisteredOAuth2AuthorizedClient(registrationId = "...") 
OAuth2AuthorizedClient authorizedClient

As all this flow happens in the service layer (@Service bean) and I'm trying to authenticate "myself" (the service itself running) with downstream while not even looking at my own service's security implementation, and this annotation doesn't seem to havean effect in an @Service annotated bean method:

@Service
public class MyNaiveServiceExample {
    @Autowired private DownstreamClient downstreamClient;

    public Optional<QouteDTO> getQouteById(
            @RegisteredOAuth2AuthorizedClient("...")
            OAuth2AuthorizedClient authorizedClient,
            String id) {
        QouteReponse response = downstreamClient(authorizedClient, id, "quoteId");
        return Optional.of(QuoteDTO.from(response));
    }
}

In a nutshell, that would be my usecase. This however obviously doesn't work, so I checked OAuth2AuthorizedClientService or OAuth2AuthorizedClientProvider (namely ClientCredentialsOAuth2AuthorizedClientProvider as I have a client-secret).

OAuth2AuthorizedClientService also doesn't seem to be the way to go because:

T loadAuthorizedClient(String clientRegistrationId, String principalName) Returns the OAuth2AuthorizedClient associated to the provided client registration identifier and End-User's Principal name or null if not available.

ClientCredentialsOAuth2AuthorizedClientProvider seems more like what I need, but I'll be honest I have no idea what the expected OAuth2AuthorizationContext might be that is required for getting an instance of OAuth2AuthorizedClient.

I don't need that... do not even look at the authenticated principal, do it for ourselves! Still, there seems to be an issue on how on earth am I going to set that attribute on the WebClient behind the proxy. I guess, I could use something like:

@GetExchange("/quotes/{id}")
QouteResponse getQuoteById(
        @PathVariable("id") String id, 
        @RequestParam("type") String type,
        @RequestAttribute("org.springframework.security.oauth2.client.OAuth2AuthorizedClient") WebClient authorizedClient);

Looks horrendous but that's the name of the attribute that the exchangefilterfunction uses for resolving the client. What I'm trying to do..., can it be done using spring-security-oauth2-client?

@Addition to RestClient based solution:

@Configuration
public class DownstreamClientConfiguration {
    @Bean
    ClientRegistration clientRegistration() {
        return ClientRegistration.<omitted>.build();
    }

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

    @Bean
    OAuth2AuthorizedClientService authorizedClientService(ClientRegistrationRepository clientRegistrationRepository) {
        return new InMemoryOAuth2AuthorizedCClientService(clientRegistrationRepository);
    }

    @Bean
    OAuth2AuthorizedClientRepository authorizedClientRepository(OAuth2AuthorizedClientService authorizedClientService) {
        return new AuthenticatedPrincipalOAuth2AuthorizedClientRepository(authorizedClientService);
    }

    @Bean
    OAuth2AuthorizedClientProvider authorizedClientProvider() {
        return OAuth2AuthorizedClientProviderBuilder.builder().clientCredentials().build();
    }

    @Bean
    OAuth2AuthorizedClientManager authorizedClientManager(ClientRegistrationRepository clientRegistrationRepository, OAuth2AuthorizedClientRepository authorizedClientRepository, OAuth2AuthorizedClientProvider authorizedClientProvider) {
        var authorizedClientManager = new DefaultOAuth2AuthorizedClientManager(clientRegistrationRepository, authorizedClientRepository);
        authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);
        return authorizedClientManager;
    }

    <RestClient omitted>
}

RestClient is indeed easier to debug. The client sends a request to the (might be wrong on this, I logged out already) authorizationUri containing Authorization: Basic base64(clientId:clientSecret) that seems to be in place. Then I get back az unauthorized_client error, that was the point I gave up for the day.

My issue understanding this version is around OAuth2AuthorizedClientRepository. This interface has two implementations:

Neither seems to be good for service-to-service authentication, I wonder why there's no InMemory version of it. The first associates the oauth2 client with the authenticated user whoever called my service (wrong, it should be the entity running my service - aka my service itself not a user who'll have 0 access to my downstream service). HttpSession on the other hand seems completely off as my service is configured with sessionCreationPolicy.STATELESS, there are no sessions here as far as I'm concerned (fair enough session returns null).

I'm sure it a lot more simple when spring does it's magic and one can just autowire stuff and things magically work, I just want to understand how to actually do it before I abstract things away.


Solution

  • First, as you seem to be working with a servlet, RestClient (synchronized) is probably more adapted than WebClient (reactive). If you want to stick with WebClient, replace the OAuth2ClientHttpRequestInterceptor in the following with the ServerOAuth2AuthorizedClientExchangeFilterFunction (in a reactive app) or ServletOAuth2AuthorizedClientExchangeFilterFunction (in a servlet).

    Second, it would be easier if you were using spring-boot-starter-security-oauth2-client and Spring Boot properties:

    spring:
      security:
        oauth2:
          client:
            provider:
              sso:
               issuer-uri: ${issuer}
            registration:
              downstream-api-registration:
                provider: sso
                authorization-grant-type: client_credentials
                client-id: chouette-api
                client-secret: change-me
                scope: openid
    

    This would register the authorized clients repo and manager you need.

    Last, to avoid confusion between the RestClient instance and @HttpExchange implementation, I rename the later to DownstreamApi:

    @HttpExchange("/api")
    public interface DownstreamApi {
      @GetExchange("/quotes/{id}")
      QouteResponse getQuoteById(@PathVariable("id") String id, @RequestParam("type") String type);
    }
    

    With just "official" libs

    Provided that you have such a property:

    downstream-api-base-uri: http://localhost:8180
    

    You can define a RestClient bean as follows:

    @Bean
    RestClient downstreamRestClient(
        @Value("downstream-api-base-uri") URI downstreamApiBaseUri,
        OAuth2AuthorizedClientManager authorizedClientManager,
        OAuth2AuthorizedClientRepository authorizedClientRepository) {
      return RestClient.builder().baseUrl(downstreamApiBaseUri)
          .requestInterceptor(registrationClientHttpRequestInterceptor(authorizedClientManager,
              authorizedClientRepository, "downstream-api-registration"))
          .build();
    }
    
    ClientHttpRequestInterceptor registrationClientHttpRequestInterceptor(
        OAuth2AuthorizedClientManager authorizedClientManager,
        OAuth2AuthorizedClientRepository authorizedClientRepository,
        String registrationId) {
      final var interceptor = new OAuth2ClientHttpRequestInterceptor(authorizedClientManager);
      interceptor.setClientRegistrationIdResolver((HttpRequest request) -> registrationId);
      interceptor.setAuthorizationFailureHandler(
          OAuth2ClientHttpRequestInterceptor.authorizationFailureHandler(authorizedClientRepository));
      return interceptor;
    }
    

    You can have Spring generate an implementation for your @HttpExchange interface as follows:

    @Bean
    DownstreamApi downstreamApi(RestClient downstreamRestClient) {
      return HttpServiceProxyFactory.builderFor(RestClientAdapter.create(downstreamRestClient)).build()
          .createClient(DownstreamApi.class);
    }
    

    At voilĂ ! you can auto-wire this downstreamApi in any @Component you like.

    With spring-addons-starter-rest

    I maintain a Spring Boot Strater to ease RestClient and WebClient configuration. With it, you can configure request authorizations with OAuth2 client credentials flow as follows (keeping the "official" boot properties for the client registration):

    com:
      c4-soft:
        springaddons:
          rest:
            client:
              downstream-rest-client:
                base-url: http://localhost:8180
                authorization:
                  oauth2:
                    oauth2-registration-id: downstream-api-registration
    
    @Bean
    DownstreamApi downstreamApi(RestClient downstreamRestClient) throws Exception {
      return new RestClientHttpExchangeProxyFactoryBean<>(DownstreamApi.class, downstreamRestClient).getObject();
    }
    

    Configuration options using application properties include: