diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java index bccf4a3d97..6379c47700 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java +++ b/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(Map.of(CsrfException.class, new AccessDeniedHandlerImpl())), new BearerTokenAccessDeniedHandler()); @@ -250,6 +256,18 @@ public final class OAuth2ResourceServerConfigurer protectedResourceMetadata( + Customizer protectedResourceMetadataCustomizer) { + protectedResourceMetadataCustomizer.customize(this.protectedResourceMetadataConfigurer); + return this; + } + @Override public void init(H http) { validateConfiguration(); @@ -277,10 +295,19 @@ public final class OAuth2ResourceServerConfigurer 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 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 protectedResourceMetadataCustomizer) { + this.protectedResourceMetadataCustomizer = protectedResourceMetadataCustomizer; + return this; + } + + } + private static final class BearerTokenRequestMatcher implements RequestMatcher { private AuthenticationConverter authenticationConverter; diff --git a/config/src/test/java/org/springframework/security/SerializationSamples.java b/config/src/test/java/org/springframework/security/SerializationSamples.java index 33c61fd28d..41418ecafa 100644 --- a/config/src/test/java/org/springframework/security/SerializationSamples.java +++ b/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(); diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ProtectedResourceMetadataTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ProtectedResourceMetadataTests.java new file mode 100644 index 0000000000..718b8ccee4 --- /dev/null +++ b/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 protectedResourceMetadataCustomizer() { + return (protectedResourceMetadata) -> protectedResourceMetadata.authorizationServer(ISSUER_1) + .authorizationServer(ISSUER_2) + .scope("scope1") + .scope("scope2") + .resourceName("resourceName"); + } + + } + +} 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 b380474810..9be3310c90 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 @@ -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() { 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 310571b014..b8bb4ec3cc 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 @@ -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() { diff --git a/config/src/test/resources/serialized/7.0.x/org.springframework.security.oauth2.server.resource.OAuth2ProtectedResourceMetadata.serialized b/config/src/test/resources/serialized/7.0.x/org.springframework.security.oauth2.server.resource.OAuth2ProtectedResourceMetadata.serialized new file mode 100644 index 0000000000..37ec6cda5e Binary files /dev/null and b/config/src/test/resources/serialized/7.0.x/org.springframework.security.oauth2.server.resource.OAuth2ProtectedResourceMetadata.serialized differ diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/OAuth2ProtectedResourceMetadata.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/OAuth2ProtectedResourceMetadata.java new file mode 100644 index 0000000000..7e66520872 --- /dev/null +++ b/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 2. + * Protected Resource Metadata + */ +public final class OAuth2ProtectedResourceMetadata + implements OAuth2ProtectedResourceMetadataClaimAccessor, Serializable { + + @Serial + private static final long serialVersionUID = -18589911827039000L; + + private final Map claims; + + private OAuth2ProtectedResourceMetadata(Map 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 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 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> 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> 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> 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> 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()); + ((List) this.claims.get(name)).add(value); + } + + @SuppressWarnings("unchecked") + private void acceptClaimValues(String name, Consumer> valuesConsumer) { + Assert.hasText(name, "name cannot be empty"); + Assert.notNull(valuesConsumer, "valuesConsumer cannot be null"); + this.claims.computeIfAbsent(name, (k) -> new LinkedList()); + List values = (List) 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); + } + } + + } + +} diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/OAuth2ProtectedResourceMetadataClaimAccessor.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/OAuth2ProtectedResourceMetadataClaimAccessor.java new file mode 100644 index 0000000000..10fcb54497 --- /dev/null +++ b/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 2. + * Protected Resource Metadata + */ +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 getAuthorizationServers() { + List authorizationServers = getClaimAsStringList( + OAuth2ProtectedResourceMetadataClaimNames.AUTHORIZATION_SERVERS); + List 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 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 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)); + } + +} diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/OAuth2ProtectedResourceMetadataClaimNames.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/OAuth2ProtectedResourceMetadataClaimNames.java new file mode 100644 index 0000000000..8f434c83f8 --- /dev/null +++ b/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 2. + * Protected Resource Metadata + */ +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() { + } + +} 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 309338d0b8..9a0792b0d9 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 @@ -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 parameters) { StringBuilder wwwAuthenticate = new StringBuilder(); wwwAuthenticate.append("Bearer"); diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/OAuth2ProtectedResourceMetadataFilter.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/OAuth2ProtectedResourceMetadataFilter.java new file mode 100644 index 0000000000..66948bccf6 --- /dev/null +++ b/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 3.1. Protected Resource + * Metadata Request + */ +public final class OAuth2ProtectedResourceMetadataFilter extends OncePerRequestFilter { + + private static final ParameterizedTypeReference> STRING_OBJECT_MAP = new ParameterizedTypeReference<>() { + }; + + private static final GenericHttpMessageConverter 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 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 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 getJsonMessageConverter() { + if (jackson2Present) { + return new MappingJackson2HttpMessageConverter(); + } + if (gsonPresent) { + return new GsonHttpMessageConverter(); + } + if (jsonbPresent) { + return new JsonbHttpMessageConverter(); + } + return null; + } + + } + +} 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 f828d839fc..45bb04060d 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 @@ -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 diff --git a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/web/OAuth2ProtectedResourceMetadataFilterTests.java b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/web/OAuth2ProtectedResourceMetadataFilterTests.java new file mode 100644 index 0000000000..524aeb19aa --- /dev/null +++ b/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"); + } + +}