'principalName cannot be empty' when using both oauth2-client and oauth2-resource-server

See original GitHub issue

Describe the bug

Sample architecture:

 ____________                            ____________                         ____________  
|             |          JWT#1         |              |          JWT#2       |             | 
| Service A   |        ---------->     |  Service B   |        ---------->   | Service C   | 
|             |                        |              |                      |             | 
 --------------                         --------------                       -------------- 

Hello,

I have an application (Service B) that is both oauth2-client and oauth2-resource-server (please take a look at the architecture sample).

To authenticate with the Service B, Service A sends JWT#1 token without “sub” field:

{
  "scope": ["myscope"],
  "client_id": "helloworld",
  "iss": "https://helloworld",
  "exp": 1660858223
}

Next, we are trying to call service C - but first, we need to request another JWT from external source and use new JWT to call service C.

The problem is that it seems like JWT#1 is stored in reactive context and while trying to call service C, we get the following exception. If the call is made outside of main reactive context (for instance, subscribe is called somewhere on the side), everything works fine.

2022-08-22 10:41:42.703 ERROR 104229 --- [     parallel-1] a.w.r.e.AbstractErrorWebExceptionHandler : [f88479ae-2]  500 Server Error for HTTP POST "/helloworld"

java.lang.IllegalArgumentException: principalName cannot be empty
	at org.springframework.util.Assert.hasText(Assert.java:289) ~[spring-core-5.3.22.jar:5.3.22]
	Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException: 
Error has been observed at the following site(s):
	*__checkpoint ⇢ Request to POST null [DefaultWebClient]
	*__checkpoint ⇢ Handler com.example.demo.HttpApiController#helloWorld(Model, ServerWebExchange) [DispatcherHandler]
	*__checkpoint ⇢ org.springframework.security.web.server.authorization.AuthorizationWebFilter [DefaultWebFilterChain]
	*__checkpoint ⇢ org.springframework.security.web.server.authorization.ExceptionTranslationWebFilter [DefaultWebFilterChain]
	*__checkpoint ⇢ org.springframework.security.web.server.authentication.logout.LogoutWebFilter [DefaultWebFilterChain]
	*__checkpoint ⇢ org.springframework.security.web.server.savedrequest.ServerRequestCacheWebFilter [DefaultWebFilterChain]
	*__checkpoint ⇢ org.springframework.security.web.server.context.SecurityContextServerWebExchangeWebFilter [DefaultWebFilterChain]
	*__checkpoint ⇢ org.springframework.security.web.server.authentication.AuthenticationWebFilter [DefaultWebFilterChain]
	*__checkpoint ⇢ org.springframework.security.web.server.context.ReactorContextWebFilter [DefaultWebFilterChain]
	*__checkpoint ⇢ org.springframework.security.web.server.header.HttpHeaderWriterWebFilter [DefaultWebFilterChain]
	*__checkpoint ⇢ org.springframework.security.config.web.server.ServerHttpSecurity$ServerWebExchangeReactorContextWebFilter [DefaultWebFilterChain]
	*__checkpoint ⇢ org.springframework.security.web.server.WebFilterChainProxy [DefaultWebFilterChain]
	*__checkpoint ⇢ HTTP POST "/helloworld" [ExceptionHandlingWebHandler]
Original Stack Trace:
		at org.springframework.util.Assert.hasText(Assert.java:289) ~[spring-core-5.3.22.jar:5.3.22]
		at org.springframework.security.oauth2.client.InMemoryReactiveOAuth2AuthorizedClientService.loadAuthorizedClient(InMemoryReactiveOAuth2AuthorizedClientService.java:63) ~[spring-security-oauth2-client-5.7.3.jar:5.7.3]
		at org.springframework.security.oauth2.client.AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager.lambda$createAuthorizationContext$4(AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager.java:135) ~[spring-security-oauth2-client-5.7.3.jar:5.7.3]
		at reactor.core.publisher.FluxFlatMap.trySubscribeScalarMap(FluxFlatMap.java:152) ~[reactor-core-3.4.22.jar:3.4.22]
		at reactor.core.publisher.MonoFlatMap.subscribeOrReturn(MonoFlatMap.java:53) ~[reactor-core-3.4.22.jar:3.4.22]
		at reactor.core.publisher.InternalMonoOperator.subscribe(InternalMonoOperator.java:57) ~[reactor-core-3.4.22.jar:3.4.22]
		at reactor.core.publisher.MonoDefer.subscribe(MonoDefer.java:52) ~[reactor-core-3.4.22.jar:3.4.22]
		at reactor.core.publisher.Mono.subscribe(Mono.java:4397) ~[reactor-core-3.4.22.jar:3.4.22]
		at reactor.core.publisher.FluxSwitchIfEmpty$SwitchIfEmptySubscriber.onComplete(FluxSwitchIfEmpty.java:82) ~[reactor-core-3.4.22.jar:3.4.22]
		at reactor.core.publisher.FluxMap$MapSubscriber.onComplete(FluxMap.java:144) ~[reactor-core-3.4.22.jar:3.4.22]
		at reactor.core.publisher.Operators.complete(Operators.java:137) ~[reactor-core-3.4.22.jar:3.4.22]
		at reactor.core.publisher.MonoEmpty.subscribe(MonoEmpty.java:46) ~[reactor-core-3.4.22.jar:3.4.22]
		at reactor.core.publisher.InternalMonoOperator.subscribe(InternalMonoOperator.java:64) ~[reactor-core-3.4.22.jar:3.4.22]
		at reactor.core.publisher.MonoFlatMap$FlatMapMain.onNext(MonoFlatMap.java:157) ~[reactor-core-3.4.22.jar:3.4.22]
		at reactor.core.publisher.FluxMap$MapSubscriber.onNext(FluxMap.java:122) ~[reactor-core-3.4.22.jar:3.4.22]
		at reactor.core.publisher.Operators$MonoSubscriber.complete(Operators.java:1816) ~[reactor-core-3.4.22.jar:3.4.22]
		at reactor.core.publisher.MonoZip$ZipCoordinator.signal(MonoZip.java:251) ~[reactor-core-3.4.22.jar:3.4.22]
		at reactor.core.publisher.MonoZip$ZipInner.onNext(MonoZip.java:336) ~[reactor-core-3.4.22.jar:3.4.22]
		at reactor.core.publisher.FluxDefaultIfEmpty$DefaultIfEmptySubscriber.onNext(FluxDefaultIfEmpty.java:101) ~[reactor-core-3.4.22.jar:3.4.22]
		at reactor.core.publisher.FluxMap$MapSubscriber.onNext(FluxMap.java:122) ~[reactor-core-3.4.22.jar:3.4.22]
		at reactor.core.publisher.FluxSwitchIfEmpty$SwitchIfEmptySubscriber.onNext(FluxSwitchIfEmpty.java:74) ~[reactor-core-3.4.22.jar:3.4.22]
		at reactor.core.publisher.FluxMapFuseable$MapFuseableSubscriber.onNext(FluxMapFuseable.java:129) ~[reactor-core-3.4.22.jar:3.4.22]
		at reactor.core.publisher.FluxFilterFuseable$FilterFuseableSubscriber.onNext(FluxFilterFuseable.java:118) ~[reactor-core-3.4.22.jar:3.4.22]
		at reactor.core.publisher.Operators$ScalarSubscription.request(Operators.java:2398) ~[reactor-core-3.4.22.jar:3.4.22]
		at reactor.core.publisher.FluxFilterFuseable$FilterFuseableSubscriber.request(FluxFilterFuseable.java:191) ~[reactor-core-3.4.22.jar:3.4.22]
		at reactor.core.publisher.FluxMapFuseable$MapFuseableSubscriber.request(FluxMapFuseable.java:171) ~[reactor-core-3.4.22.jar:3.4.22]
		at reactor.core.publisher.Operators$MultiSubscriptionSubscriber.set(Operators.java:2194) ~[reactor-core-3.4.22.jar:3.4.22]
		at reactor.core.publisher.Operators$MultiSubscriptionSubscriber.onSubscribe(Operators.java:2068) ~[reactor-core-3.4.22.jar:3.4.22]
		at reactor.core.publisher.FluxMapFuseable$MapFuseableSubscriber.onSubscribe(FluxMapFuseable.java:96) ~[reactor-core-3.4.22.jar:3.4.22]
		at reactor.core.publisher.FluxFilterFuseable$FilterFuseableSubscriber.onSubscribe(FluxFilterFuseable.java:87) ~[reactor-core-3.4.22.jar:3.4.22]
		at reactor.core.publisher.MonoCurrentContext.subscribe(MonoCurrentContext.java:36) ~[reactor-core-3.4.22.jar:3.4.22]
		at reactor.core.publisher.Mono.subscribe(Mono.java:4397) ~[reactor-core-3.4.22.jar:3.4.22]
		at reactor.core.publisher.FluxSwitchIfEmpty$SwitchIfEmptySubscriber.onComplete(FluxSwitchIfEmpty.java:82) ~[reactor-core-3.4.22.jar:3.4.22]
...

To Reproduce

  1. Call Service B with JWT without “sub” field.
  2. Observe an error while Service B is trying to call Service C.

Expected behavior

It is allowed to call service C, no exceptions thrown.

Sample

@RestController
public record HttpApiController(LogProcessor logProcessor) {
    @PostMapping("/helloworld")
    public Mono<ResponseEntity<Void>> helloWorld(Model logs, ServerWebExchange exchange) {
        return logProcessor
                .push(Flux.just(logs))
                .then(Mono.just(new ResponseEntity<>(HttpStatus.OK)));
    }
}
public record LogProcessor(WebClient webClient) {

    public Mono<Object> push(Flux<Model> items) {
        return items
                .collectList()
                .flatMap(item -> webClient
                        .post()
                        .bodyValue(item)
                        .exchangeToMono(response -> Mono.just(response.headers())));
    }
}
@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {

    @Bean
    public SecurityWebFilterChain configure(ServerHttpSecurity http)  {
        http
                .securityContextRepository(NoOpServerSecurityContextRepository.getInstance())
                .csrf()
                .disable()
                .headers(it -> it.contentSecurityPolicy(contentSecurityPolicy -> 
                        contentSecurityPolicy
                                .policyDirectives("default-src 'none'; script-src 'none'; object-src 'none'; base-uri 'none'; require-trusted-types-for 'script';")))
                .authorizeExchange (it -> it.pathMatchers("/helloworld").access(AuthorityReactiveAuthorizationManager.hasAuthority("SCOPE_myscope")))
                .oauth2ResourceServer(ServerHttpSecurity.OAuth2ResourceServerSpec::jwt);

        return http.build();
    }
}

@Configuration
public class WebClientConfig {

    @Bean
    LogProcessor logProcessor(
            WebClient.Builder builder,
            @Value("${spring.security.oauth2.client.provider.token-uri}") String tokenUri,
            @Value("${spring.security.oauth2.client.registration.client-id}") String clientId,
            @Value("${spring.security.oauth2.client.registration.client-secret}") String clientSecret,
            @Value("${spring.security.oauth2.client.registration.authorization-grant-type}") String authorizationGrantType) {
        if (!Objects.equals(authorizationGrantType, AuthorizationGrantType.CLIENT_CREDENTIALS.getValue())) {
            throw new IllegalArgumentException("Only client_credentials auth is supported, found: $authorizationGrantType");
        }

        var registration = ClientRegistration
                .withRegistrationId("clientRegistrationId")
                .tokenUri(tokenUri)
                .clientId(clientId)
                .clientSecret(clientSecret)
                .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
                .build();

        var clientRegistration = new InMemoryReactiveClientRegistrationRepository(registration);

        var clientService = new InMemoryReactiveOAuth2AuthorizedClientService(clientRegistration);
        var authorizedClientManager =
                new AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager(clientRegistration, clientService);

        var oauth = new ServerOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
        oauth.setDefaultClientRegistrationId("clientRegistrationId");

        var client =  WebClient.builder()
                .baseUrl("http://google.com")
                .filter(oauth)
                .build();

        return new LogProcessor(client);
    }
}

@Configuration
class JwtConfig {
        @Bean
        ReactiveJwtDecoder jwtPrefetchingDecoderByJwkKeySetUri(OAuth2ResourceServerProperties properties) {
            var props = properties.getJwt();
            return NimbusReactiveJwtDecoder.withJwkSetUri(props.getJwkSetUri())
                    .jwsAlgorithm(SignatureAlgorithm.from(props.getJwsAlgorithm()))
                    .build();
        }
}
plugins {
	id 'org.springframework.boot' version '2.7.3'
	id 'io.spring.dependency-management' version '1.0.13.RELEASE'
	id 'java'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-webflux'
	implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
	implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'

	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'io.projectreactor:reactor-test'
}

tasks.named('test') {
	useJUnitPlatform()
}

Issue Analytics

  • State:closed
  • Created a year ago
  • Comments:6 (3 by maintainers)

github_iconTop GitHub Comments

1reaction
arturk9commented, Aug 23, 2022

That makes sense, now I got it, thank you once again!

0reactions
sjohnrcommented, Aug 23, 2022

In this case, you can observe the silent fallback to annonymousUser, and as you mentioned - shouldn’t this also throw the same error, to avoid giving users access to resources they shouldn’t have access to?

I’m sorry, I misspoke. I said “in this case” but I meant “in the general case.” In your specific case, you can “opt-in” to using a global access token with client_credentials using the example code with contextWrite above.

The second test case you provided is manually subscribing to the reactive stream instead of the framework (Spring WebFlux) doing so. In that case, you’re operating outside the context of a request (there’s no ServerWebExchange available in the reactor context), which means you automatically fall into the 2nd case. When there is no authentication context (no request being processed, or a truly anonymous request e.g. permitAll()) the framework is making that assumption for you. However, you’ll note that warning in the docs when using oauth.setDefaultClientRegistrationId("..."):

WARNING: It is recommended to be cautious with this feature since all HTTP requests will receive the access token.

If you hadn’t configured your filter function with a default clientRegistrationId then this wouldn’t work. Meaning, it’s still an “opt-in” feature.

Read more comments on GitHub >

github_iconTop Results From Across the Web

Spring Security 5 with oauth2 causing 'principalName cannot ...
Spring security throws a principalName cannot be empty error. java.lang.IllegalArgumentException: principalName cannot be empty at org.
Read more >
WebClient reusing inbound WebSecurity on outbound calls
... implements both a oauth2 resource server and oauth2 client and both use ... IllegalArgumentException: principalName cannot be empty at ...
Read more >
Spring Security 5 with oauth2 causing 'principalName cannot ...
The problem is that you have an application that is simultaneously a resourceServer and an oauth2Client , as weird as that may sound....
Read more >
Extracting Principal and Authorities using Spring Security OAuth
Learn how to extract user information in an OAuth setup.
Read more >
spring-projects/spring-security - Gitter
IllegalArgumentException : principalName cannot be empty at org.springframework.util. ... offers "OAuth2 Client" and "OAuth2 Resource Server" under Security.
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found