Skip to content

Commit 15e5af8

Browse files
committed
Address PR review feedback for HTMX entry point
- 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
1 parent 9d1359b commit 15e5af8

5 files changed

Lines changed: 112 additions & 7 deletions

File tree

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,15 @@ public void commence(HttpServletRequest request, HttpServletResponse response,
5858
}
5959

6060
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
61-
response.setContentType("application/json");
61+
response.setCharacterEncoding("UTF-8");
62+
response.setContentType("application/json;charset=UTF-8");
6263
response.setHeader(HX_REDIRECT_HEADER, loginUrl);
63-
String escapedLoginUrl = loginUrl.replace("\\", "\\\\").replace("\"", "\\\"");
64+
String escapedLoginUrl = loginUrl
65+
.replace("\\", "\\\\")
66+
.replace("\"", "\\\"")
67+
.replace("\n", "\\n")
68+
.replace("\r", "\\r")
69+
.replace("\t", "\\t");
6470
response.getWriter().write("{\"error\":\"authentication_required\","
6571
+ "\"message\":\"Session expired. Please log in.\","
6672
+ "\"loginUrl\":\"" + escapedLoginUrl + "\"}");

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
55
import org.springframework.context.annotation.Bean;
66
import org.springframework.context.annotation.Configuration;
7+
import org.springframework.context.annotation.Primary;
78
import org.springframework.security.web.AuthenticationEntryPoint;
89
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
910
import lombok.extern.slf4j.Slf4j;
@@ -40,15 +41,18 @@ public class HtmxAwareAuthenticationEntryPointConfiguration {
4041
* @return an {@link HtmxAwareAuthenticationEntryPoint} wrapping the appropriate inner entry point
4142
*/
4243
@Bean
44+
@Primary
4345
@ConditionalOnMissingBean(AuthenticationEntryPoint.class)
4446
public AuthenticationEntryPoint authenticationEntryPoint() {
4547
AuthenticationEntryPoint inner;
4648
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.
4751
inner = new CustomOAuth2AuthenticationEntryPoint(null, loginPageURI);
48-
log.info("Configuring HtmxAwareAuthenticationEntryPoint wrapping CustomOAuth2AuthenticationEntryPoint");
52+
log.debug("Configuring HtmxAwareAuthenticationEntryPoint wrapping CustomOAuth2AuthenticationEntryPoint");
4953
} else {
5054
inner = new LoginUrlAuthenticationEntryPoint(loginPageURI);
51-
log.info("Configuring HtmxAwareAuthenticationEntryPoint wrapping LoginUrlAuthenticationEntryPoint");
55+
log.debug("Configuring HtmxAwareAuthenticationEntryPoint wrapping LoginUrlAuthenticationEntryPoint");
5256
}
5357
return new HtmxAwareAuthenticationEntryPoint(inner, loginPageURI);
5458
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,12 @@
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;
2625
import org.springframework.security.core.session.SessionRegistry;
2726
import org.springframework.security.core.session.SessionRegistryImpl;
2827
import org.springframework.security.core.userdetails.UserDetailsService;
2928
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
3029
import org.springframework.security.crypto.password.PasswordEncoder;
30+
import org.springframework.security.web.AuthenticationEntryPoint;
3131
import org.springframework.security.web.SecurityFilterChain;
3232
import org.springframework.security.web.access.DelegatingMissingAuthorityAccessDeniedHandler;
3333
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
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+
}

src/test/java/com/digitalsanctuary/spring/user/security/HtmxAwareAuthenticationEntryPointTest.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ void shouldReturn401WhenHtmxRequestReceived() throws IOException, ServletExcepti
7777
}
7878

7979
@Test
80-
@DisplayName("Should set JSON content type when HTMX request received")
80+
@DisplayName("Should set JSON content type with UTF-8 charset when HTMX request received")
8181
void shouldSetJsonContentTypeWhenHtmxRequestReceived() throws IOException, ServletException {
8282
// Given
8383
AuthenticationException authException = new InsufficientAuthenticationException("Session expired");
@@ -86,7 +86,8 @@ void shouldSetJsonContentTypeWhenHtmxRequestReceived() throws IOException, Servl
8686
entryPoint.commence(request, response, authException);
8787

8888
// Then
89-
verify(response).setContentType("application/json");
89+
verify(response).setCharacterEncoding("UTF-8");
90+
verify(response).setContentType("application/json;charset=UTF-8");
9091
}
9192

9293
@Test

0 commit comments

Comments
 (0)