spring-securityoauth-2.0openid-connectspring-cloud-gatewayspring-authorization-server

Spring authorization server RP-initiated logout not working


I have a project with spring gateway as oauth client for spring authorization server. Everything is working fine in terms of oidc authentication besides the logout. Logout does not work due to CORS, as somehow after pressing the logout button on the default spring logout page, the origin is null. If I add the "null" origin to allowed origins list, or disable cors, it works as expected.

Below is the configuration for spring gateway client:

@Configuration
@EnableWebFluxSecurity
public class SecurityConfiguration {

    @Autowired
    public SecurityConfiguration(ReactiveClientRegistrationRepository clientRegistrationRepository) {
        this.clientRegistrationRepository = clientRegistrationRepository;
    }

    ReactiveClientRegistrationRepository clientRegistrationRepository;

    private ServerLogoutSuccessHandler serverLogoutSuccessHandler() {
        OidcClientInitiatedServerLogoutSuccessHandler successHandler = new OidcClientInitiatedServerLogoutSuccessHandler(clientRegistrationRepository);
        successHandler.setPostLogoutRedirectUri("{baseUrl}/welcome");
        return successHandler;
    }


    @Bean
    public SecurityWebFilterChain filterChain(ServerHttpSecurity http) {
        String[] unprotectedPaths = new String[]{"/api-docs/**", "/swagger-ui.html", "/webjars/swagger-ui/**", "/actuator/**",
                "/oidc/**","/welcome/**", "/customer/registration", "/customer/registration-confirmation/**"};

        http.cors(withDefaults()).csrf(csrfSpec -> csrfSpec.csrfTokenRepository(CookieServerCsrfTokenRepository.withHttpOnlyFalse())
                        .csrfTokenRequestHandler(new SpaServerCsrfTokenRequestHandler()))
                .authorizeExchange(authorizeExchangeSpec -> authorizeExchangeSpec.pathMatchers(unprotectedPaths).permitAll()
                        .pathMatchers("/notification/ws-connect").hasAuthority("SCOPE_notification.read")
                        .anyExchange().authenticated())
                .oauth2Login(withDefaults())
                .oauth2ResourceServer(oAuth2ResourceServerSpec -> oAuth2ResourceServerSpec.jwt(withDefaults()))
                .logout(httpSecurityLogoutConfigurer -> httpSecurityLogoutConfigurer.logoutSuccessHandler(serverLogoutSuccessHandler()));

        return http.build();
    }


    static final class SpaServerCsrfTokenRequestHandler extends ServerCsrfTokenRequestAttributeHandler {
        private final ServerCsrfTokenRequestAttributeHandler delegate = new XorServerCsrfTokenRequestAttributeHandler();

        @Override
        public void handle(ServerWebExchange exchange, Mono<CsrfToken> csrfToken) {
            // Always use XorCsrfTokenRequestAttributeHandler to provide BREACH protection of the CsrfToken when it is rendered in the response body.
            this.delegate.handle(exchange, csrfToken);
        }

        @Override
        public Mono<String> resolveCsrfTokenValue(ServerWebExchange exchange, CsrfToken csrfToken) {
            final var hasHeader = exchange.getRequest().getHeaders().get(csrfToken.getHeaderName()) != null;
            return hasHeader ? super.resolveCsrfTokenValue(exchange, csrfToken) : this.delegate.resolveCsrfTokenValue(exchange, csrfToken);
        }
    }

    @Bean
    public WebFilter csrfCookieWebFilter() {
        return (exchange, chain) -> {
            exchange.getAttributeOrDefault(CsrfToken.class.getName(), Mono.empty()).subscribe(o -> ((CsrfToken) o).getToken());
            return chain.filter(exchange);
        };
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(List.of("http://localhost:63342"));
        configuration.setAllowedMethods(List.of(CorsConfiguration.ALL));
        configuration.setAllowCredentials(true);
        configuration.setAllowedHeaders(List.of(CorsConfiguration.ALL));
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }

}

Bellow is the api-gateway log:

2024-07-16T22:37:22.392+03:00 DEBUG 90681 --- [api-gateway] [ctor-http-nio-6] o.s.s.w.s.u.m.OrServerWebExchangeMatcher : Trying to match using PathMatcherServerWebExchangeMatcher{pattern='/logout', method=GET}
2024-07-16T22:37:22.392+03:00 DEBUG 90681 --- [api-gateway] [ctor-http-nio-6] athPatternParserServerWebExchangeMatcher : Checking match of request : '/logout'; against '/logout'
2024-07-16T22:37:22.392+03:00 DEBUG 90681 --- [api-gateway] [ctor-http-nio-6] o.s.s.w.s.u.m.OrServerWebExchangeMatcher : matched
2024-07-16T22:37:22.395+03:00 TRACE 90681 --- [api-gateway] [ctor-http-nio-6] o.s.w.s.adapter.HttpWebHandlerAdapter    : [130c2a73-11] Completed 200 OK, headers={masked}
2024-07-16T22:37:22.395+03:00 TRACE 90681 --- [api-gateway] [ctor-http-nio-6] o.s.h.s.r.ReactorHttpHandlerAdapter      : [130c2a73-5, L:/[0:0:0:0:0:0:0:1]:9990 - R:/[0:0:0:0:0:0:0:1]:63469] Handling completed
2024-07-16T22:37:37.036+03:00 TRACE 90681 --- [api-gateway] [ctor-http-nio-6] o.s.w.s.adapter.HttpWebHandlerAdapter    : [130c2a73-12] HTTP POST "/logout", headers={masked}
2024-07-16T22:37:37.038+03:00 DEBUG 90681 --- [api-gateway] [ctor-http-nio-6] o.s.w.c.reactive.DefaultCorsProcessor    : Reject: 'null' origin is not allowed
2024-07-16T22:37:37.038+03:00 TRACE 90681 --- [api-gateway] [ctor-http-nio-6] o.s.w.s.adapter.HttpWebHandlerAdapter    : [130c2a73-12] Completed 403 FORBIDDEN, headers={masked}
2024-07-16T22:37:37.039+03:00 TRACE 90681 --- [api-gateway] [ctor-http-nio-6] o.s.h.s.r.ReactorHttpHandlerAdapter      : [130c2a73-6, L:/[0:0:0:0:0:0:0:1]:9990 - R:/[0:0:0:0:0:0:0:1]:63469] Handling completed

Later Edit:

It seems Chrome treats 2 different ports on localhost as same site. Also The JSESSIONID used by spring security is not SameSite, so it will be sent in cross origin request However, my original issue does not involve cross origin requests


Solution

  • RP-Initiated Logout (open the link and read the spec) starts after the logout was performed on the Relying Party (Spring app configured with oauth2Login). To initiate this 1st logout, a SPA should POST to the gateway /logout endpoint.

    As any request to an app configured with oauth2Login, it is authorized with a session (the request must hold the session cookie). For this reason, the frontend sending this request must be served from the same host as the RP.

    In the case of a SPA, the easiest option, by far, is to serve both the SPA and the BFF through the same reverse proxy (which can be the gateway itself). This removes cross origin requests between front and back ends. If the SPA is to be served from another domain than the API, then the BFF (gateway with oauth2Login and the TokenRelay filter) should be hosted on this other domain.

    As any POST, PUT, PATCH or DELETE request authorized with a session, it should be protected against CSRF (contain a CSRF token). In the case of a single-page app, this token is read from a cookie (with http-only false) and set as a header.

    The answer to the POST request (after the gateway session is ended) should contain a Location header with an URI to end the session on the authorization server. If the origin from which the SPA is served (the reverse-proxy) is allowed on the authorization server, you can let the browser in which the SPA runs follow to this location. Otherwise, you might have to change the status of the gateway response from 302 to something in the 2xx range so that the Javascript code can observe the response, read the Location, and follow by setting the window.location.href (change the browser tab origin instead of sending a cross-origin request).

    Detailed working sample (including logout) of spring-cloud-gateway used as OAuth2 BFF (with oauth2Login and the TokenRelay filter) for SPAs in this Baeldung article I wrote.