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:
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
.
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))
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?
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:
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");
}
}
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.