Polish "Support custom token validators for OAuth2"

See gh-35874
pull/35191/head
Andy Wilkinson 1 year ago
parent 7500dab321
commit 4feaa28fd1

@ -72,12 +72,12 @@ class ReactiveOAuth2ResourceServerJwkConfiguration {
private final OAuth2ResourceServerProperties.Jwt properties; private final OAuth2ResourceServerProperties.Jwt properties;
private final List<OAuth2TokenValidator<Jwt>> customOAuth2TokenValidators; private final List<OAuth2TokenValidator<Jwt>> additionalValidators;
JwtConfiguration(OAuth2ResourceServerProperties properties, JwtConfiguration(OAuth2ResourceServerProperties properties,
List<OAuth2TokenValidator<Jwt>> customOAuth2TokenValidators) { ObjectProvider<OAuth2TokenValidator<Jwt>> additionalValidators) {
this.properties = properties.getJwt(); this.properties = properties.getJwt();
this.customOAuth2TokenValidators = customOAuth2TokenValidators; this.additionalValidators = additionalValidators.orderedStream().toList();
} }
@Bean @Bean
@ -102,17 +102,17 @@ class ReactiveOAuth2ResourceServerJwkConfiguration {
} }
private OAuth2TokenValidator<Jwt> getValidators(OAuth2TokenValidator<Jwt> defaultValidator) { private OAuth2TokenValidator<Jwt> getValidators(OAuth2TokenValidator<Jwt> defaultValidator) {
List<String> audiences = this.properties.getAudiences();
if (CollectionUtils.isEmpty(audiences) && this.additionalValidators.isEmpty()) {
return defaultValidator;
}
List<OAuth2TokenValidator<Jwt>> validators = new ArrayList<>(); List<OAuth2TokenValidator<Jwt>> validators = new ArrayList<>();
validators.add(defaultValidator); validators.add(defaultValidator);
validators.addAll(this.customOAuth2TokenValidators);
List<String> audiences = this.properties.getAudiences();
if (!CollectionUtils.isEmpty(audiences)) { if (!CollectionUtils.isEmpty(audiences)) {
validators.add(new JwtClaimValidator<List<String>>(JwtClaimNames.AUD, validators.add(new JwtClaimValidator<List<String>>(JwtClaimNames.AUD,
(aud) -> aud != null && !Collections.disjoint(aud, audiences))); (aud) -> aud != null && !Collections.disjoint(aud, audiences)));
} }
if (validators.size() == 1) { validators.addAll(this.additionalValidators);
return validators.get(0);
}
return new DelegatingOAuth2TokenValidator<>(validators); return new DelegatingOAuth2TokenValidator<>(validators);
} }

@ -73,12 +73,12 @@ class OAuth2ResourceServerJwtConfiguration {
private final OAuth2ResourceServerProperties.Jwt properties; private final OAuth2ResourceServerProperties.Jwt properties;
private final List<OAuth2TokenValidator<Jwt>> customOAuth2TokenValidators; private final List<OAuth2TokenValidator<Jwt>> additionalValidators;
JwtDecoderConfiguration(OAuth2ResourceServerProperties properties, JwtDecoderConfiguration(OAuth2ResourceServerProperties properties,
List<OAuth2TokenValidator<Jwt>> customOAuth2TokenValidators) { ObjectProvider<OAuth2TokenValidator<Jwt>> additionalValidators) {
this.properties = properties.getJwt(); this.properties = properties.getJwt();
this.customOAuth2TokenValidators = customOAuth2TokenValidators; this.additionalValidators = additionalValidators.orderedStream().toList();
} }
@Bean @Bean
@ -102,17 +102,17 @@ class OAuth2ResourceServerJwtConfiguration {
} }
private OAuth2TokenValidator<Jwt> getValidators(OAuth2TokenValidator<Jwt> defaultValidator) { private OAuth2TokenValidator<Jwt> getValidators(OAuth2TokenValidator<Jwt> defaultValidator) {
List<String> audiences = this.properties.getAudiences();
if (CollectionUtils.isEmpty(audiences) && this.additionalValidators.isEmpty()) {
return defaultValidator;
}
List<OAuth2TokenValidator<Jwt>> validators = new ArrayList<>(); List<OAuth2TokenValidator<Jwt>> validators = new ArrayList<>();
validators.add(defaultValidator); validators.add(defaultValidator);
validators.addAll(this.customOAuth2TokenValidators);
List<String> audiences = this.properties.getAudiences();
if (!CollectionUtils.isEmpty(audiences)) { if (!CollectionUtils.isEmpty(audiences)) {
validators.add(new JwtClaimValidator<List<String>>(JwtClaimNames.AUD, validators.add(new JwtClaimValidator<List<String>>(JwtClaimNames.AUD,
(aud) -> aud != null && !Collections.disjoint(aud, audiences))); (aud) -> aud != null && !Collections.disjoint(aud, audiences)));
} }
if (validators.size() == 1) { validators.addAll(this.additionalValidators);
return validators.get(0);
}
return new DelegatingOAuth2TokenValidator<>(validators); return new DelegatingOAuth2TokenValidator<>(validators);
} }

@ -17,16 +17,17 @@
package org.springframework.boot.autoconfigure.security.oauth2.resource.reactive; package org.springframework.boot.autoconfigure.security.oauth2.resource.reactive;
import java.io.IOException; import java.io.IOException;
import java.net.MalformedURLException; import java.net.URI;
import java.net.URL; import java.net.URL;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors; import java.util.function.Consumer;
import java.util.stream.Stream; import java.util.stream.Stream;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
@ -35,6 +36,7 @@ import com.nimbusds.jose.JWSAlgorithm;
import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer; import okhttp3.mockwebserver.MockWebServer;
import org.assertj.core.api.InstanceOfAssertFactories; import org.assertj.core.api.InstanceOfAssertFactories;
import org.assertj.core.api.ThrowingConsumer;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.InOrder; import org.mockito.InOrder;
@ -441,7 +443,6 @@ class ReactiveOAuth2ResourceServerAutoConfigurationTests {
.run((context) -> assertThat(context).doesNotHaveBean(ReactiveOpaqueTokenIntrospector.class)); .run((context) -> assertThat(context).doesNotHaveBean(ReactiveOpaqueTokenIntrospector.class));
} }
@SuppressWarnings("unchecked")
@Test @Test
void autoConfigurationShouldConfigureResourceServerUsingJwkSetUriAndIssuerUri() throws Exception { void autoConfigurationShouldConfigureResourceServerUsingJwkSetUriAndIssuerUri() throws Exception {
this.server = new MockWebServer(); this.server = new MockWebServer();
@ -457,15 +458,11 @@ class ReactiveOAuth2ResourceServerAutoConfigurationTests {
.run((context) -> { .run((context) -> {
assertThat(context).hasSingleBean(ReactiveJwtDecoder.class); assertThat(context).hasSingleBean(ReactiveJwtDecoder.class);
ReactiveJwtDecoder reactiveJwtDecoder = context.getBean(ReactiveJwtDecoder.class); ReactiveJwtDecoder reactiveJwtDecoder = context.getBean(ReactiveJwtDecoder.class);
DelegatingOAuth2TokenValidator<Jwt> jwtValidator = (DelegatingOAuth2TokenValidator<Jwt>) ReflectionTestUtils validate(jwt().claim("iss", issuer), reactiveJwtDecoder,
.getField(reactiveJwtDecoder, "jwtValidator"); (validators) -> assertThat(validators).hasAtLeastOneElementOfType(JwtIssuerValidator.class));
Collection<OAuth2TokenValidator<Jwt>> tokenValidators = (Collection<OAuth2TokenValidator<Jwt>>) ReflectionTestUtils
.getField(jwtValidator, "tokenValidators");
assertThat(tokenValidators).hasAtLeastOneElementOfType(JwtIssuerValidator.class);
}); });
} }
@SuppressWarnings("unchecked")
@Test @Test
void autoConfigurationShouldNotConfigureIssuerUriAndAudienceJwtValidatorIfPropertyNotConfigured() throws Exception { void autoConfigurationShouldNotConfigureIssuerUriAndAudienceJwtValidatorIfPropertyNotConfigured() throws Exception {
this.server = new MockWebServer(); this.server = new MockWebServer();
@ -479,13 +476,8 @@ class ReactiveOAuth2ResourceServerAutoConfigurationTests {
.run((context) -> { .run((context) -> {
assertThat(context).hasSingleBean(ReactiveJwtDecoder.class); assertThat(context).hasSingleBean(ReactiveJwtDecoder.class);
ReactiveJwtDecoder reactiveJwtDecoder = context.getBean(ReactiveJwtDecoder.class); ReactiveJwtDecoder reactiveJwtDecoder = context.getBean(ReactiveJwtDecoder.class);
DelegatingOAuth2TokenValidator<Jwt> jwtValidator = (DelegatingOAuth2TokenValidator<Jwt>) ReflectionTestUtils validate(jwt(), reactiveJwtDecoder, (validators) -> assertThat(validators).singleElement()
.getField(reactiveJwtDecoder, "jwtValidator"); .isInstanceOf(JwtTimestampValidator.class));
Collection<OAuth2TokenValidator<Jwt>> tokenValidators = (Collection<OAuth2TokenValidator<Jwt>>) ReflectionTestUtils
.getField(jwtValidator, "tokenValidators");
assertThat(tokenValidators).hasExactlyElementsOfTypes(JwtTimestampValidator.class);
assertThat(tokenValidators).doesNotHaveAnyElementsOfTypes(JwtClaimValidator.class);
assertThat(tokenValidators).doesNotHaveAnyElementsOfTypes(JwtIssuerValidator.class);
}); });
} }
@ -505,13 +497,18 @@ class ReactiveOAuth2ResourceServerAutoConfigurationTests {
.run((context) -> { .run((context) -> {
assertThat(context).hasSingleBean(ReactiveJwtDecoder.class); assertThat(context).hasSingleBean(ReactiveJwtDecoder.class);
ReactiveJwtDecoder reactiveJwtDecoder = context.getBean(ReactiveJwtDecoder.class); ReactiveJwtDecoder reactiveJwtDecoder = context.getBean(ReactiveJwtDecoder.class);
validate(issuerUri, reactiveJwtDecoder, null); validate(
jwt().claim("iss", URI.create(issuerUri).toURL())
.claim("aud", List.of("https://test-audience.com")),
reactiveJwtDecoder,
(validators) -> assertThat(validators).hasAtLeastOneElementOfType(JwtIssuerValidator.class)
.satisfiesOnlyOnce(audClaimValidator()));
}); });
} }
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
@Test @Test
void autoConfigurationShouldConfigureAudienceAndCustomValidatorsIfPropertyProvidedAndIssuerUri() throws Exception { void autoConfigurationShouldConfigureAudienceValidatorIfPropertyProvidedAndIssuerUri() throws Exception {
this.server = new MockWebServer(); this.server = new MockWebServer();
this.server.start(); this.server.start();
String path = "test"; String path = "test";
@ -519,98 +516,63 @@ class ReactiveOAuth2ResourceServerAutoConfigurationTests {
String cleanIssuerPath = cleanIssuerPath(issuer); String cleanIssuerPath = cleanIssuerPath(issuer);
setupMockResponse(cleanIssuerPath); setupMockResponse(cleanIssuerPath);
String issuerUri = "http://" + this.server.getHostName() + ":" + this.server.getPort() + "/" + path; String issuerUri = "http://" + this.server.getHostName() + ":" + this.server.getPort() + "/" + path;
this.contextRunner.withPropertyValues( this.contextRunner.withPropertyValues("spring.security.oauth2.resourceserver.jwt.issuer-uri=" + issuerUri,
"spring.security.oauth2.resourceserver.jwt.jwk-set-uri=https://jwk-set-uri.com",
"spring.security.oauth2.resourceserver.jwt.issuer-uri=" + issuerUri,
"spring.security.oauth2.resourceserver.jwt.audiences=https://test-audience.com,https://test-audience1.com") "spring.security.oauth2.resourceserver.jwt.audiences=https://test-audience.com,https://test-audience1.com")
.withUserConfiguration(CustomTokenValidatorsConfig.class)
.run((context) -> { .run((context) -> {
assertThat(context).hasSingleBean(ReactiveJwtDecoder.class); SupplierReactiveJwtDecoder supplierJwtDecoderBean = context.getBean(SupplierReactiveJwtDecoder.class);
ReactiveJwtDecoder reactiveJwtDecoder = context.getBean(ReactiveJwtDecoder.class); Mono<ReactiveJwtDecoder> jwtDecoderSupplier = (Mono<ReactiveJwtDecoder>) ReflectionTestUtils
assertThat(context).hasBean("customJwtClaimValidator"); .getField(supplierJwtDecoderBean, "jwtDecoderMono");
OAuth2TokenValidator<Jwt> customValidator = (OAuth2TokenValidator<Jwt>) context ReactiveJwtDecoder jwtDecoder = jwtDecoderSupplier.block();
.getBean("customJwtClaimValidator"); validate(
validate(issuerUri, reactiveJwtDecoder, customValidator); jwt().claim("iss", URI.create(issuerUri).toURL())
.claim("aud", List.of("https://test-audience.com")),
jwtDecoder,
(validators) -> assertThat(validators).hasAtLeastOneElementOfType(JwtIssuerValidator.class)
.satisfiesOnlyOnce(audClaimValidator()));
}); });
} }
@SuppressWarnings("unchecked")
private void validate(String issuerUri, ReactiveJwtDecoder jwtDecoder, OAuth2TokenValidator<Jwt> customValidator)
throws MalformedURLException {
DelegatingOAuth2TokenValidator<Jwt> jwtValidator = (DelegatingOAuth2TokenValidator<Jwt>) ReflectionTestUtils
.getField(jwtDecoder, "jwtValidator");
Jwt.Builder builder = jwt().claim("aud", Collections.singletonList("https://test-audience.com"));
if (issuerUri != null) {
builder.claim("iss", new URL(issuerUri));
}
if (customValidator != null) {
builder.claim("custom_claim", "custom_claim_value");
}
Jwt jwt = builder.build();
assertThat(jwtValidator.validate(jwt).hasErrors()).isFalse();
Collection<OAuth2TokenValidator<Jwt>> delegates = (Collection<OAuth2TokenValidator<Jwt>>) ReflectionTestUtils
.getField(jwtValidator, "tokenValidators");
validateDelegates(issuerUri, delegates, customValidator);
}
private void validateDelegates(String issuerUri, Collection<OAuth2TokenValidator<Jwt>> delegates,
OAuth2TokenValidator<Jwt> customValidator) {
assertThat(delegates).hasAtLeastOneElementOfType(JwtClaimValidator.class);
OAuth2TokenValidator<Jwt> delegatingValidator = delegates.stream()
.filter((v) -> v instanceof DelegatingOAuth2TokenValidator)
.findFirst()
.get();
if (issuerUri != null) {
assertThat(delegatingValidator).extracting("tokenValidators")
.asInstanceOf(InstanceOfAssertFactories.collection(OAuth2TokenValidator.class))
.hasAtLeastOneElementOfType(JwtIssuerValidator.class);
}
List<OAuth2TokenValidator<Jwt>> claimValidators = delegates.stream()
.filter((d) -> d instanceof JwtClaimValidator<?>)
.collect(Collectors.toList());
assertThat(claimValidators).anyMatch((v) -> "aud".equals(ReflectionTestUtils.getField(v, "claim")));
if (customValidator != null) {
assertThat(claimValidators)
.anyMatch((v) -> "custom_claim".equals(ReflectionTestUtils.getField(v, "claim")));
}
}
@SuppressWarnings("unchecked")
@Test @Test
void autoConfigurationShouldConfigureAudienceValidatorIfPropertyProvidedAndIssuerUri() throws Exception { void autoConfigurationShouldConfigureAudienceValidatorIfPropertyProvidedAndPublicKey() throws Exception {
this.server = new MockWebServer(); this.server = new MockWebServer();
this.server.start(); this.server.start();
String path = "test"; String path = "test";
String issuer = this.server.url(path).toString(); String issuer = this.server.url(path).toString();
String cleanIssuerPath = cleanIssuerPath(issuer); String cleanIssuerPath = cleanIssuerPath(issuer);
setupMockResponse(cleanIssuerPath); setupMockResponse(cleanIssuerPath);
String issuerUri = "http://" + this.server.getHostName() + ":" + this.server.getPort() + "/" + path; this.contextRunner.withPropertyValues(
this.contextRunner.withPropertyValues("spring.security.oauth2.resourceserver.jwt.issuer-uri=" + issuerUri, "spring.security.oauth2.resourceserver.jwt.public-key-location=classpath:public-key-location",
"spring.security.oauth2.resourceserver.jwt.audiences=https://test-audience.com,https://test-audience1.com") "spring.security.oauth2.resourceserver.jwt.audiences=https://test-audience.com,https://test-audience1.com")
.run((context) -> { .run((context) -> {
SupplierReactiveJwtDecoder supplierJwtDecoderBean = context.getBean(SupplierReactiveJwtDecoder.class); assertThat(context).hasSingleBean(ReactiveJwtDecoder.class);
Mono<ReactiveJwtDecoder> jwtDecoderSupplier = (Mono<ReactiveJwtDecoder>) ReflectionTestUtils ReactiveJwtDecoder jwtDecoder = context.getBean(ReactiveJwtDecoder.class);
.getField(supplierJwtDecoderBean, "jwtDecoderMono"); validate(jwt().claim("aud", List.of("https://test-audience.com")), jwtDecoder,
ReactiveJwtDecoder jwtDecoder = jwtDecoderSupplier.block(); (validators) -> assertThat(validators).satisfiesOnlyOnce(audClaimValidator()));
validate(issuerUri, jwtDecoder, null);
}); });
} }
@SuppressWarnings("unchecked")
@Test @Test
void autoConfigurationShouldConfigureAudienceValidatorIfPropertyProvidedAndPublicKey() throws Exception { void autoConfigurationShouldConfigureCustomValidators() throws Exception {
this.server = new MockWebServer(); this.server = new MockWebServer();
this.server.start(); this.server.start();
String path = "test"; String path = "test";
String issuer = this.server.url(path).toString(); String issuer = this.server.url(path).toString();
String cleanIssuerPath = cleanIssuerPath(issuer); String cleanIssuerPath = cleanIssuerPath(issuer);
setupMockResponse(cleanIssuerPath); setupMockResponse(cleanIssuerPath);
this.contextRunner.withPropertyValues( String issuerUri = "http://" + this.server.getHostName() + ":" + this.server.getPort() + "/" + path;
"spring.security.oauth2.resourceserver.jwt.public-key-location=classpath:public-key-location", this.contextRunner
"spring.security.oauth2.resourceserver.jwt.audiences=https://test-audience.com,https://test-audience1.com") .withPropertyValues("spring.security.oauth2.resourceserver.jwt.jwk-set-uri=https://jwk-set-uri.com",
"spring.security.oauth2.resourceserver.jwt.issuer-uri=" + issuerUri)
.withUserConfiguration(CustomJwtClaimValidatorConfig.class)
.run((context) -> { .run((context) -> {
assertThat(context).hasSingleBean(ReactiveJwtDecoder.class); assertThat(context).hasSingleBean(ReactiveJwtDecoder.class);
ReactiveJwtDecoder jwtDecoder = context.getBean(ReactiveJwtDecoder.class); ReactiveJwtDecoder reactiveJwtDecoder = context.getBean(ReactiveJwtDecoder.class);
validate(null, jwtDecoder, null); OAuth2TokenValidator<Jwt> customValidator = (OAuth2TokenValidator<Jwt>) context
.getBean("customJwtClaimValidator");
validate(jwt().claim("iss", URI.create(issuerUri).toURL()).claim("custom_claim", "custom_claim_value"),
reactiveJwtDecoder, (validators) -> assertThat(validators).contains(customValidator)
.hasAtLeastOneElementOfType(JwtIssuerValidator.class));
}); });
} }
@ -640,6 +602,30 @@ class ReactiveOAuth2ResourceServerAutoConfigurationTests {
}); });
} }
@SuppressWarnings("unchecked")
@Test
void customValidatorWhenInvalid() throws Exception {
this.server = new MockWebServer();
this.server.start();
String path = "test";
String issuer = this.server.url(path).toString();
String cleanIssuerPath = cleanIssuerPath(issuer);
setupMockResponse(cleanIssuerPath);
String issuerUri = "http://" + this.server.getHostName() + ":" + this.server.getPort() + "/" + path;
this.contextRunner
.withPropertyValues("spring.security.oauth2.resourceserver.jwt.jwk-set-uri=https://jwk-set-uri.com",
"spring.security.oauth2.resourceserver.jwt.issuer-uri=" + issuerUri)
.withUserConfiguration(CustomJwtClaimValidatorConfig.class)
.run((context) -> {
assertThat(context).hasSingleBean(ReactiveJwtDecoder.class);
ReactiveJwtDecoder jwtDecoder = context.getBean(ReactiveJwtDecoder.class);
DelegatingOAuth2TokenValidator<Jwt> jwtValidator = (DelegatingOAuth2TokenValidator<Jwt>) ReflectionTestUtils
.getField(jwtDecoder, "jwtValidator");
Jwt jwt = jwt().claim("iss", new URL(issuerUri)).claim("custom_claim", "invalid_value").build();
assertThat(jwtValidator.validate(jwt).hasErrors()).isTrue();
});
}
private void assertFilterConfiguredWithJwtAuthenticationManager(AssertableReactiveWebApplicationContext context) { private void assertFilterConfiguredWithJwtAuthenticationManager(AssertableReactiveWebApplicationContext context) {
MatcherSecurityWebFilterChain filterChain = (MatcherSecurityWebFilterChain) context MatcherSecurityWebFilterChain filterChain = (MatcherSecurityWebFilterChain) context
.getBean(BeanIds.SPRING_SECURITY_FILTER_CHAIN); .getBean(BeanIds.SPRING_SECURITY_FILTER_CHAIN);
@ -723,6 +709,37 @@ class ReactiveOAuth2ResourceServerAutoConfigurationTests {
.subject("mock-test-subject"); .subject("mock-test-subject");
} }
@SuppressWarnings("unchecked")
private void validate(Jwt.Builder builder, ReactiveJwtDecoder jwtDecoder,
ThrowingConsumer<List<OAuth2TokenValidator<Jwt>>> validatorsConsumer) {
DelegatingOAuth2TokenValidator<Jwt> jwtValidator = (DelegatingOAuth2TokenValidator<Jwt>) ReflectionTestUtils
.getField(jwtDecoder, "jwtValidator");
assertThat(jwtValidator.validate(builder.build()).hasErrors()).isFalse();
validatorsConsumer.accept(extractValidators(jwtValidator));
}
@SuppressWarnings("unchecked")
private List<OAuth2TokenValidator<Jwt>> extractValidators(DelegatingOAuth2TokenValidator<Jwt> delegatingValidator) {
Collection<OAuth2TokenValidator<Jwt>> delegates = (Collection<OAuth2TokenValidator<Jwt>>) ReflectionTestUtils
.getField(delegatingValidator, "tokenValidators");
List<OAuth2TokenValidator<Jwt>> extracted = new ArrayList<>();
for (OAuth2TokenValidator<Jwt> delegate : delegates) {
if (delegate instanceof DelegatingOAuth2TokenValidator<Jwt> delegatingDelegate) {
extracted.addAll(extractValidators(delegatingDelegate));
}
else {
extracted.add(delegate);
}
}
return extracted;
}
private Consumer<OAuth2TokenValidator<Jwt>> audClaimValidator() {
return (validator) -> assertThat(validator).isInstanceOf(JwtClaimValidator.class)
.extracting("claim")
.isEqualTo("aud");
}
@EnableWebFluxSecurity @EnableWebFluxSecurity
static class TestConfig { static class TestConfig {
@ -781,7 +798,7 @@ class ReactiveOAuth2ResourceServerAutoConfigurationTests {
} }
@Configuration(proxyBeanMethods = false) @Configuration(proxyBeanMethods = false)
static class CustomTokenValidatorsConfig { static class CustomJwtClaimValidatorConfig {
@Bean @Bean
JwtClaimValidator<String> customJwtClaimValidator() { JwtClaimValidator<String> customJwtClaimValidator() {

@ -16,16 +16,17 @@
package org.springframework.boot.autoconfigure.security.oauth2.resource.servlet; package org.springframework.boot.autoconfigure.security.oauth2.resource.servlet;
import java.net.MalformedURLException; import java.net.URI;
import java.net.URL; import java.net.URL;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.function.Consumer;
import java.util.function.Supplier; import java.util.function.Supplier;
import java.util.stream.Collectors;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
@ -34,6 +35,7 @@ import jakarta.servlet.Filter;
import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer; import okhttp3.mockwebserver.MockWebServer;
import org.assertj.core.api.InstanceOfAssertFactories; import org.assertj.core.api.InstanceOfAssertFactories;
import org.assertj.core.api.ThrowingConsumer;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.InOrder; import org.mockito.InOrder;
@ -192,8 +194,8 @@ class OAuth2ResourceServerAutoConfigurationTests {
}); });
} }
@Test
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
@Test
void autoConfigurationShouldConfigureResourceServerUsingOidcIssuerUri() throws Exception { void autoConfigurationShouldConfigureResourceServerUsingOidcIssuerUri() throws Exception {
this.server = new MockWebServer(); this.server = new MockWebServer();
this.server.start(); this.server.start();
@ -217,8 +219,8 @@ class OAuth2ResourceServerAutoConfigurationTests {
assertThat(this.server.getRequestCount()).isEqualTo(2); assertThat(this.server.getRequestCount()).isEqualTo(2);
} }
@Test
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
@Test
void autoConfigurationShouldConfigureResourceServerUsingOidcRfc8414IssuerUri() throws Exception { void autoConfigurationShouldConfigureResourceServerUsingOidcRfc8414IssuerUri() throws Exception {
this.server = new MockWebServer(); this.server = new MockWebServer();
this.server.start(); this.server.start();
@ -242,8 +244,8 @@ class OAuth2ResourceServerAutoConfigurationTests {
assertThat(this.server.getRequestCount()).isEqualTo(3); assertThat(this.server.getRequestCount()).isEqualTo(3);
} }
@Test
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
@Test
void autoConfigurationShouldConfigureResourceServerUsingOAuthIssuerUri() throws Exception { void autoConfigurationShouldConfigureResourceServerUsingOAuthIssuerUri() throws Exception {
this.server = new MockWebServer(); this.server = new MockWebServer();
this.server.start(); this.server.start();
@ -474,9 +476,8 @@ class OAuth2ResourceServerAutoConfigurationTests {
.run((context) -> { .run((context) -> {
assertThat(context).hasSingleBean(JwtDecoder.class); assertThat(context).hasSingleBean(JwtDecoder.class);
JwtDecoder jwtDecoder = context.getBean(JwtDecoder.class); JwtDecoder jwtDecoder = context.getBean(JwtDecoder.class);
assertThat(jwtDecoder).extracting("jwtValidator.tokenValidators") validate(jwt().claim("iss", issuer), jwtDecoder,
.asInstanceOf(InstanceOfAssertFactories.collection(OAuth2TokenValidator.class)) (validators) -> assertThat(validators).hasAtLeastOneElementOfType(JwtIssuerValidator.class));
.hasAtLeastOneElementOfType(JwtIssuerValidator.class);
}); });
} }
@ -493,11 +494,8 @@ class OAuth2ResourceServerAutoConfigurationTests {
.run((context) -> { .run((context) -> {
assertThat(context).hasSingleBean(JwtDecoder.class); assertThat(context).hasSingleBean(JwtDecoder.class);
JwtDecoder jwtDecoder = context.getBean(JwtDecoder.class); JwtDecoder jwtDecoder = context.getBean(JwtDecoder.class);
assertThat(jwtDecoder).extracting("jwtValidator.tokenValidators") validate(jwt(), jwtDecoder, (validators) -> assertThat(validators).singleElement()
.asInstanceOf(InstanceOfAssertFactories.collection(OAuth2TokenValidator.class)) .isInstanceOf(JwtTimestampValidator.class));
.hasExactlyElementsOfTypes(JwtTimestampValidator.class)
.doesNotHaveAnyElementsOfTypes(JwtClaimValidator.class)
.doesNotHaveAnyElementsOfTypes(JwtIssuerValidator.class);
}); });
} }
@ -517,7 +515,12 @@ class OAuth2ResourceServerAutoConfigurationTests {
.run((context) -> { .run((context) -> {
assertThat(context).hasSingleBean(JwtDecoder.class); assertThat(context).hasSingleBean(JwtDecoder.class);
JwtDecoder jwtDecoder = context.getBean(JwtDecoder.class); JwtDecoder jwtDecoder = context.getBean(JwtDecoder.class);
validate(issuerUri, jwtDecoder, null); validate(
jwt().claim("iss", URI.create(issuerUri).toURL())
.claim("aud", List.of("https://test-audience.com")),
jwtDecoder,
(validators) -> assertThat(validators).hasAtLeastOneElementOfType(JwtIssuerValidator.class)
.satisfiesOnlyOnce(audClaimValidator()));
}); });
} }
@ -538,13 +541,18 @@ class OAuth2ResourceServerAutoConfigurationTests {
Supplier<JwtDecoder> jwtDecoderSupplier = (Supplier<JwtDecoder>) ReflectionTestUtils Supplier<JwtDecoder> jwtDecoderSupplier = (Supplier<JwtDecoder>) ReflectionTestUtils
.getField(supplierJwtDecoderBean, "delegate"); .getField(supplierJwtDecoderBean, "delegate");
JwtDecoder jwtDecoder = jwtDecoderSupplier.get(); JwtDecoder jwtDecoder = jwtDecoderSupplier.get();
validate(issuerUri, jwtDecoder, null); validate(
jwt().claim("iss", URI.create(issuerUri).toURL())
.claim("aud", List.of("https://test-audience.com")),
jwtDecoder,
(validators) -> assertThat(validators).hasAtLeastOneElementOfType(JwtIssuerValidator.class)
.satisfiesOnlyOnce(audClaimValidator()));
}); });
} }
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
@Test @Test
void autoConfigurationShouldConfigureAudienceAndCustomValidatorsIfPropertyProvidedAndIssuerUri() throws Exception { void autoConfigurationShouldConfigureCustomValidators() throws Exception {
this.server = new MockWebServer(); this.server = new MockWebServer();
this.server.start(); this.server.start();
String path = "test"; String path = "test";
@ -552,9 +560,8 @@ class OAuth2ResourceServerAutoConfigurationTests {
String cleanIssuerPath = cleanIssuerPath(issuer); String cleanIssuerPath = cleanIssuerPath(issuer);
setupMockResponse(cleanIssuerPath); setupMockResponse(cleanIssuerPath);
String issuerUri = "http://" + this.server.getHostName() + ":" + this.server.getPort() + "/" + path; String issuerUri = "http://" + this.server.getHostName() + ":" + this.server.getPort() + "/" + path;
this.contextRunner.withPropertyValues("spring.security.oauth2.resourceserver.jwt.issuer-uri=" + issuerUri, this.contextRunner.withPropertyValues("spring.security.oauth2.resourceserver.jwt.issuer-uri=" + issuerUri)
"spring.security.oauth2.resourceserver.jwt.audiences=https://test-audience.com,https://test-audience1.com") .withUserConfiguration(CustomJwtClaimValidatorConfig.class)
.withUserConfiguration(CustomTokenValidatorsConfig.class)
.run((context) -> { .run((context) -> {
SupplierJwtDecoder supplierJwtDecoderBean = context.getBean(SupplierJwtDecoder.class); SupplierJwtDecoder supplierJwtDecoderBean = context.getBean(SupplierJwtDecoder.class);
Supplier<JwtDecoder> jwtDecoderSupplier = (Supplier<JwtDecoder>) ReflectionTestUtils Supplier<JwtDecoder> jwtDecoderSupplier = (Supplier<JwtDecoder>) ReflectionTestUtils
@ -563,51 +570,12 @@ class OAuth2ResourceServerAutoConfigurationTests {
assertThat(context).hasBean("customJwtClaimValidator"); assertThat(context).hasBean("customJwtClaimValidator");
OAuth2TokenValidator<Jwt> customValidator = (OAuth2TokenValidator<Jwt>) context OAuth2TokenValidator<Jwt> customValidator = (OAuth2TokenValidator<Jwt>) context
.getBean("customJwtClaimValidator"); .getBean("customJwtClaimValidator");
validate(issuerUri, jwtDecoder, customValidator); validate(jwt().claim("iss", URI.create(issuerUri).toURL()).claim("custom_claim", "custom_claim_value"),
jwtDecoder, (validators) -> assertThat(validators).contains(customValidator)
.hasAtLeastOneElementOfType(JwtIssuerValidator.class));
}); });
} }
@SuppressWarnings("unchecked")
private void validate(String issuerUri, JwtDecoder jwtDecoder, OAuth2TokenValidator<Jwt> customValidator)
throws MalformedURLException {
DelegatingOAuth2TokenValidator<Jwt> jwtValidator = (DelegatingOAuth2TokenValidator<Jwt>) ReflectionTestUtils
.getField(jwtDecoder, "jwtValidator");
Jwt.Builder builder = jwt().claim("aud", Collections.singletonList("https://test-audience.com"));
if (issuerUri != null) {
builder.claim("iss", new URL(issuerUri));
}
if (customValidator != null) {
builder.claim("custom_claim", "custom_claim_value");
}
Jwt jwt = builder.build();
assertThat(jwtValidator.validate(jwt).hasErrors()).isFalse();
Collection<OAuth2TokenValidator<Jwt>> delegates = (Collection<OAuth2TokenValidator<Jwt>>) ReflectionTestUtils
.getField(jwtValidator, "tokenValidators");
validateDelegates(issuerUri, delegates, customValidator);
}
private void validateDelegates(String issuerUri, Collection<OAuth2TokenValidator<Jwt>> delegates,
OAuth2TokenValidator<Jwt> customValidator) {
assertThat(delegates).hasAtLeastOneElementOfType(JwtClaimValidator.class);
OAuth2TokenValidator<Jwt> delegatingValidator = delegates.stream()
.filter((v) -> v instanceof DelegatingOAuth2TokenValidator)
.findFirst()
.get();
if (issuerUri != null) {
assertThat(delegatingValidator).extracting("tokenValidators")
.asInstanceOf(InstanceOfAssertFactories.collection(OAuth2TokenValidator.class))
.hasAtLeastOneElementOfType(JwtIssuerValidator.class);
}
List<OAuth2TokenValidator<Jwt>> claimValidators = delegates.stream()
.filter((d) -> d instanceof JwtClaimValidator<?>)
.collect(Collectors.toList());
assertThat(claimValidators).anyMatch((v) -> "aud".equals(ReflectionTestUtils.getField(v, "claim")));
if (customValidator != null) {
assertThat(claimValidators)
.anyMatch((v) -> "custom_claim".equals(ReflectionTestUtils.getField(v, "claim")));
}
}
@Test @Test
void autoConfigurationShouldConfigureAudienceValidatorIfPropertyProvidedAndPublicKey() throws Exception { void autoConfigurationShouldConfigureAudienceValidatorIfPropertyProvidedAndPublicKey() throws Exception {
this.server = new MockWebServer(); this.server = new MockWebServer();
@ -622,7 +590,8 @@ class OAuth2ResourceServerAutoConfigurationTests {
.run((context) -> { .run((context) -> {
assertThat(context).hasSingleBean(JwtDecoder.class); assertThat(context).hasSingleBean(JwtDecoder.class);
JwtDecoder jwtDecoder = context.getBean(JwtDecoder.class); JwtDecoder jwtDecoder = context.getBean(JwtDecoder.class);
validate(null, jwtDecoder, null); validate(jwt().claim("aud", List.of("https://test-audience.com")), jwtDecoder,
(validators) -> assertThat(validators).satisfiesOnlyOnce(audClaimValidator()));
}); });
} }
@ -732,6 +701,37 @@ class OAuth2ResourceServerAutoConfigurationTests {
.subject("mock-test-subject"); .subject("mock-test-subject");
} }
@SuppressWarnings("unchecked")
private void validate(Jwt.Builder builder, JwtDecoder jwtDecoder,
ThrowingConsumer<List<OAuth2TokenValidator<Jwt>>> validatorsConsumer) {
DelegatingOAuth2TokenValidator<Jwt> jwtValidator = (DelegatingOAuth2TokenValidator<Jwt>) ReflectionTestUtils
.getField(jwtDecoder, "jwtValidator");
assertThat(jwtValidator.validate(builder.build()).hasErrors()).isFalse();
validatorsConsumer.accept(extractValidators(jwtValidator));
}
@SuppressWarnings("unchecked")
private List<OAuth2TokenValidator<Jwt>> extractValidators(DelegatingOAuth2TokenValidator<Jwt> delegatingValidator) {
Collection<OAuth2TokenValidator<Jwt>> delegates = (Collection<OAuth2TokenValidator<Jwt>>) ReflectionTestUtils
.getField(delegatingValidator, "tokenValidators");
List<OAuth2TokenValidator<Jwt>> extracted = new ArrayList<>();
for (OAuth2TokenValidator<Jwt> delegate : delegates) {
if (delegate instanceof DelegatingOAuth2TokenValidator<Jwt> delegatingDelegate) {
extracted.addAll(extractValidators(delegatingDelegate));
}
else {
extracted.add(delegate);
}
}
return extracted;
}
private Consumer<OAuth2TokenValidator<Jwt>> audClaimValidator() {
return (validator) -> assertThat(validator).isInstanceOf(JwtClaimValidator.class)
.extracting("claim")
.isEqualTo("aud");
}
@Configuration(proxyBeanMethods = false) @Configuration(proxyBeanMethods = false)
@EnableWebSecurity @EnableWebSecurity
static class TestConfig { static class TestConfig {
@ -786,7 +786,7 @@ class OAuth2ResourceServerAutoConfigurationTests {
} }
@Configuration(proxyBeanMethods = false) @Configuration(proxyBeanMethods = false)
static class CustomTokenValidatorsConfig { static class CustomJwtClaimValidatorConfig {
@Bean @Bean
JwtClaimValidator<String> customJwtClaimValidator() { JwtClaimValidator<String> customJwtClaimValidator() {

Loading…
Cancel
Save