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()
}
Comment From: sjohnr
@arturk9, thanks for reaching out!
Looking at the architecture, there looks to be quite a bit going on. Would you be able to provide a full reproducible sample (including all 3 services) either as a zip file attached to this issue or a link to a GitHub repository? This would be preferred to copy/paste of the provided sources in text.
Also, can you provide details on how you're constructing the JWT that is used to call the service? Providing a command or set of commands used to reproduce the issue along with the sample would be very helpful.
Comment From: arturk9
Hello @sjohnr, thank you for your response!
I have prepared testcase with hardcoded tokens and keys that allowed to reproduce the issue in the test.
To reproduce, launch testcase: https://github.com/arturk9/spring-security-client-server/blob/master/src/test/java/com/example/demo/DemoApplicationTests.java#L44
You can find mentioned error in the logs:
12:41:43.015 [parallel-1] ERROR o.s.b.a.w.r.e.AbstractErrorWebExceptionHandler - [3bfa094b] 500 Server Error for HTTP POST "/helloworld"
java.lang.IllegalArgumentException: principalName cannot be empty
at org.springframework.util.Assert.hasText(Assert.java:289)
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)
at org.springframework.security.oauth2.client.InMemoryReactiveOAuth2AuthorizedClientService.loadAuthorizedClient(InMemoryReactiveOAuth2AuthorizedClientService.java:63)
at org.springframework.security.oauth2.client.AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager.lambda$createAuthorizationContext$4(AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager.java:135)
Please find the entire code sample in the link below: https://github.com/arturk9/spring-security-client-server
Please let me know if there is something more I could do to showcase the exception, I would be happy to help.
Comment From: sjohnr
Thanks for the sample, @arturk9!
Spring Security is designed to work with authentication contexts that have a principal name. Without a principal name (missing sub claim), this setup assumes that an anonymous authentication context is adequate to perform authorization with OAuth2. At a high level, there are two basic use cases for an OAuth client:
- A resource owner is authorizing the client to access resources on its behalf. This is usually the
authorization_codegrant and authorizations are typically stored per-user. - A client is acting on its own behalf. This is usually the
client_credentialsgrant and authorizations are typically global to the application.
Your sample seems to operate under the 2nd case. However, on top of those two categories is the context of the application that is acting as a client. In this case (in many/most cases, probably) there is also a request that is being processed. Since the request does have an authentication context, Spring Security assumes it will be used as the context of the downstream authorization. That way, each user (resource owner) would have a separate authorization stored. The authorizations are stored under a key consisting of registrationId and principalName. At this level of abstraction, the framework doesn't differentiate between grant type. (It would be nice to be able to, but this is quite difficult due to the complexity of the various OAuth specs/flows that need to be supported.)
In order to adapt an application that is processing a request (1st case) to the 2nd case above, you will need to supply a principal name that makes the client_credentials authorization global to the application. For example, to make the filter function fall back to a principal name of anonymousUser, you can clear the authentication context like this:
webClient.post()
.bodyValue(item)
.exchangeToMono(response -> Mono.just(response.headers()))
.contextWrite(ReactiveSecurityContextHolder.clearContext())
Of course, when doing this, all requests will shared the same access_token. Without a principal name in the incoming request, there's really no way to consistently differentiate between requests.
The WebClient section of the docs has examples for a few other common scenarios (such as manually providing the OAuth2AuthorizedClient). You may also consider AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager instead of DefaultReactiveOAuth2AuthorizedClientManager.
At this time, I don't believe this is a bug. The assertion that caused your stack trace is there to actually point out that a principalName is required for the oauth client features to work correctly. It would probably be worse to silently fall back to anonymousUser in this case, because it would be unexpected and could give users access to resources they shouldn't have access to.
Given that, I'm going to close this issue for now. If I have misunderstood anything or you have other thoughts, feel free to add additional comments and we can re-open if necessary.
Comment From: arturk9
Hello @sjohnr, thank you for the answer!
I am trying to understand it better and your answer was really helpful, thank you!
I supplied following example with one more test case.
https://github.com/arturk9/spring-security-client-server/blob/master/src/test/java/com/example/demo/DemoApplicationTests.java#L75
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?
In the following example, it is allowed to perform request to downstream service.
https://github.com/arturk9/spring-security-client-server/blob/master/src/main/java/com/example/demo/HttpApiController.java#L22
Thank you once again for the explanation.
Comment From: sjohnr
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.
Comment From: arturk9
That makes sense, now I got it, thank you once again!