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"
+
+ }
+
+}