Skip to content

Commit f7a34d6

Browse files
authored
Merge pull request #295 from devondragon/worktree-GH-294
Add HTMX-aware AuthenticationEntryPoint for session expiry handling
2 parents e19ff38 + 7d77c10 commit f7a34d6

8 files changed

Lines changed: 612 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
1+
## [Unreleased]
2+
### Features
3+
- HTMX-aware AuthenticationEntryPoint for session expiry handling (#294)
4+
- When HTMX requests (identified by `HX-Request: true` header) hit an expired session, the framework now returns a 401 JSON response with an `HX-Redirect` header instead of the default 302 redirect that causes HTMX to swap login page HTML into fragment targets.
5+
- New classes:
6+
- `HtmxAwareAuthenticationEntryPoint` — detects HTMX requests and returns 401 + JSON + `HX-Redirect`; delegates to wrapped entry point for standard browser requests
7+
- `HtmxAwareAuthenticationEntryPointConfiguration` — registers the entry point via `@ConditionalOnMissingBean(AuthenticationEntryPoint.class)`
8+
- `WebSecurityConfig` now always configures `exceptionHandling()` with the injected entry point (previously only configured when OAuth2 was enabled)
9+
- Consumer override: define any `AuthenticationEntryPoint` bean to replace the default
10+
- 100% backward-compatible: non-HTMX browser requests get the same 302 redirect as before
11+
112
## [4.3.1] - 2026-03-22
213
### Features
314
- No new user-facing features in this release.

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,12 +111,14 @@ com.digitalsanctuary.spring.user
111111
- `DSUserDetails` - Custom UserDetails implementation wrapping User entity
112112
- `DSOAuth2UserService` / `DSOidcUserService` - OAuth2/OIDC user services
113113
- `LoginAttemptService` - Brute force protection with account lockout
114+
- `HtmxAwareAuthenticationEntryPoint` - Returns 401 JSON for HTMX requests instead of 302 redirect on session expiry
114115

115116
**Extension Points:**
116117
- `BaseUserProfile` - Extend for custom user data (see PROFILE.md)
117118
- `UserProfileService<T>` - Interface for profile management
118119
- `BaseSessionProfile<T>` - Session-scoped profile access
119120
- `UserPreDeleteEvent` - Listen for user deletion to clean up related data
121+
- `AuthenticationEntryPoint` - Override via `@ConditionalOnMissingBean` to customize session expiry behavior
120122

121123
### Auto-Configuration
122124

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ Check out the [Spring User Framework Demo Application](https://github.com/devond
3939
- [Role-Based Access Control](#role-based-access-control)
4040
- [Account Lockout](#account-lockout)
4141
- [Audit Logging](#audit-logging)
42+
- [HTMX Support](#htmx-support)
4243
- [User Management](#user-management)
4344
- [Registration](#registration)
4445
- [Profile Management](#profile-management)
@@ -83,6 +84,7 @@ Check out the [Spring User Framework Demo Application](https://github.com/devond
8384
- Audit event framework for recording and logging security events, customizable to store audit events in a database or publish them via a REST API.
8485
- Role and Privilege setup service to define roles, associated privileges, and role inheritance hierarchy using `application.yml`.
8586
- Configurable Account Lockout after too many failed login attempts
87+
- HTMX-aware session expiry handling — returns 401 JSON instead of 302 redirect for HTMX requests, preventing broken UI fragments
8688

8789
- **Advanced Security**
8890
- Role and privilege-based authorization
@@ -504,6 +506,29 @@ user:
504506
flushRate: 10000
505507
```
506508
509+
### HTMX Support
510+
511+
When HTMX-powered pages make requests (polling, fragment loading, etc.) 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.
512+
513+
The framework automatically detects HTMX requests (via the `HX-Request` header) and returns a proper 401 response instead:
514+
515+
- **Status**: `401 Unauthorized`
516+
- **Header**: `HX-Redirect: <loginUrl>` (triggers HTMX full-page redirect)
517+
- **Body**: `{"error": "authentication_required", "message": "Session expired. Please log in.", "loginUrl": "<loginUrl>"}`
518+
519+
Non-HTMX browser requests continue to receive the standard 302 redirect to the login page.
520+
521+
**Overriding the default behavior:**
522+
523+
To provide a custom `AuthenticationEntryPoint`, define your own bean and the framework's default will back off automatically:
524+
525+
```java
526+
@Bean
527+
public AuthenticationEntryPoint authenticationEntryPoint() {
528+
return new MyCustomAuthenticationEntryPoint();
529+
}
530+
```
531+
507532
## User Management
508533

509534
### Registration
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
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+
// Prepend the servlet context path so deployments with server.servlet.context-path work correctly.
61+
// LoginUrlAuthenticationEntryPoint does the same when building its redirect URL.
62+
String contextPath = request.getContextPath();
63+
String fullLoginUrl = contextPath + loginUrl;
64+
65+
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
66+
response.setCharacterEncoding("UTF-8");
67+
response.setContentType("application/json;charset=UTF-8");
68+
response.setHeader(HX_REDIRECT_HEADER, fullLoginUrl);
69+
String escapedLoginUrl = fullLoginUrl
70+
.replace("\\", "\\\\")
71+
.replace("\"", "\\\"")
72+
.replace("\n", "\\n")
73+
.replace("\r", "\\r")
74+
.replace("\t", "\\t");
75+
response.getWriter().write("{\"error\":\"authentication_required\","
76+
+ "\"message\":\"Session expired. Please log in.\","
77+
+ "\"loginUrl\":\"" + escapedLoginUrl + "\"}");
78+
} else {
79+
delegate.commence(request, response, authException);
80+
}
81+
}
82+
83+
private boolean isHtmxRequest(HttpServletRequest request) {
84+
return "true".equalsIgnoreCase(request.getHeader(HX_REQUEST_HEADER));
85+
}
86+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
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.context.annotation.Primary;
8+
import org.springframework.security.web.AuthenticationEntryPoint;
9+
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
10+
import lombok.extern.slf4j.Slf4j;
11+
12+
/**
13+
* Auto-configuration for the {@link AuthenticationEntryPoint}.
14+
*
15+
* <p>Registers an {@link HtmxAwareAuthenticationEntryPoint} that wraps the appropriate inner entry point
16+
* (form-login or OAuth2) when no custom {@link AuthenticationEntryPoint} bean is defined by the consuming
17+
* application.</p>
18+
*
19+
* <p>Consuming applications can override this by defining their own {@link AuthenticationEntryPoint} bean:</p>
20+
* <pre>{@code
21+
* @Bean
22+
* public AuthenticationEntryPoint myCustomEntryPoint() {
23+
* return new MyCustomAuthenticationEntryPoint();
24+
* }
25+
* }</pre>
26+
*/
27+
@Slf4j
28+
@Configuration
29+
public class HtmxAwareAuthenticationEntryPointConfiguration {
30+
31+
@Value("${user.security.loginPageURI}")
32+
private String loginPageURI;
33+
34+
@Value("${spring.security.oauth2.enabled:false}")
35+
private boolean oauth2Enabled;
36+
37+
/**
38+
* Creates the default {@link AuthenticationEntryPoint} bean. This bean is only registered when no custom
39+
* {@link AuthenticationEntryPoint} bean is provided by the consuming application.
40+
*
41+
* @return an {@link HtmxAwareAuthenticationEntryPoint} wrapping the appropriate inner entry point
42+
*/
43+
@Bean
44+
@Primary
45+
@ConditionalOnMissingBean(AuthenticationEntryPoint.class)
46+
public AuthenticationEntryPoint authenticationEntryPoint() {
47+
AuthenticationEntryPoint inner;
48+
if (oauth2Enabled) {
49+
// null failureHandler is intentional: OAuth2AuthenticationExceptions without a handler fall through
50+
// to the redirect path in CustomOAuth2AuthenticationEntryPoint, which is the desired behavior.
51+
inner = new CustomOAuth2AuthenticationEntryPoint(null, loginPageURI);
52+
log.debug("Configuring HtmxAwareAuthenticationEntryPoint wrapping CustomOAuth2AuthenticationEntryPoint");
53+
} else {
54+
inner = new LoginUrlAuthenticationEntryPoint(loginPageURI);
55+
log.debug("Configuring HtmxAwareAuthenticationEntryPoint wrapping LoginUrlAuthenticationEntryPoint");
56+
}
57+
return new HtmxAwareAuthenticationEntryPoint(inner, loginPageURI);
58+
}
59+
}

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

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import org.springframework.security.core.userdetails.UserDetailsService;
2828
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
2929
import org.springframework.security.crypto.password.PasswordEncoder;
30+
import org.springframework.security.web.AuthenticationEntryPoint;
3031
import org.springframework.security.web.SecurityFilterChain;
3132
import org.springframework.security.web.access.DelegatingMissingAuthorityAccessDeniedHandler;
3233
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
@@ -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);
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package com.digitalsanctuary.spring.user.security;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import org.junit.jupiter.api.DisplayName;
6+
import org.junit.jupiter.api.Nested;
7+
import org.junit.jupiter.api.Test;
8+
import org.springframework.boot.autoconfigure.AutoConfigurations;
9+
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
10+
import org.springframework.context.annotation.Bean;
11+
import org.springframework.context.annotation.Configuration;
12+
import org.springframework.security.web.AuthenticationEntryPoint;
13+
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
14+
15+
@DisplayName("HtmxAwareAuthenticationEntryPointConfiguration Tests")
16+
class HtmxAwareAuthenticationEntryPointConfigurationTest {
17+
18+
// Register as auto-configuration so it is processed after user-defined beans,
19+
// which is required for @ConditionalOnMissingBean to evaluate correctly.
20+
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
21+
.withConfiguration(AutoConfigurations.of(HtmxAwareAuthenticationEntryPointConfiguration.class))
22+
.withPropertyValues(
23+
"user.security.loginPageURI=/user/login.html"
24+
);
25+
26+
@Nested
27+
@DisplayName("Non-OAuth2 Configuration")
28+
class NonOAuth2Configuration {
29+
30+
@Test
31+
@DisplayName("Should register HtmxAwareAuthenticationEntryPoint wrapping LoginUrlAuthenticationEntryPoint when OAuth2 disabled")
32+
void shouldRegisterHtmxEntryPointWrappingLoginUrlWhenOAuth2Disabled() {
33+
contextRunner
34+
.withPropertyValues("spring.security.oauth2.enabled=false")
35+
.run(context -> {
36+
assertThat(context).hasSingleBean(AuthenticationEntryPoint.class);
37+
assertThat(context.getBean(AuthenticationEntryPoint.class))
38+
.isInstanceOf(HtmxAwareAuthenticationEntryPoint.class);
39+
});
40+
}
41+
42+
@Test
43+
@DisplayName("Should register HtmxAwareAuthenticationEntryPoint when OAuth2 property absent")
44+
void shouldRegisterHtmxEntryPointWhenOAuth2PropertyAbsent() {
45+
contextRunner.run(context -> {
46+
assertThat(context).hasSingleBean(AuthenticationEntryPoint.class);
47+
assertThat(context.getBean(AuthenticationEntryPoint.class))
48+
.isInstanceOf(HtmxAwareAuthenticationEntryPoint.class);
49+
});
50+
}
51+
}
52+
53+
@Nested
54+
@DisplayName("OAuth2 Configuration")
55+
class OAuth2Configuration {
56+
57+
@Test
58+
@DisplayName("Should register HtmxAwareAuthenticationEntryPoint when OAuth2 enabled")
59+
void shouldRegisterHtmxEntryPointWhenOAuth2Enabled() {
60+
contextRunner
61+
.withPropertyValues("spring.security.oauth2.enabled=true")
62+
.run(context -> {
63+
assertThat(context).hasSingleBean(AuthenticationEntryPoint.class);
64+
assertThat(context.getBean(AuthenticationEntryPoint.class))
65+
.isInstanceOf(HtmxAwareAuthenticationEntryPoint.class);
66+
});
67+
}
68+
}
69+
70+
@Nested
71+
@DisplayName("Consumer Override via @ConditionalOnMissingBean")
72+
class ConsumerOverride {
73+
74+
@Test
75+
@DisplayName("Should not register library bean when consumer provides an AuthenticationEntryPoint")
76+
void shouldNotRegisterLibraryBeanWhenConsumerProvidesEntryPoint() {
77+
contextRunner
78+
.withUserConfiguration(ConsumerEntryPointConfiguration.class)
79+
.run(context -> {
80+
assertThat(context).hasSingleBean(AuthenticationEntryPoint.class);
81+
assertThat(context.getBean(AuthenticationEntryPoint.class))
82+
.isInstanceOf(LoginUrlAuthenticationEntryPoint.class);
83+
});
84+
}
85+
}
86+
87+
@Configuration
88+
static class ConsumerEntryPointConfiguration {
89+
@Bean
90+
public AuthenticationEntryPoint consumerEntryPoint() {
91+
return new LoginUrlAuthenticationEntryPoint("/custom/login");
92+
}
93+
}
94+
}

0 commit comments

Comments
 (0)