Add HTMX-aware AuthenticationEntryPoint for session expiry handling#295
Add HTMX-aware AuthenticationEntryPoint for session expiry handling#295devondragon merged 4 commits intomainfrom
Conversation
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
There was a problem hiding this comment.
Pull request overview
Adds an HTMX-aware AuthenticationEntryPoint to prevent HTMX fragment requests from following Spring Security’s default 302-to-login redirect (which can cause full login HTML to be swapped into fragments), and wires it in as the default entry point while remaining overrideable by consumers.
Changes:
- Introduces
HtmxAwareAuthenticationEntryPointto return 401 + JSON +HX-Redirectfor HTMX requests, delegating to the existing entry point for non-HTMX requests. - Adds
HtmxAwareAuthenticationEntryPointConfigurationto register the default entry point when not overridden by consumers. - Updates
WebSecurityConfigto always configureexceptionHandling().authenticationEntryPoint(...)using the injected bean, plus adds unit tests.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| src/main/java/com/digitalsanctuary/spring/user/security/HtmxAwareAuthenticationEntryPoint.java | Implements HTMX detection and custom 401 JSON response behavior. |
| src/main/java/com/digitalsanctuary/spring/user/security/HtmxAwareAuthenticationEntryPointConfiguration.java | Auto-registers the framework’s default entry point (wrapping OAuth2 or form-login entry points). |
| src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java | Injects and applies the entry point globally instead of only in OAuth2 setup. |
| src/test/java/com/digitalsanctuary/spring/user/security/HtmxAwareAuthenticationEntryPointTest.java | Adds unit tests covering HTMX vs non-HTMX behavior and edge cases. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| private final AuthenticationEntryPoint authenticationEntryPoint; | ||
| private final UserDetailsService userDetailsService; | ||
| private final LoginSuccessService loginSuccessService; | ||
| private final LogoutSuccessService logoutSuccessService; |
There was a problem hiding this comment.
WebSecurityConfig now requires a single AuthenticationEntryPoint bean via constructor injection. In any consuming app that defines multiple AuthenticationEntryPoint beans (common when separating browser vs API entry points), this will fail with NoUniqueBeanDefinitionException unless one is @Primary/qualified. Consider injecting by @Qualifier (e.g., a dedicated bean name for this framework’s entry point) or marking the framework-provided bean as @Primary to keep backward compatibility and avoid startup failures.
| * @return an {@link HtmxAwareAuthenticationEntryPoint} wrapping the appropriate inner entry point | ||
| */ | ||
| @Bean | ||
| @ConditionalOnMissingBean(AuthenticationEntryPoint.class) |
There was a problem hiding this comment.
@ConditionalOnMissingBean(AuthenticationEntryPoint.class) is very broad: if a consuming app provides an AuthenticationEntryPoint bean for some other security use-case (e.g., a REST API), this auto-config will back off even if they still want the framework’s default browser entry point. Using a name-based condition (or documenting that consumers must provide a single/primary AuthenticationEntryPoint) would avoid accidental disablement.
| @ConditionalOnMissingBean(AuthenticationEntryPoint.class) | |
| @ConditionalOnMissingBean(name = "authenticationEntryPoint") |
| response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); | ||
| response.setContentType("application/json"); | ||
| response.setHeader(HX_REDIRECT_HEADER, loginUrl); | ||
| String escapedLoginUrl = loginUrl.replace("\\", "\\\\").replace("\"", "\\\""); | ||
| response.getWriter().write("{\"error\":\"authentication_required\"," | ||
| + "\"message\":\"Session expired. Please log in.\"," | ||
| + "\"loginUrl\":\"" + escapedLoginUrl + "\"}"); |
There was a problem hiding this comment.
The HTMX branch builds JSON manually and only partially escapes loginUrl (quotes/backslashes). This can still produce invalid JSON for other characters (e.g., newlines) and is easy to regress. Prefer serializing a small DTO/Map with the project’s Jackson ObjectMapper (used elsewhere for safe JSON escaping) and set an explicit character encoding (e.g., UTF-8) before writing.
| log.info("Configuring HtmxAwareAuthenticationEntryPoint wrapping CustomOAuth2AuthenticationEntryPoint"); | ||
| } else { | ||
| inner = new LoginUrlAuthenticationEntryPoint(loginPageURI); | ||
| log.info("Configuring HtmxAwareAuthenticationEntryPoint wrapping LoginUrlAuthenticationEntryPoint"); |
There was a problem hiding this comment.
These startup logs run at INFO every time the context starts. Since this is configuration detail (and may be noisy in consuming apps), consider lowering to DEBUG or removing unless it’s actionable for operators.
| log.info("Configuring HtmxAwareAuthenticationEntryPoint wrapping CustomOAuth2AuthenticationEntryPoint"); | |
| } else { | |
| inner = new LoginUrlAuthenticationEntryPoint(loginPageURI); | |
| log.info("Configuring HtmxAwareAuthenticationEntryPoint wrapping LoginUrlAuthenticationEntryPoint"); | |
| log.debug("Configuring HtmxAwareAuthenticationEntryPoint wrapping CustomOAuth2AuthenticationEntryPoint"); | |
| } else { | |
| inner = new LoginUrlAuthenticationEntryPoint(loginPageURI); | |
| log.debug("Configuring HtmxAwareAuthenticationEntryPoint wrapping LoginUrlAuthenticationEntryPoint"); |
CLAUDE.mdThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. Project OverviewSpring User Framework is a reusable Spring Boot library (not an application) that provides user authentication and management features built on Spring Security. It supports Spring Boot 4.0 (Java 21+) and Spring Boot 3.5 (Java 17+). This is a library, not an app. All Spring Boot starters are Commands# Build
./gradlew build
# Run tests
./gradlew test
# Run single test
./gradlew test --tests "com.digitalsanctuary.spring.user.service.UserServiceTest"
# Test with specific JDK
./gradlew testJdk17
./gradlew testJdk21
# Test all JDKs
./gradlew testAll
# Lint/check
./gradlew check
# Publish locally (for testing in consuming apps)
./gradlew publishLocalLocal Testing with Demo AppThe SpringUserFrameworkDemoApp is a Spring Boot app that consumes this library for testing and demonstration. It is typically checked out alongside this repo at Workflow
ArchitecturePackage StructureKey ComponentsEntry Points:
Core Services:
Security:
Extension Points:
Auto-Configuration
Startup Behavior
Design Decisions to Preserve
ConfigurationAll configuration uses
Code Style
TestingTests use H2 in-memory database with JUnit 5 parallel execution. Key dependencies: Testcontainers, WireMock, GreenMail, AssertJ, REST Assured. Custom Test Annotations (use these instead of raw Spring annotations)
All annotations are in Test Configuration Classes (
|
|
PR Review - Add HTMX-aware AuthenticationEntryPoint This is a clean, well-motivated solution to a real HTMX/Spring Security pain point. The delegation pattern is correct, the JavaDoc is thorough, and the test coverage for HtmxAwareAuthenticationEntryPoint is excellent (13 tests covering the key branches). A few issues worth addressing before merging: Bug/Correctness 1: Incomplete JSON string escaping HtmxAwareAuthenticationEntryPoint.java line 63 only escapes backslash and double-quote. It misses control characters (newline, carriage return, tab, backspace, form-feed) that are invalid inside a JSON string. While loginUrl comes from application configuration rather than user input so the practical risk is low, manually rolling JSON serialization is fragile. Consider using Jacksons ObjectMapper which is already on the classpath in any Spring Boot app. Bug/Correctness 2: Missing response.setCharacterEncoding HtmxAwareAuthenticationEntryPoint.java lines 57-65: setContentType(application/json) does not guarantee UTF-8 encoding on all servlet containers. Add response.setCharacterEncoding(UTF-8) before writing the body, or use application/json;charset=UTF-8 as the content type. Design Concern 3: CustomOAuth2AuthenticationEntryPoint(null, loginPageURI) - null AuthenticationFailureHandler HtmxAwareAuthenticationEntryPointConfiguration.java line 46: The null failureHandler argument is intentional (same as the previous usage in WebSecurityConfig), but since this is now in a separate configuration class the intent is not obvious. When failureHandler is null, OAuth2AuthenticationExceptions fall through to the else branch and redirect to loginPageURI. A short comment explaining this would help future readers. Design Concern 4: MFA + HTMX interaction setupMfa() calls http.exceptionHandling with an accessDeniedHandler after securityFilterChain() has already called http.exceptionHandling with an authenticationEntryPoint. Spring Security merges these since entry point and access denied handler are independent, so there is no regression. A comment confirming this, or an integration test for the MFA + expired HTMX session case, would add confidence. Missing Test Coverage 5: No tests for HtmxAwareAuthenticationEntryPointConfiguration The configuration class has two code paths (OAuth2 enabled vs. disabled) and is the wiring glue. Tests verifying: (a) the correct bean type is registered when OAuth2 is disabled, (b) the correct bean type is registered when OAuth2 is enabled, (c) a consumer-defined AuthenticationEntryPoint suppresses registration via @ConditionalOnMissingBean - would catch regressions and document intended behavior. Minor/Nit 6: Import ordering in WebSecurityConfig.java Per project style (alphabetical imports), org.springframework.security.web.AuthenticationEntryPoint should appear after org.springframework.security.core.session.SessionRegistry since 'core' sorts before 'web'. It was inserted before SessionRegistry in the diff. Minor/Nit 7: Case-insensitive HTMX header detection is broader than the spec true.equalsIgnoreCase() accepts TRUE, True, etc. The HTMX spec always sends lowercase true. The test shouldHandleHxRequestHeaderCaseInsensitively makes this behavior contractual, but worth noting it is more permissive than any real HTMX client would send. Summary:
The core logic is sound and the entry point test suite is well-structured. Addressing the JSON encoding and adding a configuration test would make this PR production-ready. |
- Add response.setCharacterEncoding("UTF-8") and use
application/json;charset=UTF-8 content type
- Escape newline, carriage return, and tab in loginUrl JSON output
- Change startup INFO logs to DEBUG (less noise in consuming apps)
- Add @primary to library's AuthenticationEntryPoint bean to prevent
NoUniqueBeanDefinitionException if consumer has multiple entry points
- Add comment explaining intent of null AuthenticationFailureHandler
- Fix import ordering (security.web.AuthenticationEntryPoint after
security.crypto.* per alphabetical convention)
- Add HtmxAwareAuthenticationEntryPointConfigurationTest covering
OAuth2 enabled/disabled paths and @ConditionalOnMissingBean override
|
test comment - will delete |
PR Review - Add HTMX-aware AuthenticationEntryPointThis is a well-motivated and well-implemented solution. The delegation pattern is correct, JavaDoc is thorough, and the test suite for HtmxAwareAuthenticationEntryPoint is excellent. A few things worth addressing before merging: Bug / Correctness 1. JSON escaping is incomplete HtmxAwareAuthenticationEntryPoint.java escapes backslash, double-quote, newline, carriage-return, tab but misses backspace (U+0008) and form-feed (U+000C), both required by the JSON spec. The simplest fix is Jackson ObjectMapper (already on the Spring Boot classpath), which handles all cases and eliminates the escapedLoginUrl variable entirely. Design Concerns 2. @value field injection in the configuration class HtmxAwareAuthenticationEntryPointConfiguration uses @value field injection. Per CLAUDE.md: DI should use @requiredargsconstructor with final fields - this should use constructor injection. It also makes the class easier to test directly. 3. @primary alongside @ConditionalOnMissingBean is redundant @ConditionalOnMissingBean ensures at most one AuthenticationEntryPoint bean is registered, so @primary has no effect. If a consumer provides their own bean, registration is skipped entirely. Remove it to avoid confusion. 4. spring.security.oauth2.enabled is non-standard Not a regression (it existed in WebSecurityConfig before this PR), but consuming apps that configure OAuth2 via spring.security.oauth2.client.registration.* without explicitly setting spring.security.oauth2.enabled=true will silently get LoginUrlAuthenticationEntryPoint as the inner delegate instead of CustomOAuth2AuthenticationEntryPoint. A comment or CONFIG.md note explaining this requirement would prevent misconfiguration. Test Coverage 5. Tests should use @servicetest annotation HtmxAwareAuthenticationEntryPointTest uses raw @ExtendWith(MockitoExtension.class). Per CLAUDE.md, @servicetest is the correct annotation for Mockito-only unit tests in this project. 6. Configuration tests do not verify the inner delegate type HtmxAwareAuthenticationEntryPointConfigurationTest verifies the outer wrapper type but not whether the inner delegate is LoginUrlAuthenticationEntryPoint (OAuth2 disabled) vs CustomOAuth2AuthenticationEntryPoint (OAuth2 enabled). A package-private getDelegate() accessor would enable this verification. Minor 7. No null guard on constructor parameters A null delegate causes a NullPointerException at runtime on the first non-HTMX request. Objects.requireNonNull in the constructor is appropriate fail-fast behaviour for a security component. 8. Import ordering in WebSecurityConfig.java Per project style (alphabetical imports), org.springframework.security.web.AuthenticationEntryPoint should appear after org.springframework.security.core.session.SessionRegistry (core sorts before web). Summary
The core logic is sound and the entry point test suite is very well structured. Addressing the JSON encoding and DI style alignment would make this PR production-ready. |
- CHANGELOG.md: Add [Unreleased] entry for the new feature - README.md: Add HTMX Support section under Security Features with override instructions; update features list and table of contents - CLAUDE.md: Add HtmxAwareAuthenticationEntryPoint to Security section and AuthenticationEntryPoint override to Extension Points
PR Review: HTMX-aware AuthenticationEntryPointOverall this is a clean, well-motivated feature. The delegation pattern is correct, the test coverage is solid, and the backward-compatibility story is clear. A few issues worth addressing before merge. Medium: @primary + @ConditionalOnMissingBean are contradictoryFile: HtmxAwareAuthenticationEntryPointConfiguration.java @ConditionalOnMissingBean ensures this bean is only created when no other AuthenticationEntryPoint exists. When it IS created, it is the only bean in context, making @primary meaningless. When a consumer overrides it, this bean is suppressed, so @primary is never exercised. The annotation is at best confusing (implying there could be multiple competing beans) and at worst misleading to future readers trying to understand the override semantics. Remove @primary; @ConditionalOnMissingBean alone is the correct mechanism here. Minor: Manual JSON construction has incomplete control-character escapingFile: HtmxAwareAuthenticationEntryPoint.java (commence method) The current escaping handles backslash, double-quote, newline, carriage-return, and tab, but misses other JSON-illegal control characters (U+0000 through U+001F). For a loginUrl sourced from application.yml this is low risk in practice, but the code is fragile. Since the response body is always the same shape and loginUrl is fixed at construction time, consider pre-computing the JSON body string once in the constructor rather than building it on every request. That removes the per-request escaping concern entirely. Minor: Import ordering does not follow project conventionFiles: HtmxAwareAuthenticationEntryPoint.java and HtmxAwareAuthenticationEntryPointConfiguration.java CLAUDE.md requires alphabetical imports. The current order (org.* then jakarta.* then lombok.) is not alphabetical. Correct order: jakarta. then java.* then lombok.* then org.* Minor: No null guards in the HtmxAwareAuthenticationEntryPoint constructorA null delegate would cause an NPE on the first non-HTMX request; a null loginUrl would produce a malformed HX-Redirect header. A couple of Objects.requireNonNull calls would surface misconfiguration at startup rather than at runtime. Minor: OAuth2 configuration test only checks the outer typeFile: HtmxAwareAuthenticationEntryPointConfigurationTest.java, OAuth2Configuration nested class The non-OAuth2 test verifies the bean wraps a LoginUrlAuthenticationEntryPoint. The OAuth2 test only checks the outer type is HtmxAwareAuthenticationEntryPoint. Since the oauth2Enabled branch wraps CustomOAuth2AuthenticationEntryPoint instead, asserting on the inner delegate type would prevent a future regression where both branches silently produce the same inner delegate. Positive observations
|
When server.servlet.context-path is set (e.g. /app), the HX-Redirect header and loginUrl JSON field now correctly include the context path (e.g. /app/user/login.html) matching LoginUrlAuthenticationEntryPoint behavior. Found by Codex review.
Summary
HtmxAwareAuthenticationEntryPoint— detects HTMX requests (HX-Request: trueheader) and returns a 401 JSON response withHX-Redirectheader instead of the default 302 redirect that breaks HTMX UIsHtmxAwareAuthenticationEntryPointConfiguration— registers the entry point via@ConditionalOnMissingBean(AuthenticationEntryPoint.class), allowing consumers to override with their own beanWebSecurityConfig— always configuresexceptionHandling()with the injected entry point (previously only configured when OAuth2 was enabled)Backward Compatibility
AuthenticationEntryPointbeanCloses #294
Test Plan