Skip to content

Add HTMX-aware AuthenticationEntryPoint for session expiry handling#295

Merged
devondragon merged 4 commits intomainfrom
worktree-GH-294
Mar 28, 2026
Merged

Add HTMX-aware AuthenticationEntryPoint for session expiry handling#295
devondragon merged 4 commits intomainfrom
worktree-GH-294

Conversation

@devondragon
Copy link
Copy Markdown
Owner

Summary

  • New HtmxAwareAuthenticationEntryPoint — detects HTMX requests (HX-Request: true header) and returns a 401 JSON response with HX-Redirect header instead of the default 302 redirect that breaks HTMX UIs
  • New HtmxAwareAuthenticationEntryPointConfiguration — registers the entry point via @ConditionalOnMissingBean(AuthenticationEntryPoint.class), allowing consumers to override with their own bean
  • Modified WebSecurityConfig — always configures exceptionHandling() with the injected entry point (previously only configured when OAuth2 was enabled)

Backward Compatibility

  • Non-HTMX browser requests get the same 302 redirect behavior as before
  • OAuth2, MFA, and WebAuthn flows are unaffected
  • Consumers can override by defining their own AuthenticationEntryPoint bean

Closes #294

Test Plan

  • 13 unit tests covering HTMX detection, delegation, edge cases, and exception propagation
  • Full test suite passes with no regressions
  • Clean build
  • Manual testing with HTMX polling in demo app (session expiry scenario)

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
Copilot AI review requested due to automatic review settings March 28, 2026 21:34
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 HtmxAwareAuthenticationEntryPoint to return 401 + JSON + HX-Redirect for HTMX requests, delegating to the existing entry point for non-HTMX requests.
  • Adds HtmxAwareAuthenticationEntryPointConfiguration to register the default entry point when not overridden by consumers.
  • Updates WebSecurityConfig to always configure exceptionHandling().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.

Comment on lines +126 to 129
private final AuthenticationEntryPoint authenticationEntryPoint;
private final UserDetailsService userDetailsService;
private final LoginSuccessService loginSuccessService;
private final LogoutSuccessService logoutSuccessService;
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
* @return an {@link HtmxAwareAuthenticationEntryPoint} wrapping the appropriate inner entry point
*/
@Bean
@ConditionalOnMissingBean(AuthenticationEntryPoint.class)
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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.

Suggested change
@ConditionalOnMissingBean(AuthenticationEntryPoint.class)
@ConditionalOnMissingBean(name = "authenticationEntryPoint")

Copilot uses AI. Check for mistakes.
Comment on lines +60 to +66
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 + "\"}");
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +48 to +51
log.info("Configuring HtmxAwareAuthenticationEntryPoint wrapping CustomOAuth2AuthenticationEntryPoint");
} else {
inner = new LoginUrlAuthenticationEntryPoint(loginPageURI);
log.info("Configuring HtmxAwareAuthenticationEntryPoint wrapping LoginUrlAuthenticationEntryPoint");
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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");

Copilot uses AI. Check for mistakes.
@claude
Copy link
Copy Markdown

claude bot commented Mar 28, 2026

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Project Overview

Spring 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 compileOnly dependencies. Consuming applications provide their own database, mail server, and security configuration. Never add Spring starters as implementation dependencies.

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 publishLocal

Local Testing with Demo App

The SpringUserFrameworkDemoApp is a Spring Boot app that consumes this library for testing and demonstration. It is typically checked out alongside this repo at ../SpringUserFrameworkDemoApp.

Workflow

  1. Publish the library locally:

    ./gradlew publishLocal

    This publishes the current SNAPSHOT version (from gradle.properties) to your local Maven repository.

  2. Update the demo app dependency (if needed):
    In ../SpringUserFrameworkDemoApp/build.gradle, ensure the dependency version matches the SNAPSHOT:

    implementation 'com.digitalsanctuary:ds-spring-user-framework:X.Y.Z-SNAPSHOT'

    Check gradle.properties for the current version.

  3. Start the demo app:

    cd ../SpringUserFrameworkDemoApp
    ./gradlew bootRun --args='--spring.profiles.active=local,playwright-test'

    The app runs on http://localhost:8080 by default. The playwright-test profile activates TestDataController and TestApiSecurityConfig, which the Playwright tests require for test data setup/teardown. Omit playwright-test if only doing manual browser testing.

  4. Run Playwright tests:

    cd ../SpringUserFrameworkDemoApp/playwright
    npx playwright test --project=chromium
  5. Manual browser testing can be done with Playwright MCP tools or directly in Chrome at http://localhost:8080.

Architecture

Package Structure

com.digitalsanctuary.spring.user
├── api/              # REST endpoints (UserAPI)
├── audit/            # Audit logging system
├── controller/       # MVC controllers for HTML pages
├── dev/              # Dev login auto-configuration (local profile only)
├── dto/              # Data transfer objects
├── event/            # Spring application events
├── exceptions/       # Custom exceptions
├── listener/         # Event listeners (auth, registration)
├── mail/             # Email service components
├── persistence/      # JPA entities and repositories
│   ├── model/        # User, Role, Privilege, tokens
│   └── repository/   # Spring Data repositories
├── profile/          # User profile extension framework
├── roles/            # Role/privilege configuration
├── security/         # Spring Security configuration
├── service/          # Business logic services
├── validation/       # Custom validators
└── web/              # Web interceptors and config

Key Components

Entry Points:

  • UserConfiguration - Main auto-configuration class, enables async/retry/scheduling/method security
  • WebSecurityConfig - Spring Security filter chain configuration
  • UserAPI - REST endpoints at /user/* (registration, password reset, profile update)

Core Services:

  • UserService - User CRUD, password management, token handling
  • UserEmailService - Verification and password reset emails
  • PasswordPolicyService - Password strength validation using Passay
  • DSUserDetailsService - Spring Security UserDetailsService implementation
  • SessionInvalidationService - Session management for security events

Security:

  • DSUserDetails - Custom UserDetails implementation wrapping User entity
  • DSOAuth2UserService / DSOidcUserService - OAuth2/OIDC user services
  • LoginAttemptService - Brute force protection with account lockout

Extension Points:

  • BaseUserProfile - Extend for custom user data (see PROFILE.md)
  • UserProfileService<T> - Interface for profile management
  • BaseSessionProfile<T> - Session-scoped profile access
  • UserPreDeleteEvent - Listen for user deletion to clean up related data

Auto-Configuration

  • Entry point: META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.importsUserConfiguration
  • UserAutoConfigurationRegistrar dynamically registers the library package for entity/repository scanning
  • No conditional annotations — all features load, controlled by user.* properties

Startup Behavior

  • RolePrivilegeSetupService listens for ContextRefreshedEvent and creates/updates Role and Privilege entities from user.roles-and-privileges config. Must complete before any auth requests.
  • PasswordHashTimeTester runs async on ApplicationStartedEvent to benchmark bcrypt performance (controlled by user.security.testHashTime).

Design Decisions to Preserve

  • UserService.createUser() uses @Transactional(isolation = Isolation.SERIALIZABLE) to prevent race conditions during concurrent registration
  • Event-driven architecture: AuthenticationEventListener, RegistrationListener, AuditEventListener, BaseAuthenticationListener — don't bypass events
  • All Spring starters are compileOnly — this is intentional for library consumers

Configuration

All configuration uses user.* prefix in application.yml. Key property groups:

  • user.security.* - URIs, default action (allow/deny), bcrypt strength, lockout settings, testHashTime
  • user.registration.* - Email verification toggle, OAuth provider toggles
  • user.mail.* - Email sender settings (fromAddress)
  • user.audit.* - Audit logging (logFilePath, flushOnWrite, logEvents, maxQueryResults)
  • user.roles-and-privileges - Role-to-privilege mapping (applied on startup)
  • user.role-hierarchy - Role inheritance (e.g., ROLE_ADMIN > ROLE_MANAGER)
  • user.gdpr.* - GDPR features (enabled, exportBeforeDeletion, consentTracking)
  • user.dev.* - Dev login (autoLoginEnabled, loginRedirectUrl) — requires local profile
  • user.purgetokens.cron.* - Token cleanup schedule
  • user.session.invalidation.warn-threshold - Performance warning threshold
  • user.actuallyDeleteAccount - Hard delete vs disable

Code Style

  • Imports: Alphabetical, no wildcards
  • Indentation: 4 spaces
  • Logging: SLF4J via Lombok @Slf4j
  • DI: @RequiredArgsConstructor with final fields
  • Documentation: JavaDoc on public classes/methods
  • Assertions: AssertJ fluent style (not JUnit assertEquals)
  • Test naming: should[ExpectedBehavior]When[Condition] pattern

Testing

Tests 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)

Annotation Use When Spring Context Key Imports
@ServiceTest Unit testing services with mocks None (Mockito only) BaseTestConfiguration
@DatabaseTest Testing JPA repositories @DataJpaTest slice DatabaseTestConfiguration
@IntegrationTest Full workflow testing Full context All 5 test configs
@SecurityTest Testing auth/authorization Full context + security SecurityTestConfiguration
@OAuth2Test Testing OAuth2/OIDC flows Full context + OAuth2 OAuth2TestConfiguration

All annotations are in com.digitalsanctuary.spring.user.test.annotations.

Test Configuration Classes (test.config package)

  • BaseTestConfiguration - BCrypt strength 4 (fast), fixed Clock (2024-01-15 10:00 UTC), mock EventPublisher, SessionRegistry
  • SecurityTestConfiguration - Pre-built test users: user@test.com, admin@test.com, moderator@test.com (password: "password"). TestSecurityContextFactory for custom contexts.
  • OAuth2TestConfiguration - Mock OAuth2/OIDC providers (Google, GitHub, OIDC), test token factory
  • MockMailConfiguration - Captures sent emails via TestMailCapture instead of sending
  • DatabaseTestConfiguration - Database-specific test setup

Test Application

Integration tests use TestApplication (in test.app package) as their Spring Boot context class.

Related Documentation

  • TESTING.md - Comprehensive testing guide with patterns and troubleshooting
  • PROFILE.md - User profile extension framework
  • REGISTRATION-GUARD.md - Registration Guard SPI for pre-registration hooks
  • CONFIG.md - Configuration reference
  • MIGRATION.md - Version migration guide
  • CONTRIBUTING.md - Contributor guidelines (fork/branch/PR workflow)

Release Process

Version is in gradle.properties. Do not manually update version numbers. The release process (./gradlew release) handles versioning, changelog generation, tagging, and Maven Central publishing automatically.

@claude
Copy link
Copy Markdown

claude bot commented Mar 28, 2026

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:

  • Should fix: Incomplete JSON escaping and missing charset
  • Should fix: Missing tests for HtmxAwareAuthenticationEntryPointConfiguration
  • Minor: Document null failureHandler intent
  • Nit: Import ordering

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
@claude
Copy link
Copy Markdown

claude bot commented Mar 28, 2026

test comment - will delete

@claude
Copy link
Copy Markdown

claude bot commented Mar 28, 2026

PR Review - Add HTMX-aware AuthenticationEntryPoint

This 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

Severity Item
Should fix Incomplete JSON escaping - use Jackson ObjectMapper
Should fix @value field injection - use constructor injection
Nice-to-have Remove redundant @primary
Nice-to-have Document spring.security.oauth2.enabled requirement
Nice-to-have Use @servicetest in unit tests
Minor Add null guards in constructor
Nit Import ordering

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
@claude
Copy link
Copy Markdown

claude bot commented Mar 28, 2026

PR Review: HTMX-aware AuthenticationEntryPoint

Overall 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 contradictory

File: 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 escaping

File: 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 convention

Files: 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 constructor

A 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 type

File: 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

  • The isCommitted() guard before writing the HTMX response is a nice defensive touch.
  • equalsIgnoreCase for the HX-Request header value is the right call.
  • Moving exceptionHandling() out of setupOAuth2() into the main securityFilterChain() is a clean simplification; OAuth2 flows still get the correct entry point via the injected bean.
  • Test structure with @nested and @DisplayName is well-organised and consistent with the codebase style.
  • 13 unit tests covering all key paths including committed-response and exception-propagation edge cases.

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.
@devondragon devondragon merged commit f7a34d6 into main Mar 28, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add HTMX-aware AuthenticationEntryPoint for session expiry handling

2 participants