Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package com.digitalsanctuary.spring.user.security;

import java.io.IOException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;

/**
* An {@link AuthenticationEntryPoint} that detects HTMX requests and returns a JSON 401 response instead of the
* default 302 redirect to the login page.
*
* <p>When an HTMX-powered page has polling or dynamic fragment requests and the user's session expires, Spring
* Security's default entry point sends a 302 redirect to the login page. HTMX transparently follows the redirect and
* swaps the full login page HTML into each target element, breaking the UI. This entry point intercepts HTMX requests
* (identified by the {@code HX-Request} header) and returns a 401 status with a JSON body and an {@code HX-Redirect}
* header, allowing the HTMX client to handle the session expiry gracefully.</p>
*
* <p>Non-HTMX requests are delegated to the wrapped {@link AuthenticationEntryPoint}, preserving the existing
* redirect behavior for standard browser requests.</p>
*
* @see <a href="https://htmx.org/attributes/hx-request/">HTMX HX-Request Header</a>
* @see <a href="https://htmx.org/headers/hx-redirect/">HTMX HX-Redirect Response Header</a>
*/
@Slf4j
public class HtmxAwareAuthenticationEntryPoint implements AuthenticationEntryPoint {

static final String HX_REQUEST_HEADER = "HX-Request";
static final String HX_REDIRECT_HEADER = "HX-Redirect";

private final AuthenticationEntryPoint delegate;
private final String loginUrl;

/**
* Creates a new HTMX-aware authentication entry point.
*
* @param delegate the entry point to delegate to for non-HTMX requests
* @param loginUrl the login page URL used in the JSON response and HX-Redirect header
*/
public HtmxAwareAuthenticationEntryPoint(AuthenticationEntryPoint delegate, String loginUrl) {
this.delegate = delegate;
this.loginUrl = loginUrl;
}

@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
if (isHtmxRequest(request)) {
log.debug("HTMX request detected for URI {}; returning 401 with JSON body and HX-Redirect to {}",
request.getRequestURI(), loginUrl);

if (response.isCommitted()) {
log.warn("Response already committed for HTMX request to {}; cannot write 401 response",
request.getRequestURI());
return;
}

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 + "\"}");
Comment on lines +65 to +77
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.
} else {
delegate.commence(request, response, authException);
}
}

private boolean isHtmxRequest(HttpServletRequest request) {
return "true".equalsIgnoreCase(request.getHeader(HX_REQUEST_HEADER));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package com.digitalsanctuary.spring.user.security;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import lombok.extern.slf4j.Slf4j;

/**
* Auto-configuration for the {@link AuthenticationEntryPoint}.
*
* <p>Registers an {@link HtmxAwareAuthenticationEntryPoint} that wraps the appropriate inner entry point
* (form-login or OAuth2) when no custom {@link AuthenticationEntryPoint} bean is defined by the consuming
* application.</p>
*
* <p>Consuming applications can override this by defining their own {@link AuthenticationEntryPoint} bean:</p>
* <pre>{@code
* @Bean
* public AuthenticationEntryPoint myCustomEntryPoint() {
* return new MyCustomAuthenticationEntryPoint();
* }
* }</pre>
*/
@Slf4j
@Configuration
public class HtmxAwareAuthenticationEntryPointConfiguration {

@Value("${user.security.loginPageURI}")
private String loginPageURI;

@Value("${spring.security.oauth2.enabled:false}")
private boolean oauth2Enabled;

/**
* Creates the default {@link AuthenticationEntryPoint} bean. This bean is only registered when no custom
* {@link AuthenticationEntryPoint} bean is provided by the consuming application.
*
* @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.
public AuthenticationEntryPoint authenticationEntryPoint() {
AuthenticationEntryPoint inner;
if (oauth2Enabled) {
inner = new CustomOAuth2AuthenticationEntryPoint(null, loginPageURI);
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.
}
return new HtmxAwareAuthenticationEntryPoint(inner, loginPageURI);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import org.springframework.security.config.ObjectPostProcessor;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.core.session.SessionRegistry;
import org.springframework.security.core.session.SessionRegistryImpl;
import org.springframework.security.core.userdetails.UserDetailsService;
Expand Down Expand Up @@ -122,6 +123,7 @@ public class WebSecurityConfig {
@Value("${user.dev.auto-login-enabled:false}")
private boolean devAutoLoginEnabled;

private final AuthenticationEntryPoint authenticationEntryPoint;
private final UserDetailsService userDetailsService;
private final LoginSuccessService loginSuccessService;
private final LogoutSuccessService logoutSuccessService;
Comment on lines +126 to 129
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.
Expand Down Expand Up @@ -150,6 +152,9 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
http.formLogin(
formLogin -> formLogin.loginPage(loginPageURI).loginProcessingUrl(loginActionURI).successHandler(loginSuccessService).permitAll());

// Always configure exception handling with the injected entry point (HTMX-aware by default)
http.exceptionHandling(handling -> handling.authenticationEntryPoint(authenticationEntryPoint));

// Configure remember-me only if explicitly enabled and key is provided
if (rememberMeEnabled && rememberMeKey != null && !rememberMeKey.trim().isEmpty()) {
http.rememberMe(rememberMe -> rememberMe.key(rememberMeKey).userDetailsService(userDetailsService));
Expand Down Expand Up @@ -211,10 +216,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
* @throws Exception the exception
*/
private void setupOAuth2(HttpSecurity http) throws Exception {
CustomOAuth2AuthenticationEntryPoint loginAuthenticationEntryPoint = new CustomOAuth2AuthenticationEntryPoint(null, loginPageURI);

http.exceptionHandling(handling -> handling.authenticationEntryPoint(loginAuthenticationEntryPoint))
.oauth2Login(o -> o.loginPage(loginPageURI).successHandler(loginSuccessService).failureHandler((request, response, exception) -> {
// Entry point is handled globally in securityFilterChain via the injected authenticationEntryPoint bean
http.oauth2Login(o -> o.loginPage(loginPageURI).successHandler(loginSuccessService).failureHandler((request, response, exception) -> {
log.error("WebSecurityConfig.configure: OAuth2 login failure: {}", exception.getMessage());
request.getSession().setAttribute("error.message", exception.getMessage());
response.sendRedirect(loginPageURI);
Expand Down
Loading
Loading