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

Comment From: pszemus

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));
        }

    }

Comment From: jgrandja

@foo4u The test you provided respondsWithHttp401() is not the correct behaviour.

When oauth2Login() is configured, unauthenticated XHR requests will be redirected to the default login page /login. See this test for the expected behaviour.

NOTE: To be clear, the redirect is to the default login page in the client application NOT the login page of the external provider.

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

Comment From: foo4u

@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.

Comment From: jgrandja

@foo4u I looked into this further and my comment still holds.

To further support the expected behaviour in OAuth2LoginSpec.setDefaultEntryPoints(), these tests pass:

@Test
void xhrRequestThenRedirectToDefaultLogin()
{
    client
            .get()
            .accept(MediaType.APPLICATION_JSON)
            .header("X-Requested-With","XMLHttpRequest")
            .exchange()
            .expectStatus()
            .is3xxRedirection()
            .expectHeader().location("/login");
    // NOTE:
    // It does not trigger the authorization request redirect -> '/oauth2/authorization/smartling'
    // This is the expected behaviour configured in setDefaultEntryPoints()
}

@Test
void notXhrRequestThenRedirectToProviderLogin()
{
    client
            .get()
            .exchange()
            .expectStatus()
            .is3xxRedirection()
            .expectHeader().location("/oauth2/authorization/smartling");
}

Does this make sense? If not, please provide additional tests to demonstrate because respondsWithHttp401() is not the expected behaviour.

Comment From: spring-projects-issues

If you would like us to look at this issue, please provide the requested information. If the information is not provided within the next 7 days this issue will be closed.

Comment From: pszemus

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?

Comment From: foo4u

@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.

Comment From: everflux

@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.

Comment From: jgrandja

@foo4u

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

I still have not received a code sample clarifying the problem.

To further clarify:

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.

If you're not already aware, the OAuth 2.0 Authorization Code grant is redirection-based flow:

The authorization code grant type is used to obtain both access tokens and refresh tokens and is optimized for confidential clients. Since this is a redirection-based flow, the client must be capable of interacting with the resource owner's user-agent (typically a web browser) and capable of receiving incoming requests (via redirection) from the authorization server.

oauth2Login() is implemented with the OAuth 2.0 Authorization Code grant flow. Therefore, the authentication flow should be handled by the browser agent NOT the XHR client.

The comments and explanation you provided in this issue is related to oauth2Login() and the usage is incorrect. You should not use XHR to authenticate via oauth2Login(). Furthermore, if the user is not authenticated and an XHR request is issued than 302 is the expected behaviour if only oauth2Login() is configured as the authentication mechanism.

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.

If you still feel there is an issue that is NOT related to oauth2Login(), then please provide a minimal sample that reproduces the issue. Otherwise, I'll close this issue.

Comment From: candrews

I agree with the reporter of this issue. XHR should not trigger a redirect to login

Since this is a redirection-based flow, the client must be capable of interacting with the resource owner's user-agent (typically a web browser) and capable of receiving incoming requests (via redirection) from the authorization server.

XHR requests are not capable of interacting with the user, therefore they should result in immediate denial (not redirect) if unauthorized.

And, that's how Spring Security already works for every method except OAuth2; see https://github.com/spring-projects/spring-security/issues/3887

IMHO, Spring Security OAuth2 Client should behave the same as the rest of Spring Security. So either Spring Security should change (undoing https://github.com/spring-projects/spring-security/issues/3887) or Spring Security OAuth2 Client should change.

Comment From: jgrandja

@foo4u I put together some tests that demonstrate the correct behaviour depending on the authentication configured in the application, e.g. httpBasic(), formLogin() and oauth2Login().

I did find an issue with one of the tests that should pass. Can you take a look at this test and let me know if this is the same issue you are experiencing?

Comment From: spring-projects-issues

If you would like us to look at this issue, please provide the requested information. If the information is not provided within the next 7 days this issue will be closed.

Comment From: jgrandja

@foo4u I'm closing this since I haven't received a minimal sample reproducing the issue.

However, I believe this issue is fixed via gh-9660.

I also added a few tests (53e94bc) demonstrating the expected behaviour.

Comment From: jensdt

@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.

Comment From: jgrandja

@jensdt

I believe the "culprit" is indeed this line what was mentioned when this issue was raised

This should have been fixed in gh-9660. Take a look at the tests that were added via 53e94bc, specifically, OAuth2LoginConfigurerTests.oauth2LoginWithHttpBasicOneClientConfiguredAndRequestXHRNotAuthenticatedThenUnauthorized().

If that doesn't help, please log a new issue and provide a sample or test the reproduces the issue you are having.