spring-security: WebFlux security should not overwrite the default entry point with a delegating entry point

Describe the bug

When ServerHttpSecurity.build() creates a SecurityWebFilterChain, it replaces the default entry point with the last delegating entry point in this.defaultEntryPoints.

https://github.com/spring-projects/spring-security/blob/3cba4eccdcd5f17932ea9f365bd6df2684820819/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java#L1434-L1445

Specifically:

result.setDefaultEntryPoint(this.defaultEntryPoints.get(this.defaultEntryPoints.size() - 1).getEntryPoint());

Since delegating entry points should be applied conditionally, this breaks the contract for certain default entry points, such as OAuth2LoginSpec.setDefaultEntryponits, which are designed to be conditional. For example, the OAuth2LoginSpec is designed to not redirect on XHR requests but this behavior breaks that contract.

This cannot be worked around by configuring a new default entry point (e.g., .exceptionHandling().authenticationEntryPoint(...)) because doing so triggers another bug, which I’ll open another issue about.

To Reproduce

The issue is reproducible most easily with OAuth2 login. However, the problem is not unique to this scenario.

  1. Configure Spring WebFlux security with OAuth2 login
@Configuration
@EnableWebFluxSecurity
public class SecurityConfig
{
    @Bean
    public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http)
    {
        return http
                .authorizeExchange(exchanges -> exchanges.anyExchange().authenticated())
                .oauth2Login()
                .and()
                .build();
    }
}

https://github.com/foo4u/spring-security-bugs/blob/38ba4cd007eb3a7d75f5912f5a013b7ee4ac197d/src/main/java/com/example/demo/SecurityConfig.java#L9-L22

  1. Make an XHR request to an endpoint requiring authentication with XHR headers expecting an HTTP 401.
	@Test
	void respondsWithHttp401()
	{
		client
				.get()
				.accept(MediaType.APPLICATION_JSON)
				.header("X-Requested-With","XMLHttpRequest")
				.exchange()
				.expectStatus()
				.isUnauthorized();
	}

https://github.com/foo4u/spring-security-bugs/blob/38ba4cd007eb3a7d75f5912f5a013b7ee4ac197d/src/test/java/com/example/demo/DemoApplicationTests.java#L27-L37

  1. Note a 302 redirect to the login provider is sent as the response instead of a 401.

    [ERROR] Failures: [ERROR] DemoApplicationTests.respondsWithHttp401:36 Status expected:<401 UNAUTHORIZED> but was:<302 FOUND>

Expected behavior

The web filter chain should return an HTTP 401, not redirect.

Sample

GitHub repository with minimal, reproducible sample.

Two ways to reproduce with sample:

  1. Run the test suite
  2. Start the server and cURL any endpoint

About this issue

  • Original URL
  • State: closed
  • Created 4 years ago
  • Reactions: 1
  • Comments: 15 (7 by maintainers)

Commits related to this issue

Most upvoted comments

@jgrandja, as I mentioned previously, the test case is incorrect. I’m not sure why you’re fixated on this test passing. It makes zero sense to send a redirect on an XHR request.

I’ll attempt to send another code sample clarifying the problem.

But why would anyone expect a redirect as a response to an unauthorized XHR request? I thought that a default spring-security response since #3887 is 401 Unauthorized. If so, why this behaviour was changed for oauth2?

@jgrandja could you look into this? Since XHR is triggered by a browser application a 3xx has to be handled in code, since the result page will not be presented to a user. Hard to distinguish form a valid redirect after creating a resource.

@jgrandja sorry to bump this old issue again - if you prefer I create a new one let me know.

I’m still unclear what the intended behavior is here. I have an application set up as follows:

  • User logs in through .oauth2Login(), using redirects of course since that’s how .oauth2Login works. These redirects are not triggered by XHR requests since that wouldn’t make sense, but actual browser redirects.
  • Once logged in, a session is created and XHR requests auth state is managed through that session.

So when you say:

In a typical SPA (or XHR/JS) application that has oauth2Login() configured for authentication, the authentication flow is triggered at the start allowing the browser agent to handle it. After authentication completes, the SPA (or XHR/JS) code is loaded and continues from there.

Indeed that is what I do. The issue is what happens if your session expires.

What I would expect:

If the session expires, the next XHR request returns a 401. The SPA can handle this case as an expiration.

What I get:

If the session expires, the next XHR request returns a 302 to /login, which is followed and then returns a 200 with HTML for the default login page.

Is this the intended behavior?

I believe the “culprit” is indeed this line what was mentioned when this issue was raised: https://github.com/spring-projects/spring-security/blob/5.6.3/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java#L1511

Without this override of the defaultHandler, the behavior would indeed be that an error code is returned for XHR requests (although it would return a 403 and I would expect a 401, but not at least not a 302 to /login).

What I don’t understand is in this comment: https://github.com/spring-projects/spring-security/issues/9266#issuecomment-763857025 you mention that a redirect to the identity provider makes no sense for XHR requests (which I fully agree with!) but then a redirect to /login which returns 200 with some HTML does make sense for XHR requests? For me neither makes sense.

@jgrandja why would you intentionally redirect XHR requests to the login page? Also, the test case you mentioned doing a 3XX redirect only “works” as a side effect of this bug.

The code in OAuth2LoginSpec specifically attempts to not apply these handlers in the case of an XHR request:

https://github.com/spring-projects/spring-security/blob/8cefc8a792dab5bfef7dc698cf44c7c2bb909d54/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java#L3274-L3312

Can you provide another test that demonstrates if this is a bug?

It is a bug, that test case should be expecting a 401, not a 3XX.

I took me 2 days to track this problem and finally find this issue, so +1 from me.

My workaround is to create a custom ServerAuthenticationEntryPoint and redirect all requests to /oauth2/authorization/cas except from requests to /api/**:

	public class RedirectOrFailAuthenticationEntryPoint implements ServerAuthenticationEntryPoint {

		private final ServerWebExchangeMatcher failMatcher;

		private final RedirectServerAuthenticationEntryPoint redirectEntryPoint;
		private final HttpStatusServerEntryPoint httpStatusEntryPoint;

		RedirectOrFailAuthenticationEntryPoint(final String redirectionUri, final String failurePath) {
			this.failMatcher = ServerWebExchangeMatchers.pathMatchers(failurePath);
			redirectEntryPoint = new RedirectServerAuthenticationEntryPoint(redirectionUri);
			httpStatusEntryPoint = new HttpStatusServerEntryPoint(HttpStatus.UNAUTHORIZED);
		}

		@Override
		public Mono<Void> commence(ServerWebExchange exchange, AuthenticationException e) {
			return failMatcher.matches(exchange)
				.map(result -> result.isMatch() ? httpStatusEntryPoint : redirectEntryPoint)
				.flatMap(entryPoint -> entryPoint.commence(exchange, e));
		}

	}