I've noticed this for some time in my application logs and thought I was just misconfiguring Spring Security WebFlux somehow. But upon closer examination, I think there's a bug in AuthenticationWebFilter that causes this behavior:

https://github.com/spring-projects/spring-security/blob/8e2a4bf3562133c78230ec5a96ec993c5c92374b/web/src/main/java/org/springframework/security/web/server/authentication/AuthenticationWebFilter.java#L114

        @Override
    public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
        return this.requiresAuthenticationMatcher.matches(exchange)
            .filter((matchResult) -> matchResult.isMatch())
            .flatMap((matchResult) -> this.authenticationConverter.convert(exchange))
            .switchIfEmpty(chain.filter(exchange).then(Mono.empty()))
            .flatMap((token) -> authenticate(exchange, chain, token))
            .onErrorResume(AuthenticationException.class, (ex) -> this.authenticationFailureHandler
                .onAuthenticationFailure(new WebFilterExchange(exchange, chain), ex));
    }

The .switchIfEmpty() here is actually subscribing to the filter chain on downstream subscription, rather than deferring it until it is needed. Shouldn't that line be more like:

.switchIfEmpy(Mono.defer(() -> chain.filter(exchange).then(Mono.empty()))

And, in fact, the method directly below this one uses Mono.defer() for error cases.

Comment From: ngocnhan-tran1996

I search with keyword .switchIfEmpty(chain.filter( and result

Image

*Filter may be run twice as you said and I am not sure whether it's a bug

Comment From: pat-mccusker

Out of curiosity, do you know what implementation of WebSecurityFilterChain you have selected? In spring-web's DefaultWebFilterChain the filter method contains its own deferred Mono:

@Override
public Mono<Void> filter(ServerWebExchange exchange) {
    return Mono.defer(() ->
        this.currentFilter != null && this.chain != null ?
                invokeFilter(this.currentFilter, this.chain, exchange) :
                this.handler.handle(exchange));
}

Comment From: jdolan-chwy

I'm mocking it. Pretty sure I see this in the application when it's running as well. I'll confirm that tomorrow.

@Test
void doFilterSuccessful() {

  final var req = MockServerHttpRequest.get("/api/customer/test")
      .cookie(new HttpCookie(ACCESS_TOKEN_COOKIE_NAME, AUTHENTICATED.getAccessTokenValue()))
      .build();

  final var ex = MockServerWebExchange.from(req);
  final var chain = mock(WebFilterChain.class);
  when(chain.filter(ex)).thenReturn(Mono.empty());

  when(authContextService.getAuthContext(any(), any())).thenReturn(AUTHENTICATED);

  StepVerifier.create(filter.filter(ex, chain).verifyComplete();

  verify(authContextService).getAuthContext(any(), any());

  // https://github.com/spring-projects/spring-security/issues/16553
  verify(chain, times(2)).filter(ex);
}

Comment From: pat-mccusker

I think it all depends on what your real WebSecurityFilterChain is doing. Multiple invocations of that mock doesn't itself indicate a problem if the real chain that is used at runtime would handle the deferral itself.

Comment From: jdolan-chwy

I can see that side of it -- that in practice this shouldn't happen due to the default implementation. But shouldn't AuthenticationWebFilter treat the chain as a black box and not rely on its implementation details to correct for its eager subscriptions?

Comment From: pat-mccusker

There probably wouldn't be any harm in deferrals on both sides, and I definitely think that issues around eagerness are bound to arise with reactor's switchIfEmpty behavior. Interestingly enough, reactor's own kotlin switchIfEmpty extension function includes a deferral in its definition, seemingly indicating an accounting for what might be a common problem.

All that being the case, it may not be the cause of your current duplicated execution problem.

Comment From: jdolan-chwy

Yea, that's very telling that they put foam padding around that method for Kotlin 😄