From 705fa60a01e13ca7bc6e6f60fde9076f3a6ce6b1 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:16:18 -0700 Subject: [PATCH] Document Method Security hasScope Support Issue gh-18013 Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com> --- .../servlet/oauth2/resource-server/jwt.adoc | 15 +++++ .../oauth2/resource-server/opaque-token.adoc | 15 +++++ .../MessageService.java | 16 +++++ .../MethodSecurityHasScopeConfiguration.java | 18 ++++++ ...hodSecurityHasScopeConfigurationTests.java | 59 ++++++++++++++++++ ...ethodSecurityHasScopeMfaConfiguration.java | 21 +++++++ .../methodsecurityhasscope/MessageService.kt | 15 +++++ .../MethodSecurityHasScopeConfiguration.kt | 18 ++++++ ...ethodSecurityHasScopeConfigurationTests.kt | 61 +++++++++++++++++++ .../MethodSecurityHasScopeMfaConfiguration.kt | 20 ++++++ 10 files changed, 258 insertions(+) create mode 100644 docs/src/test/java/org/springframework/security/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MessageService.java create mode 100644 docs/src/test/java/org/springframework/security/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MethodSecurityHasScopeConfiguration.java create mode 100644 docs/src/test/java/org/springframework/security/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MethodSecurityHasScopeConfigurationTests.java create mode 100644 docs/src/test/java/org/springframework/security/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MethodSecurityHasScopeMfaConfiguration.java create mode 100644 docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MessageService.kt create mode 100644 docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MethodSecurityHasScopeConfiguration.kt create mode 100644 docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MethodSecurityHasScopeConfigurationTests.kt create mode 100644 docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MethodSecurityHasScopeMfaConfiguration.kt diff --git a/docs/modules/ROOT/pages/servlet/oauth2/resource-server/jwt.adoc b/docs/modules/ROOT/pages/servlet/oauth2/resource-server/jwt.adoc index af3667531d..e6fd6e5b40 100644 --- a/docs/modules/ROOT/pages/servlet/oauth2/resource-server/jwt.adoc +++ b/docs/modules/ROOT/pages/servlet/oauth2/resource-server/jwt.adoc @@ -949,6 +949,21 @@ fun getMessages(): List { } ---- ====== +[[method-security-has-scope]] +=== Using `hasScope` in Method Security + +Because method security expressions can evaluation `AuthorizationManager` instances, you can also use the `hasScope` API by publishing a `DefaultOAuth2AuthorizationManagerFactory` `@Bean`: + +include-code::./MethodSecurityHasScopeConfiguration[tag=declare-factory,indent=0] + +and then doing: + +include-code::./MessageService[tag=protected-method,indent=0] + +If you are using xref:servlet/authentication/mfa.adoc[Spring Security's MFA feature], then you can supply its `AuthorizationManagerFactory` instance to ensure that your authentication factors are automatically checked as well by including it in your `DefaultOAuth2AuthorizationManagerFactory` constructor as follows: + +include-code::./MethodSecurityHasScopeMfaConfiguration[tag=declare-factory,indent=0] + [[oauth2resourceserver-jwt-authorization-extraction]] === Extracting Authorities Manually diff --git a/docs/modules/ROOT/pages/servlet/oauth2/resource-server/opaque-token.adoc b/docs/modules/ROOT/pages/servlet/oauth2/resource-server/opaque-token.adoc index 80654024cb..6c78c32480 100644 --- a/docs/modules/ROOT/pages/servlet/oauth2/resource-server/opaque-token.adoc +++ b/docs/modules/ROOT/pages/servlet/oauth2/resource-server/opaque-token.adoc @@ -638,6 +638,21 @@ fun getMessages(): List {} ---- ====== +[[method-security-has-scope]] +=== Using `hasScope` in Method Security + +Because method security expressions can evaluation `AuthorizationManager` instances, you can also use the `hasScope` API by publishing a `DefaultOAuth2AuthorizationManagerFactory` `@Bean`: + +include-code::./MethodSecurityHasScopeConfiguration[tag=declare-factory,indent=0] + +and then doing: + +include-code::./MessageService[tag=protected-method,indent=0] + +If you are using xref:servlet/authentication/mfa.adoc[Spring Security's MFA feature], then you can supply its `AuthorizationManagerFactory` instance to ensure that your authentication factors are automatically checked as well by including it in your `DefaultOAuth2AuthorizationManagerFactory` constructor as follows: + +include-code::./MethodSecurityHasScopeMfaConfiguration[tag=declare-factory,indent=0] + [[oauth2resourceserver-opaque-authorization-extraction]] === Extracting Authorities Manually diff --git a/docs/src/test/java/org/springframework/security/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MessageService.java b/docs/src/test/java/org/springframework/security/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MessageService.java new file mode 100644 index 0000000000..24e4220277 --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MessageService.java @@ -0,0 +1,16 @@ +package org.springframework.security.docs.servlet.oauth2.resourceserver.methodsecurityhasscope; + + +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Service; + +@Service +class MessageService { + + // tag::protected-method[] + @PreAuthorize("@oauth2.hasScope('message:read')") + String readMessage() { + return "message"; + } + // end::protected-method[] +} diff --git a/docs/src/test/java/org/springframework/security/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MethodSecurityHasScopeConfiguration.java b/docs/src/test/java/org/springframework/security/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MethodSecurityHasScopeConfiguration.java new file mode 100644 index 0000000000..d88fe6cea5 --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MethodSecurityHasScopeConfiguration.java @@ -0,0 +1,18 @@ +package org.springframework.security.docs.servlet.oauth2.resourceserver.methodsecurityhasscope; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.oauth2.core.authorization.DefaultOAuth2AuthorizationManagerFactory; +import org.springframework.security.oauth2.core.authorization.OAuth2AuthorizationManagerFactory; + +@Configuration +@EnableMethodSecurity +class MethodSecurityHasScopeConfiguration { + // tag::declare-factory[] + @Bean + OAuth2AuthorizationManagerFactory oauth2() { + return new DefaultOAuth2AuthorizationManagerFactory<>(); + } + // end::declare-factory[] +} diff --git a/docs/src/test/java/org/springframework/security/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MethodSecurityHasScopeConfigurationTests.java b/docs/src/test/java/org/springframework/security/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MethodSecurityHasScopeConfigurationTests.java new file mode 100644 index 0000000000..e77b63ece6 --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MethodSecurityHasScopeConfigurationTests.java @@ -0,0 +1,59 @@ +package org.springframework.security.docs.servlet.oauth2.resourceserver.methodsecurityhasscope; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.config.test.SpringTestContext; +import org.springframework.security.config.test.SpringTestContextExtension; +import org.springframework.security.test.context.annotation.SecurityTestExecutionListeners; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +@ExtendWith(SpringTestContextExtension.class) +@ExtendWith(SpringExtension.class) +@SecurityTestExecutionListeners +public class MethodSecurityHasScopeConfigurationTests { + public final SpringTestContext spring = new SpringTestContext(this).mockMvcAfterSpringSecurityOk(); + + @Autowired + private MessageService messages; + + @Test + @WithMockUser(authorities = "SCOPE_message:read") + void readMessageWhenMessageReadThenAllowed() { + this.spring.register(MethodSecurityHasScopeConfiguration.class, MessageService.class).autowire(); + this.messages.readMessage(); + } + + @Test + @WithMockUser + void readMessageWhenNoScopeThenDenied() { + this.spring.register(MethodSecurityHasScopeConfiguration.class, MessageService.class).autowire(); + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(this.messages::readMessage); + } + + @Test + @WithMockUser(authorities = { "SCOPE_message:read", "FACTOR_BEARER", "FACTOR_X509" }) + void mfaReadMessageWhenMessageReadAndFactorsThenAllowed() { + this.spring.register(MethodSecurityHasScopeMfaConfiguration.class, MessageService.class).autowire(); + this.messages.readMessage(); + } + + @Test + @WithMockUser(authorities = { "SCOPE_message:read" }) + void mfaReadMessageWhenMessageReadThenDenied() { + this.spring.register(MethodSecurityHasScopeMfaConfiguration.class, MessageService.class).autowire(); + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(this.messages::readMessage); + } + + @Test + @WithMockUser + void mfaReadMessageWhenNoScopeThenDenied() { + this.spring.register(MethodSecurityHasScopeMfaConfiguration.class, MessageService.class).autowire(); + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(this.messages::readMessage); + } +} diff --git a/docs/src/test/java/org/springframework/security/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MethodSecurityHasScopeMfaConfiguration.java b/docs/src/test/java/org/springframework/security/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MethodSecurityHasScopeMfaConfiguration.java new file mode 100644 index 0000000000..93a4eeffc8 --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MethodSecurityHasScopeMfaConfiguration.java @@ -0,0 +1,21 @@ +package org.springframework.security.docs.servlet.oauth2.resourceserver.methodsecurityhasscope; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authorization.AuthorizationManagerFactory; +import org.springframework.security.config.annotation.authorization.EnableMultiFactorAuthentication; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.oauth2.core.authorization.DefaultOAuth2AuthorizationManagerFactory; +import org.springframework.security.oauth2.core.authorization.OAuth2AuthorizationManagerFactory; + +@Configuration +@EnableMethodSecurity +@EnableMultiFactorAuthentication(authorities = { "FACTOR_BEARER", "FACTOR_X509" }) +class MethodSecurityHasScopeMfaConfiguration { + // tag::declare-factory[] + @Bean + OAuth2AuthorizationManagerFactory oauth2(AuthorizationManagerFactory authz) { + return new DefaultOAuth2AuthorizationManagerFactory<>(authz); + } + // end::declare-factory[] +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MessageService.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MessageService.kt new file mode 100644 index 0000000000..e16f076a34 --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MessageService.kt @@ -0,0 +1,15 @@ +package org.springframework.security.kt.docs.servlet.oauth2.resourceserver.methodsecurityhasscope + +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.stereotype.Service + + +@Service +open class MessageService { + // tag::protected-method[] + @PreAuthorize("@oauth2.hasScope('message:read')") + open fun readMessage(): String { + return "message" + } + // end::protected-method[] +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MethodSecurityHasScopeConfiguration.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MethodSecurityHasScopeConfiguration.kt new file mode 100644 index 0000000000..0fa8ac5e7b --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MethodSecurityHasScopeConfiguration.kt @@ -0,0 +1,18 @@ +package org.springframework.security.kt.docs.servlet.oauth2.resourceserver.methodsecurityhasscope + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity +import org.springframework.security.oauth2.core.authorization.DefaultOAuth2AuthorizationManagerFactory +import org.springframework.security.oauth2.core.authorization.OAuth2AuthorizationManagerFactory + +@Configuration +@EnableMethodSecurity +open class MethodSecurityHasScopeConfiguration { + // tag::declare-factory[] + @Bean + open fun oauth2(): OAuth2AuthorizationManagerFactory { + return DefaultOAuth2AuthorizationManagerFactory() + } + // end::declare-factory[] +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MethodSecurityHasScopeConfigurationTests.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MethodSecurityHasScopeConfigurationTests.kt new file mode 100644 index 0000000000..155f03ffcd --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MethodSecurityHasScopeConfigurationTests.kt @@ -0,0 +1,61 @@ +package org.springframework.security.kt.docs.servlet.oauth2.resourceserver.methodsecurityhasscope + +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.security.access.AccessDeniedException +import org.springframework.security.config.test.SpringTestContext +import org.springframework.security.config.test.SpringTestContextExtension +import org.springframework.security.test.context.annotation.SecurityTestExecutionListeners +import org.springframework.security.test.context.support.WithMockUser +import org.springframework.test.context.junit.jupiter.SpringExtension + +@ExtendWith(SpringTestContextExtension::class) +@ExtendWith(SpringExtension::class) +@SecurityTestExecutionListeners +class MethodSecurityHasScopeConfigurationTests { + @JvmField + val spring: SpringTestContext = SpringTestContext(this).mockMvcAfterSpringSecurityOk() + + @Autowired + var messages: MessageService? = null + + @Test + @WithMockUser(authorities = ["SCOPE_message:read"]) + fun readMessageWhenMessageReadThenAllowed() { + this.spring.register(MethodSecurityHasScopeConfiguration::class.java, MessageService::class.java).autowire() + this.messages!!.readMessage() + } + + @Test + @WithMockUser + fun readMessageWhenNoScopeThenDenied() { + this.spring.register(MethodSecurityHasScopeConfiguration::class.java, MessageService::class.java).autowire() + Assertions.assertThatExceptionOfType(AccessDeniedException::class.java) + .isThrownBy({ this.messages!!.readMessage() }) + } + + @Test + @WithMockUser(authorities = ["SCOPE_message:read", "FACTOR_BEARER", "FACTOR_X509"]) + fun mfaReadMessageWhenMessageReadAndFactorsThenAllowed() { + this.spring.register(MethodSecurityHasScopeMfaConfiguration::class.java, MessageService::class.java).autowire() + this.messages!!.readMessage() + } + + @Test + @WithMockUser(authorities = ["SCOPE_message:read"]) + fun mfaReadMessageWhenMessageReadThenDenied() { + this.spring.register(MethodSecurityHasScopeMfaConfiguration::class.java, MessageService::class.java).autowire() + Assertions.assertThatExceptionOfType(AccessDeniedException::class.java) + .isThrownBy({ this.messages!!.readMessage() }) + } + + @Test + @WithMockUser + fun mfaReadMessageWhenNoScopeThenDenied() { + this.spring.register(MethodSecurityHasScopeMfaConfiguration::class.java, MessageService::class.java).autowire() + Assertions.assertThatExceptionOfType(AccessDeniedException::class.java) + .isThrownBy({ this.messages!!.readMessage() }) + } +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MethodSecurityHasScopeMfaConfiguration.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MethodSecurityHasScopeMfaConfiguration.kt new file mode 100644 index 0000000000..3672f76e22 --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/oauth2/resourceserver/methodsecurityhasscope/MethodSecurityHasScopeMfaConfiguration.kt @@ -0,0 +1,20 @@ +package org.springframework.security.kt.docs.servlet.oauth2.resourceserver.methodsecurityhasscope + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.authorization.AuthorizationManagerFactory +import org.springframework.security.config.annotation.authorization.EnableMultiFactorAuthentication +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity +import org.springframework.security.oauth2.core.authorization.DefaultOAuth2AuthorizationManagerFactory +import org.springframework.security.oauth2.core.authorization.OAuth2AuthorizationManagerFactory + +@Configuration +@EnableMethodSecurity +@EnableMultiFactorAuthentication(authorities = ["FACTOR_BEARER", "FACTOR_X509"]) +open class MethodSecurityHasScopeMfaConfiguration { + // tag::declare-factory[] + @Bean + open fun oauth2(authz: AuthorizationManagerFactory): OAuth2AuthorizationManagerFactory { + return DefaultOAuth2AuthorizationManagerFactory(authz) + } // end::declare-factory[] +}