From 44f1c4eae3a726b5b010602ff4bf52472d295c9c Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Sat, 19 Sep 2026 15:51:16 +0200 Subject: [PATCH] fix(auth): keep a stale bearer token from blocking sign-in BearerTokenAuthenticationFilter runs before authorization, so a request carrying an undecodable Authorization header was answered with a bodyless 401 even on a permitAll path. A stale token in a browser profile made sign-in impossible while a private window worked. Public endpoints now answer on their own filter chain, which registers no resource server and keeps the CORS and CSRF configuration of the application chain. --- ...curityFilterChainRoutingIntegrationTest.kt | 94 +++++++++++++++++++ .../auth/config/SecurityConfig.kt | 42 ++++++++- 2 files changed, 134 insertions(+), 2 deletions(-) diff --git a/api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/auth/flow/SecurityFilterChainRoutingIntegrationTest.kt b/api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/auth/flow/SecurityFilterChainRoutingIntegrationTest.kt index 6e6e58c..0e4eec7 100644 --- a/api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/auth/flow/SecurityFilterChainRoutingIntegrationTest.kt +++ b/api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/auth/flow/SecurityFilterChainRoutingIntegrationTest.kt @@ -156,6 +156,100 @@ class SecurityFilterChainRoutingIntegrationTest : IntegrationTestBase() { } } + @Nested + inner class StaleBearerTokenOnPublicEndpoints { + // A browser profile can hold an Authorization header from an old + // client build or an extension. The resource-server filter rejects an + // undecodable token before authorization runs, so a public endpoint + // answered 401 with an empty body and sign-in was impossible until the + // profile was cleared -- which is why it always worked in a private + // window. + private val garbageToken = "garbage.token.value" + private val wellFormedButUnsigned = + "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJzb21lb25lIiwiZXhwIjoxfQ.bm90LWEtc2lnbmF0dXJl" + + @Test + fun `POST session-login with an undecodable bearer token is not rejected`() { + val result = + mockMvc + .post("/api/v1/auth/session-login") { + contentType = MediaType.APPLICATION_JSON + header("Authorization", "Bearer $garbageToken") + content = """{"username":"nonexistent","password":"test-password"}""" + }.andReturn() + assertNotUnauthorized("/api/v1/auth/session-login", result.response.status) + } + + @Test + fun `POST session-login with a wrongly signed bearer token is not rejected`() { + val result = + mockMvc + .post("/api/v1/auth/session-login") { + contentType = MediaType.APPLICATION_JSON + header("Authorization", "Bearer $wellFormedButUnsigned") + content = """{"username":"nonexistent","password":"test-password"}""" + }.andReturn() + assertNotUnauthorized("/api/v1/auth/session-login", result.response.status) + } + + @Test + fun `POST login with an undecodable bearer token is not rejected`() { + val result = + mockMvc + .post("/api/v1/auth/login") { + contentType = MediaType.APPLICATION_JSON + header("Authorization", "Bearer $garbageToken") + content = """{"username":"nonexistent","password":"test-password"}""" + }.andReturn() + assertNotUnauthorized("/api/v1/auth/login", result.response.status) + } + + @Test + fun `POST register with an undecodable bearer token is not rejected`() { + val result = + mockMvc + .post("/api/v1/users/register") { + contentType = MediaType.APPLICATION_JSON + header("Authorization", "Bearer $garbageToken") + content = "{}" + }.andReturn() + assertNotUnauthorized("/api/v1/users/register", result.response.status) + } + + @Test + fun `POST refresh with an undecodable bearer token is not rejected`() { + val result = + mockMvc + .post("/api/v1/auth/refresh") { + contentType = MediaType.APPLICATION_JSON + header("Authorization", "Bearer $garbageToken") + content = "{}" + }.andReturn() + assertNotUnauthorized("/api/v1/auth/refresh", result.response.status) + } + + @Test + fun `GET confirm-email with an undecodable bearer token is not rejected`() { + val result = + mockMvc + .get("/api/v1/auth/confirm-email") { + header("Authorization", "Bearer $garbageToken") + param("token", "invalid-token") + }.andReturn() + assertNotUnauthorized("/api/v1/auth/confirm-email", result.response.status) + } + + @Test + fun `GET v1 health with an undecodable bearer token is not rejected`() { + val result = + mockMvc + .get("/api/v1/health") { + header("Authorization", "Bearer $garbageToken") + }.andReturn() + assertNotUnauthorized("/api/v1/health", result.response.status) + } + } + @Nested inner class ProtectedEndpoints { @Test diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/auth/config/SecurityConfig.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/auth/config/SecurityConfig.kt index 512d685..6587c9d 100644 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/auth/config/SecurityConfig.kt +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/auth/config/SecurityConfig.kt @@ -27,6 +27,8 @@ import org.springframework.security.web.csrf.CookieCsrfTokenRepository import org.springframework.security.web.csrf.CsrfFilter import org.springframework.security.web.csrf.CsrfToken import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler +import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher +import org.springframework.security.web.util.matcher.OrRequestMatcher import org.springframework.security.web.util.matcher.RequestMatcher import org.springframework.web.cors.CorsConfiguration import org.springframework.web.cors.CorsConfigurationSource @@ -34,8 +36,9 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource import org.springframework.web.filter.OncePerRequestFilter import java.net.URLEncoder -private const val FORWARD_AUTH_SECURITY_ORDER = 2 -private const val APPLICATION_SECURITY_ORDER = 3 +private const val PUBLIC_ENDPOINTS_SECURITY_ORDER = 2 +private const val FORWARD_AUTH_SECURITY_ORDER = 3 +private const val APPLICATION_SECURITY_ORDER = 4 /** * Browser requests keep using the session cookie, while native clients may @@ -51,6 +54,41 @@ class SecurityConfig( @param:Value("\${session.cookie.domain:}") private val cookieDomain: String, ) { + /** + * Public endpoints answer on their own chain, without the resource server. + * + * `permitAll` does not stop `BearerTokenAuthenticationFilter`: it runs + * before authorization, so any request carrying an `Authorization: Bearer` + * header it cannot decode is rejected with a bodyless 401 even on an + * endpoint that needs no authentication. A stale token left in a browser + * profile therefore made sign-in impossible — `POST /api/v1/auth/session-login` + * returned 401, the SPA could only show a generic "Login failed", and the + * same credentials worked in a private window because no token was there to + * send. Keeping these paths off the resource-server chain makes the header + * irrelevant where it carries no meaning. + * + * CSRF stays configured exactly as on the application chain so the + * `XSRF-TOKEN` cookie is still issued from here. + */ + @Bean + @Order(PUBLIC_ENDPOINTS_SECURITY_ORDER) + fun publicEndpointsSecurityFilterChain( + http: HttpSecurity, + corsConfigurationSource: CorsConfigurationSource, + ): SecurityFilterChain { + http + .securityMatcher( + OrRequestMatcher(PUBLIC_ENDPOINTS.map { PathPatternRequestMatcher.pathPattern(it) }), + ) + .cors { it.configurationSource(corsConfigurationSource) } + .securityContext { it.securityContextRepository(HttpSessionSecurityContextRepository()) } + .csrf { configureCsrf(it) } + .addFilterAfter(CsrfCookieFilter(), CsrfFilter::class.java) + .authorizeHttpRequests { it.anyRequest().permitAll() } + .exceptionHandling { it.authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)) } + return http.build() + } + @Bean @Order(FORWARD_AUTH_SECURITY_ORDER) fun forwardAuthSecurityFilterChain(