diff --git a/module/spring-boot-security-oauth2-resource-server/src/main/java/org/springframework/boot/security/oauth2/server/resource/autoconfigure/JwtConverterConfiguration.java b/module/spring-boot-security-oauth2-resource-server/src/main/java/org/springframework/boot/security/oauth2/server/resource/autoconfigure/JwtConverterConfiguration.java index 655284b76ae..22f3aca92db 100644 --- a/module/spring-boot-security-oauth2-resource-server/src/main/java/org/springframework/boot/security/oauth2/server/resource/autoconfigure/JwtConverterConfiguration.java +++ b/module/spring-boot-security-oauth2-resource-server/src/main/java/org/springframework/boot/security/oauth2/server/resource/autoconfigure/JwtConverterConfiguration.java @@ -16,17 +16,31 @@ package org.springframework.boot.security.oauth2.server.resource.autoconfigure; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + import org.springframework.boot.autoconfigure.condition.AnyNestedCondition; +import org.springframework.boot.autoconfigure.condition.ConditionMessage; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.OnPropertyListCondition; import org.springframework.boot.context.properties.PropertyMapper; +import org.springframework.boot.context.properties.source.MutuallyExclusiveConfigurationPropertiesException; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Configuration; +import org.springframework.core.convert.converter.Converter; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.server.resource.authentication.DelegatingJwtGrantedAuthoritiesConverter; +import org.springframework.security.oauth2.server.resource.authentication.ExpressionJwtGrantedAuthoritiesConverter; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; +import org.springframework.util.CollectionUtils; /** * {@link Configuration @Configuration} for JWT converter beans. @@ -47,18 +61,24 @@ class JwtConverterConfiguration { @Bean JwtAuthenticationConverter jwtAuthenticationConverter(OAuth2ResourceServerProperties properties) { - return jwtAuthenticationConverter(properties.getJwt()); - } - - private JwtAuthenticationConverter jwtAuthenticationConverter(OAuth2ResourceServerProperties.Jwt properties) { PropertyMapper map = PropertyMapper.get(); + OAuth2ResourceServerProperties.Jwt jwtProperties = properties.getJwt(); JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); - map.from(properties::getPrincipalClaimName).to(converter::setPrincipalClaimName); - map.from(properties).as(this::getGrantedAuthoritiesConverter).to(converter::setJwtGrantedAuthoritiesConverter); + map.from(jwtProperties::getPrincipalClaimName).to(converter::setPrincipalClaimName); + map.from(jwtProperties).as(this::grantedAuthoritiesConverter).to(converter::setJwtGrantedAuthoritiesConverter); return converter; } - private JwtGrantedAuthoritiesConverter getGrantedAuthoritiesConverter( + private Converter> grantedAuthoritiesConverter( + OAuth2ResourceServerProperties.Jwt properties) { + List authoritiesExpressions = properties.getAuthoritiesExpressions(); + if (CollectionUtils.isEmpty(authoritiesExpressions)) { + return createJwtGrantedAuthoritiesConverter(properties); + } + return createExpressionJwtGrantedAuthoritiesConverters(properties, authoritiesExpressions); + } + + private Converter> createJwtGrantedAuthoritiesConverter( OAuth2ResourceServerProperties.Jwt properties) { PropertyMapper map = PropertyMapper.get(); JwtGrantedAuthoritiesConverter converter = new JwtGrantedAuthoritiesConverter(); @@ -68,6 +88,44 @@ class JwtConverterConfiguration { return converter; } + private Converter> createExpressionJwtGrantedAuthoritiesConverters( + OAuth2ResourceServerProperties.Jwt properties, List authoritiesExpressions) { + checkMutualExclusivity(properties); + List>> converters = new ArrayList<>(); + SpelExpressionParser parser = new SpelExpressionParser(); + for (String authoritiesExpression : authoritiesExpressions) { + ExpressionJwtGrantedAuthoritiesConverter converter = new ExpressionJwtGrantedAuthoritiesConverter( + parser.parseExpression(authoritiesExpression)); + if (properties.getAuthorityPrefix() != null) { + converter.setAuthorityPrefix(properties.getAuthorityPrefix()); + } + converters.add(converter); + } + return (converters.size() == 1) ? converters.get(0) : new DelegatingJwtGrantedAuthoritiesConverter(converters); + } + + private void checkMutualExclusivity(OAuth2ResourceServerProperties.Jwt properties) { + MutuallyExclusiveConfigurationPropertiesException.throwIfMultipleMatchingValuesIn((entries) -> { + entries.put("spring.security.oauth2.resourceserver.jwt.authorities-expressions", + properties.getAuthoritiesExpressions()); + entries.put("spring.security.oauth2.resourceserver.jwt.authorities-claim-name", + properties.getAuthoritiesClaimName()); + }, (value) -> !nullOrEmptyList(value)); + MutuallyExclusiveConfigurationPropertiesException.throwIfMultipleMatchingValuesIn((entries) -> { + entries.put("spring.security.oauth2.resourceserver.jwt.authorities-expressions", + properties.getAuthoritiesExpressions()); + entries.put("spring.security.oauth2.resourceserver.jwt.authorities-claim-delimiter", + properties.getAuthoritiesClaimDelimiter()); + }, (value) -> !nullOrEmptyList(value)); + } + + private boolean nullOrEmptyList(Object value) { + if (value == null) { + return true; + } + return (value instanceof List list) ? list.isEmpty() : false; + } + static class PropertiesCondition extends AnyNestedCondition { PropertiesCondition() { @@ -89,6 +147,20 @@ class JwtConverterConfiguration { } + @Conditional(OnAuthoritiesExpressionsCondition.class) + static class OnAuthoritiesExpressions { + + } + + static class OnAuthoritiesExpressionsCondition extends OnPropertyListCondition { + + OnAuthoritiesExpressionsCondition() { + super("spring.security.oauth2.resourceserver.jwt.authorities-expressions", + () -> ConditionMessage.forCondition("Authorities expressions")); + } + + } + } } diff --git a/module/spring-boot-security-oauth2-resource-server/src/main/java/org/springframework/boot/security/oauth2/server/resource/autoconfigure/OAuth2ResourceServerProperties.java b/module/spring-boot-security-oauth2-resource-server/src/main/java/org/springframework/boot/security/oauth2/server/resource/autoconfigure/OAuth2ResourceServerProperties.java index 594cfbc860f..d025da819da 100644 --- a/module/spring-boot-security-oauth2-resource-server/src/main/java/org/springframework/boot/security/oauth2/server/resource/autoconfigure/OAuth2ResourceServerProperties.java +++ b/module/spring-boot-security-oauth2-resource-server/src/main/java/org/springframework/boot/security/oauth2/server/resource/autoconfigure/OAuth2ResourceServerProperties.java @@ -98,6 +98,14 @@ public class OAuth2ResourceServerProperties { */ private @Nullable String authoritiesClaimName; + /** + * List of expressions to use to extract authorities from a JWT. Mutually + * exclusive with + * 'spring.security.oauth2.resourceserver.jwt.authorities-claim-name' and + * 'spring.security.oauth2.resourceserver.jwt.authorities-claim-delimiter'. + */ + private List authoritiesExpressions = new ArrayList<>(); + /** * JWT principal claim name. */ @@ -167,6 +175,14 @@ public class OAuth2ResourceServerProperties { this.authoritiesClaimName = authoritiesClaimName; } + public List getAuthoritiesExpressions() { + return this.authoritiesExpressions; + } + + public void setAuthoritiesExpressions(List authoritiesExpressions) { + this.authoritiesExpressions = authoritiesExpressions; + } + public @Nullable String getPrincipalClaimName() { return this.principalClaimName; } diff --git a/module/spring-boot-security-oauth2-resource-server/src/main/java/org/springframework/boot/security/oauth2/server/resource/autoconfigure/reactive/ReactiveJwtConverterConfiguration.java b/module/spring-boot-security-oauth2-resource-server/src/main/java/org/springframework/boot/security/oauth2/server/resource/autoconfigure/reactive/ReactiveJwtConverterConfiguration.java index b287d04fbb2..f2d4a951bfb 100644 --- a/module/spring-boot-security-oauth2-resource-server/src/main/java/org/springframework/boot/security/oauth2/server/resource/autoconfigure/reactive/ReactiveJwtConverterConfiguration.java +++ b/module/spring-boot-security-oauth2-resource-server/src/main/java/org/springframework/boot/security/oauth2/server/resource/autoconfigure/reactive/ReactiveJwtConverterConfiguration.java @@ -16,19 +16,33 @@ package org.springframework.boot.security.oauth2.server.resource.autoconfigure.reactive; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + import org.springframework.boot.autoconfigure.condition.AnyNestedCondition; +import org.springframework.boot.autoconfigure.condition.ConditionMessage; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.OnPropertyListCondition; import org.springframework.boot.context.properties.PropertyMapper; +import org.springframework.boot.context.properties.source.MutuallyExclusiveConfigurationPropertiesException; import org.springframework.boot.security.oauth2.server.resource.autoconfigure.OAuth2ResourceServerProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Configuration; +import org.springframework.core.convert.converter.Converter; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder; +import org.springframework.security.oauth2.server.resource.authentication.DelegatingJwtGrantedAuthoritiesConverter; +import org.springframework.security.oauth2.server.resource.authentication.ExpressionJwtGrantedAuthoritiesConverter; import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; import org.springframework.security.oauth2.server.resource.authentication.ReactiveJwtAuthenticationConverter; import org.springframework.security.oauth2.server.resource.authentication.ReactiveJwtGrantedAuthoritiesConverterAdapter; +import org.springframework.util.CollectionUtils; /** * {@link Configuration @Configuration} for JWT converter beans. @@ -50,24 +64,74 @@ class ReactiveJwtConverterConfiguration { @Bean ReactiveJwtAuthenticationConverter reactiveJwtAuthenticationConverter(OAuth2ResourceServerProperties properties) { - return reactiveJwtAuthenticationConverter(properties.getJwt()); - } - - private ReactiveJwtAuthenticationConverter reactiveJwtAuthenticationConverter( - OAuth2ResourceServerProperties.Jwt properties1) { PropertyMapper map = PropertyMapper.get(); - JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter(); - map.from(properties1.getAuthorityPrefix()).to(grantedAuthoritiesConverter::setAuthorityPrefix); - map.from(properties1.getAuthoritiesClaimDelimiter()) - .to(grantedAuthoritiesConverter::setAuthoritiesClaimDelimiter); - map.from(properties1.getAuthoritiesClaimName()).to(grantedAuthoritiesConverter::setAuthoritiesClaimName); + OAuth2ResourceServerProperties.Jwt jwtProperties = properties.getJwt(); ReactiveJwtAuthenticationConverter jwtAuthenticationConverter = new ReactiveJwtAuthenticationConverter(); - map.from(properties1.getPrincipalClaimName()).to(jwtAuthenticationConverter::setPrincipalClaimName); - jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter( - new ReactiveJwtGrantedAuthoritiesConverterAdapter(grantedAuthoritiesConverter)); + map.from(jwtProperties::getPrincipalClaimName).to(jwtAuthenticationConverter::setPrincipalClaimName); + map.from(jwtProperties) + .as(this::grantedAuthoritiesConverter) + .as(ReactiveJwtGrantedAuthoritiesConverterAdapter::new) + .to(jwtAuthenticationConverter::setJwtGrantedAuthoritiesConverter); return jwtAuthenticationConverter; } + private Converter> grantedAuthoritiesConverter( + OAuth2ResourceServerProperties.Jwt properties) { + List authoritiesExpressions = properties.getAuthoritiesExpressions(); + if (CollectionUtils.isEmpty(authoritiesExpressions)) { + return createJwtGrantedAuthoritiesConverter(properties); + } + return createExpressionJwtGrantedAuthoritiesConverters(properties, authoritiesExpressions); + } + + private Converter> createJwtGrantedAuthoritiesConverter( + OAuth2ResourceServerProperties.Jwt properties) { + PropertyMapper map = PropertyMapper.get(); + JwtGrantedAuthoritiesConverter converter = new JwtGrantedAuthoritiesConverter(); + map.from(properties::getAuthorityPrefix).to(converter::setAuthorityPrefix); + map.from(properties::getAuthoritiesClaimDelimiter).to(converter::setAuthoritiesClaimDelimiter); + map.from(properties::getAuthoritiesClaimName).to(converter::setAuthoritiesClaimName); + return converter; + } + + private Converter> createExpressionJwtGrantedAuthoritiesConverters( + OAuth2ResourceServerProperties.Jwt properties, List authoritiesExpressions) { + checkMutualExclusivity(properties); + List>> converters = new ArrayList<>(); + SpelExpressionParser parser = new SpelExpressionParser(); + for (String authoritiesExpression : authoritiesExpressions) { + ExpressionJwtGrantedAuthoritiesConverter converter = new ExpressionJwtGrantedAuthoritiesConverter( + parser.parseExpression(authoritiesExpression)); + if (properties.getAuthorityPrefix() != null) { + converter.setAuthorityPrefix(properties.getAuthorityPrefix()); + } + converters.add(converter); + } + return (converters.size() == 1) ? converters.get(0) : new DelegatingJwtGrantedAuthoritiesConverter(converters); + } + + private void checkMutualExclusivity(OAuth2ResourceServerProperties.Jwt properties) { + MutuallyExclusiveConfigurationPropertiesException.throwIfMultipleMatchingValuesIn((entries) -> { + entries.put("spring.security.oauth2.resourceserver.jwt.authorities-expressions", + properties.getAuthoritiesExpressions()); + entries.put("spring.security.oauth2.resourceserver.jwt.authorities-claim-name", + properties.getAuthoritiesClaimName()); + }, (value) -> !nullOrEmptyList(value)); + MutuallyExclusiveConfigurationPropertiesException.throwIfMultipleMatchingValuesIn((entries) -> { + entries.put("spring.security.oauth2.resourceserver.jwt.authorities-expressions", + properties.getAuthoritiesExpressions()); + entries.put("spring.security.oauth2.resourceserver.jwt.authorities-claim-delimiter", + properties.getAuthoritiesClaimDelimiter()); + }, (value) -> !nullOrEmptyList(value)); + } + + private boolean nullOrEmptyList(Object value) { + if (value == null) { + return true; + } + return (value instanceof List list) ? list.isEmpty() : false; + } + static class PropertiesCondition extends AnyNestedCondition { PropertiesCondition() { @@ -89,6 +153,20 @@ class ReactiveJwtConverterConfiguration { } + @Conditional(OnAuthoritiesExpressionsCondition.class) + static class OnAuthoritiesExpressions { + + } + + static class OnAuthoritiesExpressionsCondition extends OnPropertyListCondition { + + OnAuthoritiesExpressionsCondition() { + super("spring.security.oauth2.resourceserver.jwt.authorities-expressions", + () -> ConditionMessage.forCondition("Authorities expressions")); + } + + } + } } diff --git a/module/spring-boot-security-oauth2-resource-server/src/test/java/org/springframework/boot/security/oauth2/server/resource/autoconfigure/OAuth2ResourceServerAutoConfigurationTests.java b/module/spring-boot-security-oauth2-resource-server/src/test/java/org/springframework/boot/security/oauth2/server/resource/autoconfigure/OAuth2ResourceServerAutoConfigurationTests.java index 0a9f063ab9e..a4047ecb333 100644 --- a/module/spring-boot-security-oauth2-resource-server/src/test/java/org/springframework/boot/security/oauth2/server/resource/autoconfigure/OAuth2ResourceServerAutoConfigurationTests.java +++ b/module/spring-boot-security-oauth2-resource-server/src/test/java/org/springframework/boot/security/oauth2/server/resource/autoconfigure/OAuth2ResourceServerAutoConfigurationTests.java @@ -47,6 +47,7 @@ import tools.jackson.databind.json.JsonMapper; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.context.properties.source.InvalidConfigurationPropertyValueException; +import org.springframework.boot.context.properties.source.MutuallyExclusiveConfigurationPropertiesException; import org.springframework.boot.test.context.FilteredClassLoader; import org.springframework.boot.test.context.assertj.AssertableWebApplicationContext; import org.springframework.boot.test.context.runner.WebApplicationContextRunner; @@ -74,6 +75,7 @@ import org.springframework.security.oauth2.jwt.JwtTypeValidator; import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import org.springframework.security.oauth2.jwt.SupplierJwtDecoder; import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken; +import org.springframework.security.oauth2.server.resource.authentication.ExpressionJwtGrantedAuthoritiesConverter; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector; import org.springframework.security.web.SecurityFilterChain; @@ -689,6 +691,72 @@ class OAuth2ResourceServerAutoConfigurationTests { .run((context) -> assertThat(context).hasSingleBean(JwtAuthenticationConverter.class)); } + @Test + void shouldConfigureJwtConverterIfAuthoritiesExpressionIsSet() { + this.contextRunner.withPropertyValues("spring.security.oauth2.resourceserver.jwt.authorities-expressions=zero") + .run((context) -> { + assertThat(context).hasSingleBean(JwtAuthenticationConverter.class); + JwtAuthenticationConverter converter = context.getBean(JwtAuthenticationConverter.class); + assertThat(converter).extracting("jwtGrantedAuthoritiesConverter") + .isInstanceOf(ExpressionJwtGrantedAuthoritiesConverter.class) + .extracting("authorityPrefix") + .isEqualTo("SCOPE_"); + }); + } + + @Test + void shouldConfigureJwtConverterIfAuthoritiesExpressionsAreSet() { + this.contextRunner + .withPropertyValues("spring.security.oauth2.resourceserver.jwt.authorities-expressions[0]=zero", + "spring.security.oauth2.resourceserver.jwt.authorities-expressions[1]=one") + .run((context) -> { + assertThat(context).hasSingleBean(JwtAuthenticationConverter.class); + JwtAuthenticationConverter converter = context.getBean(JwtAuthenticationConverter.class); + assertThat(converter) + .extracting("jwtGrantedAuthoritiesConverter.authoritiesConverters", InstanceOfAssertFactories.LIST) + .hasSize(2) + .extracting("authorityPrefix") + .containsOnly("SCOPE_"); + }); + } + + @Test + void shouldApplyCustomAuthorityPrefixIfAuthoritiesExpressionsAreSet() { + this.contextRunner + .withPropertyValues("spring.security.oauth2.resourceserver.jwt.authorities-expressions[0]=zero", + "spring.security.oauth2.resourceserver.jwt.authorities-expressions[1]=one", + "spring.security.oauth2.resourceserver.jwt.authority-prefix=CUSTOM_") + .run((context) -> { + assertThat(context).hasSingleBean(JwtAuthenticationConverter.class); + JwtAuthenticationConverter converter = context.getBean(JwtAuthenticationConverter.class); + assertThat(converter) + .extracting("jwtGrantedAuthoritiesConverter.authoritiesConverters", InstanceOfAssertFactories.LIST) + .hasSize(2) + .extracting("authorityPrefix") + .containsOnly("CUSTOM_"); + }); + } + + @Test + void shouldFailIfBothAuthoritiesExpressionsAndAuthoritiesClaimDelimiterAreSet() { + this.contextRunner + .withPropertyValues("spring.security.oauth2.resourceserver.jwt.authorities-expressions[0]=zero", + "spring.security.oauth2.resourceserver.jwt.authorities-claim-delimiter=delimiter") + .run((context) -> assertThat(context).getFailure() + .rootCause() + .isInstanceOf(MutuallyExclusiveConfigurationPropertiesException.class)); + } + + @Test + void shouldFailIfBothAuthoritiesExpressionsAndAuthoritiesClaimNameAreSet() { + this.contextRunner + .withPropertyValues("spring.security.oauth2.resourceserver.jwt.authorities-expressions[0]=zero", + "spring.security.oauth2.resourceserver.jwt.authorities-claim-name=name") + .run((context) -> assertThat(context).getFailure() + .rootCause() + .isInstanceOf(MutuallyExclusiveConfigurationPropertiesException.class)); + } + @Test void jwtAuthenticationConverterByJwtConfigIsConditionalOnMissingBean() { String propertiesPrincipalClaim = "principal_from_properties"; diff --git a/module/spring-boot-security-oauth2-resource-server/src/test/java/org/springframework/boot/security/oauth2/server/resource/autoconfigure/reactive/ReactiveOAuth2ResourceServerAutoConfigurationTests.java b/module/spring-boot-security-oauth2-resource-server/src/test/java/org/springframework/boot/security/oauth2/server/resource/autoconfigure/reactive/ReactiveOAuth2ResourceServerAutoConfigurationTests.java index e9b2e3bb26f..c1fcf8c0d8b 100644 --- a/module/spring-boot-security-oauth2-resource-server/src/test/java/org/springframework/boot/security/oauth2/server/resource/autoconfigure/reactive/ReactiveOAuth2ResourceServerAutoConfigurationTests.java +++ b/module/spring-boot-security-oauth2-resource-server/src/test/java/org/springframework/boot/security/oauth2/server/resource/autoconfigure/reactive/ReactiveOAuth2ResourceServerAutoConfigurationTests.java @@ -48,6 +48,7 @@ import reactor.core.publisher.Mono; import tools.jackson.databind.json.JsonMapper; import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.context.properties.source.MutuallyExclusiveConfigurationPropertiesException; import org.springframework.boot.security.oauth2.server.resource.autoconfigure.JwtConverterCustomizationsArgumentsProvider; import org.springframework.boot.test.context.FilteredClassLoader; import org.springframework.boot.test.context.assertj.AssertableReactiveWebApplicationContext; @@ -77,6 +78,7 @@ import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder; import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder; import org.springframework.security.oauth2.jwt.SupplierReactiveJwtDecoder; import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken; +import org.springframework.security.oauth2.server.resource.authentication.ExpressionJwtGrantedAuthoritiesConverter; import org.springframework.security.oauth2.server.resource.authentication.ReactiveJwtAuthenticationConverter; import org.springframework.security.oauth2.server.resource.introspection.ReactiveOpaqueTokenIntrospector; import org.springframework.security.web.server.SecurityWebFilterChain; @@ -678,6 +680,77 @@ class ReactiveOAuth2ResourceServerAutoConfigurationTests { .run((context) -> assertThat(context).hasSingleBean(ReactiveJwtAuthenticationConverter.class)); } + @Test + void shouldConfigureJwtConverterIfAuthoritiesExpressionIsSet() { + this.contextRunner.withPropertyValues("spring.security.oauth2.resourceserver.jwt.authorities-expressions=zero") + .run((context) -> { + assertThat(context).hasSingleBean(ReactiveJwtAuthenticationConverter.class); + ReactiveJwtAuthenticationConverter converter = context + .getBean(ReactiveJwtAuthenticationConverter.class); + assertThat(converter).extracting("jwtGrantedAuthoritiesConverter.grantedAuthoritiesConverter") + .isInstanceOf(ExpressionJwtGrantedAuthoritiesConverter.class) + .extracting("authorityPrefix") + .isEqualTo("SCOPE_"); + }); + } + + @Test + void shouldConfigureJwtConverterIfAuthoritiesExpressionsAreSet() { + this.contextRunner + .withPropertyValues("spring.security.oauth2.resourceserver.jwt.authorities-expressions[0]=zero", + "spring.security.oauth2.resourceserver.jwt.authorities-expressions[1]=one") + .run((context) -> { + assertThat(context).hasSingleBean(ReactiveJwtAuthenticationConverter.class); + ReactiveJwtAuthenticationConverter converter = context + .getBean(ReactiveJwtAuthenticationConverter.class); + assertThat(converter) + .extracting("jwtGrantedAuthoritiesConverter.grantedAuthoritiesConverter.authoritiesConverters", + InstanceOfAssertFactories.LIST) + .hasSize(2) + .extracting("authorityPrefix") + .containsOnly("SCOPE_"); + }); + } + + @Test + void shouldApplyCustomAuthorityPrefixIfAuthoritiesExpressionsAreSet() { + this.contextRunner + .withPropertyValues("spring.security.oauth2.resourceserver.jwt.authorities-expressions[0]=zero", + "spring.security.oauth2.resourceserver.jwt.authorities-expressions[1]=one", + "spring.security.oauth2.resourceserver.jwt.authority-prefix=CUSTOM_") + .run((context) -> { + assertThat(context).hasSingleBean(ReactiveJwtAuthenticationConverter.class); + ReactiveJwtAuthenticationConverter converter = context + .getBean(ReactiveJwtAuthenticationConverter.class); + assertThat(converter) + .extracting("jwtGrantedAuthoritiesConverter.grantedAuthoritiesConverter.authoritiesConverters", + InstanceOfAssertFactories.LIST) + .hasSize(2) + .extracting("authorityPrefix") + .containsOnly("CUSTOM_"); + }); + } + + @Test + void shouldFailIfBothAuthoritiesExpressionsAndAuthoritiesClaimDelimiterAreSet() { + this.contextRunner + .withPropertyValues("spring.security.oauth2.resourceserver.jwt.authorities-expressions[0]=zero", + "spring.security.oauth2.resourceserver.jwt.authorities-claim-delimiter=delimiter") + .run((context) -> assertThat(context).getFailure() + .rootCause() + .isInstanceOf(MutuallyExclusiveConfigurationPropertiesException.class)); + } + + @Test + void shouldFailIfBothAuthoritiesExpressionsAndAuthoritiesClaimNameAreSet() { + this.contextRunner + .withPropertyValues("spring.security.oauth2.resourceserver.jwt.authorities-expressions[0]=zero", + "spring.security.oauth2.resourceserver.jwt.authorities-claim-name=name") + .run((context) -> assertThat(context).getFailure() + .rootCause() + .isInstanceOf(MutuallyExclusiveConfigurationPropertiesException.class)); + } + @ParameterizedTest(name = "{0}") @ArgumentsSource(JwtConverterCustomizationsArgumentsProvider.class) void autoConfigurationShouldConfigureResourceServerWithJwtConverterCustomizations(String[] properties, Jwt jwt,