Skip to content

Commit 9d1359b

Browse files
committed
Add HTMX-aware AuthenticationEntryPoint for session expiry handling
When HTMX-powered pages poll or fetch fragments and the user's session expires, Spring Security's default 302 redirect causes HTMX to swap the full login page HTML into each target element, breaking the UI. This adds HtmxAwareAuthenticationEntryPoint which detects HTMX requests (HX-Request header) and returns a 401 JSON response with HX-Redirect header instead of the 302 redirect, allowing HTMX clients to handle session expiry gracefully. Non-HTMX requests are delegated unchanged. Consumers can override by defining their own AuthenticationEntryPoint bean. Closes #294
1 parent e19ff38 commit 9d1359b

4 files changed

Lines changed: 405 additions & 4 deletions

File tree

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package com.digitalsanctuary.spring.user.security;
2+
3+
import java.io.IOException;
4+
import org.springframework.security.core.AuthenticationException;
5+
import org.springframework.security.web.AuthenticationEntryPoint;
6+
import jakarta.servlet.ServletException;
7+
import jakarta.servlet.http.HttpServletRequest;
8+
import jakarta.servlet.http.HttpServletResponse;
9+
import lombok.extern.slf4j.Slf4j;
10+
11+
/**
12+
* An {@link AuthenticationEntryPoint} that detects HTMX requests and returns a JSON 401 response instead of the
13+
* default 302 redirect to the login page.
14+
*
15+
* <p>When an HTMX-powered page has polling or dynamic fragment requests and the user's session expires, Spring
16+
* Security's default entry point sends a 302 redirect to the login page. HTMX transparently follows the redirect and
17+
* swaps the full login page HTML into each target element, breaking the UI. This entry point intercepts HTMX requests
18+
* (identified by the {@code HX-Request} header) and returns a 401 status with a JSON body and an {@code HX-Redirect}
19+
* header, allowing the HTMX client to handle the session expiry gracefully.</p>
20+
*
21+
* <p>Non-HTMX requests are delegated to the wrapped {@link AuthenticationEntryPoint}, preserving the existing
22+
* redirect behavior for standard browser requests.</p>
23+
*
24+
* @see <a href="https://htmx.org/attributes/hx-request/">HTMX HX-Request Header</a>
25+
* @see <a href="https://htmx.org/headers/hx-redirect/">HTMX HX-Redirect Response Header</a>
26+
*/
27+
@Slf4j
28+
public class HtmxAwareAuthenticationEntryPoint implements AuthenticationEntryPoint {
29+
30+
static final String HX_REQUEST_HEADER = "HX-Request";
31+
static final String HX_REDIRECT_HEADER = "HX-Redirect";
32+
33+
private final AuthenticationEntryPoint delegate;
34+
private final String loginUrl;
35+
36+
/**
37+
* Creates a new HTMX-aware authentication entry point.
38+
*
39+
* @param delegate the entry point to delegate to for non-HTMX requests
40+
* @param loginUrl the login page URL used in the JSON response and HX-Redirect header
41+
*/
42+
public HtmxAwareAuthenticationEntryPoint(AuthenticationEntryPoint delegate, String loginUrl) {
43+
this.delegate = delegate;
44+
this.loginUrl = loginUrl;
45+
}
46+
47+
@Override
48+
public void commence(HttpServletRequest request, HttpServletResponse response,
49+
AuthenticationException authException) throws IOException, ServletException {
50+
if (isHtmxRequest(request)) {
51+
log.debug("HTMX request detected for URI {}; returning 401 with JSON body and HX-Redirect to {}",
52+
request.getRequestURI(), loginUrl);
53+
54+
if (response.isCommitted()) {
55+
log.warn("Response already committed for HTMX request to {}; cannot write 401 response",
56+
request.getRequestURI());
57+
return;
58+
}
59+
60+
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
61+
response.setContentType("application/json");
62+
response.setHeader(HX_REDIRECT_HEADER, loginUrl);
63+
String escapedLoginUrl = loginUrl.replace("\\", "\\\\").replace("\"", "\\\"");
64+
response.getWriter().write("{\"error\":\"authentication_required\","
65+
+ "\"message\":\"Session expired. Please log in.\","
66+
+ "\"loginUrl\":\"" + escapedLoginUrl + "\"}");
67+
} else {
68+
delegate.commence(request, response, authException);
69+
}
70+
}
71+
72+
private boolean isHtmxRequest(HttpServletRequest request) {
73+
return "true".equalsIgnoreCase(request.getHeader(HX_REQUEST_HEADER));
74+
}
75+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package com.digitalsanctuary.spring.user.security;
2+
3+
import org.springframework.beans.factory.annotation.Value;
4+
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
5+
import org.springframework.context.annotation.Bean;
6+
import org.springframework.context.annotation.Configuration;
7+
import org.springframework.security.web.AuthenticationEntryPoint;
8+
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
9+
import lombok.extern.slf4j.Slf4j;
10+
11+
/**
12+
* Auto-configuration for the {@link AuthenticationEntryPoint}.
13+
*
14+
* <p>Registers an {@link HtmxAwareAuthenticationEntryPoint} that wraps the appropriate inner entry point
15+
* (form-login or OAuth2) when no custom {@link AuthenticationEntryPoint} bean is defined by the consuming
16+
* application.</p>
17+
*
18+
* <p>Consuming applications can override this by defining their own {@link AuthenticationEntryPoint} bean:</p>
19+
* <pre>{@code
20+
* @Bean
21+
* public AuthenticationEntryPoint myCustomEntryPoint() {
22+
* return new MyCustomAuthenticationEntryPoint();
23+
* }
24+
* }</pre>
25+
*/
26+
@Slf4j
27+
@Configuration
28+
public class HtmxAwareAuthenticationEntryPointConfiguration {
29+
30+
@Value("${user.security.loginPageURI}")
31+
private String loginPageURI;
32+
33+
@Value("${spring.security.oauth2.enabled:false}")
34+
private boolean oauth2Enabled;
35+
36+
/**
37+
* Creates the default {@link AuthenticationEntryPoint} bean. This bean is only registered when no custom
38+
* {@link AuthenticationEntryPoint} bean is provided by the consuming application.
39+
*
40+
* @return an {@link HtmxAwareAuthenticationEntryPoint} wrapping the appropriate inner entry point
41+
*/
42+
@Bean
43+
@ConditionalOnMissingBean(AuthenticationEntryPoint.class)
44+
public AuthenticationEntryPoint authenticationEntryPoint() {
45+
AuthenticationEntryPoint inner;
46+
if (oauth2Enabled) {
47+
inner = new CustomOAuth2AuthenticationEntryPoint(null, loginPageURI);
48+
log.info("Configuring HtmxAwareAuthenticationEntryPoint wrapping CustomOAuth2AuthenticationEntryPoint");
49+
} else {
50+
inner = new LoginUrlAuthenticationEntryPoint(loginPageURI);
51+
log.info("Configuring HtmxAwareAuthenticationEntryPoint wrapping LoginUrlAuthenticationEntryPoint");
52+
}
53+
return new HtmxAwareAuthenticationEntryPoint(inner, loginPageURI);
54+
}
55+
}

src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import org.springframework.security.config.ObjectPostProcessor;
2323
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
2424
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
25+
import org.springframework.security.web.AuthenticationEntryPoint;
2526
import org.springframework.security.core.session.SessionRegistry;
2627
import org.springframework.security.core.session.SessionRegistryImpl;
2728
import org.springframework.security.core.userdetails.UserDetailsService;
@@ -122,6 +123,7 @@ public class WebSecurityConfig {
122123
@Value("${user.dev.auto-login-enabled:false}")
123124
private boolean devAutoLoginEnabled;
124125

126+
private final AuthenticationEntryPoint authenticationEntryPoint;
125127
private final UserDetailsService userDetailsService;
126128
private final LoginSuccessService loginSuccessService;
127129
private final LogoutSuccessService logoutSuccessService;
@@ -150,6 +152,9 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
150152
http.formLogin(
151153
formLogin -> formLogin.loginPage(loginPageURI).loginProcessingUrl(loginActionURI).successHandler(loginSuccessService).permitAll());
152154

155+
// Always configure exception handling with the injected entry point (HTMX-aware by default)
156+
http.exceptionHandling(handling -> handling.authenticationEntryPoint(authenticationEntryPoint));
157+
153158
// Configure remember-me only if explicitly enabled and key is provided
154159
if (rememberMeEnabled && rememberMeKey != null && !rememberMeKey.trim().isEmpty()) {
155160
http.rememberMe(rememberMe -> rememberMe.key(rememberMeKey).userDetailsService(userDetailsService));
@@ -211,10 +216,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
211216
* @throws Exception the exception
212217
*/
213218
private void setupOAuth2(HttpSecurity http) throws Exception {
214-
CustomOAuth2AuthenticationEntryPoint loginAuthenticationEntryPoint = new CustomOAuth2AuthenticationEntryPoint(null, loginPageURI);
215-
216-
http.exceptionHandling(handling -> handling.authenticationEntryPoint(loginAuthenticationEntryPoint))
217-
.oauth2Login(o -> o.loginPage(loginPageURI).successHandler(loginSuccessService).failureHandler((request, response, exception) -> {
219+
// Entry point is handled globally in securityFilterChain via the injected authenticationEntryPoint bean
220+
http.oauth2Login(o -> o.loginPage(loginPageURI).successHandler(loginSuccessService).failureHandler((request, response, exception) -> {
218221
log.error("WebSecurityConfig.configure: OAuth2 login failure: {}", exception.getMessage());
219222
request.getSession().setAttribute("error.message", exception.getMessage());
220223
response.sendRedirect(loginPageURI);

0 commit comments

Comments
 (0)