Explorar el Código

Add support for OAuth 2.0 Protected Resource Metadata

Closes gh-17244
Joe Grandja hace 1 semana
padre
commit
54aae36f98
Se han modificado 13 ficheros con 1061 adiciones y 19 borrados
  1. 51 0
      config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java
  2. 10 0
      config/src/test/java/org/springframework/security/SerializationSamples.java
  3. 200 0
      config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ProtectedResourceMetadataTests.java
  4. 8 4
      config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests.java
  5. 7 3
      config/src/test/java/org/springframework/security/config/http/OAuth2ResourceServerBeanDefinitionParserTests.java
  6. BIN
      config/src/test/resources/serialized/7.0.x/org.springframework.security.oauth2.server.resource.OAuth2ProtectedResourceMetadata.serialized
  7. 289 0
      oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/OAuth2ProtectedResourceMetadata.java
  8. 112 0
      oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/OAuth2ProtectedResourceMetadataClaimAccessor.java
  9. 70 0
      oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/OAuth2ProtectedResourceMetadataClaimNames.java
  10. 14 0
      oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/BearerTokenAuthenticationEntryPoint.java
  11. 174 0
      oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/OAuth2ProtectedResourceMetadataFilter.java
  12. 17 12
      oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/web/BearerTokenAuthenticationEntryPointTests.java
  13. 109 0
      oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/web/OAuth2ProtectedResourceMetadataFilterTests.java

+ 51 - 0
config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java

@@ -20,6 +20,7 @@ import java.util.Arrays;
 import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.Map;
+import java.util.function.Consumer;
 import java.util.function.Supplier;
 
 import jakarta.servlet.http.HttpServletRequest;
@@ -43,6 +44,7 @@ import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
 import org.springframework.security.oauth2.jwt.Jwt;
 import org.springframework.security.oauth2.jwt.JwtDecoder;
 import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
+import org.springframework.security.oauth2.server.resource.OAuth2ProtectedResourceMetadata;
 import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
 import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider;
 import org.springframework.security.oauth2.server.resource.authentication.OpaqueTokenAuthenticationProvider;
@@ -51,6 +53,7 @@ import org.springframework.security.oauth2.server.resource.introspection.OpaqueT
 import org.springframework.security.oauth2.server.resource.introspection.SpringOpaqueTokenIntrospector;
 import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint;
 import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver;
+import org.springframework.security.oauth2.server.resource.web.OAuth2ProtectedResourceMetadataFilter;
 import org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler;
 import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationConverter;
 import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;
@@ -59,6 +62,7 @@ import org.springframework.security.web.access.AccessDeniedHandler;
 import org.springframework.security.web.access.AccessDeniedHandlerImpl;
 import org.springframework.security.web.access.DelegatingAccessDeniedHandler;
 import org.springframework.security.web.authentication.AuthenticationConverter;
+import org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter;
 import org.springframework.security.web.csrf.CsrfException;
 import org.springframework.security.web.util.matcher.AndRequestMatcher;
 import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;
@@ -172,6 +176,8 @@ public final class OAuth2ResourceServerConfigurer<H extends HttpSecurityBuilder<
 
 	private OpaqueTokenConfigurer opaqueTokenConfigurer;
 
+	private final ProtectedResourceMetadataConfigurer protectedResourceMetadataConfigurer = new ProtectedResourceMetadataConfigurer();
+
 	private AccessDeniedHandler accessDeniedHandler = new DelegatingAccessDeniedHandler(
 			new LinkedHashMap<>(Map.of(CsrfException.class, new AccessDeniedHandlerImpl())),
 			new BearerTokenAccessDeniedHandler());
@@ -250,6 +256,18 @@ public final class OAuth2ResourceServerConfigurer<H extends HttpSecurityBuilder<
 		return this;
 	}
 
+	/**
+	 * Configure OAuth 2.0 Protected Resource Metadata.
+	 * @param protectedResourceMetadataCustomizer the {@link Customizer} to provide more
+	 * options for the {@link ProtectedResourceMetadataConfigurer}
+	 * @return the {@link OAuth2ResourceServerConfigurer} for further customizations
+	 */
+	public OAuth2ResourceServerConfigurer<H> protectedResourceMetadata(
+			Customizer<ProtectedResourceMetadataConfigurer> protectedResourceMetadataCustomizer) {
+		protectedResourceMetadataCustomizer.customize(this.protectedResourceMetadataConfigurer);
+		return this;
+	}
+
 	@Override
 	public void init(H http) {
 		validateConfiguration();
@@ -277,10 +295,19 @@ public final class OAuth2ResourceServerConfigurer<H extends HttpSecurityBuilder<
 		filter.setSecurityContextHolderStrategy(getSecurityContextHolderStrategy());
 		filter = postProcess(filter);
 		http.addFilter(filter);
+
 		if (dPoPAuthenticationAvailable) {
 			DPoPAuthenticationConfigurer<H> dPoPAuthenticationConfigurer = new DPoPAuthenticationConfigurer<>();
 			dPoPAuthenticationConfigurer.configure(http);
 		}
+
+		OAuth2ProtectedResourceMetadataFilter protectedResourceMetadataFilter = new OAuth2ProtectedResourceMetadataFilter();
+		if (this.protectedResourceMetadataConfigurer.protectedResourceMetadataCustomizer != null) {
+			protectedResourceMetadataFilter.setProtectedResourceMetadataCustomizer(
+					this.protectedResourceMetadataConfigurer.protectedResourceMetadataCustomizer);
+		}
+		protectedResourceMetadataFilter = postProcess(protectedResourceMetadataFilter);
+		http.addFilterBefore(protectedResourceMetadataFilter, AbstractPreAuthenticatedProcessingFilter.class);
 	}
 
 	private void validateConfiguration() {
@@ -562,6 +589,30 @@ public final class OAuth2ResourceServerConfigurer<H extends HttpSecurityBuilder<
 
 	}
 
+	public static final class ProtectedResourceMetadataConfigurer {
+
+		private Consumer<OAuth2ProtectedResourceMetadata.Builder> protectedResourceMetadataCustomizer;
+
+		private ProtectedResourceMetadataConfigurer() {
+		}
+
+		/**
+		 * Sets the {@code Consumer} providing access to the
+		 * {@link OAuth2ProtectedResourceMetadata.Builder} allowing the ability to
+		 * customize the claims of the Resource Server's configuration.
+		 * @param protectedResourceMetadataCustomizer the {@code Consumer} providing
+		 * access to the {@link OAuth2ProtectedResourceMetadata.Builder}
+		 * @return the {@link ProtectedResourceMetadataConfigurer} for further
+		 * configuration
+		 */
+		public ProtectedResourceMetadataConfigurer protectedResourceMetadataCustomizer(
+				Consumer<OAuth2ProtectedResourceMetadata.Builder> protectedResourceMetadataCustomizer) {
+			this.protectedResourceMetadataCustomizer = protectedResourceMetadataCustomizer;
+			return this;
+		}
+
+	}
+
 	private static final class BearerTokenRequestMatcher implements RequestMatcher {
 
 		private AuthenticationConverter authenticationConverter;

+ 10 - 0
config/src/test/java/org/springframework/security/SerializationSamples.java

@@ -192,6 +192,7 @@ import org.springframework.security.oauth2.server.authorization.settings.TokenSe
 import org.springframework.security.oauth2.server.resource.BearerTokenError;
 import org.springframework.security.oauth2.server.resource.BearerTokenErrors;
 import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException;
+import org.springframework.security.oauth2.server.resource.OAuth2ProtectedResourceMetadata;
 import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthentication;
 import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken;
 import org.springframework.security.oauth2.server.resource.authentication.DPoPAuthenticationToken;
@@ -406,6 +407,15 @@ final class SerializationSamples {
 				(r) -> new OAuth2IntrospectionException("message", new RuntimeException()));
 		generatorByClassName.put(DPoPAuthenticationToken.class,
 				(r) -> applyDetails(new DPoPAuthenticationToken("token", "proof", "method", "uri")));
+		generatorByClassName.put(OAuth2ProtectedResourceMetadata.class,
+				(r) -> OAuth2ProtectedResourceMetadata.builder()
+					.resource("https://localhost/resource")
+					.authorizationServer("https://localhost/authorizationServer")
+					.scope("scope")
+					.bearerMethod("bearerMethod")
+					.resourceName("resourceName")
+					.tlsClientCertificateBoundAccessTokens(true)
+					.build());
 
 		// oauth2-authorization-server
 		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();

+ 200 - 0
config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ProtectedResourceMetadataTests.java

@@ -0,0 +1,200 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.config.annotation.web.configurers.oauth2.server.resource;
+
+import java.util.function.Consumer;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.Customizer;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.test.SpringTestContext;
+import org.springframework.security.config.test.SpringTestContextExtension;
+import org.springframework.security.oauth2.jose.TestKeys;
+import org.springframework.security.oauth2.jwt.JwtDecoder;
+import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
+import org.springframework.security.oauth2.server.resource.OAuth2ProtectedResourceMetadata;
+import org.springframework.security.oauth2.server.resource.OAuth2ProtectedResourceMetadataClaimNames;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.test.web.servlet.MockMvc;
+
+import static org.hamcrest.Matchers.hasItem;
+import static org.hamcrest.Matchers.hasSize;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * Integration tests for OAuth 2.0 Protected Resource Metadata Requests.
+ *
+ * @author Joe Grandja
+ */
+@ExtendWith(SpringTestContextExtension.class)
+public class OAuth2ProtectedResourceMetadataTests {
+
+	private static final String DEFAULT_OAUTH2_PROTECTED_RESOURCE_METADATA_ENDPOINT_URI = "/.well-known/oauth-protected-resource";
+
+	private static final String RESOURCE = "https://resource.com:8443";
+
+	private static final String ISSUER_1 = "https://provider1.com";
+
+	private static final String ISSUER_2 = "https://provider2.com";
+
+	public final SpringTestContext spring = new SpringTestContext(this);
+
+	@Autowired
+	private MockMvc mvc;
+
+	@Test
+	public void requestWhenProtectedResourceMetadataRequestThenReturnMetadataResponse() throws Exception {
+		this.spring.register(ResourceServerConfiguration.class).autowire();
+
+		this.mvc.perform(get(RESOURCE.concat(DEFAULT_OAUTH2_PROTECTED_RESOURCE_METADATA_ENDPOINT_URI)))
+			.andExpect(status().is2xxSuccessful())
+			.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.RESOURCE).value(RESOURCE))
+			.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.BEARER_METHODS_SUPPORTED).isArray())
+			.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.BEARER_METHODS_SUPPORTED).value(hasSize(1)))
+			.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.BEARER_METHODS_SUPPORTED)
+				.value(hasItem("header")))
+			.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.TLS_CLIENT_CERTIFICATE_BOUND_ACCESS_TOKENS)
+				.value(true))
+			.andReturn();
+	}
+
+	@Test
+	public void requestWhenProtectedResourceMetadataRequestIncludesResourcePathThenMetadataResponseHasResourcePath()
+			throws Exception {
+		this.spring.register(ResourceServerConfiguration.class).autowire();
+
+		String host = RESOURCE;
+
+		String resourcePath = "/resource1";
+		String resource = host.concat(resourcePath);
+		this.mvc.perform(get(host.concat(DEFAULT_OAUTH2_PROTECTED_RESOURCE_METADATA_ENDPOINT_URI).concat(resourcePath)))
+			.andExpect(status().is2xxSuccessful())
+			.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.RESOURCE).value(resource))
+			.andReturn();
+
+		resourcePath = "/path1/resource2";
+		resource = host.concat(resourcePath);
+		this.mvc.perform(get(host.concat(DEFAULT_OAUTH2_PROTECTED_RESOURCE_METADATA_ENDPOINT_URI).concat(resourcePath)))
+			.andExpect(status().is2xxSuccessful())
+			.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.RESOURCE).value(resource))
+			.andReturn();
+
+		resourcePath = "/path1/path2/resource3";
+		resource = host.concat(resourcePath);
+		this.mvc.perform(get(host.concat(DEFAULT_OAUTH2_PROTECTED_RESOURCE_METADATA_ENDPOINT_URI).concat(resourcePath)))
+			.andExpect(status().is2xxSuccessful())
+			.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.RESOURCE).value(resource))
+			.andReturn();
+	}
+
+	@Test
+	public void requestWhenProtectedResourceMetadataRequestAndMetadataCustomizerSetThenReturnCustomMetadataResponse()
+			throws Exception {
+		this.spring.register(ResourceServerConfigurationWithMetadataCustomizer.class).autowire();
+
+		this.mvc.perform(get(RESOURCE.concat(DEFAULT_OAUTH2_PROTECTED_RESOURCE_METADATA_ENDPOINT_URI)))
+			.andExpect(status().is2xxSuccessful())
+			.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.RESOURCE).value(RESOURCE))
+			.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.AUTHORIZATION_SERVERS).isArray())
+			.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.AUTHORIZATION_SERVERS).value(hasSize(2)))
+			.andExpect(
+					jsonPath(OAuth2ProtectedResourceMetadataClaimNames.AUTHORIZATION_SERVERS).value(hasItem(ISSUER_1)))
+			.andExpect(
+					jsonPath(OAuth2ProtectedResourceMetadataClaimNames.AUTHORIZATION_SERVERS).value(hasItem(ISSUER_2)))
+			.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.SCOPES_SUPPORTED).isArray())
+			.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.SCOPES_SUPPORTED).value(hasSize(2)))
+			.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.SCOPES_SUPPORTED).value(hasItem("scope1")))
+			.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.SCOPES_SUPPORTED).value(hasItem("scope2")))
+			.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.BEARER_METHODS_SUPPORTED).isArray())
+			.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.BEARER_METHODS_SUPPORTED).value(hasSize(1)))
+			.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.BEARER_METHODS_SUPPORTED)
+				.value(hasItem("header")))
+			.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.RESOURCE_NAME).value("resourceName"))
+			.andExpect(jsonPath(OAuth2ProtectedResourceMetadataClaimNames.TLS_CLIENT_CERTIFICATE_BOUND_ACCESS_TOKENS)
+				.value(true))
+			.andReturn();
+	}
+
+	@EnableWebSecurity
+	@Configuration(proxyBeanMethods = false)
+	static class ResourceServerConfiguration {
+
+		@Bean
+		SecurityFilterChain securityFilterChain(HttpSecurity http) {
+			// @formatter:off
+			http
+				.authorizeHttpRequests((authorize) ->
+					authorize
+						.anyRequest().authenticated()
+				)
+				.oauth2ResourceServer((oauth2) ->
+					oauth2
+						.jwt(Customizer.withDefaults())
+				);
+			// @formatter:on
+			return http.build();
+		}
+
+		@Bean
+		JwtDecoder jwtDecoder() {
+			return NimbusJwtDecoder.withPublicKey(TestKeys.DEFAULT_PUBLIC_KEY).build();
+		}
+
+	}
+
+	@EnableWebSecurity
+	@Configuration(proxyBeanMethods = false)
+	static class ResourceServerConfigurationWithMetadataCustomizer extends ResourceServerConfiguration {
+
+		@Bean
+		SecurityFilterChain securityFilterChain(HttpSecurity http) {
+			// @formatter:off
+			http
+				.authorizeHttpRequests((authorize) ->
+					authorize
+						.anyRequest().authenticated()
+				)
+				.oauth2ResourceServer((oauth2) ->
+					oauth2
+						.jwt(Customizer.withDefaults())
+						.protectedResourceMetadata((metadata) ->
+							metadata.protectedResourceMetadataCustomizer(protectedResourceMetadataCustomizer())
+						)
+				);
+			// @formatter:on
+			return http.build();
+		}
+
+		private Consumer<OAuth2ProtectedResourceMetadata.Builder> protectedResourceMetadataCustomizer() {
+			return (protectedResourceMetadata) -> protectedResourceMetadata.authorizationServer(ISSUER_1)
+				.authorizationServer(ISSUER_2)
+				.scope("scope1")
+				.scope("scope2")
+				.resourceName("resourceName");
+		}
+
+	}
+
+}

+ 8 - 4
config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests.java

@@ -415,7 +415,7 @@ public class OAuth2ResourceServerConfigurerTests {
 		// @formatter:off
 		this.mvc.perform(post("/").header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE).with(bearerToken("token").asParam()))
 				.andExpect(status().isUnauthorized())
-				.andExpect(header().string(HttpHeaders.WWW_AUTHENTICATE, "Bearer"));
+				.andExpect(header().string(HttpHeaders.WWW_AUTHENTICATE, "Bearer resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\""));
 		// @formatter:on
 	}
 
@@ -437,7 +437,7 @@ public class OAuth2ResourceServerConfigurerTests {
 		// @formatter:off
 		this.mvc.perform(get("/"))
 				.andExpect(status().isUnauthorized())
-				.andExpect(header().string(HttpHeaders.WWW_AUTHENTICATE, "Bearer"));
+				.andExpect(header().string(HttpHeaders.WWW_AUTHENTICATE, "Bearer resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\""));
 		// @formatter:on
 	}
 
@@ -1472,14 +1472,18 @@ public class OAuth2ResourceServerConfigurerTests {
 		return header().string(HttpHeaders.WWW_AUTHENTICATE,
 				AllOf.allOf(new StringStartsWith("Bearer " + "error=\"invalid_request\", " + "error_description=\""),
 						new StringContains(message),
-						new StringEndsWith(", " + "error_uri=\"https://tools.ietf.org/html/rfc6750#section-3.1\"")));
+						new StringContains(", " + "error_uri=\"https://tools.ietf.org/html/rfc6750#section-3.1\""),
+						new StringEndsWith(
+								", " + "resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\"")));
 	}
 
 	private static ResultMatcher invalidTokenHeader(String message) {
 		return header().string(HttpHeaders.WWW_AUTHENTICATE,
 				AllOf.allOf(new StringStartsWith("Bearer " + "error=\"invalid_token\", " + "error_description=\""),
 						new StringContains(message),
-						new StringEndsWith(", " + "error_uri=\"https://tools.ietf.org/html/rfc6750#section-3.1\"")));
+						new StringContains(", " + "error_uri=\"https://tools.ietf.org/html/rfc6750#section-3.1\""),
+						new StringEndsWith(
+								", " + "resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\"")));
 	}
 
 	private static ResultMatcher insufficientScopeHeader() {

+ 7 - 3
config/src/test/java/org/springframework/security/config/http/OAuth2ResourceServerBeanDefinitionParserTests.java

@@ -294,7 +294,7 @@ public class OAuth2ResourceServerBeanDefinitionParserTests {
 		// @formatter:off
 		this.mvc.perform(get("/"))
 				.andExpect(status().isUnauthorized())
-				.andExpect(header().string(HttpHeaders.WWW_AUTHENTICATE, "Bearer"));
+				.andExpect(header().string(HttpHeaders.WWW_AUTHENTICATE, "Bearer resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\""));
 		// @formatter:on
 	}
 
@@ -928,14 +928,18 @@ public class OAuth2ResourceServerBeanDefinitionParserTests {
 		return header().string(HttpHeaders.WWW_AUTHENTICATE,
 				AllOf.allOf(new StringStartsWith("Bearer " + "error=\"invalid_request\", " + "error_description=\""),
 						new StringContains(message),
-						new StringEndsWith(", " + "error_uri=\"https://tools.ietf.org/html/rfc6750#section-3.1\"")));
+						new StringContains(", " + "error_uri=\"https://tools.ietf.org/html/rfc6750#section-3.1\""),
+						new StringEndsWith(
+								", " + "resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\"")));
 	}
 
 	private static ResultMatcher invalidTokenHeader(String message) {
 		return header().string(HttpHeaders.WWW_AUTHENTICATE,
 				AllOf.allOf(new StringStartsWith("Bearer " + "error=\"invalid_token\", " + "error_description=\""),
 						new StringContains(message),
-						new StringEndsWith(", " + "error_uri=\"https://tools.ietf.org/html/rfc6750#section-3.1\"")));
+						new StringContains(", " + "error_uri=\"https://tools.ietf.org/html/rfc6750#section-3.1\""),
+						new StringEndsWith(
+								", " + "resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\"")));
 	}
 
 	private static ResultMatcher insufficientScopeHeader() {

BIN
config/src/test/resources/serialized/7.0.x/org.springframework.security.oauth2.server.resource.OAuth2ProtectedResourceMetadata.serialized


+ 289 - 0
oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/OAuth2ProtectedResourceMetadata.java

@@ -0,0 +1,289 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.oauth2.server.resource;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.net.URI;
+import java.net.URL;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+
+import org.springframework.util.Assert;
+
+/**
+ * A representation of an OAuth 2.0 Protected Resource Metadata response, which is
+ * returned from an OAuth 2.0 Resource Server's Metadata Endpoint, and contains a set of
+ * claims about the Resource Server's configuration. The claims are defined by the OAuth
+ * 2.0 Protected Resource Metadata specification (RFC 9728).
+ *
+ * @author Joe Grandja
+ * @since 7.0
+ * @see OAuth2ProtectedResourceMetadataClaimAccessor
+ * @see <a target="_blank" href="https://www.rfc-editor.org/rfc/rfc9728.html#section-2">2.
+ * Protected Resource Metadata</a>
+ */
+public final class OAuth2ProtectedResourceMetadata
+		implements OAuth2ProtectedResourceMetadataClaimAccessor, Serializable {
+
+	@Serial
+	private static final long serialVersionUID = -18589911827039000L;
+
+	private final Map<String, Object> claims;
+
+	private OAuth2ProtectedResourceMetadata(Map<String, Object> claims) {
+		Assert.notEmpty(claims, "claims cannot be empty");
+		this.claims = Collections.unmodifiableMap(new LinkedHashMap<>(claims));
+	}
+
+	/**
+	 * Returns the metadata as claims.
+	 * @return a {@code Map} of the metadata as claims
+	 */
+	public Map<String, Object> getClaims() {
+		return this.claims;
+	}
+
+	/**
+	 * Constructs a new {@link Builder} with empty claims.
+	 * @return the {@link Builder}
+	 */
+	public static Builder builder() {
+		return new Builder();
+	}
+
+	/**
+	 * Helps configure an {@link OAuth2ProtectedResourceMetadata}.
+	 */
+	public static final class Builder {
+
+		private final Map<String, Object> claims = new LinkedHashMap<>();
+
+		private Builder() {
+		}
+
+		/**
+		 * Sets the resource identifier for the protected resource, REQUIRED.
+		 * @param resource the resource identifier {@code URL} for the protected resource
+		 * @return the {@link Builder} for further configuration
+		 */
+		public Builder resource(String resource) {
+			return claim(OAuth2ProtectedResourceMetadataClaimNames.RESOURCE, resource);
+		}
+
+		/**
+		 * Add the issuer identifier for an authorization server, OPTIONAL.
+		 * @param authorizationServer the issuer identifier {@code URL} for an
+		 * authorization server
+		 * @return the {@link Builder} for further configuration
+		 */
+		public Builder authorizationServer(String authorizationServer) {
+			addClaimToClaimList(OAuth2ProtectedResourceMetadataClaimNames.AUTHORIZATION_SERVERS, authorizationServer);
+			return this;
+		}
+
+		/**
+		 * A {@code Consumer} of the issuer identifier values for the authorization
+		 * servers, allowing the ability to add, replace, or remove, OPTIONAL.
+		 * @param authorizationServersConsumer a {@code Consumer} of the issuer identifier
+		 * values for the authorization servers
+		 * @return the {@link Builder} for further configuration
+		 */
+		public Builder authorizationServers(Consumer<List<String>> authorizationServersConsumer) {
+			acceptClaimValues(OAuth2ProtectedResourceMetadataClaimNames.AUTHORIZATION_SERVERS,
+					authorizationServersConsumer);
+			return this;
+		}
+
+		/**
+		 * Add a {@code scope} supported in authorization requests to the protected
+		 * resource, RECOMMENDED.
+		 * @param scope a {@code scope} supported in authorization requests to the
+		 * protected resource
+		 * @return the {@link Builder} for further configuration
+		 */
+		public Builder scope(String scope) {
+			addClaimToClaimList(OAuth2ProtectedResourceMetadataClaimNames.SCOPES_SUPPORTED, scope);
+			return this;
+		}
+
+		/**
+		 * A {@code Consumer} of the {@code scope} values supported in authorization
+		 * requests to the protected resource, allowing the ability to add, replace, or
+		 * remove, RECOMMENDED.
+		 * @param scopesConsumer a {@code Consumer} of the {@code scope} values supported
+		 * in authorization requests to the protected resource
+		 * @return the {@link Builder} for further configuration
+		 */
+		public Builder scopes(Consumer<List<String>> scopesConsumer) {
+			acceptClaimValues(OAuth2ProtectedResourceMetadataClaimNames.SCOPES_SUPPORTED, scopesConsumer);
+			return this;
+		}
+
+		/**
+		 * Add a supported method for sending an OAuth 2.0 bearer token to the protected
+		 * resource, OPTIONAL. Defined values are "header", "body" and "query".
+		 * @param bearerMethod a supported method for sending an OAuth 2.0 bearer token to
+		 * the protected resource
+		 * @return the {@link Builder} for further configuration
+		 */
+		public Builder bearerMethod(String bearerMethod) {
+			addClaimToClaimList(OAuth2ProtectedResourceMetadataClaimNames.BEARER_METHODS_SUPPORTED, bearerMethod);
+			return this;
+		}
+
+		/**
+		 * A {@code Consumer} of the supported methods for sending an OAuth 2.0 bearer
+		 * token to the protected resource, allowing the ability to add, replace, or
+		 * remove, OPTIONAL.
+		 * @param bearerMethodsConsumer a {@code Consumer} of the supported methods for
+		 * sending an OAuth 2.0 bearer token to the protected resource
+		 * @return the {@link Builder} for further configuration
+		 */
+		public Builder bearerMethods(Consumer<List<String>> bearerMethodsConsumer) {
+			acceptClaimValues(OAuth2ProtectedResourceMetadataClaimNames.BEARER_METHODS_SUPPORTED,
+					bearerMethodsConsumer);
+			return this;
+		}
+
+		/**
+		 * Sets the name of the protected resource intended for display to the end user,
+		 * RECOMMENDED.
+		 * @param resourceName the name of the protected resource intended for display to
+		 * the end user
+		 * @return the {@link Builder} for further configuration
+		 */
+		public Builder resourceName(String resourceName) {
+			return claim(OAuth2ProtectedResourceMetadataClaimNames.RESOURCE_NAME, resourceName);
+		}
+
+		/**
+		 * Set to {@code true} to indicate protected resource support for mutual-TLS
+		 * client certificate-bound access tokens, OPTIONAL.
+		 * @param tlsClientCertificateBoundAccessTokens {@code true} to indicate protected
+		 * resource support for mutual-TLS client certificate-bound access tokens
+		 * @return the {@link Builder} for further configuration
+		 */
+		public Builder tlsClientCertificateBoundAccessTokens(boolean tlsClientCertificateBoundAccessTokens) {
+			return claim(OAuth2ProtectedResourceMetadataClaimNames.TLS_CLIENT_CERTIFICATE_BOUND_ACCESS_TOKENS,
+					tlsClientCertificateBoundAccessTokens);
+		}
+
+		/**
+		 * Sets the claim.
+		 * @param name the claim name
+		 * @param value the claim value
+		 * @return the {@link Builder} for further configuration
+		 */
+		public Builder claim(String name, Object value) {
+			Assert.hasText(name, "name cannot be empty");
+			Assert.notNull(value, "value cannot be null");
+			this.claims.put(name, value);
+			return this;
+		}
+
+		/**
+		 * Provides access to every {@link #claim(String, Object)} declared so far
+		 * allowing the ability to add, replace, or remove.
+		 * @param claimsConsumer a {@code Consumer} of the claims
+		 * @return the {@link Builder} for further configurations
+		 */
+		public Builder claims(Consumer<Map<String, Object>> claimsConsumer) {
+			claimsConsumer.accept(this.claims);
+			return this;
+		}
+
+		/**
+		 * Validate the claims and build the {@link OAuth2ProtectedResourceMetadata}.
+		 * @return the {@link OAuth2ProtectedResourceMetadata}
+		 */
+		public OAuth2ProtectedResourceMetadata build() {
+			validate();
+			return new OAuth2ProtectedResourceMetadata(this.claims);
+		}
+
+		private void validate() {
+			Assert.notNull(this.claims.get(OAuth2ProtectedResourceMetadataClaimNames.RESOURCE),
+					"resource cannot be null");
+			validateURL(this.claims.get(OAuth2ProtectedResourceMetadataClaimNames.RESOURCE),
+					"resource must be a valid URL");
+			if (this.claims.get(OAuth2ProtectedResourceMetadataClaimNames.AUTHORIZATION_SERVERS) != null) {
+				Assert.isInstanceOf(List.class,
+						this.claims.get(OAuth2ProtectedResourceMetadataClaimNames.AUTHORIZATION_SERVERS),
+						"authorization_servers must be of type List");
+				Assert.notEmpty(
+						(List<?>) this.claims.get(OAuth2ProtectedResourceMetadataClaimNames.AUTHORIZATION_SERVERS),
+						"authorization_servers cannot be empty");
+				List<?> authorizationServers = (List<?>) this.claims
+					.get(OAuth2ProtectedResourceMetadataClaimNames.AUTHORIZATION_SERVERS);
+				authorizationServers.forEach((authorizationServer) -> validateURL(authorizationServer,
+						"authorization_server must be a valid URL"));
+			}
+			if (this.claims.get(OAuth2ProtectedResourceMetadataClaimNames.SCOPES_SUPPORTED) != null) {
+				Assert.isInstanceOf(List.class,
+						this.claims.get(OAuth2ProtectedResourceMetadataClaimNames.SCOPES_SUPPORTED),
+						"scopes must be of type List");
+				Assert.notEmpty((List<?>) this.claims.get(OAuth2ProtectedResourceMetadataClaimNames.SCOPES_SUPPORTED),
+						"scopes cannot be empty");
+			}
+			if (this.claims.get(OAuth2ProtectedResourceMetadataClaimNames.BEARER_METHODS_SUPPORTED) != null) {
+				Assert.isInstanceOf(List.class,
+						this.claims.get(OAuth2ProtectedResourceMetadataClaimNames.BEARER_METHODS_SUPPORTED),
+						"bearer methods must be of type List");
+				Assert.notEmpty(
+						(List<?>) this.claims.get(OAuth2ProtectedResourceMetadataClaimNames.BEARER_METHODS_SUPPORTED),
+						"bearer methods cannot be empty");
+			}
+		}
+
+		@SuppressWarnings("unchecked")
+		private void addClaimToClaimList(String name, String value) {
+			Assert.hasText(name, "name cannot be empty");
+			Assert.notNull(value, "value cannot be null");
+			this.claims.computeIfAbsent(name, (k) -> new LinkedList<String>());
+			((List<String>) this.claims.get(name)).add(value);
+		}
+
+		@SuppressWarnings("unchecked")
+		private void acceptClaimValues(String name, Consumer<List<String>> valuesConsumer) {
+			Assert.hasText(name, "name cannot be empty");
+			Assert.notNull(valuesConsumer, "valuesConsumer cannot be null");
+			this.claims.computeIfAbsent(name, (k) -> new LinkedList<String>());
+			List<String> values = (List<String>) this.claims.get(name);
+			valuesConsumer.accept(values);
+		}
+
+		private static void validateURL(Object url, String errorMessage) {
+			if (URL.class.isAssignableFrom(url.getClass())) {
+				return;
+			}
+
+			try {
+				new URI(url.toString()).toURL();
+			}
+			catch (Exception ex) {
+				throw new IllegalArgumentException(errorMessage, ex);
+			}
+		}
+
+	}
+
+}

+ 112 - 0
oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/OAuth2ProtectedResourceMetadataClaimAccessor.java

@@ -0,0 +1,112 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.oauth2.server.resource;
+
+import java.net.URI;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.springframework.security.oauth2.core.ClaimAccessor;
+
+/**
+ * A {@link ClaimAccessor} for the claims a Resource Server describes about its
+ * configuration, used in OAuth 2.0 Protected Resource Metadata.
+ *
+ * @author Joe Grandja
+ * @since 7.0
+ * @see ClaimAccessor
+ * @see OAuth2ProtectedResourceMetadataClaimNames
+ * @see <a target="_blank" href="https://www.rfc-editor.org/rfc/rfc9728.html#section-2">2.
+ * Protected Resource Metadata</a>
+ */
+public interface OAuth2ProtectedResourceMetadataClaimAccessor extends ClaimAccessor {
+
+	/**
+	 * Returns the {@code URL} the protected resource asserts as its resource identifier
+	 * {@code (resource)}.
+	 * @return the {@code URL} the protected resource asserts as its resource identifier
+	 */
+	default URL getResource() {
+		return getClaimAsURL(OAuth2ProtectedResourceMetadataClaimNames.RESOURCE);
+	}
+
+	/**
+	 * Returns a list of {@code issuer} identifier {@code URL}'s, for authorization
+	 * servers that can be used with this protected resource
+	 * {@code (authorization_servers)}.
+	 * @return a list of {@code issuer} identifier {@code URL}'s, for authorization
+	 * servers that can be used with this protected resource
+	 */
+	default List<URL> getAuthorizationServers() {
+		List<String> authorizationServers = getClaimAsStringList(
+				OAuth2ProtectedResourceMetadataClaimNames.AUTHORIZATION_SERVERS);
+		List<URL> urls = new ArrayList<>();
+		authorizationServers.forEach((authorizationServer) -> {
+			try {
+				urls.add(new URI(authorizationServer).toURL());
+			}
+			catch (Exception ex) {
+				throw new IllegalArgumentException("Failed to convert authorization_server to URL", ex);
+			}
+		});
+		return urls;
+	}
+
+	/**
+	 * Returns a list of {@code scope} values supported, that are used in authorization
+	 * requests to request access to this protected resource {@code (scopes_supported)}.
+	 * @return a list of {@code scope} values supported, that are used in authorization
+	 * requests to request access to this protected resource
+	 */
+	default List<String> getScopes() {
+		return getClaimAsStringList(OAuth2ProtectedResourceMetadataClaimNames.SCOPES_SUPPORTED);
+	}
+
+	/**
+	 * Returns a list of the supported methods for sending an OAuth 2.0 bearer token to
+	 * the protected resource. Defined values are "header", "body" and "query".
+	 * {@code (bearer_methods_supported)}.
+	 * @return a list of the supported methods for sending an OAuth 2.0 bearer token to
+	 * the protected resource
+	 */
+	default List<String> getBearerMethodsSupported() {
+		return getClaimAsStringList(OAuth2ProtectedResourceMetadataClaimNames.BEARER_METHODS_SUPPORTED);
+	}
+
+	/**
+	 * Returns the name of the protected resource intended for display to the end user
+	 * {@code (resource_name)}.
+	 * @return the name of the protected resource intended for display to the end user
+	 */
+	default String getResourceName() {
+		return getClaimAsString(OAuth2ProtectedResourceMetadataClaimNames.RESOURCE_NAME);
+	}
+
+	/**
+	 * Returns {@code true} to indicate protected resource support for mutual-TLS client
+	 * certificate-bound access tokens
+	 * {@code (tls_client_certificate_bound_access_tokens)}.
+	 * @return {@code true} to indicate protected resource support for mutual-TLS client
+	 * certificate-bound access tokens
+	 */
+	default boolean isTlsClientCertificateBoundAccessTokens() {
+		return Boolean.TRUE.equals(getClaimAsBoolean(
+				OAuth2ProtectedResourceMetadataClaimNames.TLS_CLIENT_CERTIFICATE_BOUND_ACCESS_TOKENS));
+	}
+
+}

+ 70 - 0
oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/OAuth2ProtectedResourceMetadataClaimNames.java

@@ -0,0 +1,70 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.oauth2.server.resource;
+
+/**
+ * The names of the claims a Resource Server describes about its configuration, used in
+ * OAuth 2.0 Protected Resource Metadata.
+ *
+ * @author Joe Grandja
+ * @since 7.0
+ * @see <a target="_blank" href="https://www.rfc-editor.org/rfc/rfc9728.html#section-2">2.
+ * Protected Resource Metadata</a>
+ */
+public final class OAuth2ProtectedResourceMetadataClaimNames {
+
+	/**
+	 * {@code resource} - the {@code URL} the protected resource asserts as its resource
+	 * identifier
+	 */
+	public static final String RESOURCE = "resource";
+
+	/**
+	 * {@code authorization_servers} - a list of {@code issuer} identifier {@code URL}'s,
+	 * for authorization servers that can be used with this protected resource
+	 */
+	public static final String AUTHORIZATION_SERVERS = "authorization_servers";
+
+	/**
+	 * {@code scopes_supported} - a list of {@code scope} values supported, that are used
+	 * in authorization requests to request access to this protected resource
+	 */
+	public static final String SCOPES_SUPPORTED = "scopes_supported";
+
+	/**
+	 * {@code bearer_methods_supported} - a list of the supported methods for sending an
+	 * OAuth 2.0 bearer token to the protected resource. Defined values are "header",
+	 * "body" and "query".
+	 */
+	public static final String BEARER_METHODS_SUPPORTED = "bearer_methods_supported";
+
+	/**
+	 * {@code resource_name} - the name of the protected resource intended for display to
+	 * the end user
+	 */
+	public static final String RESOURCE_NAME = "resource_name";
+
+	/**
+	 * {@code tls_client_certificate_bound_access_tokens} - {@code true} to indicate
+	 * protected resource support for mutual-TLS client certificate-bound access tokens
+	 */
+	public static final String TLS_CLIENT_CERTIFICATE_BOUND_ACCESS_TOKENS = "tls_client_certificate_bound_access_tokens";
+
+	private OAuth2ProtectedResourceMetadataClaimNames() {
+	}
+
+}

+ 14 - 0
oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/BearerTokenAuthenticationEntryPoint.java

@@ -30,7 +30,9 @@ import org.springframework.security.oauth2.core.OAuth2Error;
 import org.springframework.security.oauth2.server.resource.BearerTokenError;
 import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;
 import org.springframework.security.web.AuthenticationEntryPoint;
+import org.springframework.security.web.util.UrlUtils;
 import org.springframework.util.StringUtils;
+import org.springframework.web.util.UriComponentsBuilder;
 
 /**
  * An {@link AuthenticationEntryPoint} implementation used to commence authentication of
@@ -81,6 +83,7 @@ public final class BearerTokenAuthenticationEntryPoint implements Authentication
 				status = bearerTokenError.getHttpStatus();
 			}
 		}
+		parameters.put("resource_metadata", getResourceMetadataParameter(request));
 		String wwwAuthenticate = computeWWWAuthenticateHeaderValue(parameters);
 		response.addHeader(HttpHeaders.WWW_AUTHENTICATE, wwwAuthenticate);
 		response.setStatus(status.value());
@@ -94,6 +97,17 @@ public final class BearerTokenAuthenticationEntryPoint implements Authentication
 		this.realmName = realmName;
 	}
 
+	private static String getResourceMetadataParameter(HttpServletRequest request) {
+		// @formatter:off
+		return UriComponentsBuilder.fromUriString(UrlUtils.buildFullRequestUrl(request))
+				.replacePath(OAuth2ProtectedResourceMetadataFilter.DEFAULT_OAUTH2_PROTECTED_RESOURCE_METADATA_ENDPOINT_URI)
+				.replaceQuery(null)
+				.fragment(null)
+				.build()
+				.toUriString();
+		// @formatter:on
+	}
+
 	private static String computeWWWAuthenticateHeaderValue(Map<String, String> parameters) {
 		StringBuilder wwwAuthenticate = new StringBuilder();
 		wwwAuthenticate.append("Bearer");

+ 174 - 0
oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/OAuth2ProtectedResourceMetadataFilter.java

@@ -0,0 +1,174 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.oauth2.server.resource.web;
+
+import java.io.IOException;
+import java.util.Map;
+import java.util.function.Consumer;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.http.converter.GenericHttpMessageConverter;
+import org.springframework.http.converter.HttpMessageNotWritableException;
+import org.springframework.http.converter.json.GsonHttpMessageConverter;
+import org.springframework.http.converter.json.JsonbHttpMessageConverter;
+import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
+import org.springframework.http.server.ServletServerHttpResponse;
+import org.springframework.security.oauth2.server.resource.OAuth2ProtectedResourceMetadata;
+import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
+import org.springframework.security.web.util.UrlUtils;
+import org.springframework.security.web.util.matcher.RequestMatcher;
+import org.springframework.util.Assert;
+import org.springframework.util.ClassUtils;
+import org.springframework.util.StringUtils;
+import org.springframework.web.filter.OncePerRequestFilter;
+import org.springframework.web.util.UriComponentsBuilder;
+
+/**
+ * A {@code Filter} that processes OAuth 2.0 Protected Resource Metadata Requests.
+ *
+ * @author Joe Grandja
+ * @since 7.0
+ * @see OAuth2ProtectedResourceMetadata
+ * @see <a target="_blank" href=
+ * "https://www.rfc-editor.org/rfc/rfc9728.html#section-3.1">3.1. Protected Resource
+ * Metadata Request</a>
+ */
+public final class OAuth2ProtectedResourceMetadataFilter extends OncePerRequestFilter {
+
+	private static final ParameterizedTypeReference<Map<String, Object>> STRING_OBJECT_MAP = new ParameterizedTypeReference<>() {
+	};
+
+	private static final GenericHttpMessageConverter<Object> JSON_MESSAGE_CONVERTER = HttpMessageConverters
+		.getJsonMessageConverter();
+
+	/**
+	 * The default endpoint {@code URI} for OAuth 2.0 Protected Resource Metadata
+	 * requests.
+	 */
+	static final String DEFAULT_OAUTH2_PROTECTED_RESOURCE_METADATA_ENDPOINT_URI = "/.well-known/oauth-protected-resource";
+
+	private final RequestMatcher requestMatcher = PathPatternRequestMatcher.withDefaults()
+		.matcher(HttpMethod.GET, DEFAULT_OAUTH2_PROTECTED_RESOURCE_METADATA_ENDPOINT_URI.concat("/**"));
+
+	private Consumer<OAuth2ProtectedResourceMetadata.Builder> protectedResourceMetadataCustomizer = (
+			protectedResourceMetadata) -> {
+	};
+
+	/**
+	 * Sets the {@code Consumer} providing access to the
+	 * {@link OAuth2ProtectedResourceMetadata.Builder} allowing the ability to customize
+	 * the claims of the Resource Server's configuration.
+	 * @param protectedResourceMetadataCustomizer the {@code Consumer} providing access to
+	 * the {@link OAuth2ProtectedResourceMetadata.Builder}
+	 */
+	public void setProtectedResourceMetadataCustomizer(
+			Consumer<OAuth2ProtectedResourceMetadata.Builder> protectedResourceMetadataCustomizer) {
+		Assert.notNull(protectedResourceMetadataCustomizer, "protectedResourceMetadataCustomizer cannot be null");
+		this.protectedResourceMetadataCustomizer = protectedResourceMetadataCustomizer;
+	}
+
+	@Override
+	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+			throws ServletException, IOException {
+
+		if (!this.requestMatcher.matches(request)) {
+			filterChain.doFilter(request, response);
+			return;
+		}
+
+		OAuth2ProtectedResourceMetadata.Builder builder = OAuth2ProtectedResourceMetadata.builder()
+			.resource(resolveResourceIdentifier(request))
+			.bearerMethod("header")
+			.tlsClientCertificateBoundAccessTokens(true);
+
+		this.protectedResourceMetadataCustomizer.accept(builder);
+
+		OAuth2ProtectedResourceMetadata protectedResourceMetadata = builder.build();
+
+		try {
+			ServletServerHttpResponse httpResponse = new ServletServerHttpResponse(response);
+			JSON_MESSAGE_CONVERTER.write(protectedResourceMetadata.getClaims(), STRING_OBJECT_MAP.getType(),
+					MediaType.APPLICATION_JSON, httpResponse);
+		}
+		catch (Exception ex) {
+			throw new HttpMessageNotWritableException(
+					"An error occurred writing the OAuth 2.0 Protected Resource Metadata: " + ex.getMessage(), ex);
+		}
+	}
+
+	private static String resolveResourceIdentifier(HttpServletRequest request) {
+		// Resolve Resource Identifier dynamically from request
+		String path = request.getRequestURI();
+		if (!StringUtils.hasText(path)) {
+			path = "";
+		}
+		else {
+			path = path.replace(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
+	}
+
+	private static final class HttpMessageConverters {
+
+		private static final boolean jackson2Present;
+
+		private static final boolean gsonPresent;
+
+		private static final boolean jsonbPresent;
+
+		static {
+			ClassLoader classLoader = HttpMessageConverters.class.getClassLoader();
+			jackson2Present = ClassUtils.isPresent("com.fasterxml.jackson.databind.ObjectMapper", classLoader)
+					&& ClassUtils.isPresent("com.fasterxml.jackson.core.JsonGenerator", classLoader);
+			gsonPresent = ClassUtils.isPresent("com.google.gson.Gson", classLoader);
+			jsonbPresent = ClassUtils.isPresent("jakarta.json.bind.Jsonb", classLoader);
+		}
+
+		private HttpMessageConverters() {
+		}
+
+		private static GenericHttpMessageConverter<Object> getJsonMessageConverter() {
+			if (jackson2Present) {
+				return new MappingJackson2HttpMessageConverter();
+			}
+			if (gsonPresent) {
+				return new GsonHttpMessageConverter();
+			}
+			if (jsonbPresent) {
+				return new JsonbHttpMessageConverter();
+			}
+			return null;
+		}
+
+	}
+
+}

+ 17 - 12
oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/web/BearerTokenAuthenticationEntryPointTests.java

@@ -50,7 +50,8 @@ public class BearerTokenAuthenticationEntryPointTests {
 		MockHttpServletResponse response = new MockHttpServletResponse();
 		this.authenticationEntryPoint.commence(request, response, new BadCredentialsException("test"));
 		assertThat(response.getStatus()).isEqualTo(401);
-		assertThat(response.getHeader("WWW-Authenticate")).isEqualTo("Bearer");
+		assertThat(response.getHeader("WWW-Authenticate"))
+			.isEqualTo("Bearer resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\"");
 	}
 
 	@Test
@@ -60,7 +61,8 @@ public class BearerTokenAuthenticationEntryPointTests {
 		this.authenticationEntryPoint.setRealmName("test");
 		this.authenticationEntryPoint.commence(request, response, new BadCredentialsException("test"));
 		assertThat(response.getStatus()).isEqualTo(401);
-		assertThat(response.getHeader("WWW-Authenticate")).isEqualTo("Bearer realm=\"test\"");
+		assertThat(response.getHeader("WWW-Authenticate")).isEqualTo(
+				"Bearer realm=\"test\", resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\"");
 	}
 
 	@Test
@@ -71,7 +73,8 @@ public class BearerTokenAuthenticationEntryPointTests {
 				null, null);
 		this.authenticationEntryPoint.commence(request, response, new OAuth2AuthenticationException(error));
 		assertThat(response.getStatus()).isEqualTo(400);
-		assertThat(response.getHeader("WWW-Authenticate")).isEqualTo("Bearer error=\"invalid_request\"");
+		assertThat(response.getHeader("WWW-Authenticate")).isEqualTo(
+				"Bearer error=\"invalid_request\", resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\"");
 	}
 
 	@Test
@@ -82,8 +85,8 @@ public class BearerTokenAuthenticationEntryPointTests {
 				"The access token expired", null, null);
 		this.authenticationEntryPoint.commence(request, response, new OAuth2AuthenticationException(error));
 		assertThat(response.getStatus()).isEqualTo(400);
-		assertThat(response.getHeader("WWW-Authenticate"))
-			.isEqualTo("Bearer error=\"invalid_request\", error_description=\"The access token expired\"");
+		assertThat(response.getHeader("WWW-Authenticate")).isEqualTo(
+				"Bearer error=\"invalid_request\", error_description=\"The access token expired\", resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\"");
 	}
 
 	@Test
@@ -94,8 +97,8 @@ public class BearerTokenAuthenticationEntryPointTests {
 				null, "https://example.com", null);
 		this.authenticationEntryPoint.commence(request, response, new OAuth2AuthenticationException(error));
 		assertThat(response.getStatus()).isEqualTo(400);
-		assertThat(response.getHeader("WWW-Authenticate"))
-			.isEqualTo("Bearer error=\"invalid_request\", error_uri=\"https://example.com\"");
+		assertThat(response.getHeader("WWW-Authenticate")).isEqualTo(
+				"Bearer error=\"invalid_request\", error_uri=\"https://example.com\", resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\"");
 	}
 
 	@Test
@@ -106,7 +109,8 @@ public class BearerTokenAuthenticationEntryPointTests {
 				null, null);
 		this.authenticationEntryPoint.commence(request, response, new OAuth2AuthenticationException(error));
 		assertThat(response.getStatus()).isEqualTo(401);
-		assertThat(response.getHeader("WWW-Authenticate")).isEqualTo("Bearer error=\"invalid_token\"");
+		assertThat(response.getHeader("WWW-Authenticate")).isEqualTo(
+				"Bearer error=\"invalid_token\", resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\"");
 	}
 
 	@Test
@@ -117,7 +121,8 @@ public class BearerTokenAuthenticationEntryPointTests {
 				null, null);
 		this.authenticationEntryPoint.commence(request, response, new OAuth2AuthenticationException(error));
 		assertThat(response.getStatus()).isEqualTo(403);
-		assertThat(response.getHeader("WWW-Authenticate")).isEqualTo("Bearer error=\"insufficient_scope\"");
+		assertThat(response.getHeader("WWW-Authenticate")).isEqualTo(
+				"Bearer error=\"insufficient_scope\", resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\"");
 	}
 
 	@Test
@@ -128,8 +133,8 @@ public class BearerTokenAuthenticationEntryPointTests {
 				null, null, "test.read test.write");
 		this.authenticationEntryPoint.commence(request, response, new OAuth2AuthenticationException(error));
 		assertThat(response.getStatus()).isEqualTo(403);
-		assertThat(response.getHeader("WWW-Authenticate"))
-			.isEqualTo("Bearer error=\"insufficient_scope\", scope=\"test.read test.write\"");
+		assertThat(response.getHeader("WWW-Authenticate")).isEqualTo(
+				"Bearer error=\"insufficient_scope\", scope=\"test.read test.write\", resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\"");
 	}
 
 	@Test
@@ -144,7 +149,7 @@ public class BearerTokenAuthenticationEntryPointTests {
 		assertThat(response.getStatus()).isEqualTo(403);
 		assertThat(response.getHeader("WWW-Authenticate"))
 			.isEqualTo("Bearer realm=\"test\", error=\"insufficient_scope\", error_description=\"Insufficient scope\", "
-					+ "error_uri=\"https://example.com\", scope=\"test.read test.write\"");
+					+ "error_uri=\"https://example.com\", scope=\"test.read test.write\", resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\"");
 	}
 
 	@Test

+ 109 - 0
oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/web/OAuth2ProtectedResourceMetadataFilterTests.java

@@ -0,0 +1,109 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.oauth2.server.resource.web;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.http.MediaType;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+
+/**
+ * Tests for {@link OAuth2ProtectedResourceMetadataFilter}.
+ *
+ * @author Joe Grandja
+ */
+public class OAuth2ProtectedResourceMetadataFilterTests {
+
+	private static final String DEFAULT_OAUTH2_PROTECTED_RESOURCE_METADATA_ENDPOINT_URI = "/.well-known/oauth-protected-resource";
+
+	private final OAuth2ProtectedResourceMetadataFilter filter = new OAuth2ProtectedResourceMetadataFilter();
+
+	@Test
+	public void setProtectedResourceMetadataCustomizerWhenNullThenThrowIllegalArgumentException() {
+		assertThatExceptionOfType(IllegalArgumentException.class)
+			.isThrownBy(() -> this.filter.setProtectedResourceMetadataCustomizer(null))
+			.withMessage("protectedResourceMetadataCustomizer cannot be null");
+	}
+
+	@Test
+	public void doFilterWhenNotProtectedResourceMetadataRequestThenNotProcessed() throws Exception {
+		String requestUri = "/path";
+		MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri);
+		request.setServletPath(requestUri);
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		this.filter.doFilter(request, response, filterChain);
+
+		verify(filterChain).doFilter(any(HttpServletRequest.class), any(HttpServletResponse.class));
+	}
+
+	@Test
+	public void doFilterWhenProtectedResourceMetadataRequestPostThenNotProcessed() throws Exception {
+		String requestUri = DEFAULT_OAUTH2_PROTECTED_RESOURCE_METADATA_ENDPOINT_URI;
+		MockHttpServletRequest request = new MockHttpServletRequest("POST", requestUri);
+		request.setServletPath(requestUri);
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		this.filter.doFilter(request, response, filterChain);
+
+		verify(filterChain).doFilter(any(HttpServletRequest.class), any(HttpServletResponse.class));
+	}
+
+	@Test
+	public void doFilterWhenProtectedResourceMetadataRequestThenMetadataResponse() throws Exception {
+		this.filter.setProtectedResourceMetadataCustomizer(
+				(protectedResourceMetadata) -> protectedResourceMetadata.authorizationServer("https://provider1.com")
+					.authorizationServer("https://provider2.com")
+					.scope("scope1")
+					.scope("scope2")
+					.resourceName("resourceName"));
+
+		String requestUri = DEFAULT_OAUTH2_PROTECTED_RESOURCE_METADATA_ENDPOINT_URI;
+		MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri);
+		request.setServletPath(requestUri);
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		this.filter.doFilter(request, response, filterChain);
+
+		verifyNoInteractions(filterChain);
+
+		assertThat(response.getContentType()).isEqualTo(MediaType.APPLICATION_JSON_VALUE);
+		String protectedResourceMetadataResponse = response.getContentAsString();
+		assertThat(protectedResourceMetadataResponse).contains("\"resource\":\"http://localhost\"");
+		assertThat(protectedResourceMetadataResponse)
+			.contains("\"authorization_servers\":[\"https://provider1.com\",\"https://provider2.com\"]");
+		assertThat(protectedResourceMetadataResponse).contains("\"scopes_supported\":[\"scope1\",\"scope2\"]");
+		assertThat(protectedResourceMetadataResponse).contains("\"bearer_methods_supported\":[\"header\"]");
+		assertThat(protectedResourceMetadataResponse).contains("\"resource_name\":\"resourceName\"");
+		assertThat(protectedResourceMetadataResponse).contains("\"tls_client_certificate_bound_access_tokens\":true");
+	}
+
+}