spring-bootspring-securityoauth-2.0single-page-application

In OAuth2, how to get information from the request to the successHandler


I'm using Spring Boot with Spring Security to implement Google OAuth2 login. I want to pass a custom parameter from the frontend (Single Page Application) when initiating the login flow, and then retrieve it in my AuthenticationSuccessHandler after login. This parameter is needed to return to the frontend as I have multiple frontends on the same backend.

Here's what I'm doing:

  1. I use a custom AuthorizationRequestResolver to extract the query parameter from the /oauth2/authorization/google?redirect_origin=... and add it to the OAuth2AuthorizationRequest's additionalParameters.

  2. I register a custom HttpSessionOAuth2AuthorizationRequestRepository bean and plug it into the security config together with the resolver:

                .oauth2Login(oauth2 -> oauth2
                        .authorizationEndpoint(authorization -> authorization
                                .authorizationRequestResolver(customAuthorizationRequestResolver)
                                .authorizationRequestRepository(authorizationRequestRepository)
                        )
                        .successHandler(oAuth2SuccessHandler)
                        .failureHandler(oAuth2FailureHandler))
    
  3. In my AuthenticationSuccessHandler, I try to retrieve the original request:

    public void onAuthenticationSuccess(HttpServletRequest request,
                                        HttpServletResponse response,
                                        Authentication authentication) throws IOException {
        OAuth2AuthorizationRequest authorizationRequest = 
            authorizationRequestRepository.removeAuthorizationRequest(request, response);
    

    But authorizationRequest is always null.

Why is OAuth2AuthorizationRequest null in the success handler, even though I registered a shared HttpSessionOAuth2AuthorizationRequestRepository? Is it removed by Spring Security before the success handler runs? If so, what’s the best way to retain access to it for post-login logic?


Solution

  • T.L.D.R.:
    Split the SecurityFilterChain in a statefull and a stateless one. Use the former for the OAuth client and use cookies to store the state. The latter can be used as a resource server.

    Full explanation:

    1. OAuth Client
      SecurityFilterChain:

          @Bean
          @Order(Ordered.LOWEST_PRECEDENCE - 1)
          public SecurityFilterChain oAuth2SecurityFilterChain(HttpSecurity http,
                                                               OAuth2OriginCaptureFilter oAuth2OriginCaptureFilter,                                                         CustomOAuth2SuccessHandler oAuth2SuccessHandler,                                                         CustomOAuth2FailureHandler oAuth2FailureHandler) throws Exception {
              return http
                      // ⚠️ CSRF can be disabled because all the requestMatchers are GET (no POST,PUT,PATCH or DELETE)
                      .csrf(AbstractHttpConfigurer::disable)
                      .sessionManagement(session -> session
                              .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
                      )
                      .securityMatcher("/oauth2/**", "/login/oauth2/**")
                      .authorizeHttpRequests(auth -> auth
                              .requestMatchers(HttpMethod.GET,"/oauth2/authorization/**", "/login/oauth2/code/").permitAll()
                      )
                      .oauth2Login(oauth2 -> oauth2
                              .successHandler(oAuth2SuccessHandler)
                              .failureHandler(oAuth2FailureHandler))
                      .addFilterBefore(oAuth2OriginCaptureFilter, OAuth2AuthorizationRequestRedirectFilter.class)
                      .build();
          }
      

      The oAuth2OriginCaptureFilter is used to fetch the information from the query parameter before the OAuth implementation of Spring Boot starts. This information is then stored in a cookie and added to the response. It needs to be placed before OAuth2AuthorizationRequestRedirectFilter because Spring boot handles the OAuth messages very early.

      I also added the success and failure handlers (implements AuthenticationSuccessHandler and AuthenticationFailureHandler)
      Don't forget to add a securityMatcher when using multiple securityFilterChains!

      Code for oAuth2OriginCaptureFilter:

      @Component
      public class OAuth2OriginCaptureFilter extends OncePerRequestFilter {
      
          @Override
          protected void doFilterInternal(HttpServletRequest request,
                                          @NonNull HttpServletResponse response,
                                          @NonNull FilterChain filterChain) throws ServletException, IOException {
              if (request.getRequestURI().startsWith("/oauth2/authorization/")) {
                  String origin = request.getParameter("origin_redirect");
                  if (origin != null) {
                      Cookie cookie = new Cookie("OAUTH2_ORIGIN_REDIRECT", origin);
                      cookie.setHttpOnly(false); // only set to false if you expect JS to read it
                      cookie.setSecure(true);
                      cookie.setPath("/");
                      cookie.setMaxAge(300); // 5 min validity
                      response.addCookie(cookie);
                  }
              }
              filterChain.doFilter(request, response);
          }
      }
      

      SuccessHandler:

      @Component
      public class CustomOAuth2SuccessHandler implements AuthenticationSuccessHandler {
      
          ...
      
          @Override
          public void onAuthenticationSuccess(HttpServletRequest request,
                                              HttpServletResponse response,
                                              Authentication authentication) throws IOException {
      
              String origin;
              Cookie[] cookies = request.getCookies();
              if (cookies != null) {
                  origin = Arrays.stream(cookies)
                          .filter(cookie -> "OAUTH2_ORIGIN_REDIRECT".equals(cookie.getName()))
                          .map(Cookie::getValue)
                          .findFirst()
                          .orElse(oAuthOriginDefault);
      
                  // Optionally remove cookie
                  Cookie clear = new Cookie("OAUTH2_ORIGIN_REDIRECT", null);
                  clear.setMaxAge(0);
                  clear.setPath("/");
                  response.addCookie(clear);
              } else {
                  origin = oAuthOriginDefault;
              }
      
      
       // Validate the origin against a white list
       ...
      
       // Get the tokens
              String tokenJson = ...
      
              String html = """
                  <!DOCTYPE html>
                  <html>
                  <head><title>Login Complete</title></head>
                  <body>
                    <script>
                      const tokenData = %s;
      
                      window.opener.postMessage(tokenData, "%s");
                      window.close();
                    </script>
                  </body>
                  </html>
                  """.formatted(tokenJson, origin);
      
              response.setContentType("text/html");
              response.setStatus(HttpServletResponse.SC_OK);
              response.getWriter().write(html);
              log.info("[onAuthenticationSuccess] end");
          }
      }
      
    2. Resource server
      Stateless
      SecurityFilterChain:

          @Bean
          @Order(Ordered.LOWEST_PRECEDENCE)
          public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
      
              return http
                      .csrf(AbstractHttpConfigurer::disable)
                      .sessionManagement((session) -> session
                              .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                      )
                      .authorizeHttpRequests(
                              authenticate -> authenticate
                                      .requestMatchers(...).permitAll()
                                      .anyRequest().authenticated()
                      )
                      .addFilterBefore(authenticationFilter, UsernamePasswordAuthenticationFilter.class)
                      .build();
          }
      

      I added an authenticationFilter (extends OncePerRequestFilter) before the UserNamePasswordAuthenticationFilter, to check for tokens.