Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

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:

  • Feign and Spring Security 5 - Client Credentials (Provided answer uses Spring Boot 2.2.4 - thus not relevant anymore)
  • Alternative For OAuth2FeignRequestInterceptor as it is deprecated NOW another gentleman looking for an alternative for OAuth2FeignRequestInterceptor
  • OAuth2FeignRequestInterceptor class deprecated in Spring Boot 2.3 - solution here again required an active user-session
  • https://github.com/jgrandja/spring-security-oauth-5-2-migrate this Github repo pops up every now and then, I studied it, but I deem it irrelevant to my question - maybe I missed something? From what I understood, this sample application has multiple providers using multiple scopes - but still a user that triggers a login and thus the automagic generation of an OAuth2 token through Spring Security. (also featured in this question: Migrating from Spring Boot Oauth2 to Spring Security 5) [1]
  • https://github.com/spring-cloud/spring-cloud-openfeign/issues/417 -> as of right now there is no replacement for OAuth2FeignRequestInterceptor

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'
like image 748
almac777 Avatar asked Feb 14 '21 12:02

almac777


Video Answer


2 Answers

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
}
like image 120
Denys Mietielov Avatar answered Oct 13 '22 01:10

Denys Mietielov


According documentation need use AuthorizedClientServiceOAuth2AuthorizedClientManager instead of DefaultOAuth2AuthorizedClientManager

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

like image 38
hellnn Avatar answered Oct 13 '22 00:10

hellnn