2
votes

I'm trying to implement an oauth2 secured Spring Boot API with Spring Security 5. I want the API to be an oauth2 resource server and to be able to access external oauth2 resource servers using a WebClient, with client credentials grant.

I can configure the API to be an oauth2 resource server or an oauth2 client, but not both at the same time.

Below is the minimal setup for configuring the API to be a resource server with Spring security 5. I'm using Opaque Tokens so server is configured for that.

application.properties

spring.security.oauth2.resourceserver.opaquetoken.introspection-uri=http://localhost:8086/auth/oauth/check_token
spring.security.oauth2.resourceserver.opaquetoken.client-id=test-api
spring.security.oauth2.resourceserver.opaquetoken.client-secret=e61aa5d6-074d-4216-b15f-1bf3fc71b833

WebSecurity Configuration class

@EnableWebSecurity
public class WebSecurity extends WebSecurityConfigurerAdapter {
    
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests(authorizeRequests ->
                    authorizeRequests
                        .anyRequest().authenticated()
            )
            .oauth2ResourceServer(OAuth2ResourceServerConfigurer::opaqueToken);

    }
}

With this setup I can hit this api endpoints with a valid token to access its protected resources. So resource server configuration works fine on its own.

Below is the minimal Spring security 5 setup configured to use a WebClient to access external protected resources using client credentials grant.

application.properties

spring.security.oauth2.client.provider.my-oauth-provider.token-uri=http://127.0.0.1:8086/auth/oauth/token
spring.security.oauth2.client.registration.test-api.client-id=test-store
spring.security.oauth2.client.registration.test-api.client-secret=password
spring.security.oauth2.client.registration.test-api.provider=my-oauth-provider
spring.security.oauth2.client.registration.test-api.scope=read,write
spring.security.oauth2.client.registration.test-api.authorization-grant-type=client_credentials

WebSecurity Configuration class

@EnableWebSecurity
public class WebSecurity extends WebSecurityConfigurerAdapter {
    
    @Autowired
    ClientRegistrationRepository clientRegistrationRepository;
    
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .oauth2Client(oauth2 -> oauth2
                .clientRegistrationRepository(clientRegistrationRepository)
            );
    }
}

Oauth2Client Configuration class

@Configuration
public class Oauth2ClientConfig {
    
    @Bean
    public OAuth2AuthorizedClientManager authorizedClientManager(
            ClientRegistrationRepository clientRegistrationRepository,
            OAuth2AuthorizedClientRepository authorizedClientRepository) {

        OAuth2AuthorizedClientProvider authorizedClientProvider =
                OAuth2AuthorizedClientProviderBuilder.builder()
                        .clientCredentials()
                        .build();

        DefaultOAuth2AuthorizedClientManager authorizedClientManager =
                new DefaultOAuth2AuthorizedClientManager(
                        clientRegistrationRepository, authorizedClientRepository);
        authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);

        return authorizedClientManager;
    }

    
    @Bean
    WebClient webClient(OAuth2AuthorizedClientManager oAuth2AuthorizedClientManager) {
        ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2Client =
                new ServletOAuth2AuthorizedClientExchangeFilterFunction(
                        oAuth2AuthorizedClientManager);
        
        // default registrationId
       oauth2Client.setDefaultClientRegistrationId("test-api");
        
        // set client to use oauth2 by default globally
       oauth2Client.setDefaultOAuth2AuthorizedClient(true);
        
        return WebClient.builder()
          .apply(oauth2Client.oauth2Configuration())
          .build();
    }
}

With this setup I can hit an external protected endpoint to access a protected resource using the client credentials grant.

However when I put these two configurations together, it doesn't work when I try to hit my api endpoint which in turn tries to access an external resource using WebClient.

    @Autowired
    WebClient webClient;

    @GetMapping("test")
    public String test() {
        String message = webClient
                .get()
                .uri("http://localhost:8084/external/api/endpoint")
                .retrieve()
                .bodyToMono(String.class)
                .block();
        return message;
    }

Spring security throws a principalName cannot be empty error.


java.lang.IllegalArgumentException: principalName cannot be empty
    at org.springframework.util.Assert.hasText(Assert.java:287) ~[spring-core-5.2.8.RELEASE.jar:5.2.8.RELEASE]
    Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException: 
Error has been observed at the following site(s):
    |_ checkpoint ⇢ Request to GET http://localhost:8084/ocr/document/test [DefaultWebClient]
Stack trace:
        at org.springframework.util.Assert.hasText(Assert.java:287) ~[spring-core-5.2.8.RELEASE.jar:5.2.8.RELEASE]
        at org.springframework.security.oauth2.client.InMemoryOAuth2AuthorizedClientService.loadAuthorizedClient(InMemoryOAuth2AuthorizedClientService.java:73) ~[spring-security-oauth2-client-5.3.3.RELEASE.jar:5.3.3.RELEASE]
        at org.springframework.security.oauth2.client.web.AuthenticatedPrincipalOAuth2AuthorizedClientRepository.loadAuthorizedClient(AuthenticatedPrincipalOAuth2AuthorizedClientRepository.java:73) ~[spring-security-oauth2-client-5.3.3.RELEASE.jar:5.3.3.RELEASE]
        at org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizedClientManager.authorize(DefaultOAuth2AuthorizedClientManager.java:144) ~[spring-security-oauth2-client-5.3.3.RELEASE.jar:5.3.3.RELEASE]
        at org.springframework.security.oauth2.client.web.reactive.function.client.ServletOAuth2AuthorizedClientExchangeFilterFunction.lambda$authorizeClient$24(ServletOAuth2AuthorizedClientExchangeFilterFunction.java:534) ~[spring-security-oauth2-client-5.3.3.RELEASE.jar:5.3.3.RELEASE]
...

Any pointers on solving this issue is highly appreciated. Is there a different way I should configure the WebClient to work when the API is also a resource server?

I have these dependencies in my maven project. Spring security version is 5.3.3.RELEASE pom.xml

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.3.2.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>

    ...

        <!-- Spring Boot Web starter -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!-- Security Starter -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <!-- Oauth2 resource server -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
        </dependency>
        <dependency>
            <groupId>com.nimbusds</groupId>
            <artifactId>oauth2-oidc-sdk</artifactId>
        </dependency>
        <!-- Oauth2 client -->
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-oauth2-client</artifactId>
        </dependency>
        <!-- WebClient -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webflux</artifactId>
        </dependency>
2

2 Answers

3
votes

From here: https://www.gitmemory.com/issue/spring-projects/spring-security/8398/614615036

This is expected behaviour -> "principalName cannot be empty".

The OAuth2AuthorizedClient requires the principalName since an access token is always associated with a principal. The ReactiveOAuth2AuthorizedClientService validates the principalName and is expected to fail if the principalName is not available.

The reason principalName is not available is because JwtAuthenticationToken.getName() will default to the sub claim, which is not available in the claims set of the Jwt, as per your token example above. What you need to do is specify that the user_name claim be used as the principalName.

Take a look at this sample that shows you how to configure the JwtDecoder to default to the user_name for JwtAuthenticationToken.getName().

The linked sample doesn't work as is because there is no "user_name" value in convertedClaims so I just made up my own username. This is what I added to my configuration:

http.authorizeExchange(exchangeSpec -> exchangeSpec.anyExchange().authenticated())
                .oauth2ResourceServer(oAuth2 -> oAuth2.jwt(jwt -> jwt.jwtDecoder(jwtDecoder())));
http.oauth2Login();

and

public class UsernameSubClaimAdapter implements Converter<Map<String, Object>, Map<String, Object>> {
    private final MappedJwtClaimSetConverter delegate =
            MappedJwtClaimSetConverter.withDefaults(Collections.emptyMap());

    public Map<String, Object> convert(Map<String, Object> claims) {
        Map<String, Object> convertedClaims = this.delegate.convert(claims);

        String username = "anonymous_user";
        convertedClaims.put("sub", username);

        return convertedClaims;
    }
}

@Bean
public NimbusReactiveJwtDecoder jwtDecoder() {
    NimbusReactiveJwtDecoder jwtDecoder = new NimbusReactiveJwtDecoder(processor());
    jwtDecoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(_jwtIssuerUrl));
    jwtDecoder.setClaimSetConverter(new UsernameSubClaimAdapter());
    return jwtDecoder;
}
1
votes

The problem is that you have an application that is simultaneously a resourceServer and an oauth2Client, as weird as that may sound. Once you include the .oauth2ResourceServer(OAuth2ResourceServerConfigurer::opaqueToken) configuration into your httpSecurity, all Authentications will now be Instances of BearerTokenAuthentication. Those will not have a name associated with them, since your opaque token has no name field. If you exclude the .oauth2ResourceServer(OAuth2ResourceServerConfigurer::opaqueToken), all authentications will be instances of AnonymousAuthenticationToken, with the default principal anonymousUser.

The solution to this problem is implementing your own introspector for opaque tokens, that will set a principal name to the authentication. I did this quick and dirty by extendind the NimbusReactiveOpaqueTokenIntrospector and copying the Oauth2Authentication with a default principal name.

Introspector:

class ExtendedNimbusReactiveOpaqueTokenIntrospector(introspectionUri: String, clientId: String, clientSecret: String) : NimbusReactiveOpaqueTokenIntrospector(introspectionUri, clientId, clientSecret) {
    override fun introspect(token: String?): Mono<OAuth2AuthenticatedPrincipal> {
        return super.introspect(token).map { auth ->
            val name = SecurityContextHolder.getContext().authentication?.name ?: "defaultPrincipal"
            DefaultOAuth2AuthenticatedPrincipal(name, auth.attributes, auth.authorities)
    }
}

}

WebSecurityConfig:

@Configuration
@EnableWebFluxSecurity
class OAuthConfig
@Autowired constructor(@Value("\${spring.security.oauth2.resourceserver.opaquetoken.introspection-uri}") private val introspectionUri: String,
                   @Value("\${spring.security.oauth2.resourceserver.opaquetoken.client-id}") private val clientId: String,
                   @Value("\${spring.security.oauth2.resourceserver.opaquetoken.client-secret}") private val clientSecret: String){

    @Bean
    fun securityWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
        return http
            .oauth2Client {

            }
            .authorizeExchange { authExchange ->
                authExchange.pathMatchers(
                        "/apidoc",
                        "/swagger-ui.html",
                        "/webjars/**",
                        "/swagger-resources/**",
                        "/v2/api-docs")
                        .permitAll()
                        .anyExchange()
                        .permitAll()
            }
            .oauth2Client {}
            .oauth2ResourceServer {
                it.opaqueToken {opaqueTokenSpec ->
                    opaqueTokenSpec.introspector(ReweNimbusReactiveOpaqueTokenIntrospector(introspectionUri, clientId, clientSecret))
                }
            }
            .build()
    }

}

WebClientConfig:

@Configuration
class WebClientConfig @Autowired constructor() {

    @Bean
    fun authorizedClientManager(
            clientRegistrationRepository: ReactiveClientRegistrationRepository?,
            authorizedClientService: ReactiveOAuth2AuthorizedClientService?
    ): ReactiveOAuth2AuthorizedClientManager? {
        val authorizedClientProvider = ReactiveOAuth2AuthorizedClientProviderBuilder.builder()
            .clientCredentials()
            .build()
        val authorizedClientManager = AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager(clientRegistrationRepository, authorizedClientService)
        authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider)
        return authorizedClientManager
    }

    @Bean
    fun webClientBuilder(authorizedClientManager: ReactiveOAuth2AuthorizedClientManager?): WebClient.Builder? {
        val oauth = ServerOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager)
    oauth.setDefaultClientRegistrationId("rewe")
        return WebClient.builder()
            .filter(oauth)
    }

    @Bean
    fun authorizedWebClient(webClientBuilder: WebClient.Builder, @Value("\${host}") host: String): WebClient {
        return webClientBuilder.baseUrl(host).build()
    }
}