Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,18 @@ 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
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
Expand All @@ -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(
Expand Down
Loading