Search code examples
springspring-bootspring-securityhtmx

How to add a header on an auth redirect response with Spring?


For integration of Spring Boot with htmx, I need a way to add a header if an incoming request is done by htmx and the user is no longer logged on.

In the normal flow, the user gets redirected to the login page. However, when there is a request done by htmx, this is an AJAX request and the redirect is not happening.

The recommended solution is that if there is an HX-Request header on the request, that the server should put an HX-Refresh: true header on the response. This will make htmx do a full page refresh.

My security config looks like this:

@Configuration
public class WebSecurityConfiguration {
    private final ClientRegistrationRepository clientRegistrationRepository;

    public WebSecurityConfiguration(ClientRegistrationRepository clientRegistrationRepository) {
        this.clientRegistrationRepository = clientRegistrationRepository;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.authorizeRequests(registry -> {
            registry.mvcMatchers("/actuator/info", "/actuator/health").permitAll();
            registry.mvcMatchers("/**").hasAuthority(Roles.ADMIN);
            registry.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll();
            registry.anyRequest().authenticated();
        });
        http.oauth2Client();
        http.oauth2Login();
        http.logout(logout -> logout.logoutSuccessHandler(oidcLogoutSuccessHandler()));

        return http.build();
    }

    private LogoutSuccessHandler oidcLogoutSuccessHandler() {
        OidcClientInitiatedLogoutSuccessHandler logoutSuccessHandler = new OidcClientInitiatedLogoutSuccessHandler(clientRegistrationRepository);

        // Sets the location that the End-User's User Agent will be redirected to
        // after the logout has been performed at the Provider
        logoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}");

        return logoutSuccessHandler;
    }
}

I tried with a Filter:

public Filter htmxFilter() {
        return new Filter() {
            @Override
            public void doFilter(ServletRequest servletRequest,
                                 ServletResponse servletResponse,
                                 FilterChain filterChain) throws IOException, ServletException {
                HttpServletRequest request = (HttpServletRequest) servletRequest;
                HttpServletResponse response = (HttpServletResponse) servletResponse;

                filterChain.doFilter(servletRequest, servletResponse);
                String htmxRequestHeader = request.getHeader("HX-Request");
                System.out.println("htmxRequestHeader = " + htmxRequestHeader);
                System.out.println(response.getStatus());
                if (htmxRequestHeader != null
                        && response.getStatus() == 302) {
                    System.out.println("XXXXXXXXXXX");
                    response.setHeader("HX-Refresh", "true");
                }
            }
        };
    }

But response.getStatus() is never 302 (altough I can see the 302 response status in Chrome).

I also tried with an Interceptor:

    @Bean
    public HandlerInterceptor htmxHandlerInterceptor() {
        return new HandlerInterceptor() {

            @Override
            public void postHandle(HttpServletRequest request,
                                   HttpServletResponse response,
                                   Object handler,
                                   ModelAndView modelAndView) throws Exception {
                boolean htmxRequest = request.getHeader("HX-Request") != null;
                String htmxRequestHeader = request.getHeader("HX-Request");
                System.out.println("htmxRequestHeader = " + htmxRequestHeader);
                System.out.println(response.getStatus());

                if( htmxRequest && response.getStatus() == 302) {
                    response.setHeader("HX-Refresh", "true");
                }
            }
        };
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(localeInterceptor());
        registry.addInterceptor(htmxHandlerInterceptor());//.order(Ordered.HIGHEST_PRECEDENCE);
    }
    

Which also does not work, there is no 302 response status.

I also tried with the commented out order(Ordered.HIGHEST_PRECEDENCE), but that did not make any difference.

Are there other options?


Solution

  • When a request comes to a protected endpoint and it is not authenticated, Spring Security executes its AuthenticationEntryPoints interface to commence an authentication scheme.

    You could create your own AuthenticationEntryPoint that adds the header and delegates to the LoginUrlAuthenticationEntryPoint (or other implementation that you are using).

    @Bean
    SecurityFilterChain appSecurity(HttpSecurity http) throws Exception {
        http
           //...
           .exceptionHandling(exception -> exception
               .authenticationEntryPoint(new HxRefreshHeaderAuthenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login")))
           );
        return http.build();
    }
    
    public class HxRefreshHeaderAuthenticationEntryPoint implements AuthenticationEntryPoint {
    
        private final AuthenticationEntryPoint delegate;
    
        public HxRefreshHeaderAuthenticationEntryPoint(AuthenticationEntryPoint delegate) {
            this.delegate = delegate;
        }
    
        @Override
        public void commence(HttpServletRequest request, HttpServletResponse response,
                AuthenticationException authException) throws IOException, ServletException {
            
            // Add the header
    
            this.delegate.commence(request, response, authException);
        }
    }