spring-securitygoogle-oauthspring-webfluxcsrf

Spring Webflux Security not attaching CSRF cookie after logging in via Google Oauth2


I'm implementing a simple Sign-In via Google Oauth2 for my Spring Webflux backend and ReactJS frontend. For now I'm just trying to get the API working and test it with an OpenAPI swagger page.

I've almost got the sign-in working, except for one problem - no X-CSRF cookie is being attached to my API's response after logging into Google - it's only attaching a SESSION cookie. So GET requests to secured endpoints are working fine, because that SESSION cookie links to a valid session with the user details, however POST requests are failing because there is no JS-accessible X-CSRF cookie to attach as a header.

Specifically, the request to http://localhost:8080/login/oauth2/code/google?state=...&code=...&scope=email+profile+...... responds with a 302 status and headers:

But that's it, there's no Set-Cookie header for the X-CSRF token.

Confusingly, the X-CSRF token IS being attached via Set-Cookie when I go to Spring's /logout page... But that's pretty useless, I want the X-CSRF cookie attached in the same response that sets the SESSION cookie in the first place. Because at that point the User has logged in, so I want my Spring API to dish them out a X-CSRF cookie alongside the SESSION cookie.

What am I missing here in my config?

EDIT: To be clear, my swagger UI is working just fine once the X-CSRF cookie is returned to my browser. As long as I first go to the Spring /logout page, which responds with a Set-Cookie header with the X-CSRF cookie, if I go back to the Swagger UI - it will then use that cookie correctly by attaching it in a matching header.

My question is - how do I get this X-CSRF cookie attached to the same response that sets the SESSION cookie in the login process?

My SecurityConfig.kt:

@Configuration
@EnableWebFluxSecurity
class SecurityConfig {
    @Bean
    fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
        return http
            .csrf { csrf -> csrf
                .csrfTokenRepository(CookieServerCsrfTokenRepository.withHttpOnlyFalse())
                .csrfTokenRequestHandler(ServerCsrfTokenRequestAttributeHandler())
            }
            .authorizeExchange { exchange ->
                exchange
                    .anyExchange()
                    .authenticated()
            }
            .oauth2Login(Customizer.withDefaults())
            .build()
    }
}

My application.yml:

springdoc:
  swagger-ui:
    enabled: true
    csrf:
      enabled: true
      header-name: "X-XSRF-TOKEN"
      cookie-name: "XSRF-TOKEN"
    url: /my-open-api-spec.yaml
    path: /swagger-ui.html
  api-docs:
    enabled: true
    path: /api-docs

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: ${google_client_id}
            client-secret: ${google_client_secret}
            scope:
              - email
              - profile

My Application.kt:

@SpringBootApplication
@EnableR2dbcAuditing
class MyApplication

fun main(args: Array<String>) {
    runApplication<MyApplication>(*args)
}

My HttpRouter.kt:

@Configuration
class HttpRouter(val userHandler: UserHandler) {

    @Bean
    fun routes(): RouterFunction<ServerResponse> {
        return router {
            "/users".nest {
                GET("/me", userHandler::getMyUserDetails)
                POST("", userHandler::inviteUser)
                GET("", userHandler::searchUsers)
            }
        }
    }
}

Solution

  • You might need to explicitly subscribe to the CSRF cookie by exposing a csrfCookieWebFilter bean as described there: https://docs.spring.io/spring-security/reference/5.8/migration/reactive.html#_i_am_using_angularjs_or_another_javascript_framework

    @Bean
    WebFilter csrfCookieWebFilter() {
        return (exchange, chain) -> {
            Mono<CsrfToken> csrfToken = exchange.getAttributeOrDefault(CsrfToken.class.getName(), Mono.empty());
            return csrfToken.doOnSuccess(token -> {
                /* Ensures the token is subscribed to. */
            }).then(chain.filter(exchange));
        };
    }