Feign and Spring Security 5 - Client Credentials

I am trying to invoke some backend system which is secured by a client_credentials grant type from a Feign client application.

The access token from the backend system can be retrieved with the following curl structure (just as an example):

curl --location --request POST '[SERVER URL]/oauth/grant' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--header 'Cookie: WebSessionID=172.22.72.1.1558614080219404; b8d49fdc74b7190aacd4ac9b22e85db8=2f0e4c4dbf6d4269fd3349f61c151223' \
--data-raw 'grant_type=client_credentials' \
--data-raw 'client_id=[CLIENT_ID]' \
--data-raw 'client_secret=[CLIENT_SECRET]'

{"accessToken":"V29C90D1917528E9C29795EF52EC2462D091F9DC106FAFD829D0FA537B78147E20","tokenType":"Bearer","expiresSeconds":7200}

This accessToken should then be set in a header to subsequent business calls to the backend system.

So now my question is, how to implement this using Feign and Spring Boot Security 5. After some research I come to this solution (which doesn't work):

  1. Define my client in the application.yml:
spring:
  security:
    oauth2:
      client:
        registration:
          backend:
            client-id:[CLIENT_ID]
            client-secret: [CLIENT_SECRET]
            authorization-grant-type: client_credentials
    
        provider:
          backend:
            token-uri: [SERVER URL]/oauth/grant
  1. Create a OAuth2AuthorizedClientManager Bean to be able to authorize (or re-authorize) an OAuth 2.0 client:
@Bean
public OAuth2AuthorizedClientManager authorizedClientManager(
        ClientRegistrationRepository clientRegistrationRepository,
        OAuth2AuthorizedClientRepository authorizedClientRepository) {

    DefaultOAuth2AuthorizedClientManager authorizedClientManager =
            new DefaultOAuth2AuthorizedClientManager(
                    clientRegistrationRepository, authorizedClientRepository);

    return authorizedClientManager;
}
  1. Create a Feign Request Interceptor that uses the OAuth2AuthorizedClientManager:
public class OAuthRequestInterceptor implements RequestInterceptor {

    private OAuth2AuthorizedClientManager manager;

    public OAuthRequestInterceptor(OAuth2AuthorizedClientManager manager) {
        this.manager = manager;
    }

    @Override
    public void apply(RequestTemplate requestTemplate) {
        OAuth2AuthorizedClient client = this.manager.authorize(OAuth2AuthorizeRequest.withClientRegistrationId("backend").principal(createPrincipal()).build());
        String accessToken = client.getAccessToken().getTokenValue();
        requestTemplate.header(HttpHeaders.AUTHORIZATION, "Bearer" + accessToken);
    }

    private Authentication createPrincipal() {
        return new Authentication() {
            @Override
            public Collection<? extends GrantedAuthority> getAuthorities() {
                return Collections.emptySet();
            }

            @Override
            public Object getCredentials() {
                return null;
            }

            @Override
            public Object getDetails() {
                return null;
            }

            @Override
            public Object getPrincipal() {
                return this;
            }

            @Override
            public boolean isAuthenticated() {
                return false;
            }

            @Override
            public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
            }

            @Override
            public String getName() {
                return "backend";
            }
        };
    }
}
  1. Create a FeignConfig that uses the Interceptor:
public class FeignClientConfig {


    @Bean
    public OAuthRequestInterceptor repositoryClientOAuth2Interceptor(OAuth2AuthorizedClientManager manager) {
        return new OAuthRequestInterceptor(manager);
    }
}
  1. And this is my Feign client:
@FeignClient(name = "BackendRepository", configuration = FeignClientConfig.class, url = "${BACKEND_URL}")
public interface BackendRepository {

    @GetMapping(path = "/healthChecks", produces = MediaType.APPLICATION_JSON_VALUE)
    public Info healthCheck();
}

When running this code, I get the error:

org.springframework.web.client.UnknownContentTypeException: Could not extract response: no suitable HttpMessageConverter found for response type [class org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse] and content type [text/html;charset=utf-8]

Debugging the code it looks like the DefaultClientCredentialsTokenResponseClient is requesting the auth endpoint using Basic Authentication. Although I never set this up.

Any advise what I can do? Maybe there is a completely different approach to do this.


Solution 1:

For this to work with Spring Security 5 and Feign you need to have

  • a working Spring Security config
  • a Feign interceptor
  • a Feign configuration using that interceptor
  1. Working Spring Security Config

Here we will register a generic internal-api client for your oauth2 client credentials. This is where you specify the client-id,client-secret, scopes and grant type. All basic Spring Security 5 stuff. This also involves setting up a provider (here I am using a custom OpenID Connect provider called "yourprovider"

spring:
  security:
    oauth2:
      client:
        registration:
          internal-api:
            provider: yourprovider
            client-id: x
            client-secret: y
            scope:
              - ROLE_ADMIN
            authorization-grant-type: client_credentials
        provider:
          yourprovider:
            issuer-uri: yourprovider.issuer-uri
      resourceserver:
        jwt:
          issuer-uri: yourprovider.issuer-uri

Next you need your feign config. This will use a OAuth2FeignRequestInterceptor

public class ServiceToServiceFeignConfiguration extends AbstractFeignConfiguration {

    @Bean
    public OAuth2FeignRequestInterceptor requestInterceptor() {
        return new OAuth2FeignRequestInterceptor(
                OAuth2AuthorizeRequest.withClientRegistrationId("internal-api")
                        .principal(new AnonymousAuthenticationToken("feignClient", "feignClient", createAuthorityList("ROLE_ANONYMOUS")))
                        .build());
    }
}

And a RequestInterceptor that looks like this :

The OAuth2AuthorizedClientManager is a bean that you can configure in your Configuration

public OAuth2AuthorizedClientManager authorizedClientManager(final ClientRegistrationRepository clientRegistrationRepository, final OAuth2AuthorizedClientService authorizedClientService) {
    return new AuthorizedClientServiceOAuth2AuthorizedClientManager(clientRegistrationRepository, authorizedClientService);
}

The OAuth2AuthorizeRequest is provided by the Feign Configuration above. The oAuth2AuthorizedClientManager can authorize the oAuth2AuthorizeRequest, get you the access token, and provide it as an Authorization header to the underlying service

public class OAuth2FeignRequestInterceptor implements RequestInterceptor {

    @Inject
    private OAuth2AuthorizedClientManager oAuth2AuthorizedClientManager;

    private OAuth2AuthorizeRequest oAuth2AuthorizeRequest;

    OAuth2FeignRequestInterceptor(OAuth2AuthorizeRequest oAuth2AuthorizeRequest) {
        this.oAuth2AuthorizeRequest = oAuth2AuthorizeRequest;
    }

    @Override
    public void apply(RequestTemplate template) {
        template.header(AUTHORIZATION,getAuthorizationToken());
    }

    private String getAuthorizationToken() {
        final OAuth2AccessToken accessToken = oAuth2AuthorizedClientManager.authorize(oAuth2AuthorizeRequest).getAccessToken();
        return String.format("%s %s", accessToken.getTokenType().getValue(), accessToken.getTokenValue());
    }

}

Solution 2:

I am quite experienced with Feign and OAuth2 and it took me some good hours to find how to do that. First, let's say that my app is based on latest Spring libraries, so I am using the following dependencies (managed version for spring-cloud-starter-openfeign is 3.0.0)

    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-openfeign</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-oauth2</artifactId>
        <version>2.2.4.RELEASE</version>
    </dependency>

In my application.properties I have the following

security.oauth2.client.access-token-uri=https://api.twitter.com/oauth2/token
security.oauth2.client.client-id=my-secret-twitter-id
security.oauth2.client.client-secret=my-secret-twitter-secret
security.oauth2.client.grant-type=client_credentials

And finally my configuration beans

package es.spanishkangaroo.ttanalyzer.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.cloud.openfeign.security.OAuth2FeignRequestInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.client.DefaultOAuth2ClientContext;
import org.springframework.security.oauth2.client.OAuth2RestTemplate;
import org.springframework.security.oauth2.client.token.grant.client.ClientCredentialsResourceDetails;

import feign.RequestInterceptor;

@Configuration
public class FeignClientConfiguration {
    
    @Bean
    @ConfigurationProperties(prefix = "security.oauth2.client")
    public ClientCredentialsResourceDetails clientCredentialsResourceDetails() {
        return new ClientCredentialsResourceDetails();
    }

    @Bean
    public RequestInterceptor oauth2FeignRequestInterceptor(){
        return new OAuth2FeignRequestInterceptor(new DefaultOAuth2ClientContext(), clientCredentialsResourceDetails());
    }

    @Bean
    public OAuth2RestTemplate clientCredentialsRestTemplate() {
        return new OAuth2RestTemplate(clientCredentialsResourceDetails());
    }

}

So then the Feign client is as simple as

package es.spanishkangaroo.ttanalyzer.api;

import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

import es.clovelly.ttanalyzer.model.Trends;

@FeignClient(name = "twitterClient", url = "https://api.twitter.com/1.1/")
public interface TwitterClient {
 
    @GetMapping("/trends/place.json")
    Trends[] getTrendsById(@RequestParam Long id);
    
}

As you may have noticed, the code is automatically getting a token (a bearer token) before the client call. If you are using a non-expiring bearer token you can just use something like

@Bean
public OAuth2ClientContext oAuth2ClientContext() {
    DefaultOAuth2ClientContext context = new DefaultOAuth2ClientContext();
    context.setAccessToken(bearerToken);
    return context;
}