From c319f7a529f38f75a1b99327f94b838e43700701 Mon Sep 17 00:00:00 2001 From: Tran Ngoc Nhan Date: Thu, 3 Sep 2026 19:27:07 +0700 Subject: [PATCH 1/2] Preserve resource path in resource metadata challenge Closes gh-19639 Signed-off-by: Tran Ngoc Nhan --- .../OAuth2ResourceServerConfigurerTests.java | 28 +++++++++-------- .../BearerTokenAuthenticationEntryPoint.java | 17 +++++----- ...rerTokenAuthenticationEntryPointTests.java | 31 +++++++++++++++++++ .../security/web/util/UrlUtils.java | 9 +++++- .../security/web/util/UrlUtilsTests.java | 26 ++++++++++++++++ 5 files changed, 87 insertions(+), 24 deletions(-) diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests.java index 20b87b4a446..7ef5a7d65e4 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests.java @@ -305,7 +305,7 @@ public void getWhenUsingDefaultsWithExpiredBearerTokenThenInvalidToken() throws // @formatter:off this.mvc.perform(get("/").with(bearerToken(token))) .andExpect(status().isUnauthorized()) - .andExpect(invalidTokenHeader("An error occurred while attempting to decode the Jwt")); + .andExpect(invalidTokenHeader("An error occurred while attempting to decode the Jwt", "/")); // @formatter:on } @@ -337,7 +337,7 @@ public void getWhenUsingDefaultsWithMalformedBearerTokenThenInvalidToken() throw // @formatter:off this.mvc.perform(get("/").with(bearerToken("an\"invalid\"token"))) .andExpect(status().isUnauthorized()) - .andExpect(invalidTokenHeader("Bearer token is malformed")); + .andExpect(invalidTokenHeader("Bearer token is malformed", "/")); // @formatter:on } @@ -349,7 +349,7 @@ public void getWhenUsingDefaultsWithMalformedPayloadThenInvalidToken() throws Ex // @formatter:off this.mvc.perform(get("/").with(bearerToken(token))) .andExpect(status().isUnauthorized()) - .andExpect(invalidTokenHeader("An error occurred while attempting to decode the Jwt: Malformed payload")); + .andExpect(invalidTokenHeader("An error occurred while attempting to decode the Jwt: Malformed payload", "/")); // @formatter:on } @@ -360,7 +360,7 @@ public void getWhenUsingDefaultsWithUnsignedBearerTokenThenInvalidToken() throws // @formatter:off this.mvc.perform(get("/").with(bearerToken(token))) .andExpect(status().isUnauthorized()) - .andExpect(invalidTokenHeader("Unsupported algorithm of none")); + .andExpect(invalidTokenHeader("Unsupported algorithm of none", "/")); // @formatter:on } @@ -372,7 +372,7 @@ public void getWhenUsingDefaultsWithBearerTokenBeforeNotBeforeThenInvalidToken() // @formatter:off this.mvc.perform(get("/").with(bearerToken(token))) .andExpect(status().isUnauthorized()) - .andExpect(invalidTokenHeader("An error occurred while attempting to decode the Jwt")); + .andExpect(invalidTokenHeader("An error occurred while attempting to decode the Jwt", "/")); // @formatter:on } @@ -486,7 +486,7 @@ public void getWhenUsingDefaultsAndAuthorizationServerHasNoMatchingKeyThenInvali // @formatter:off this.mvc.perform(get("/").with(bearerToken(token))) .andExpect(status().isUnauthorized()) - .andExpect(invalidTokenHeader("An error occurred while attempting to decode the Jwt")); + .andExpect(invalidTokenHeader("An error occurred while attempting to decode the Jwt", "/")); // @formatter:on } @@ -604,7 +604,7 @@ public void postWhenUsingDefaultsWithExpiredBearerTokenAndNoCsrfThenInvalidToken // @formatter:off this.mvc.perform(post("/authenticated").header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE).with(bearerToken(token))) .andExpect(status().isUnauthorized()) - .andExpect(invalidTokenHeader("An error occurred while attempting to decode the Jwt")); + .andExpect(invalidTokenHeader("An error occurred while attempting to decode the Jwt","/authenticated")); // @formatter:on } @@ -955,7 +955,7 @@ public void requestWhenClockSkewSetButJwtStillTooLateThenReportsExpired() throws // @formatter:off this.mvc.perform(get("/").with(bearerToken(token))) .andExpect(status().isUnauthorized()) - .andExpect(invalidTokenHeader("Jwt expired at")); + .andExpect(invalidTokenHeader("Jwt expired at", "/")); // @formatter:on } @@ -1006,7 +1006,7 @@ public void requestWhenUsingPublicKeyAndSignatureFailsThenReturnsInvalidToken() String token = this.token("WrongSignature"); // @formatter:off this.mvc.perform(get("/").with(bearerToken(token))) - .andExpect(invalidTokenHeader("signature")); + .andExpect(invalidTokenHeader("signature", "/")); // @formatter:on } @@ -1016,7 +1016,7 @@ public void requestWhenUsingPublicKeyAlgorithmDoesNotMatchThenReturnsInvalidToke String token = this.token("WrongAlgorithm"); // @formatter:off this.mvc.perform(get("/").with(bearerToken(token))) - .andExpect(invalidTokenHeader("algorithm")); + .andExpect(invalidTokenHeader("algorithm", "/")); // @formatter:on } @@ -1314,7 +1314,7 @@ public void getWhenMultipleIssuersThenUsesIssuerClaimToDifferentiate() throws Ex // @formatter:off this.mvc.perform(get("/authenticated").with(bearerToken(jwtThree))) .andExpect(status().isUnauthorized()) - .andExpect(invalidTokenHeader("Invalid issuer")); + .andExpect(invalidTokenHeader("Invalid issuer","/authenticated")); // @formatter:on } @@ -1478,13 +1478,15 @@ private static ResultMatcher invalidRequestHeader(String message) { ", " + "resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\""))); } - private static ResultMatcher invalidTokenHeader(String message) { + private static ResultMatcher invalidTokenHeader(String message, String endpoint) { + String path = "/".equals(endpoint) ? "" : endpoint; return header().string(HttpHeaders.WWW_AUTHENTICATE, AllOf.allOf(new StringStartsWith("Bearer " + "error=\"invalid_token\", " + "error_description=\""), new StringContains(message), new StringContains(", " + "error_uri=\"https://tools.ietf.org/html/rfc6750#section-3.1\""), new StringEndsWith( - ", " + "resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\""))); + ", resource_metadata=\"http://localhost/.well-known/oauth-protected-resource%s\"" + .formatted(path)))); } private static ResultMatcher insufficientScopeHeader() { diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/BearerTokenAuthenticationEntryPoint.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/BearerTokenAuthenticationEntryPoint.java index b08cf114346..dcb6d3296ab 100644 --- a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/BearerTokenAuthenticationEntryPoint.java +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/BearerTokenAuthenticationEntryPoint.java @@ -115,16 +115,13 @@ public void setResourceMetadataParameterResolver( } private static String getResourceMetadataParameter(HttpServletRequest request) { - String path = request.getContextPath() - + OAuth2ProtectedResourceMetadataFilter.DEFAULT_OAUTH2_PROTECTED_RESOURCE_METADATA_ENDPOINT_URI; - // @formatter:off - return UriComponentsBuilder.fromUriString(UrlUtils.buildFullRequestUrl(request)) - .replacePath(path) - .replaceQuery(null) - .fragment(null) - .build() - .toUriString(); - // @formatter:on + + UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(UrlUtils.buildFullRequestUrl(request)); + String[] pathSegments = builder.build().getPathSegments().toArray(String[]::new); + return builder.replacePath(request.getContextPath()) + .path(OAuth2ProtectedResourceMetadataFilter.DEFAULT_OAUTH2_PROTECTED_RESOURCE_METADATA_ENDPOINT_URI) + .pathSegment(pathSegments) + .toUriString(); } private static String computeWWWAuthenticateHeaderValue(Map parameters) { diff --git a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/web/BearerTokenAuthenticationEntryPointTests.java b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/web/BearerTokenAuthenticationEntryPointTests.java index c066b1eb97e..9f1ecec480b 100644 --- a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/web/BearerTokenAuthenticationEntryPointTests.java +++ b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/web/BearerTokenAuthenticationEntryPointTests.java @@ -18,6 +18,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.springframework.http.HttpStatus; import org.springframework.mock.web.MockHttpServletRequest; @@ -90,6 +92,35 @@ public void commenceWhenNoBearerTokenErrorAndResourceMetadataResolverSetThenStat .isEqualTo("Bearer resource_metadata=\"https://example.com/resource-from-request\""); } + // gh-19639 + @ParameterizedTest + @CsvSource( + textBlock = """ + , , https://example.com/.well-known/oauth-protected-resource, + '', '', https://example.com/.well-known/oauth-protected-resource, + /, /, https://example.com/.well-known/oauth-protected-resource, + requestUri, contextPath, https://example.com/contextPath/.well-known/oauth-protected-resource/requestUri, + /requestUri, /contextPath, https://example.com/contextPath/.well-known/oauth-protected-resource/requestUri, + requestUri/, contextPath/, https://example.com/contextPath/.well-known/oauth-protected-resource/requestUri, + /requestUri/, /contextPath/, https://example.com/contextPath/.well-known/oauth-protected-resource/requestUri, + //requestUri//, //contextPath//, https://example.com/contextPath/.well-known/oauth-protected-resource/requestUri + """) + public void commenceShouldIncludeContextPathAndRequestUriInResourceMetadata(String requestUri, String contextPath, + String expected) { + + MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri); + request.setScheme("https"); + request.setServerName("example.com"); + request.setServerPort(443); + request.setContextPath(contextPath); + MockHttpServletResponse response = new MockHttpServletResponse(); + + this.authenticationEntryPoint.commence(request, response, new BadCredentialsException("test")); + assertThat(response.getStatus()).isEqualTo(401); + assertThat(response.getHeader("WWW-Authenticate")) + .isEqualTo("Bearer resource_metadata=\"%s\"".formatted(expected)); + } + @Test public void commenceWhenInvalidRequestErrorThenStatus400AndHeaderWithError() throws Exception { MockHttpServletRequest request = new MockHttpServletRequest(); diff --git a/web/src/main/java/org/springframework/security/web/util/UrlUtils.java b/web/src/main/java/org/springframework/security/web/util/UrlUtils.java index 926872a0d1b..f04cd02e664 100644 --- a/web/src/main/java/org/springframework/security/web/util/UrlUtils.java +++ b/web/src/main/java/org/springframework/security/web/util/UrlUtils.java @@ -22,6 +22,8 @@ import jakarta.servlet.http.HttpServletRequest; import org.jspecify.annotations.Nullable; +import org.springframework.util.StringUtils; + /** * Provides static methods for composing URLs. *

@@ -67,7 +69,12 @@ else if ("https".equals(scheme)) { } // Use the requestURI as it is encoded (RFC 3986) and hence suitable for // redirects. - url.append(requestURI); + if (StringUtils.hasText(requestURI)) { + if (requestURI.charAt(0) != '/') { + url.append("/"); + } + url.append(requestURI); + } if (queryString != null) { url.append("?").append(queryString); } diff --git a/web/src/test/java/org/springframework/security/web/util/UrlUtilsTests.java b/web/src/test/java/org/springframework/security/web/util/UrlUtilsTests.java index 0a8aedebc73..2e82e653f02 100644 --- a/web/src/test/java/org/springframework/security/web/util/UrlUtilsTests.java +++ b/web/src/test/java/org/springframework/security/web/util/UrlUtilsTests.java @@ -17,14 +17,40 @@ package org.springframework.security.web.util; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import org.springframework.mock.web.MockHttpServletRequest; import static org.assertj.core.api.Assertions.assertThat; /** + * Test for {@link UrlUtils} + * * @author Luke Taylor */ public class UrlUtilsTests { + @ParameterizedTest + @CsvSource(textBlock = """ + , https://example.com, + '', https://example.com, + /, https://example.com/, + requestUri, https://example.com/requestUri, + /requestUri, https://example.com/requestUri, + requestUri/, https://example.com/requestUri/, + /requestUri/, https://example.com/requestUri/, + //requestUri//, https://example.com//requestUri// + """) + public void buildFullRequestUrl(String requestUri, String expected) { + + MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri); + request.setScheme("https"); + request.setServerName("example.com"); + request.setServerPort(443); + assertThat(UrlUtils.buildFullRequestUrl(request)).isEqualTo(expected); + } + @Test public void absoluteUrlsAreMatchedAsAbsolute() { assertThat(UrlUtils.isAbsoluteUrl("https://something/")).isTrue(); From 88cd9e2ea82df1ff4fbcb955a12be7babc999e4a Mon Sep 17 00:00:00 2001 From: Tran Ngoc Nhan Date: Thu, 3 Sep 2026 20:49:13 +0700 Subject: [PATCH 2/2] Fix OAuth2ResourceServerBeanDefinitionParserTests and OAuth2ResourceServerConfigurerTests testcase Signed-off-by: Tran Ngoc Nhan --- .../OAuth2ResourceServerConfigurerTests.java | 4 +-- ...sourceServerBeanDefinitionParserTests.java | 28 ++++++++++--------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests.java index 7ef5a7d65e4..6eb803ac32c 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests.java @@ -604,7 +604,7 @@ public void postWhenUsingDefaultsWithExpiredBearerTokenAndNoCsrfThenInvalidToken // @formatter:off this.mvc.perform(post("/authenticated").header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE).with(bearerToken(token))) .andExpect(status().isUnauthorized()) - .andExpect(invalidTokenHeader("An error occurred while attempting to decode the Jwt","/authenticated")); + .andExpect(invalidTokenHeader("An error occurred while attempting to decode the Jwt", "/authenticated")); // @formatter:on } @@ -1314,7 +1314,7 @@ public void getWhenMultipleIssuersThenUsesIssuerClaimToDifferentiate() throws Ex // @formatter:off this.mvc.perform(get("/authenticated").with(bearerToken(jwtThree))) .andExpect(status().isUnauthorized()) - .andExpect(invalidTokenHeader("Invalid issuer","/authenticated")); + .andExpect(invalidTokenHeader("Invalid issuer", "/authenticated")); // @formatter:on } diff --git a/config/src/test/java/org/springframework/security/config/http/OAuth2ResourceServerBeanDefinitionParserTests.java b/config/src/test/java/org/springframework/security/config/http/OAuth2ResourceServerBeanDefinitionParserTests.java index 4f2247cc765..b692ae12d68 100644 --- a/config/src/test/java/org/springframework/security/config/http/OAuth2ResourceServerBeanDefinitionParserTests.java +++ b/config/src/test/java/org/springframework/security/config/http/OAuth2ResourceServerBeanDefinitionParserTests.java @@ -183,7 +183,7 @@ public void getWhenExpiredBearerTokenThenInvalidToken() throws Exception { // @formatter:off this.mvc.perform(get("/").header("Authorization", "Bearer " + token)) .andExpect(status().isUnauthorized()) - .andExpect(invalidTokenHeader("An error occurred while attempting to decode the Jwt")); + .andExpect(invalidTokenHeader("An error occurred while attempting to decode the Jwt", "/")); // @formatter:on } @@ -215,7 +215,7 @@ public void getWhenMalformedBearerTokenThenInvalidToken() throws Exception { // @formatter:off this.mvc.perform(get("/").header("Authorization", "Bearer an\"invalid\"token")) .andExpect(status().isUnauthorized()) - .andExpect(invalidTokenHeader("Bearer token is malformed")); + .andExpect(invalidTokenHeader("Bearer token is malformed", "/")); // @formatter:on } @@ -227,7 +227,7 @@ public void getWhenMalformedPayloadThenInvalidToken() throws Exception { // @formatter:off this.mvc.perform(get("/").header("Authorization", "Bearer " + token)) .andExpect(status().isUnauthorized()) - .andExpect(invalidTokenHeader("An error occurred while attempting to decode the Jwt: Malformed payload")); + .andExpect(invalidTokenHeader("An error occurred while attempting to decode the Jwt: Malformed payload", "/")); // @formatter:on } @@ -238,7 +238,7 @@ public void getWhenUnsignedBearerTokenThenInvalidToken() throws Exception { // @formatter:off this.mvc.perform(get("/").header("Authorization", "Bearer " + token)) .andExpect(status().isUnauthorized()) - .andExpect(invalidTokenHeader("Unsupported algorithm of none")); + .andExpect(invalidTokenHeader("Unsupported algorithm of none", "/")); // @formatter:on } @@ -250,7 +250,7 @@ public void getWhenBearerTokenBeforeNotBeforeThenInvalidToken() throws Exception // @formatter:off this.mvc.perform(get("/").header("Authorization", "Bearer " + token)) .andExpect(status().isUnauthorized()) - .andExpect(invalidTokenHeader("An error occurred while attempting to decode the Jwt")); + .andExpect(invalidTokenHeader("An error occurred while attempting to decode the Jwt", "/")); // @formatter:on } @@ -342,7 +342,7 @@ public void getWhenAuthorizationServerHasNoMatchingKeyThenInvalidToken() throws // @formatter:off this.mvc.perform(get("/").header("Authorization", "Bearer " + token)) .andExpect(status().isUnauthorized()) - .andExpect(invalidTokenHeader("An error occurred while attempting to decode the Jwt")); + .andExpect(invalidTokenHeader("An error occurred while attempting to decode the Jwt", "/")); // @formatter:on } @@ -398,7 +398,7 @@ public void postWhenExpiredBearerTokenAndNoCsrfThenInvalidToken() throws Excepti // @formatter:off this.mvc.perform(post("/authenticated").header("Authorization", "Bearer " + token)) .andExpect(status().isUnauthorized()) - .andExpect(invalidTokenHeader("An error occurred while attempting to decode the Jwt")); + .andExpect(invalidTokenHeader("An error occurred while attempting to decode the Jwt", "/authenticated")); // @formatter:on } @@ -621,7 +621,7 @@ public void requestWhenClockSkewSetButJwtStillTooLateThenReportsExpired() throws // @formatter:off this.mvc.perform(get("/").header("Authorization", "Bearer " + token)) .andExpect(status().isUnauthorized()) - .andExpect(invalidTokenHeader("Jwt expired at")); + .andExpect(invalidTokenHeader("Jwt expired at", "/")); // @formatter:on } @@ -661,7 +661,7 @@ public void requestWhenUsingPublicKeyAndSignatureFailsThenReturnsInvalidToken() String token = this.token("WrongSignature"); // @formatter:off this.mvc.perform(get("/").header("Authorization", "Bearer " + token)) - .andExpect(invalidTokenHeader("signature")); + .andExpect(invalidTokenHeader("signature", "/")); // @formatter:on } @@ -671,7 +671,7 @@ public void requestWhenUsingPublicKeyAlgorithmDoesNotMatchThenReturnsInvalidToke String token = this.token("WrongAlgorithm"); // @formatter:off this.mvc.perform(get("/").header("Authorization", "Bearer " + token)) - .andExpect(invalidTokenHeader("algorithm")); + .andExpect(invalidTokenHeader("algorithm", "/")); // @formatter:on } @@ -779,7 +779,7 @@ public void getWhenMultipleIssuersThenUsesIssuerClaimToDifferentiate() throws Ex // @formatter:off this.mvc.perform(get("/authenticated").header("Authorization", "Bearer " + jwtThree)) .andExpect(status().isUnauthorized()) - .andExpect(invalidTokenHeader("Invalid issuer")); + .andExpect(invalidTokenHeader("Invalid issuer", "/authenticated")); // @formatter:on } @@ -934,13 +934,15 @@ private static ResultMatcher invalidRequestHeader(String message) { ", " + "resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\""))); } - private static ResultMatcher invalidTokenHeader(String message) { + private static ResultMatcher invalidTokenHeader(String message, String endpoint) { + String path = "/".equals(endpoint) ? "" : endpoint; return header().string(HttpHeaders.WWW_AUTHENTICATE, AllOf.allOf(new StringStartsWith("Bearer " + "error=\"invalid_token\", " + "error_description=\""), new StringContains(message), new StringContains(", " + "error_uri=\"https://tools.ietf.org/html/rfc6750#section-3.1\""), new StringEndsWith( - ", " + "resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\""))); + ", resource_metadata=\"http://localhost/.well-known/oauth-protected-resource%s\"" + .formatted(path)))); } private static ResultMatcher insufficientScopeHeader() {