'principalName cannot be empty' when using both oauth2-client and oauth2-resource-server
See original GitHub issueDescribe 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
- Call Service B with JWT without “sub” field.
- 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:
- Created a year ago
- Comments:6 (3 by maintainers)
Top Related StackOverflow Question
That makes sense, now I got it, thank you once again!
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_credentialsusing the example code withcontextWriteabove.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
ServerWebExchangeavailable 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 usingoauth.setDefaultClientRegistrationId("..."):If you hadn’t configured your filter function with a default
clientRegistrationIdthen this wouldn’t work. Meaning, it’s still an “opt-in” feature.