How to provide an OAuth2 token to a Feign client using Spring Security for the client_credentials workflow

10,508

Solution 1

So. I was playing with your solution in my free time. And found the simple solution:

just add SecurityContextHolder.getContext().authentication principle to your code OAuth2AuthorizeRequest request = OAuth2AuthorizeRequest.withClientRegistrationId(appClientId).build();

Should be like this:

val request = OAuth2AuthorizeRequest
                .withClientRegistrationId("keycloak") // <-- here your registered client from application.yaml
                .principal(SecurityContextHolder.getContext().authentication)
                .build()

Used packages:

implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.cloud:spring-cloud-starter-openfeign")
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")

application.yaml:

spring:
  security:
    oauth2:
      client:
        registration:
          keycloak: # <--- It's your custom client. I am using keycloak
            client-id: ${SECURITY_CLIENT_ID}
            client-secret: ${SECURITY_CLIENT_SECRET}
            authorization-grant-type: client_credentials
            scope: openid # your scopes
        provider:
          keycloak: # <--- Here Registered my custom provider
            authorization-uri: ${SECURITY_HOST}/auth/realms/${YOUR_REALM}/protocol/openid-connect/authorize
            token-uri: ${SECURITY_HOST}/auth/realms/${YOUR_REALM}/protocol/openid-connect/token

feign:
  compression:
    request:
      enabled: true
      mime-types: application/json
    response:
      enabled: true
  client.config.default:
    connectTimeout: 1000
    readTimeout: 60000
    decode404: false
    loggerLevel: ${LOG_LEVEL_FEIGN:basic}

SecurityConfiguration:

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
class SecurityConfiguration() : WebSecurityConfigurerAdapter() {

    @Throws(Exception::class)
    override fun configure(http: HttpSecurity) {
        // @formatter:off
        http
                .authorizeRequests { authorizeRequests ->
                    authorizeRequests
                            .antMatchers(HttpMethod.GET, "/test").permitAll() // Here my public endpoint which do logic with secured client enpoint
                            .anyRequest().authenticated()
                }.cors().configurationSource(corsConfigurationSource()).and()
                .csrf().disable()
                .cors().disable()
                .httpBasic().disable()
                .formLogin().disable()
                .logout().disable()
                .oauth2Client()
        // @formatter:on
    }

    @Bean
    fun authorizedClientManager(
            clientRegistration: ClientRegistrationRepository?,
            authorizedClient: OAuth2AuthorizedClientRepository?
    ): OAuth2AuthorizedClientManager? {
        val authorizedClientProvider = OAuth2AuthorizedClientProviderBuilder
                .builder()
                .clientCredentials()
                .build()
        val authorizedClientManager = DefaultOAuth2AuthorizedClientManager(clientRegistration, authorizedClient)
        authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider)
        return authorizedClientManager
    }

}

FeignClientConfiguration:

private val logger = KotlinLogging.logger {}

class FeignClientConfiguration(private val authorizedClientManager: OAuth2AuthorizedClientManager) {

    @Bean
    fun requestInterceptor(): RequestInterceptor = RequestInterceptor { template ->
        if (template.headers()["Authorization"].isNullOrEmpty()) {
            val accessToken = getAccessToken()
            logger.debug { "ACCESS TOKEN TYPE: ${accessToken?.tokenType?.value}" }
            logger.debug { "ACCESS TOKEN: ${accessToken?.tokenValue}" }
            template.header("Authorization", "Bearer ${accessToken?.tokenValue}")
        }
    }

    private fun getAccessToken(): OAuth2AccessToken? {
        val request = OAuth2AuthorizeRequest
                .withClientRegistrationId("keycloak") // <- Here you load your registered client
                .principal(SecurityContextHolder.getContext().authentication)
                .build()
        return authorizedClientManager.authorize(request)?.accessToken
    }

}

TestClient:

@FeignClient(
        name = "test",
        url = "http://localhost:8080",
        configuration = [FeignClientConfiguration::class]
)
interface TestClient {
    @GetMapping("/test")
    fun test(): ResponseEntity<Void> // Here my secured resource server endpoint. Expect 204 status
}

Solution 2

According documentation need use AuthorizedClientServiceOAuth2AuthorizedClientManager instead of DefaultOAuth2AuthorizedClientManager

When operating outside of the context of a HttpServletRequest, use AuthorizedClientServiceOAuth2AuthorizedClientManager instead.

Share:
10,508
almac777
Author by

almac777

Updated on June 13, 2022

Comments

  • almac777
    almac777 almost 2 years

    Overview

    I am trying to write a program that accesses a public REST API. In order for me to be able to consume it, I need to provide an OAuth2 token.

    My App uses Spring Boot 2.4.2 and Spring Cloud version 2020.0.1. The app itself does call the REST API once every 24h, download the data, and stores it in a database. A different microservice consumes this data at some other point and needs the data to have been refreshed daily.

    My approach to this is to use OpenFeign to declare the REST Client that consumes the REST API and provide it an OAuth2 token. This is a problem that is quite common, so I assume that machine to machine client_credentials workflow is well documented.

    And indeed, I did find a simple example to do this with OpenFeign - here: https://github.com/netshoes/sample-feign-oauth2-interceptor/blob/master/src/main/java/com/sample/feign/oauth2/interceptor/OrderFeignClientConfiguration.java

    TL;DR: Trying to write a machine-to-machine microservice requiring an OAuth2 token (client_credentials grant type).

    Problem

    This was my first try, but unfortunately with the new Spring Security release, I can't seem to get the OAuth2FeignRequestInterceptor instantiated, I might have a package problem. I then went on to study the documentation for Spring Security and the new OAuth2 rewrite, which can be found here: https://docs.spring.io/spring-security/site/docs/5.1.2.RELEASE/reference/htmlsingle/#oauth2client.

    Approaches

    My approach is to use a RequestInterceptor which injects the current OAuth2 token into the request of the OpenFeign client, by adding an Authorization Bearer header. My assumption is that I can retrieve this, more or less automagically, using the Spring Security OAuth2 layer.

    Using the documentation I tried providing a bean of OAuth2RegisteredClient to my interceptor, as well as a bean of type OAuth2AccessToken - which both didn't work. My last try looked like this and is to be seen, as a sort of hail mary, kind of approach:

        @Bean
        public OAuth2AccessToken apiAccessToken(
                @RegisteredOAuth2AuthorizedClient("MY_AWESOME_PROVIDER") OAuth2AuthorizedClient authorizedClient) {
            return authorizedClient.getAccessToken();
        }
    

    This doesn't work because RegisteredOAuth2AuthorizedClient requires a user session, lest it is null. I also saw someone else on Stackoverflow trying the same approach, but they actually did it in a Controller (=> Resolving OAuth2AuthorizedClient as a Spring bean)

    I also tried some approaches that I have found here on SO:

    My assumption is that I can somehow use Spring Security 5 to solve this, but I simply can't wrap my head around how to actually do it. It seems to me that most of the tutorials and code samples I have found actually require a user-session, or are outdated with Spring Security 5.

    It really seems that I am missing something and I hope that somebody can point me in the right direction, towards a tutorial or written documentation on how to achieve this.

    In depth example

    I tried supplying an OAuth2AuthorizedClientManager as seen in this example (https://github.com/jgrandja/spring-security-oauth-5-2-migrate). For this, I registered an OAuth2AuthorizedClientManager following the example code:

        @Bean
        public OAuth2AuthorizedClientManager authorizedClientManager(ClientRegistrationRepository clientRegistrationRepository,
                                                                     OAuth2AuthorizedClientRepository authorizedClientRepository) {
            OAuth2AuthorizedClientProvider authorizedClientProvider =
                    OAuth2AuthorizedClientProviderBuilder.builder()
                            .authorizationCode()
                            .refreshToken()
                            .clientCredentials()
                            .password()
                            .build();
            DefaultOAuth2AuthorizedClientManager authorizedClientManager = new DefaultOAuth2AuthorizedClientManager(
                    clientRegistrationRepository, authorizedClientRepository);
            authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);
    
            return authorizedClientManager;
        }
    

    and provided it for my RequestInterceptor as can be seen here:

        @Bean
        public RequestInterceptor requestInterceptor(OAuth2AuthorizedClientManager clientManager) {
            return new OAuthRequestInterceptor(clientManager);
        }
    

    Finally I wrote the interceptor, which looks like this:

        private String getAccessToken() {
            OAuth2AuthorizeRequest request = OAuth2AuthorizeRequest.withClientRegistrationId(appClientId)
                    // .principal(appClientId) // if this is not set, I receive "principal cannot be null" (or empty)
                    .build();
            return Optional.ofNullable(authorizedClientManager)
                    .map(clientManager -> clientManager.authorize(request))
                    .map(OAuth2AuthorizedClient::getAccessToken)
                    .map(AbstractOAuth2Token::getTokenValue)
                    .orElseThrow(OAuth2AccessTokenRetrievalException::failureToRetrieve);
        }
    
        @Override
        public void apply(RequestTemplate template) {
            log.debug("FeignClientInterceptor -> apply CALLED");
            String token = getAccessToken();
            if (token != null) {
                String bearerString = String.format("%s %s", BEARER, token);
                template.header(HttpHeaders.AUTHORIZATION, bearerString);
                log.debug("set the template header to this bearer string: {}", bearerString);
            } else {
                log.error("No bearer string.");
            }
        }
    

    When I run the code, I can see "FeignClientInterceptor -> apply called" output in the console, followed by an Exception:

    Caused by: java.lang.IllegalArgumentException: servletRequest cannot be null

    My assumption is that I receive this, because I don't have an active user session. It seems to me thus, that I absolutely need one to fix this problem - which I don't have in machine-to-machine communcations.

    This is a common use-case so I am sure I must have made a mistake at some point.

    Used packages

    Maybe I made a mistake with my packages?

        implementation 'org.springframework.boot:spring-boot-starter-amqp'
        implementation 'org.springframework.boot:spring-boot-starter-jooq'
        implementation 'org.springframework.boot:spring-boot-starter-security'
        implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
        implementation 'org.springframework.boot:spring-boot-starter-web'
        implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'