From 60e41ea953e1307eec463afa92073fa963d85cbc Mon Sep 17 00:00:00 2001 From: Andrey Litvitski Date: Wed, 8 Jul 2026 22:44:19 +0300 Subject: [PATCH] Add protected resource metadata kotlin dsl Includes a new ProtectedResourceMetadataDsl and Kotlin DSL integration tests. Closes: gh-19422 Signed-off-by: Andrey Litvitski --- .../annotation/web/OAuth2ResourceServerDsl.kt | 38 ++++ .../ProtectedResourceMetadataDsl.kt | 56 +++++ .../ProtectedResourceMetadataDslTests.kt | 191 ++++++++++++++++++ 3 files changed, 285 insertions(+) create mode 100644 config/src/main/kotlin/org/springframework/security/config/annotation/web/oauth2/resourceserver/ProtectedResourceMetadataDsl.kt create mode 100644 config/src/test/kotlin/org/springframework/security/config/annotation/web/oauth2/resourceserver/ProtectedResourceMetadataDslTests.kt diff --git a/config/src/main/kotlin/org/springframework/security/config/annotation/web/OAuth2ResourceServerDsl.kt b/config/src/main/kotlin/org/springframework/security/config/annotation/web/OAuth2ResourceServerDsl.kt index dc1f945bb81..19bbd797b77 100644 --- a/config/src/main/kotlin/org/springframework/security/config/annotation/web/OAuth2ResourceServerDsl.kt +++ b/config/src/main/kotlin/org/springframework/security/config/annotation/web/OAuth2ResourceServerDsl.kt @@ -23,6 +23,7 @@ import org.springframework.security.config.annotation.web.configurers.oauth2.ser import org.springframework.security.config.annotation.web.oauth2.resourceserver.DPoPDsl import org.springframework.security.config.annotation.web.oauth2.resourceserver.JwtDsl import org.springframework.security.config.annotation.web.oauth2.resourceserver.OpaqueTokenDsl +import org.springframework.security.config.annotation.web.oauth2.resourceserver.ProtectedResourceMetadataDsl import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver import org.springframework.security.web.AuthenticationEntryPoint import org.springframework.security.web.access.AccessDeniedHandler @@ -33,6 +34,7 @@ import org.springframework.security.web.access.AccessDeniedHandler * * @author Eleftheria Stein * @author Max Batischev + * @author Andrey Litvitski * @since 5.3 * @property accessDeniedHandler the [AccessDeniedHandler] to use for requests authenticating * with Bearer Tokens. @@ -51,6 +53,7 @@ class OAuth2ResourceServerDsl { private var jwt: ((OAuth2ResourceServerConfigurer.JwtConfigurer) -> Unit)? = null private var opaqueToken: ((OAuth2ResourceServerConfigurer.OpaqueTokenConfigurer) -> Unit)? = null private var dPoP: ((OAuth2ResourceServerConfigurer.DPoPConfigurer) -> Unit)? = null + private var protectedResourceMetadata: ((OAuth2ResourceServerConfigurer.ProtectedResourceMetadataConfigurer) -> Unit)? = null /** * Enables JWT-encoded bearer token support. @@ -143,6 +146,40 @@ class OAuth2ResourceServerDsl { this.dPoP = DPoPDsl().apply(dPoPConfig).get() } + /** + * Enables OAuth 2.0 Protected Resource Metadata support. + * + * Example: + * + * ``` + * @Configuration + * @EnableWebSecurity + * class SecurityConfig { + * + * @Bean + * fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + * http { + * oauth2ResourceServer { + * jwt { } + * protectedResourceMetadata { + * protectedResourceMetadataCustomizer { } + * } + * } + * } + * return http.build() + * } + * } + * ``` + * + * @param protectedResourceMetadataConfig custom configurations to configure OAuth 2.0 + * Protected Resource Metadata support + * @see [ProtectedResourceMetadataDsl] + * @since 7.1 + */ + fun protectedResourceMetadata(protectedResourceMetadataConfig: ProtectedResourceMetadataDsl.() -> Unit) { + this.protectedResourceMetadata = ProtectedResourceMetadataDsl().apply(protectedResourceMetadataConfig).get() + } + internal fun get(): (OAuth2ResourceServerConfigurer) -> Unit { return { oauth2ResourceServer -> accessDeniedHandler?.also { oauth2ResourceServer.accessDeniedHandler(accessDeniedHandler) } @@ -152,6 +189,7 @@ class OAuth2ResourceServerDsl { jwt?.also { oauth2ResourceServer.jwt(jwt) } opaqueToken?.also { oauth2ResourceServer.opaqueToken(opaqueToken) } dPoP?.also { oauth2ResourceServer.dPoP(dPoP) } + protectedResourceMetadata?.also { oauth2ResourceServer.protectedResourceMetadata(protectedResourceMetadata) } } } } diff --git a/config/src/main/kotlin/org/springframework/security/config/annotation/web/oauth2/resourceserver/ProtectedResourceMetadataDsl.kt b/config/src/main/kotlin/org/springframework/security/config/annotation/web/oauth2/resourceserver/ProtectedResourceMetadataDsl.kt new file mode 100644 index 00000000000..6ad3c894ba9 --- /dev/null +++ b/config/src/main/kotlin/org/springframework/security/config/annotation/web/oauth2/resourceserver/ProtectedResourceMetadataDsl.kt @@ -0,0 +1,56 @@ +/* + * 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.oauth2.resourceserver + +import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer +import org.springframework.security.oauth2.server.resource.OAuth2ProtectedResourceMetadata + +/** + * A Kotlin DSL to configure OAuth 2.0 Protected Resource Metadata support using + * idiomatic Kotlin code. + * + * @author Andrey Litvitski + * @since 7.1 + */ +@OAuth2ResourceServerSecurityMarker +class ProtectedResourceMetadataDsl { + + private var protectedResourceMetadataCustomizer: ((OAuth2ProtectedResourceMetadata.Builder) -> Unit)? = null + + /** + * Sets the customizer providing access to the protected resource metadata builder. + * + * @param protectedResourceMetadataCustomizer the customizer providing access to the protected + * resource metadata builder. + */ + fun protectedResourceMetadataCustomizer( + protectedResourceMetadataCustomizer: (OAuth2ProtectedResourceMetadata.Builder) -> Unit + ) { + this.protectedResourceMetadataCustomizer = protectedResourceMetadataCustomizer + } + + internal fun get(): (OAuth2ResourceServerConfigurer.ProtectedResourceMetadataConfigurer) -> Unit { + return { protectedResourceMetadata -> + protectedResourceMetadataCustomizer?.also { customizer -> + protectedResourceMetadata.protectedResourceMetadataCustomizer { builder -> + customizer(builder) + } + } + } + } + +} diff --git a/config/src/test/kotlin/org/springframework/security/config/annotation/web/oauth2/resourceserver/ProtectedResourceMetadataDslTests.kt b/config/src/test/kotlin/org/springframework/security/config/annotation/web/oauth2/resourceserver/ProtectedResourceMetadataDslTests.kt new file mode 100644 index 00000000000..55b6e3b7953 --- /dev/null +++ b/config/src/test/kotlin/org/springframework/security/config/annotation/web/oauth2/resourceserver/ProtectedResourceMetadataDslTests.kt @@ -0,0 +1,191 @@ +/* + * 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.oauth2.resourceserver + +import org.hamcrest.Matchers.hasItem +import org.hamcrest.Matchers.hasSize +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.annotation.web.invoke +import org.springframework.security.config.test.SpringTestContext +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.OAuth2ProtectedResourceMetadataClaimNames +import org.springframework.security.web.SecurityFilterChain +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders +import org.springframework.test.web.servlet.result.MockMvcResultMatchers + +/** + * Tests for [ProtectedResourceMetadataDsl]. + * + * @author Andrey Litvitski + */ +class ProtectedResourceMetadataDslTests { + + @JvmField + val spring = SpringTestContext(this) + + @Autowired + lateinit var mockMvc: MockMvc + + @Test + fun requestWhenProtectedResourceMetadataRequestThenReturnMetadataResponse() { + this.spring.register(ResourceServerConfiguration::class.java).autowire() + this.mockMvc.perform( + MockMvcRequestBuilders.get(RESOURCE + DEFAULT_OAUTH2_PROTECTED_RESOURCE_METADATA_ENDPOINT_URI) + ) + .andExpect(MockMvcResultMatchers.status().is2xxSuccessful()) + .andExpect(MockMvcResultMatchers.jsonPath(OAuth2ProtectedResourceMetadataClaimNames.RESOURCE).value(RESOURCE)) + .andExpect(MockMvcResultMatchers.jsonPath(OAuth2ProtectedResourceMetadataClaimNames.BEARER_METHODS_SUPPORTED).isArray()) + .andExpect(MockMvcResultMatchers.jsonPath(OAuth2ProtectedResourceMetadataClaimNames.BEARER_METHODS_SUPPORTED).value(hasSize(1))) + .andExpect(MockMvcResultMatchers.jsonPath(OAuth2ProtectedResourceMetadataClaimNames.BEARER_METHODS_SUPPORTED).value(hasItem("header"))) + .andExpect( + MockMvcResultMatchers.jsonPath(OAuth2ProtectedResourceMetadataClaimNames.TLS_CLIENT_CERTIFICATE_BOUND_ACCESS_TOKENS) + .value(true) + ) + .andReturn() + } + + @Test + fun requestWhenProtectedResourceMetadataRequestIncludesResourcePathThenMetadataResponseHasResourcePath() { + this.spring.register(ResourceServerConfiguration::class.java).autowire() + val host = RESOURCE + var resourcePath = "/resource1" + var resource = host + resourcePath + this.mockMvc.perform( + MockMvcRequestBuilders.get(host + DEFAULT_OAUTH2_PROTECTED_RESOURCE_METADATA_ENDPOINT_URI + resourcePath) + ) + .andExpect(MockMvcResultMatchers.status().is2xxSuccessful()) + .andExpect(MockMvcResultMatchers.jsonPath(OAuth2ProtectedResourceMetadataClaimNames.RESOURCE).value(resource)) + .andReturn() + resourcePath = "/path1/resource2" + resource = host + resourcePath + this.mockMvc.perform( + MockMvcRequestBuilders.get(host + DEFAULT_OAUTH2_PROTECTED_RESOURCE_METADATA_ENDPOINT_URI + resourcePath) + ) + .andExpect(MockMvcResultMatchers.status().is2xxSuccessful()) + .andExpect(MockMvcResultMatchers.jsonPath(OAuth2ProtectedResourceMetadataClaimNames.RESOURCE).value(resource)) + .andReturn() + resourcePath = "/path1/path2/resource3" + resource = host + resourcePath + this.mockMvc.perform( + MockMvcRequestBuilders.get(host + DEFAULT_OAUTH2_PROTECTED_RESOURCE_METADATA_ENDPOINT_URI + resourcePath) + ) + .andExpect(MockMvcResultMatchers.status().is2xxSuccessful()) + .andExpect(MockMvcResultMatchers.jsonPath(OAuth2ProtectedResourceMetadataClaimNames.RESOURCE).value(resource)) + .andReturn() + } + + @Test + fun requestWhenProtectedResourceMetadataRequestAndMetadataCustomizerSetThenReturnCustomMetadataResponse() { + this.spring.register(ResourceServerConfigurationWithMetadataCustomizer::class.java).autowire() + this.mockMvc.perform( + MockMvcRequestBuilders.get(RESOURCE + DEFAULT_OAUTH2_PROTECTED_RESOURCE_METADATA_ENDPOINT_URI) + ) + .andExpect(MockMvcResultMatchers.status().is2xxSuccessful()) + .andExpect(MockMvcResultMatchers.jsonPath(OAuth2ProtectedResourceMetadataClaimNames.RESOURCE).value(RESOURCE)) + .andExpect(MockMvcResultMatchers.jsonPath(OAuth2ProtectedResourceMetadataClaimNames.AUTHORIZATION_SERVERS).isArray()) + .andExpect(MockMvcResultMatchers.jsonPath(OAuth2ProtectedResourceMetadataClaimNames.AUTHORIZATION_SERVERS).value(hasSize(2))) + .andExpect(MockMvcResultMatchers.jsonPath(OAuth2ProtectedResourceMetadataClaimNames.AUTHORIZATION_SERVERS).value(hasItem(ISSUER_1))) + .andExpect(MockMvcResultMatchers.jsonPath(OAuth2ProtectedResourceMetadataClaimNames.AUTHORIZATION_SERVERS).value(hasItem(ISSUER_2))) + .andExpect(MockMvcResultMatchers.jsonPath(OAuth2ProtectedResourceMetadataClaimNames.SCOPES_SUPPORTED).isArray()) + .andExpect(MockMvcResultMatchers.jsonPath(OAuth2ProtectedResourceMetadataClaimNames.SCOPES_SUPPORTED).value(hasSize(2))) + .andExpect(MockMvcResultMatchers.jsonPath(OAuth2ProtectedResourceMetadataClaimNames.SCOPES_SUPPORTED).value(hasItem("scope1"))) + .andExpect(MockMvcResultMatchers.jsonPath(OAuth2ProtectedResourceMetadataClaimNames.SCOPES_SUPPORTED).value(hasItem("scope2"))) + .andExpect(MockMvcResultMatchers.jsonPath(OAuth2ProtectedResourceMetadataClaimNames.BEARER_METHODS_SUPPORTED).isArray()) + .andExpect(MockMvcResultMatchers.jsonPath(OAuth2ProtectedResourceMetadataClaimNames.BEARER_METHODS_SUPPORTED).value(hasSize(1))) + .andExpect(MockMvcResultMatchers.jsonPath(OAuth2ProtectedResourceMetadataClaimNames.BEARER_METHODS_SUPPORTED).value(hasItem("header"))) + .andExpect(MockMvcResultMatchers.jsonPath(OAuth2ProtectedResourceMetadataClaimNames.RESOURCE_NAME).value("resourceName")) + .andExpect( + MockMvcResultMatchers.jsonPath(OAuth2ProtectedResourceMetadataClaimNames.TLS_CLIENT_CERTIFICATE_BOUND_ACCESS_TOKENS) + .value(true) + ) + .andReturn() + } + + @EnableWebSecurity + @Configuration(proxyBeanMethods = false) + internal open class ResourceServerConfiguration { + + @Bean + open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http { + authorizeHttpRequests { + authorize(anyRequest, authenticated) + } + oauth2ResourceServer { + jwt { } + } + } + return http.build() + } + + @Bean + open fun jwtDecoder(): JwtDecoder { + return NimbusJwtDecoder.withPublicKey(TestKeys.DEFAULT_PUBLIC_KEY).build() + } + + } + + @EnableWebSecurity + @Configuration(proxyBeanMethods = false) + internal open class ResourceServerConfigurationWithMetadataCustomizer : ResourceServerConfiguration() { + + @Bean + override fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http { + authorizeHttpRequests { + authorize(anyRequest, authenticated) + } + oauth2ResourceServer { + jwt { } + protectedResourceMetadata { + protectedResourceMetadataCustomizer { protectedResourceMetadata -> + protectedResourceMetadata.authorizationServer(ISSUER_1) + .authorizationServer(ISSUER_2) + .scope("scope1") + .scope("scope2") + .resourceName("resourceName") + } + } + } + } + return http.build() + } + + } + + companion object { + + private const val DEFAULT_OAUTH2_PROTECTED_RESOURCE_METADATA_ENDPOINT_URI = + "/.well-known/oauth-protected-resource" + + private const val RESOURCE = "https://resource.com:8443" + + private const val ISSUER_1 = "https://provider1.com" + + private const val ISSUER_2 = "https://provider2.com" + + } + +}