From 438c783c7d01883569b558cfe27bd10a6f3b3cf6 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:54:51 -0600 Subject: [PATCH 1/5] securityMatchers uses PathPatternRequestMatcher.Builder Bean Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com> --- .../HttpSecurityConfiguration.java | 4 +- .../AuthorizeHttpRequestsConfigurerTests.java | 38 +++++++ .../HttpSecuritySecurityMatchersTests.java | 105 ++++++++++++++++++ 3 files changed, 146 insertions(+), 1 deletion(-) diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configuration/HttpSecurityConfiguration.java b/config/src/main/java/org/springframework/security/config/annotation/web/configuration/HttpSecurityConfiguration.java index efde27ad3f..e84e4f1910 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configuration/HttpSecurityConfiguration.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configuration/HttpSecurityConfiguration.java @@ -238,7 +238,9 @@ class HttpSecurityConfiguration { Map, Object> sharedObjects = new HashMap<>(); sharedObjects.put(ApplicationContext.class, this.context); sharedObjects.put(ContentNegotiationStrategy.class, this.contentNegotiationStrategy); - sharedObjects.put(PathPatternRequestMatcher.Builder.class, constructRequestMatcherBuilder(this.context)); + sharedObjects.put(PathPatternRequestMatcher.Builder.class, + this.context.getBeanProvider(PathPatternRequestMatcher.Builder.class) + .getIfUnique(() -> constructRequestMatcherBuilder(this.context))); return sharedObjects; } diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/AuthorizeHttpRequestsConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/AuthorizeHttpRequestsConfigurerTests.java index 15fdd19522..a40fd0d614 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/AuthorizeHttpRequestsConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/AuthorizeHttpRequestsConfigurerTests.java @@ -452,6 +452,18 @@ public class AuthorizeHttpRequestsConfigurerTests { this.mvc.perform(requestWithAdmin).andExpect(status().isOk()); } + @Test + public void requestMatchersWhenBuilderBeanWithBasePathAndRawStringThenHonorsBasePath() throws Exception { + this.spring.register(RequestMatchersRawStringServletPathConfig.class, BasicController.class).autowire(); + // @formatter:off + MockHttpServletRequestBuilder matchedByBasePath = get("/spring/path") + .servletPath("/spring") + .with(user("user").roles("USER")); + // @formatter:on + this.mvc.perform(matchedByBasePath).andExpect(status().isForbidden()); + this.mvc.perform(get("/path").with(user("user").roles("USER"))).andExpect(status().isOk()); + } + @Test public void getWhenAnyRequestAuthenticatedConfiguredAndNoUserThenRespondsWithUnauthorized() throws Exception { this.spring.register(AuthenticatedConfig.class, BasicController.class).autowire(); @@ -1359,6 +1371,32 @@ public class AuthorizeHttpRequestsConfigurerTests { } + @Configuration + @EnableWebMvc + @EnableWebSecurity + static class RequestMatchersRawStringServletPathConfig { + + @Bean + PathPatternRequestMatcherBuilderFactoryBean requestMatcherBuilder() { + PathPatternRequestMatcherBuilderFactoryBean bean = new PathPatternRequestMatcherBuilderFactoryBean(); + bean.setBasePath("/spring"); + return bean; + } + + @Bean + SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + // @formatter:off + return http + .authorizeHttpRequests((authorize) -> authorize + .requestMatchers("/path").hasRole("ADMIN") + .anyRequest().permitAll() + ) + .build(); + // @formatter:on + } + + } + @Configuration @EnableWebSecurity static class AuthenticatedConfig { diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/HttpSecuritySecurityMatchersTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/HttpSecuritySecurityMatchersTests.java index 5502a6c1ab..46d1305643 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/HttpSecuritySecurityMatchersTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/HttpSecuritySecurityMatchersTests.java @@ -125,6 +125,34 @@ public class HttpSecuritySecurityMatchersTests { assertThat(this.response.getStatus()).isEqualTo(HttpServletResponse.SC_OK); } + @Test + public void securityMatcherWhenBuilderBeanWithBasePathThenHonorsBasePath() throws Exception { + loadConfig(SecurityMatcherBuilderBeanConfig.class); + this.request.setServletPath("/spring"); + this.request.setRequestURI("/spring/path"); + this.springSecurityFilterChain.doFilter(this.request, this.response, this.chain); + assertThat(this.response.getStatus()).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED); + setup(); + this.request.setServletPath(""); + this.request.setRequestURI("/path"); + this.springSecurityFilterChain.doFilter(this.request, this.response, this.chain); + assertThat(this.response.getStatus()).isEqualTo(HttpServletResponse.SC_OK); + } + + @Test + public void securityMatchersWhenBuilderBeanWithBasePathAndRawStringsThenHonorsBasePath() throws Exception { + loadConfig(SecurityMatchersBuilderBeanConfig.class); + this.request.setServletPath("/spring"); + this.request.setRequestURI("/spring/path"); + this.springSecurityFilterChain.doFilter(this.request, this.response, this.chain); + assertThat(this.response.getStatus()).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED); + setup(); + this.request.setServletPath(""); + this.request.setRequestURI("/path"); + this.springSecurityFilterChain.doFilter(this.request, this.response, this.chain); + assertThat(this.response.getStatus()).isEqualTo(HttpServletResponse.SC_OK); + } + @Test public void securityMatchersWhenMultiMvcMatcherInLambdaThenAllPathsAreDenied() throws Exception { loadConfig(MultiMvcMatcherInLambdaConfig.class); @@ -430,6 +458,83 @@ public class HttpSecuritySecurityMatchersTests { } + @EnableWebSecurity + @Configuration + @EnableWebMvc + @Import(UsersConfig.class) + static class SecurityMatcherBuilderBeanConfig { + + @Bean + PathPatternRequestMatcherBuilderFactoryBean requestMatcherBuilder() { + PathPatternRequestMatcherBuilderFactoryBean bean = new PathPatternRequestMatcherBuilderFactoryBean(); + bean.setBasePath("/spring"); + return bean; + } + + @Bean + SecurityFilterChain appSecurity(HttpSecurity http) throws Exception { + // @formatter:off + http + .securityMatcher("/path") + .httpBasic(withDefaults()) + .authorizeHttpRequests((authorize) -> authorize + .anyRequest().denyAll()); + // @formatter:on + return http.build(); + } + + @RestController + static class PathController { + + @RequestMapping("/path") + String path() { + return "path"; + } + + } + + } + + @EnableWebSecurity + @Configuration + @EnableWebMvc + @Import(UsersConfig.class) + static class SecurityMatchersBuilderBeanConfig { + + @Bean + PathPatternRequestMatcherBuilderFactoryBean requestMatcherBuilder() { + PathPatternRequestMatcherBuilderFactoryBean bean = new PathPatternRequestMatcherBuilderFactoryBean(); + bean.setBasePath("/spring"); + return bean; + } + + @Bean + SecurityFilterChain appSecurity(HttpSecurity http) throws Exception { + // @formatter:off + http + .securityMatchers((matchers) -> matchers + .requestMatchers("/path") + ) + .httpBasic(withDefaults()) + .authorizeHttpRequests((authorize) -> authorize + .anyRequest().denyAll() + ); + // @formatter:on + return http.build(); + } + + @RestController + static class PathController { + + @RequestMapping("/path") + String path() { + return "path"; + } + + } + + } + @Configuration static class UsersConfig { From 53bcf0d16b414d9f0436ac178c211ca6b9520d8e Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Wed, 15 Apr 2026 17:12:08 -0600 Subject: [PATCH 2/5] Fix Servlet Path Application Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com> --- .../PathPatternRequestMatcherFactoryBean.java | 2 +- .../config/http/InterceptUrlConfigTests.java | 72 +++++++++++++++++++ ...InterceptUrlConfigTests-CiRegexMatcher.xml | 36 ++++++++++ ...sts-CiRegexMatcherAuthorizationManager.xml | 36 ++++++++++ ...lConfigTests-DefaultMatcherServletPath.xml | 3 + ...MatcherServletPathAuthorizationManager.xml | 3 + .../InterceptUrlConfigTests-RegexMatcher.xml | 36 ++++++++++ ...Tests-RegexMatcherAuthorizationManager.xml | 36 ++++++++++ 8 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 config/src/test/resources/org/springframework/security/config/http/InterceptUrlConfigTests-CiRegexMatcher.xml create mode 100644 config/src/test/resources/org/springframework/security/config/http/InterceptUrlConfigTests-CiRegexMatcherAuthorizationManager.xml create mode 100644 config/src/test/resources/org/springframework/security/config/http/InterceptUrlConfigTests-RegexMatcher.xml create mode 100644 config/src/test/resources/org/springframework/security/config/http/InterceptUrlConfigTests-RegexMatcherAuthorizationManager.xml diff --git a/config/src/main/java/org/springframework/security/config/http/PathPatternRequestMatcherFactoryBean.java b/config/src/main/java/org/springframework/security/config/http/PathPatternRequestMatcherFactoryBean.java index 116f2ccd12..39e13a02ac 100644 --- a/config/src/main/java/org/springframework/security/config/http/PathPatternRequestMatcherFactoryBean.java +++ b/config/src/main/java/org/springframework/security/config/http/PathPatternRequestMatcherFactoryBean.java @@ -70,7 +70,7 @@ public final class PathPatternRequestMatcherFactoryBean @Override public void afterPropertiesSet() throws Exception { if (this.basePath != null) { - this.builder.basePath(this.basePath); + this.builder = this.builder.basePath(this.basePath); } } diff --git a/config/src/test/java/org/springframework/security/config/http/InterceptUrlConfigTests.java b/config/src/test/java/org/springframework/security/config/http/InterceptUrlConfigTests.java index fab1e932c0..2038828317 100644 --- a/config/src/test/java/org/springframework/security/config/http/InterceptUrlConfigTests.java +++ b/config/src/test/java/org/springframework/security/config/http/InterceptUrlConfigTests.java @@ -314,6 +314,78 @@ public class InterceptUrlConfigTests { .autowire()); } + @Test + public void requestWhenUsingDefaultMatcherAndServletPathThenAuthorizesRequestsAccordingly() throws Exception { + this.spring.configLocations(this.xml("DefaultMatcherServletPath")).autowire(); + // @formatter:off + this.mvc.perform(get("/spring/path").with(userCredentials())) + .andExpect(status().isForbidden()); + this.mvc.perform(get("/path").with(userCredentials())) + .andExpect(status().isOk()); + // @formatter:on + } + + @Test + public void requestWhenUsingDefaultMatcherAndServletPathAndAuthorizationManagerThenAuthorizesRequestsAccordingly() + throws Exception { + this.spring.configLocations(this.xml("DefaultMatcherServletPathAuthorizationManager")).autowire(); + // @formatter:off + this.mvc.perform(get("/spring/path").with(userCredentials())) + .andExpect(status().isForbidden()); + this.mvc.perform(get("/path").with(userCredentials())) + .andExpect(status().isOk()); + // @formatter:on + assertThat(this.spring.getContext().getBean(AuthorizationManager.class)).isNotNull(); + } + + @Test + public void requestWhenUsingRegexMatcherThenAuthorizesRequestsAccordingly() throws Exception { + this.spring.configLocations(this.xml("RegexMatcher")).autowire(); + // @formatter:off + this.mvc.perform(get("/path").with(userCredentials())) + .andExpect(status().isForbidden()); + this.mvc.perform(get("/other").with(userCredentials())) + .andExpect(status().isNotFound()); + // @formatter:on + } + + @Test + public void requestWhenUsingRegexMatcherAndAuthorizationManagerThenAuthorizesRequestsAccordingly() + throws Exception { + this.spring.configLocations(this.xml("RegexMatcherAuthorizationManager")).autowire(); + // @formatter:off + this.mvc.perform(get("/path").with(userCredentials())) + .andExpect(status().isForbidden()); + this.mvc.perform(get("/other").with(userCredentials())) + .andExpect(status().isNotFound()); + // @formatter:on + assertThat(this.spring.getContext().getBean(AuthorizationManager.class)).isNotNull(); + } + + @Test + public void requestWhenUsingCiRegexMatcherThenAuthorizesRequestsAccordingly() throws Exception { + this.spring.configLocations(this.xml("CiRegexMatcher")).autowire(); + // @formatter:off + this.mvc.perform(get("/path").with(userCredentials())) + .andExpect(status().isForbidden()); + this.mvc.perform(get("/PATH").with(userCredentials())) + .andExpect(status().isForbidden()); + // @formatter:on + } + + @Test + public void requestWhenUsingCiRegexMatcherAndAuthorizationManagerThenAuthorizesRequestsAccordingly() + throws Exception { + this.spring.configLocations(this.xml("CiRegexMatcherAuthorizationManager")).autowire(); + // @formatter:off + this.mvc.perform(get("/path").with(userCredentials())) + .andExpect(status().isForbidden()); + this.mvc.perform(get("/PATH").with(userCredentials())) + .andExpect(status().isForbidden()); + // @formatter:on + assertThat(this.spring.getContext().getBean(AuthorizationManager.class)).isNotNull(); + } + @Test public void requestWhenUsingFilterAllDispatcherTypesAndAuthorizationManagerThenAuthorizesRequestsAccordingly() throws Exception { diff --git a/config/src/test/resources/org/springframework/security/config/http/InterceptUrlConfigTests-CiRegexMatcher.xml b/config/src/test/resources/org/springframework/security/config/http/InterceptUrlConfigTests-CiRegexMatcher.xml new file mode 100644 index 0000000000..37780a6735 --- /dev/null +++ b/config/src/test/resources/org/springframework/security/config/http/InterceptUrlConfigTests-CiRegexMatcher.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + diff --git a/config/src/test/resources/org/springframework/security/config/http/InterceptUrlConfigTests-CiRegexMatcherAuthorizationManager.xml b/config/src/test/resources/org/springframework/security/config/http/InterceptUrlConfigTests-CiRegexMatcherAuthorizationManager.xml new file mode 100644 index 0000000000..f0b4bd71d7 --- /dev/null +++ b/config/src/test/resources/org/springframework/security/config/http/InterceptUrlConfigTests-CiRegexMatcherAuthorizationManager.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + diff --git a/config/src/test/resources/org/springframework/security/config/http/InterceptUrlConfigTests-DefaultMatcherServletPath.xml b/config/src/test/resources/org/springframework/security/config/http/InterceptUrlConfigTests-DefaultMatcherServletPath.xml index 28d4a6cb4b..2f6eba90fc 100644 --- a/config/src/test/resources/org/springframework/security/config/http/InterceptUrlConfigTests-DefaultMatcherServletPath.xml +++ b/config/src/test/resources/org/springframework/security/config/http/InterceptUrlConfigTests-DefaultMatcherServletPath.xml @@ -26,8 +26,11 @@ + + + diff --git a/config/src/test/resources/org/springframework/security/config/http/InterceptUrlConfigTests-DefaultMatcherServletPathAuthorizationManager.xml b/config/src/test/resources/org/springframework/security/config/http/InterceptUrlConfigTests-DefaultMatcherServletPathAuthorizationManager.xml index 557083ccd8..fefc79d0e5 100644 --- a/config/src/test/resources/org/springframework/security/config/http/InterceptUrlConfigTests-DefaultMatcherServletPathAuthorizationManager.xml +++ b/config/src/test/resources/org/springframework/security/config/http/InterceptUrlConfigTests-DefaultMatcherServletPathAuthorizationManager.xml @@ -26,8 +26,11 @@ + + + diff --git a/config/src/test/resources/org/springframework/security/config/http/InterceptUrlConfigTests-RegexMatcher.xml b/config/src/test/resources/org/springframework/security/config/http/InterceptUrlConfigTests-RegexMatcher.xml new file mode 100644 index 0000000000..d362d91f27 --- /dev/null +++ b/config/src/test/resources/org/springframework/security/config/http/InterceptUrlConfigTests-RegexMatcher.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + diff --git a/config/src/test/resources/org/springframework/security/config/http/InterceptUrlConfigTests-RegexMatcherAuthorizationManager.xml b/config/src/test/resources/org/springframework/security/config/http/InterceptUrlConfigTests-RegexMatcherAuthorizationManager.xml new file mode 100644 index 0000000000..47fd1c675e --- /dev/null +++ b/config/src/test/resources/org/springframework/security/config/http/InterceptUrlConfigTests-RegexMatcherAuthorizationManager.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + From 88118afb8f9357ae7cc215500a441e89fee701c3 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Mon, 5 Jan 2026 12:52:39 -0700 Subject: [PATCH 3/5] Use RDN Parsing Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com> --- .../x509/SubjectX500PrincipalExtractor.java | 44 ++++++++++------ .../SubjectX500PrincipalExtractorTests.java | 16 ++++++ .../preauth/x509/X509TestUtils.java | 50 +++++++++++++++++++ 3 files changed, 96 insertions(+), 14 deletions(-) diff --git a/web/src/main/java/org/springframework/security/web/authentication/preauth/x509/SubjectX500PrincipalExtractor.java b/web/src/main/java/org/springframework/security/web/authentication/preauth/x509/SubjectX500PrincipalExtractor.java index 73146b677b..c955fb72fe 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/preauth/x509/SubjectX500PrincipalExtractor.java +++ b/web/src/main/java/org/springframework/security/web/authentication/preauth/x509/SubjectX500PrincipalExtractor.java @@ -17,9 +17,11 @@ package org.springframework.security.web.authentication.preauth.x509; import java.security.cert.X509Certificate; -import java.util.regex.Matcher; -import java.util.regex.Pattern; +import java.util.List; +import javax.naming.InvalidNameException; +import javax.naming.ldap.LdapName; +import javax.naming.ldap.Rdn; import javax.security.auth.x500.X500Principal; import org.apache.commons.logging.Log; @@ -47,14 +49,13 @@ public final class SubjectX500PrincipalExtractor implements X509PrincipalExtract private final Log logger = LogFactory.getLog(getClass()); - private static final Pattern EMAIL_SUBJECT_DN_PATTERN = Pattern.compile("OID.1.2.840.113549.1.9.1=(.*?)(?:,|$)", - Pattern.CASE_INSENSITIVE); + private static final String EMAIL_SUBJECT_DN_TYPE = "OID.1.2.840.113549.1.9.1"; - private static final Pattern CN_SUBJECT_DN_PATTERN = Pattern.compile("CN=(.*?)(?:,|$)", Pattern.CASE_INSENSITIVE); + private static final String CN_SUBJECT_DN_TYPE = "CN"; private MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor(); - private Pattern subjectDnPattern = CN_SUBJECT_DN_PATTERN; + private String subjectDnType = CN_SUBJECT_DN_TYPE; private String x500PrincipalFormat = X500Principal.RFC2253; @@ -64,16 +65,31 @@ public final class SubjectX500PrincipalExtractor implements X509PrincipalExtract X500Principal principal = clientCert.getSubjectX500Principal(); String subjectDN = principal.getName(this.x500PrincipalFormat); this.logger.debug(LogMessage.format("Subject DN is '%s'", subjectDN)); - Matcher matcher = this.subjectDnPattern.matcher(subjectDN); - if (!matcher.find()) { - throw new BadCredentialsException(this.messages.getMessage("SubjectX500PrincipalExtractor.noMatching", - new Object[] { subjectDN }, "No matching pattern was found in subject DN: {0}")); - } - String principalName = matcher.group(1); + String principalName = getSubject(subjectDN); this.logger.debug(LogMessage.format("Extracted Principal name is '%s'", principalName)); return principalName; } + private List getDns(String subjectDn) { + try { + return new LdapName(subjectDn).getRdns(); + } + catch (InvalidNameException ex) { + throw new BadCredentialsException("Failed to parse client certificate", ex); + } + } + + private String getSubject(String subjectDn) { + for (Rdn rdn : getDns(subjectDn)) { + String type = rdn.getType(); + if (this.subjectDnType.equals(type)) { + return String.valueOf(rdn.getValue()); + } + } + throw new BadCredentialsException(this.messages.getMessage("SubjectX500PrincipalExtractor.noMatching", + new Object[] { subjectDn }, "No matching pattern was found in subject DN: {0}")); + } + @Override public void setMessageSource(MessageSource messageSource) { Assert.notNull(messageSource, "messageSource cannot be null"); @@ -104,11 +120,11 @@ public final class SubjectX500PrincipalExtractor implements X509PrincipalExtract */ public void setExtractPrincipalNameFromEmail(boolean extractPrincipalNameFromEmail) { if (extractPrincipalNameFromEmail) { - this.subjectDnPattern = EMAIL_SUBJECT_DN_PATTERN; + this.subjectDnType = EMAIL_SUBJECT_DN_TYPE; this.x500PrincipalFormat = X500Principal.RFC1779; } else { - this.subjectDnPattern = CN_SUBJECT_DN_PATTERN; + this.subjectDnType = CN_SUBJECT_DN_TYPE; this.x500PrincipalFormat = X500Principal.RFC2253; } } diff --git a/web/src/test/java/org/springframework/security/web/authentication/preauth/x509/SubjectX500PrincipalExtractorTests.java b/web/src/test/java/org/springframework/security/web/authentication/preauth/x509/SubjectX500PrincipalExtractorTests.java index 3e14a3d914..d5bc6635b0 100644 --- a/web/src/test/java/org/springframework/security/web/authentication/preauth/x509/SubjectX500PrincipalExtractorTests.java +++ b/web/src/test/java/org/springframework/security/web/authentication/preauth/x509/SubjectX500PrincipalExtractorTests.java @@ -53,6 +53,22 @@ public class SubjectX500PrincipalExtractorTests { assertThat(principal).isEqualTo("Duke"); } + @Test + void extractWhenDnEmbeddedInCnThenExtractsPrincipalName() throws Exception { + Object principal = this.extractor.extractPrincipal(X509TestUtils.buildTestCertficateWithEmbeddedDn()); + + assertThat(principal).isEqualTo("luke"); + } + + @Test + void extractWhenEmailDnEmbeddedInCnThenExtractsEmail() throws Exception { + this.extractor.setExtractPrincipalNameFromEmail(true); + + Object principal = this.extractor.extractPrincipal(X509TestUtils.buildTestCertficateWithEmbeddedEmailDn()); + + assertThat(principal).isEqualTo("luke@monkeymachine"); + } + @Test void setMessageSourceWhenNullThenThrowsException() { assertThatIllegalArgumentException().isThrownBy(() -> this.extractor.setMessageSource(null)); diff --git a/web/src/test/java/org/springframework/security/web/authentication/preauth/x509/X509TestUtils.java b/web/src/test/java/org/springframework/security/web/authentication/preauth/x509/X509TestUtils.java index 3c9c844424..66c392b77f 100644 --- a/web/src/test/java/org/springframework/security/web/authentication/preauth/x509/X509TestUtils.java +++ b/web/src/test/java/org/springframework/security/web/authentication/preauth/x509/X509TestUtils.java @@ -135,4 +135,54 @@ public final class X509TestUtils { return (X509Certificate) cf.generateCertificate(in); } + public static X509Certificate buildTestCertficateWithEmbeddedDn() throws Exception { + String cert = "-----BEGIN CERTIFICATE-----\n" + + "MIIDDTCCAfWgAwIBAgIJANSyvk4gJhqPMA0GCSqGSIb3DQEBCwUAMEYxDTALBgNV\n" + + "BAMMBGx1a2UxETAPBgNVBAsMCENOPWR1a2UsMRUwEwYDVQQKDAxFeGFtcGxlIENv\n" + + "cnAxCzAJBgNVBAYTAlVTMB4XDTI2MDEwNDE5MjY0N1oXDTI3MDEwNTE5MjY0N1ow\n" + + "RjENMAsGA1UEAwwEbHVrZTERMA8GA1UECwwIQ049ZHVrZSwxFTATBgNVBAoMDEV4\n" + + "YW1wbGUgQ29ycDELMAkGA1UEBhMCVVMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw\n" + + "ggEKAoIBAQDU9fY74nEFbBKfIef7CK02J/BJb42sIF9kD8eHN5OvEwLQBeTh30it\n" + + "E7LLalXyOXeUFkPe1N1ZhGdVak9udsIqULSvQaWqTbN+IrAGklZAxuXYTC1GbhMF\n" + + "AkGWWM55J2SNqVGQaHzZUn6VPxWaDft6nZR0DxuvXMYM5kVG6VErdB3ygGUv8cjQ\n" + + "QBKAYpsZeRldnauRPt2dImmGTagvSuJVyr8X/AioE2Rl0guii456AKw+QSvRiZ+g\n" + + "w08Y8C9nDyzQmurqpdYYkp0X+4yqm1iVowMX+tSPvHnlqJdvVzaW2b0yRzrrT6ao\n" + + "UCgw25slR1P1IcyzqPKWQIoQRnYIaX1bAgMBAAEwDQYJKoZIhvcNAQELBQADggEB\n" + + "AIos+nr8DFM6bAt9AI/79O/12hcN7gVv4F3P4Vz6NRRkkvsb9WMN8fLLDEsEJ/BQ\n" + + "eQkAVnhlmAe++vrqy8OTHoQ7F5C3K0zrr19NLNoyNFTkXkFgnm4ZhYinSbusuIb7\n" + + "LPYoyCnEEiMdl0VMWWSWcOvZpipbvTtH3CiVxTqXLjFFNraEAyUN50kXjo/zuHpK\n" + + "HzTS1BAu0li9GdV3Da2ELdDx90zaUym7dDIejY4YUlXYIJ5UUYS61fqtgOHGLLdb\n" + + "UXGAr5gqEe7OrQ9D4ebg9w5ciTb7g1H2CmirjTf/rkii8AojmsGFKIfGVe3gY6EB\n" + "o9eF5FV9V9leo5yLo25ev08=\n" + + "-----END CERTIFICATE-----"; + ByteArrayInputStream in = new ByteArrayInputStream(cert.getBytes()); + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + return (X509Certificate) cf.generateCertificate(in); + } + + public static X509Certificate buildTestCertficateWithEmbeddedEmailDn() throws Exception { + String cert = "-----BEGIN CERTIFICATE-----\n" + + "MIIDfDCCAmSgAwIBAgIIXHoOUFeZ29MwDQYJKoZIhvcNAQELBQAwfjEhMB8GCSqG\n" + + "SIb3DQEJARYSbHVrZUBtb25rZXltYWNoaW5lMTUwMwYDVQQLDCxPSUQuMS4yLjg0\n" + + "MC4xMTM1NDkuMS45LjE9ZHVrZUBnb3JpbGxhZ2FkZ2V0LDEVMBMGA1UECgwMRXhh\n" + + "bXBsZSBDb3JwMQswCQYDVQQGEwJVUzAeFw0yNjAxMDQxOTMxMDhaFw0yNzAxMDUx\n" + + "OTMxMDhaMH4xITAfBgkqhkiG9w0BCQEWEmx1a2VAbW9ua2V5bWFjaGluZTE1MDMG\n" + + "A1UECwwsT0lELjEuMi44NDAuMTEzNTQ5LjEuOS4xPWR1a2VAZ29yaWxsYWdhZGdl\n" + + "dCwxFTATBgNVBAoMDEV4YW1wbGUgQ29ycDELMAkGA1UEBhMCVVMwggEiMA0GCSqG\n" + + "SIb3DQEBAQUAA4IBDwAwggEKAoIBAQDBuIQWnj+uvvG+4ZIyFMs4dSbiBavubCmC\n" + + "hudrHr93hP19QbPulbHRTVCUqEi8efvq+J9jmMdPd7tziuDX02PeG9uljp9+c5Ir\n" + + "pw9/oMoTRkF7K4PK1JxLN4tcgxjxVA4QkS+MjKLPeHrYyGCjKspcHbi+zBiQ9Xqp\n" + + "yHWq6N5XPd6mEj2gh0zamnsJCeUCOX4SJbcp3MFtcYzhguHAeVhy9Jv+EAMJejDn\n" + + "YIZmMUdP6Ykf2zTzs/4L3bRZb0oS5WvfeRdJB6SKg8mNO/jdGX87krSio//cRdDy\n" + + "TGQK+YCVDf8GyLLavYZW56AJbZxL3MWgHYilQjj4p+Kw/PWpaBVvAgMBAAEwDQYJ\n" + + "KoZIhvcNAQELBQADggEBAKVTMIo8JO0H0HRrpsEDP17E2pnfMJV4g70BwClUMMek\n" + + "wNIWZn+6XPR8oObzzjnVWXjrovMkmmyFk0vWIpF68MPyiQ++5fwdzOZiQtUP177n\n" + + "9ulAtLoIJld3olGeL9VsCZGp3J2PqiDe613zd+bkSUG1lQYC2awozWqJEdvwJJtf\n" + + "j9nlhyMsARKEEu3tFGJsCHST3XhbhFKOraf/GZ21xW650R7ap0ZNaEiB16M2a5Oe\n" + + "WXasgUukIo82Z8+yK4IITeCcr0aA1fJxwhU8J6qfYWloaoirSYj487HRnPPv3X/b\n" + + "RxZynIjtGKygT6T1dRaWennmoitqfprJnEO2tlhLwP0=\n" + "-----END CERTIFICATE-----"; + ByteArrayInputStream in = new ByteArrayInputStream(cert.getBytes()); + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + return (X509Certificate) cf.generateCertificate(in); + } + } From 4a6e0a13cd7a815b2c975ca771edbe40ab07b5c0 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Wed, 15 Apr 2026 21:14:23 -0600 Subject: [PATCH 4/5] Update DaoAuthenticationProvider Usage Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com> --- .../authentication/dao/DaoAuthenticationProviderTests.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/core/src/test/java/org/springframework/security/authentication/dao/DaoAuthenticationProviderTests.java b/core/src/test/java/org/springframework/security/authentication/dao/DaoAuthenticationProviderTests.java index 93bcf72cd9..a2aa54656f 100644 --- a/core/src/test/java/org/springframework/security/authentication/dao/DaoAuthenticationProviderTests.java +++ b/core/src/test/java/org/springframework/security/authentication/dao/DaoAuthenticationProviderTests.java @@ -467,11 +467,10 @@ public class DaoAuthenticationProviderTests { public void testDisabledUserTiming() { UsernamePasswordAuthenticationToken user = UsernamePasswordAuthenticationToken.unauthenticated("rod", "koala"); PasswordEncoder encoder = new BCryptPasswordEncoder(); - DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); - provider.setPasswordEncoder(encoder); MockUserDetailsServiceUserRod users = new MockUserDetailsServiceUserRod(); users.password = encoder.encode((CharSequence) user.getCredentials()); - provider.setUserDetailsService(users); + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(users); + provider.setPasswordEncoder(encoder); int sampleSize = 100; List enabledTimes = new ArrayList<>(sampleSize); for (int i = 0; i < sampleSize; i++) { From 19b3cae62e2f09151f70a79c7aa8efee3c5b5f94 Mon Sep 17 00:00:00 2001 From: addcontent Date: Thu, 16 Apr 2026 00:58:19 +0300 Subject: [PATCH 5/5] Add authentication validator for dynamic client registration Signed-off-by: Kelvin Mbogo --- .../OAuth2ClientRegistrationTests.java | 144 ++++++++- .../OidcClientRegistrationTests.java | 174 ++++++++++- etc/nohttp/allowlist.lines | 2 + ...ientRegistrationAuthenticationContext.java | 89 ++++++ ...entRegistrationAuthenticationProvider.java | 66 ++-- ...ntRegistrationAuthenticationValidator.java | 244 +++++++++++++++ ...ientRegistrationAuthenticationContext.java | 90 ++++++ ...entRegistrationAuthenticationProvider.java | 63 ++-- ...ntRegistrationAuthenticationValidator.java | 285 ++++++++++++++++++ ...gistrationAuthenticationProviderTests.java | 9 + ...istrationAuthenticationValidatorTests.java | 197 ++++++++++++ ...gistrationAuthenticationProviderTests.java | 15 + ...istrationAuthenticationValidatorTests.java | 188 ++++++++++++ 13 files changed, 1487 insertions(+), 79 deletions(-) create mode 100644 oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientRegistrationAuthenticationContext.java create mode 100644 oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientRegistrationAuthenticationValidator.java create mode 100644 oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcClientRegistrationAuthenticationContext.java create mode 100644 oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcClientRegistrationAuthenticationValidator.java create mode 100644 oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientRegistrationAuthenticationValidatorTests.java create mode 100644 oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcClientRegistrationAuthenticationValidatorTests.java diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2ClientRegistrationTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2ClientRegistrationTests.java index a0512af77a..f3f86a1ab1 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2ClientRegistrationTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2ClientRegistrationTests.java @@ -79,6 +79,7 @@ import org.springframework.security.oauth2.server.authorization.OAuth2Authorizat import org.springframework.security.oauth2.server.authorization.OAuth2ClientRegistration; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientRegistrationAuthenticationProvider; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientRegistrationAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientRegistrationAuthenticationValidator; import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository; import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository.RegisteredClientParametersMapper; import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; @@ -411,6 +412,102 @@ public class OAuth2ClientRegistrationTests { .isCloseTo(expectedSecretExpiryDate, allowedDelta); } + @Test + public void requestWhenProtocolRelativeRedirectUriThenBadRequest() throws Exception { + this.spring.register(DefaultValidatorConfiguration.class).autowire(); + assertThat(requestWhenInvalidClientMetadataThenBadRequest(""" + { + "client_name": "client-name", + "redirect_uris": ["//client.example.com/path"], + "grant_types": ["authorization_code"] + } + """)).isEqualTo(HttpStatus.BAD_REQUEST.value()); + } + + @Test + public void requestWhenJavascriptSchemeRedirectUriThenBadRequest() throws Exception { + this.spring.register(DefaultValidatorConfiguration.class).autowire(); + assertThat(requestWhenInvalidClientMetadataThenBadRequest(""" + { + "client_name": "client-name", + "redirect_uris": ["javascript:alert(document.cookie)"], + "grant_types": ["authorization_code"] + } + """)).isEqualTo(HttpStatus.BAD_REQUEST.value()); + } + + @Test + public void requestWhenDataSchemeRedirectUriThenBadRequest() throws Exception { + this.spring.register(DefaultValidatorConfiguration.class).autowire(); + assertThat(requestWhenInvalidClientMetadataThenBadRequest(""" + { + "client_name": "client-name", + "redirect_uris": ["data:text/html,

content

"], + "grant_types": ["authorization_code"] + } + """)).isEqualTo(HttpStatus.BAD_REQUEST.value()); + } + + @Test + public void requestWhenHttpJwkSetUriThenBadRequest() throws Exception { + this.spring.register(DefaultValidatorConfiguration.class).autowire(); + assertThat(requestWhenInvalidClientMetadataThenBadRequest(""" + { + "client_name": "client-name", + "redirect_uris": ["https://client.example.com"], + "grant_types": ["authorization_code"], + "jwks_uri": "http://169.254.169.254/keys", + "token_endpoint_auth_method": "private_key_jwt" + } + """)).isEqualTo(HttpStatus.BAD_REQUEST.value()); + } + + @Test + public void requestWhenArbitraryScopeThenBadRequest() throws Exception { + this.spring.register(DefaultValidatorConfiguration.class).autowire(); + assertThat(requestWhenInvalidClientMetadataThenBadRequest(""" + { + "client_name": "client-name", + "redirect_uris": ["https://client.example.com"], + "grant_types": ["client_credentials"], + "scope": "read write" + } + """)).isEqualTo(HttpStatus.BAD_REQUEST.value()); + } + + private int requestWhenInvalidClientMetadataThenBadRequest(String json) throws Exception { + String clientRegistrationScope = "client.create"; + // @formatter:off + RegisteredClient clientRegistrar = RegisteredClient.withId("client-registrar-" + System.nanoTime()) + .clientId("client-registrar-" + System.nanoTime()) + .clientSecret("{noop}secret") + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .scope(clientRegistrationScope) + .build(); + // @formatter:on + this.registeredClientRepository.save(clientRegistrar); + + MvcResult tokenResult = this.mvc + .perform(post(ISSUER.concat(DEFAULT_TOKEN_ENDPOINT_URI)) + .param(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.CLIENT_CREDENTIALS.getValue()) + .param(OAuth2ParameterNames.SCOPE, clientRegistrationScope) + .with(httpBasic(clientRegistrar.getClientId(), "secret"))) + .andExpect(status().isOk()) + .andReturn(); + OAuth2AccessToken accessToken = readAccessTokenResponse(tokenResult.getResponse()).getAccessToken(); + + HttpHeaders httpHeaders = new HttpHeaders(); + httpHeaders.setBearerAuth(accessToken.getTokenValue()); + + MvcResult registerResult = this.mvc + .perform(post(ISSUER.concat(DEFAULT_OAUTH2_CLIENT_REGISTRATION_ENDPOINT_URI)).headers(httpHeaders) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andReturn(); + return registerResult.getResponse().getStatus(); + } + private OAuth2ClientRegistration registerClient(OAuth2ClientRegistration clientRegistration) throws Exception { // ***** (1) Obtain the "initial" access token used for registering the client @@ -496,6 +593,17 @@ public class OAuth2ClientRegistrationTests { return clientRegistrationHttpMessageConverter.read(OAuth2ClientRegistration.class, httpResponse); } + private static Consumer> scopePermissiveValidatorCustomizer() { + return (authenticationProviders) -> authenticationProviders.forEach((authenticationProvider) -> { + if (authenticationProvider instanceof OAuth2ClientRegistrationAuthenticationProvider provider) { + provider.setAuthenticationValidator( + OAuth2ClientRegistrationAuthenticationValidator.DEFAULT_REDIRECT_URI_VALIDATOR + .andThen(OAuth2ClientRegistrationAuthenticationValidator.DEFAULT_JWK_SET_URI_VALIDATOR) + .andThen(OAuth2ClientRegistrationAuthenticationValidator.SIMPLE_SCOPE_VALIDATOR)); + } + }); + } + @EnableWebSecurity @Configuration(proxyBeanMethods = false) static class CustomClientRegistrationConfiguration extends AuthorizationServerConfiguration { @@ -512,7 +620,7 @@ public class OAuth2ClientRegistrationTests { .clientRegistrationRequestConverter(authenticationConverter) .clientRegistrationRequestConverters(authenticationConvertersConsumer) .authenticationProvider(authenticationProvider) - .authenticationProviders(authenticationProvidersConsumer) + .authenticationProviders(scopePermissiveValidatorCustomizer().andThen(authenticationProvidersConsumer)) .clientRegistrationResponseHandler(authenticationSuccessHandler) .errorResponseHandler(authenticationFailureHandler) ) @@ -539,7 +647,7 @@ public class OAuth2ClientRegistrationTests { authorizationServer .clientRegistrationEndpoint((clientRegistration) -> clientRegistration - .authenticationProviders(configureClientRegistrationConverters()) + .authenticationProviders(scopePermissiveValidatorCustomizer().andThen(configureClientRegistrationConverters())) ) ) .authorizeHttpRequests((authorize) -> @@ -577,7 +685,7 @@ public class OAuth2ClientRegistrationTests { authorizationServer .clientRegistrationEndpoint((clientRegistration) -> clientRegistration - .authenticationProviders(configureClientRegistrationConverters()) + .authenticationProviders(scopePermissiveValidatorCustomizer().andThen(configureClientRegistrationConverters())) ) ) .authorizeHttpRequests((authorize) -> @@ -614,6 +722,7 @@ public class OAuth2ClientRegistrationTests { .clientRegistrationEndpoint((clientRegistration) -> clientRegistration .openRegistrationAllowed(true) + .authenticationProviders(scopePermissiveValidatorCustomizer()) ) ) .authorizeHttpRequests((authorize) -> @@ -627,6 +736,30 @@ public class OAuth2ClientRegistrationTests { } + @EnableWebSecurity + @Configuration(proxyBeanMethods = false) + static class DefaultValidatorConfiguration extends AuthorizationServerConfiguration { + + // Override with Customizer.withDefaults() so the default (strict) + // OAuth2ClientRegistrationAuthenticationValidator is in effect. + // @formatter:off + @Bean + @Override + SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { + http + .oauth2AuthorizationServer((authorizationServer) -> + authorizationServer + .clientRegistrationEndpoint(Customizer.withDefaults()) + ) + .authorizeHttpRequests((authorize) -> + authorize.anyRequest().authenticated() + ); + return http.build(); + } + // @formatter:on + + } + @EnableWebSecurity @Configuration(proxyBeanMethods = false) static class AuthorizationServerConfiguration { @@ -637,7 +770,10 @@ public class OAuth2ClientRegistrationTests { http .oauth2AuthorizationServer((authorizationServer) -> authorizationServer - .clientRegistrationEndpoint(Customizer.withDefaults()) + .clientRegistrationEndpoint((clientRegistration) -> + clientRegistration + .authenticationProviders(scopePermissiveValidatorCustomizer()) + ) ) .authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated() diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OidcClientRegistrationTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OidcClientRegistrationTests.java index df1bd6da84..e0bf6a087f 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OidcClientRegistrationTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OidcClientRegistrationTests.java @@ -97,6 +97,7 @@ import org.springframework.security.oauth2.server.authorization.oidc.OidcClientR import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcClientConfigurationAuthenticationProvider; import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcClientRegistrationAuthenticationProvider; import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcClientRegistrationAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcClientRegistrationAuthenticationValidator; import org.springframework.security.oauth2.server.authorization.oidc.converter.OidcClientRegistrationRegisteredClientConverter; import org.springframework.security.oauth2.server.authorization.oidc.converter.RegisteredClientOidcClientRegistrationConverter; import org.springframework.security.oauth2.server.authorization.oidc.http.converter.OidcClientRegistrationHttpMessageConverter; @@ -545,6 +546,129 @@ public class OidcClientRegistrationTests { .isCloseTo(expectedSecretExpiryDate, allowedDelta); } + @Test + public void requestWhenProtocolRelativeRedirectUriThenBadRequest() throws Exception { + this.spring.register(DefaultValidatorConfiguration.class).autowire(); + assertThat(requestWhenInvalidClientMetadataThenBadRequest(""" + { + "client_name": "client-name", + "redirect_uris": ["//client.example.com/path"], + "grant_types": ["authorization_code"] + } + """)).isEqualTo(HttpStatus.BAD_REQUEST.value()); + } + + @Test + public void requestWhenJavascriptSchemeRedirectUriThenBadRequest() throws Exception { + this.spring.register(DefaultValidatorConfiguration.class).autowire(); + assertThat(requestWhenInvalidClientMetadataThenBadRequest(""" + { + "client_name": "client-name", + "redirect_uris": ["javascript:alert(document.cookie)"], + "grant_types": ["authorization_code"] + } + """)).isEqualTo(HttpStatus.BAD_REQUEST.value()); + } + + @Test + public void requestWhenDataSchemeRedirectUriThenBadRequest() throws Exception { + this.spring.register(DefaultValidatorConfiguration.class).autowire(); + assertThat(requestWhenInvalidClientMetadataThenBadRequest(""" + { + "client_name": "client-name", + "redirect_uris": ["data:text/html,

content

"], + "grant_types": ["authorization_code"] + } + """)).isEqualTo(HttpStatus.BAD_REQUEST.value()); + } + + @Test + public void requestWhenJavascriptSchemePostLogoutRedirectUriThenBadRequest() throws Exception { + this.spring.register(DefaultValidatorConfiguration.class).autowire(); + assertThat(requestWhenInvalidClientMetadataThenBadRequest(""" + { + "client_name": "client-name", + "redirect_uris": ["https://client.example.com"], + "post_logout_redirect_uris": ["javascript:alert(document.cookie)"], + "grant_types": ["authorization_code"] + } + """)).isEqualTo(HttpStatus.BAD_REQUEST.value()); + } + + @Test + public void requestWhenDataSchemePostLogoutRedirectUriThenBadRequest() throws Exception { + this.spring.register(DefaultValidatorConfiguration.class).autowire(); + assertThat(requestWhenInvalidClientMetadataThenBadRequest(""" + { + "client_name": "client-name", + "redirect_uris": ["https://client.example.com"], + "post_logout_redirect_uris": ["data:text/html,

content

"], + "grant_types": ["authorization_code"] + } + """)).isEqualTo(HttpStatus.BAD_REQUEST.value()); + } + + @Test + public void requestWhenHttpJwkSetUriThenBadRequest() throws Exception { + this.spring.register(DefaultValidatorConfiguration.class).autowire(); + assertThat(requestWhenInvalidClientMetadataThenBadRequest(""" + { + "client_name": "client-name", + "redirect_uris": ["https://client.example.com"], + "grant_types": ["authorization_code"], + "jwks_uri": "http://169.254.169.254/keys", + "token_endpoint_auth_method": "private_key_jwt" + } + """)).isEqualTo(HttpStatus.BAD_REQUEST.value()); + } + + @Test + public void requestWhenArbitraryScopeThenBadRequest() throws Exception { + this.spring.register(DefaultValidatorConfiguration.class).autowire(); + assertThat(requestWhenInvalidClientMetadataThenBadRequest(""" + { + "client_name": "client-name", + "redirect_uris": ["https://client.example.com"], + "grant_types": ["authorization_code"], + "scope": "read write" + } + """)).isEqualTo(HttpStatus.BAD_REQUEST.value()); + } + + private int requestWhenInvalidClientMetadataThenBadRequest(String json) throws Exception { + String clientRegistrationScope = "client.create"; + String clientId = "client-registrar-" + System.nanoTime(); + // @formatter:off + RegisteredClient clientRegistrar = RegisteredClient.withId(clientId) + .clientId(clientId) + .clientSecret("{noop}secret") + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .scope(clientRegistrationScope) + .build(); + // @formatter:on + this.registeredClientRepository.save(clientRegistrar); + + MvcResult tokenResult = this.mvc + .perform(post(ISSUER.concat(DEFAULT_TOKEN_ENDPOINT_URI)) + .param(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.CLIENT_CREDENTIALS.getValue()) + .param(OAuth2ParameterNames.SCOPE, clientRegistrationScope) + .with(httpBasic(clientId, "secret"))) + .andExpect(status().isOk()) + .andReturn(); + OAuth2AccessToken accessToken = readAccessTokenResponse(tokenResult.getResponse()).getAccessToken(); + + HttpHeaders httpHeaders = new HttpHeaders(); + httpHeaders.setBearerAuth(accessToken.getTokenValue()); + + MvcResult registerResult = this.mvc + .perform(post(ISSUER.concat(DEFAULT_OIDC_CLIENT_REGISTRATION_ENDPOINT_URI)).headers(httpHeaders) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andReturn(); + return registerResult.getResponse().getStatus(); + } + private OidcClientRegistration registerClient(OidcClientRegistration clientRegistration) throws Exception { // ***** (1) Obtain the "initial" access token used for registering the client @@ -642,6 +766,18 @@ public class OidcClientRegistrationTests { return clientRegistrationHttpMessageConverter.read(OidcClientRegistration.class, httpResponse); } + private static Consumer> scopePermissiveValidatorCustomizer() { + return (authenticationProviders) -> authenticationProviders.forEach((authenticationProvider) -> { + if (authenticationProvider instanceof OidcClientRegistrationAuthenticationProvider provider) { + provider.setAuthenticationValidator( + OidcClientRegistrationAuthenticationValidator.DEFAULT_REDIRECT_URI_VALIDATOR.andThen( + OidcClientRegistrationAuthenticationValidator.DEFAULT_POST_LOGOUT_REDIRECT_URI_VALIDATOR) + .andThen(OidcClientRegistrationAuthenticationValidator.DEFAULT_JWK_SET_URI_VALIDATOR) + .andThen(OidcClientRegistrationAuthenticationValidator.SIMPLE_SCOPE_VALIDATOR)); + } + }); + } + @EnableWebSecurity @Configuration(proxyBeanMethods = false) static class CustomClientRegistrationConfiguration extends AuthorizationServerConfiguration { @@ -660,7 +796,7 @@ public class OidcClientRegistrationTests { .clientRegistrationRequestConverter(authenticationConverter) .clientRegistrationRequestConverters(authenticationConvertersConsumer) .authenticationProvider(authenticationProvider) - .authenticationProviders(authenticationProvidersConsumer) + .authenticationProviders(scopePermissiveValidatorCustomizer().andThen(authenticationProvidersConsumer)) .clientRegistrationResponseHandler(authenticationSuccessHandler) .errorResponseHandler(authenticationFailureHandler) ) @@ -690,7 +826,7 @@ public class OidcClientRegistrationTests { oidc .clientRegistrationEndpoint((clientRegistration) -> clientRegistration - .authenticationProviders(configureClientRegistrationConverters()) + .authenticationProviders(scopePermissiveValidatorCustomizer().andThen(configureClientRegistrationConverters())) ) ) ) @@ -731,7 +867,7 @@ public class OidcClientRegistrationTests { oidc .clientRegistrationEndpoint((clientRegistration) -> clientRegistration - .authenticationProviders(configureClientRegistrationConverters()) + .authenticationProviders(scopePermissiveValidatorCustomizer().andThen(configureClientRegistrationConverters())) ) ) ) @@ -755,6 +891,33 @@ public class OidcClientRegistrationTests { } + @EnableWebSecurity + @Configuration(proxyBeanMethods = false) + static class DefaultValidatorConfiguration extends AuthorizationServerConfiguration { + + // Override with Customizer.withDefaults() so the default (strict) + // OidcClientRegistrationAuthenticationValidator is in effect. + // @formatter:off + @Bean + @Override + SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { + http + .oauth2AuthorizationServer((authorizationServer) -> + authorizationServer + .oidc((oidc) -> + oidc + .clientRegistrationEndpoint(Customizer.withDefaults()) + ) + ) + .authorizeHttpRequests((authorize) -> + authorize.anyRequest().authenticated() + ); + return http.build(); + } + // @formatter:on + + } + @EnableWebSecurity @Configuration(proxyBeanMethods = false) static class AuthorizationServerConfiguration { @@ -767,7 +930,10 @@ public class OidcClientRegistrationTests { authorizationServer .oidc((oidc) -> oidc - .clientRegistrationEndpoint(Customizer.withDefaults()) + .clientRegistrationEndpoint((clientRegistration) -> + clientRegistration + .authenticationProviders(scopePermissiveValidatorCustomizer()) + ) ) ) .authorizeHttpRequests((authorize) -> diff --git a/etc/nohttp/allowlist.lines b/etc/nohttp/allowlist.lines index 073132e28d..0b79593f9f 100644 --- a/etc/nohttp/allowlist.lines +++ b/etc/nohttp/allowlist.lines @@ -14,3 +14,5 @@ ^http://schemas.openid.net/event/backchannel-logout ^http://host.docker.internal:8090/back-channel/logout ^http://host.docker.internal:8090/logout +^http://169.254.169.254/keys + diff --git a/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientRegistrationAuthenticationContext.java b/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientRegistrationAuthenticationContext.java new file mode 100644 index 0000000000..a0e7dd63fa --- /dev/null +++ b/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientRegistrationAuthenticationContext.java @@ -0,0 +1,89 @@ +/* + * 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.oauth2.server.authorization.authentication; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Consumer; + +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * An {@link OAuth2AuthenticationContext} that holds an + * {@link OAuth2ClientRegistrationAuthenticationToken} and additional information and is + * used when validating the OAuth 2.0 Client Registration Request parameters. + * + * @author addcontent + * @since 7.0.5 + * @see OAuth2AuthenticationContext + * @see OAuth2ClientRegistrationAuthenticationToken + * @see OAuth2ClientRegistrationAuthenticationProvider#setAuthenticationValidator(Consumer) + */ +public final class OAuth2ClientRegistrationAuthenticationContext implements OAuth2AuthenticationContext { + + private final Map context; + + private OAuth2ClientRegistrationAuthenticationContext(Map context) { + this.context = Collections.unmodifiableMap(new HashMap<>(context)); + } + + @SuppressWarnings("unchecked") + @Nullable + @Override + public V get(Object key) { + return hasKey(key) ? (V) this.context.get(key) : null; + } + + @Override + public boolean hasKey(Object key) { + Assert.notNull(key, "key cannot be null"); + return this.context.containsKey(key); + } + + /** + * Constructs a new {@link Builder} with the provided + * {@link OAuth2ClientRegistrationAuthenticationToken}. + * @param authentication the {@link OAuth2ClientRegistrationAuthenticationToken} + * @return the {@link Builder} + */ + public static Builder with(OAuth2ClientRegistrationAuthenticationToken authentication) { + return new Builder(authentication); + } + + /** + * A builder for {@link OAuth2ClientRegistrationAuthenticationContext}. + */ + public static final class Builder extends AbstractBuilder { + + private Builder(OAuth2ClientRegistrationAuthenticationToken authentication) { + super(authentication); + } + + /** + * Builds a new {@link OAuth2ClientRegistrationAuthenticationContext}. + * @return the {@link OAuth2ClientRegistrationAuthenticationContext} + */ + @Override + public OAuth2ClientRegistrationAuthenticationContext build() { + return new OAuth2ClientRegistrationAuthenticationContext(getContext()); + } + + } + +} diff --git a/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientRegistrationAuthenticationProvider.java b/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientRegistrationAuthenticationProvider.java index 54a910ac0a..52f9fc2fbe 100644 --- a/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientRegistrationAuthenticationProvider.java +++ b/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientRegistrationAuthenticationProvider.java @@ -16,12 +16,10 @@ package org.springframework.security.oauth2.server.authorization.authentication; -import java.net.URI; -import java.net.URISyntaxException; import java.util.Collection; import java.util.Collections; -import java.util.List; import java.util.Set; +import java.util.function.Consumer; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -35,12 +33,10 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.core.ClientAuthenticationMethod; import org.springframework.security.oauth2.core.OAuth2AccessToken; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; -import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.OAuth2ErrorCodes; import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; -import org.springframework.security.oauth2.server.authorization.OAuth2ClientMetadataClaimNames; import org.springframework.security.oauth2.server.authorization.OAuth2ClientRegistration; import org.springframework.security.oauth2.server.authorization.OAuth2TokenType; import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; @@ -49,7 +45,6 @@ import org.springframework.security.oauth2.server.authorization.converter.OAuth2 import org.springframework.security.oauth2.server.authorization.converter.RegisteredClientOAuth2ClientRegistrationConverter; import org.springframework.security.oauth2.server.resource.authentication.AbstractOAuth2TokenAuthenticationToken; import org.springframework.util.Assert; -import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; /** @@ -67,8 +62,6 @@ import org.springframework.util.StringUtils; */ public final class OAuth2ClientRegistrationAuthenticationProvider implements AuthenticationProvider { - private static final String ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc7591#section-3.2.2"; - private static final String DEFAULT_CLIENT_REGISTRATION_AUTHORIZED_SCOPE = "client.create"; private final Log logger = LogFactory.getLog(getClass()); @@ -85,6 +78,8 @@ public final class OAuth2ClientRegistrationAuthenticationProvider implements Aut private boolean openRegistrationAllowed; + private Consumer authenticationValidator; + /** * Constructs an {@code OAuth2ClientRegistrationAuthenticationProvider} using the * provided parameters. @@ -99,6 +94,7 @@ public final class OAuth2ClientRegistrationAuthenticationProvider implements Aut this.clientRegistrationConverter = new RegisteredClientOAuth2ClientRegistrationConverter(); this.registeredClientConverter = new OAuth2ClientRegistrationRegisteredClientConverter(); this.passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder(); + this.authenticationValidator = new OAuth2ClientRegistrationAuthenticationValidator(); } @Override @@ -197,14 +193,35 @@ public final class OAuth2ClientRegistrationAuthenticationProvider implements Aut this.openRegistrationAllowed = openRegistrationAllowed; } + /** + * Sets the {@code Consumer} providing access to the + * {@link OAuth2ClientRegistrationAuthenticationContext} and is responsible for + * validating specific OAuth 2.0 Client Registration Request parameters associated in + * the {@link OAuth2ClientRegistrationAuthenticationToken}. The default authentication + * validator is {@link OAuth2ClientRegistrationAuthenticationValidator}. + * + *

+ * NOTE: The authentication validator MUST throw + * {@link OAuth2AuthenticationException} if validation fails. + * @param authenticationValidator the {@code Consumer} providing access to the + * {@link OAuth2ClientRegistrationAuthenticationContext} and is responsible for + * validating specific OAuth 2.0 Client Registration Request parameters + * @since 7.0.5 + */ + public void setAuthenticationValidator( + Consumer authenticationValidator) { + Assert.notNull(authenticationValidator, "authenticationValidator cannot be null"); + this.authenticationValidator = authenticationValidator; + } + private OAuth2ClientRegistrationAuthenticationToken registerClient( OAuth2ClientRegistrationAuthenticationToken clientRegistrationAuthentication, OAuth2Authorization authorization) { - if (!isValidRedirectUris(clientRegistrationAuthentication.getClientRegistration().getRedirectUris())) { - throwInvalidClientRegistration(OAuth2ErrorCodes.INVALID_REDIRECT_URI, - OAuth2ClientMetadataClaimNames.REDIRECT_URIS); - } + OAuth2ClientRegistrationAuthenticationContext authenticationContext = OAuth2ClientRegistrationAuthenticationContext + .with(clientRegistrationAuthentication) + .build(); + this.authenticationValidator.accept(authenticationContext); if (this.logger.isTraceEnabled()) { this.logger.trace("Validated client registration request parameters"); @@ -277,29 +294,4 @@ public final class OAuth2ClientRegistrationAuthenticationProvider implements Aut } } - private static boolean isValidRedirectUris(List redirectUris) { - if (CollectionUtils.isEmpty(redirectUris)) { - return true; - } - - for (String redirectUri : redirectUris) { - try { - URI validRedirectUri = new URI(redirectUri); - if (validRedirectUri.getFragment() != null) { - return false; - } - } - catch (URISyntaxException ex) { - return false; - } - } - - return true; - } - - private static void throwInvalidClientRegistration(String errorCode, String fieldName) { - OAuth2Error error = new OAuth2Error(errorCode, "Invalid Client Registration: " + fieldName, ERROR_URI); - throw new OAuth2AuthenticationException(error); - } - } diff --git a/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientRegistrationAuthenticationValidator.java b/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientRegistrationAuthenticationValidator.java new file mode 100644 index 0000000000..2fb62f469a --- /dev/null +++ b/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientRegistrationAuthenticationValidator.java @@ -0,0 +1,244 @@ +/* + * 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.oauth2.server.authorization.authentication; + +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.util.List; +import java.util.function.Consumer; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.core.log.LogMessage; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.server.authorization.OAuth2ClientMetadataClaimNames; +import org.springframework.security.oauth2.server.authorization.OAuth2ClientRegistration; +import org.springframework.util.CollectionUtils; + +/** + * A {@code Consumer} providing access to the + * {@link OAuth2ClientRegistrationAuthenticationContext} containing an + * {@link OAuth2ClientRegistrationAuthenticationToken} and is the default + * {@link OAuth2ClientRegistrationAuthenticationProvider#setAuthenticationValidator(Consumer) + * authentication validator} used for validating specific OAuth 2.0 Dynamic Client + * Registration Request parameters (RFC 7591). + * + *

+ * The default implementation validates {@link OAuth2ClientRegistration#getRedirectUris() + * redirect_uris}, {@link OAuth2ClientRegistration#getJwkSetUrl() jwks_uri}, and + * {@link OAuth2ClientRegistration#getScopes() scope}. If validation fails, an + * {@link OAuth2AuthenticationException} is thrown. + * + *

+ * Each validated field is backed by two public constants: + *

    + *
  • {@code DEFAULT_*_VALIDATOR} — strict validation that rejects unsafe values. This is + * the default behavior and may reject input that was previously accepted.
  • + *
  • {@code SIMPLE_*_VALIDATOR} — lenient validation preserving the behavior from prior + * releases. Use only when strictly required for backward compatibility and with full + * understanding that it may accept values that enable attacks against the authorization + * server.
  • + *
+ * + * @author addcontent + * @since 7.0.5 + * @see OAuth2ClientRegistrationAuthenticationContext + * @see OAuth2ClientRegistrationAuthenticationToken + * @see OAuth2ClientRegistrationAuthenticationProvider#setAuthenticationValidator(Consumer) + */ +public final class OAuth2ClientRegistrationAuthenticationValidator + implements Consumer { + + private static final String ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc7591#section-3.2.2"; + + private static final Log LOGGER = LogFactory.getLog(OAuth2ClientRegistrationAuthenticationValidator.class); + + /** + * The default validator for {@link OAuth2ClientRegistration#getRedirectUris() + * redirect_uris}. Rejects URIs that contain a fragment, have no scheme (e.g. + * protocol-relative {@code //host/path}), or use an unsafe scheme + * ({@code javascript}, {@code data}, {@code vbscript}). + */ + public static final Consumer DEFAULT_REDIRECT_URI_VALIDATOR = OAuth2ClientRegistrationAuthenticationValidator::validateRedirectUris; + + /** + * The simple validator for {@link OAuth2ClientRegistration#getRedirectUris() + * redirect_uris} that preserves prior behavior (fragment-only check). Use only when + * backward compatibility is required; values that enable open redirect and XSS + * attacks may be accepted. + */ + public static final Consumer SIMPLE_REDIRECT_URI_VALIDATOR = OAuth2ClientRegistrationAuthenticationValidator::validateRedirectUrisSimple; + + /** + * The default validator for {@link OAuth2ClientRegistration#getJwkSetUrl() jwks_uri}. + * Rejects URIs that do not use the {@code https} scheme. + */ + public static final Consumer DEFAULT_JWK_SET_URI_VALIDATOR = OAuth2ClientRegistrationAuthenticationValidator::validateJwkSetUri; + + /** + * The simple validator for {@link OAuth2ClientRegistration#getJwkSetUrl() jwks_uri} + * that preserves prior behavior (no validation). Use only when backward compatibility + * is required; values that enable SSRF attacks may be accepted. + */ + public static final Consumer SIMPLE_JWK_SET_URI_VALIDATOR = OAuth2ClientRegistrationAuthenticationValidator::validateJwkSetUriSimple; + + /** + * The default validator for {@link OAuth2ClientRegistration#getScopes() scope}. + * Rejects any request that includes a non-empty scope value. Deployers that need to + * accept scopes during Dynamic Client Registration must configure their own validator + * (for example, by chaining on top of {@link #SIMPLE_SCOPE_VALIDATOR}). + */ + public static final Consumer DEFAULT_SCOPE_VALIDATOR = OAuth2ClientRegistrationAuthenticationValidator::validateScope; + + /** + * The simple validator for {@link OAuth2ClientRegistration#getScopes() scope} that + * preserves prior behavior (accepts any scope). Use only when backward compatibility + * is required; values that enable arbitrary scope injection may be accepted. + */ + public static final Consumer SIMPLE_SCOPE_VALIDATOR = OAuth2ClientRegistrationAuthenticationValidator::validateScopeSimple; + + private final Consumer authenticationValidator = DEFAULT_REDIRECT_URI_VALIDATOR + .andThen(DEFAULT_JWK_SET_URI_VALIDATOR) + .andThen(DEFAULT_SCOPE_VALIDATOR); + + @Override + public void accept(OAuth2ClientRegistrationAuthenticationContext authenticationContext) { + this.authenticationValidator.accept(authenticationContext); + } + + private static void validateRedirectUris(OAuth2ClientRegistrationAuthenticationContext authenticationContext) { + OAuth2ClientRegistrationAuthenticationToken clientRegistrationAuthentication = authenticationContext + .getAuthentication(); + List redirectUris = clientRegistrationAuthentication.getClientRegistration().getRedirectUris(); + if (CollectionUtils.isEmpty(redirectUris)) { + return; + } + for (String redirectUri : redirectUris) { + URI parsed; + try { + parsed = new URI(redirectUri); + } + catch (URISyntaxException ex) { + if (LOGGER.isDebugEnabled()) { + LOGGER + .debug(LogMessage.format("Invalid request: redirect_uri is not parseable ('%s')", redirectUri)); + } + throwInvalidClientRegistration(OAuth2ErrorCodes.INVALID_REDIRECT_URI, + OAuth2ClientMetadataClaimNames.REDIRECT_URIS); + return; + } + if (parsed.getFragment() != null) { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug( + LogMessage.format("Invalid request: redirect_uri contains a fragment ('%s')", redirectUri)); + } + throwInvalidClientRegistration(OAuth2ErrorCodes.INVALID_REDIRECT_URI, + OAuth2ClientMetadataClaimNames.REDIRECT_URIS); + } + String scheme = parsed.getScheme(); + if (scheme == null) { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug(LogMessage.format("Invalid request: redirect_uri has no scheme ('%s')", redirectUri)); + } + throwInvalidClientRegistration(OAuth2ErrorCodes.INVALID_REDIRECT_URI, + OAuth2ClientMetadataClaimNames.REDIRECT_URIS); + } + if (isUnsafeScheme(scheme)) { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug( + LogMessage.format("Invalid request: redirect_uri uses unsafe scheme ('%s')", redirectUri)); + } + throwInvalidClientRegistration(OAuth2ErrorCodes.INVALID_REDIRECT_URI, + OAuth2ClientMetadataClaimNames.REDIRECT_URIS); + } + } + } + + private static void validateRedirectUrisSimple( + OAuth2ClientRegistrationAuthenticationContext authenticationContext) { + OAuth2ClientRegistrationAuthenticationToken clientRegistrationAuthentication = authenticationContext + .getAuthentication(); + List redirectUris = clientRegistrationAuthentication.getClientRegistration().getRedirectUris(); + if (CollectionUtils.isEmpty(redirectUris)) { + return; + } + for (String redirectUri : redirectUris) { + try { + URI parsed = new URI(redirectUri); + if (parsed.getFragment() != null) { + throwInvalidClientRegistration(OAuth2ErrorCodes.INVALID_REDIRECT_URI, + OAuth2ClientMetadataClaimNames.REDIRECT_URIS); + } + } + catch (URISyntaxException ex) { + throwInvalidClientRegistration(OAuth2ErrorCodes.INVALID_REDIRECT_URI, + OAuth2ClientMetadataClaimNames.REDIRECT_URIS); + } + } + } + + private static void validateJwkSetUri(OAuth2ClientRegistrationAuthenticationContext authenticationContext) { + OAuth2ClientRegistrationAuthenticationToken clientRegistrationAuthentication = authenticationContext + .getAuthentication(); + URL jwkSetUrl = clientRegistrationAuthentication.getClientRegistration().getJwkSetUrl(); + if (jwkSetUrl == null) { + return; + } + if (!"https".equalsIgnoreCase(jwkSetUrl.getProtocol())) { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug(LogMessage.format("Invalid request: jwks_uri does not use https ('%s')", jwkSetUrl)); + } + throwInvalidClientRegistration("invalid_client_metadata", OAuth2ClientMetadataClaimNames.JWKS_URI); + } + } + + private static void validateJwkSetUriSimple(OAuth2ClientRegistrationAuthenticationContext authenticationContext) { + // No validation. Preserves prior behavior. + } + + private static void validateScope(OAuth2ClientRegistrationAuthenticationContext authenticationContext) { + OAuth2ClientRegistrationAuthenticationToken clientRegistrationAuthentication = authenticationContext + .getAuthentication(); + List scopes = clientRegistrationAuthentication.getClientRegistration().getScopes(); + if (!CollectionUtils.isEmpty(scopes)) { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug(LogMessage.format( + "Invalid request: scope must not be set during Dynamic Client Registration ('%s')", scopes)); + } + throwInvalidClientRegistration(OAuth2ErrorCodes.INVALID_SCOPE, OAuth2ClientMetadataClaimNames.SCOPE); + } + } + + private static void validateScopeSimple(OAuth2ClientRegistrationAuthenticationContext authenticationContext) { + // No validation. Preserves prior behavior. + } + + private static boolean isUnsafeScheme(String scheme) { + return "javascript".equalsIgnoreCase(scheme) || "data".equalsIgnoreCase(scheme) + || "vbscript".equalsIgnoreCase(scheme); + } + + private static void throwInvalidClientRegistration(String errorCode, String fieldName) { + OAuth2Error error = new OAuth2Error(errorCode, "Invalid Client Registration: " + fieldName, ERROR_URI); + throw new OAuth2AuthenticationException(error); + } + +} diff --git a/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcClientRegistrationAuthenticationContext.java b/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcClientRegistrationAuthenticationContext.java new file mode 100644 index 0000000000..7027924025 --- /dev/null +++ b/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcClientRegistrationAuthenticationContext.java @@ -0,0 +1,90 @@ +/* + * 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.oauth2.server.authorization.oidc.authentication; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Consumer; + +import org.springframework.lang.Nullable; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthenticationContext; +import org.springframework.util.Assert; + +/** + * An {@link OAuth2AuthenticationContext} that holds an + * {@link OidcClientRegistrationAuthenticationToken} and additional information and is + * used when validating the OpenID Connect 1.0 Client Registration Request parameters. + * + * @author addcontent + * @since 7.0.5 + * @see OAuth2AuthenticationContext + * @see OidcClientRegistrationAuthenticationToken + * @see OidcClientRegistrationAuthenticationProvider#setAuthenticationValidator(Consumer) + */ +public final class OidcClientRegistrationAuthenticationContext implements OAuth2AuthenticationContext { + + private final Map context; + + private OidcClientRegistrationAuthenticationContext(Map context) { + this.context = Collections.unmodifiableMap(new HashMap<>(context)); + } + + @SuppressWarnings("unchecked") + @Nullable + @Override + public V get(Object key) { + return hasKey(key) ? (V) this.context.get(key) : null; + } + + @Override + public boolean hasKey(Object key) { + Assert.notNull(key, "key cannot be null"); + return this.context.containsKey(key); + } + + /** + * Constructs a new {@link Builder} with the provided + * {@link OidcClientRegistrationAuthenticationToken}. + * @param authentication the {@link OidcClientRegistrationAuthenticationToken} + * @return the {@link Builder} + */ + public static Builder with(OidcClientRegistrationAuthenticationToken authentication) { + return new Builder(authentication); + } + + /** + * A builder for {@link OidcClientRegistrationAuthenticationContext}. + */ + public static final class Builder extends AbstractBuilder { + + private Builder(OidcClientRegistrationAuthenticationToken authentication) { + super(authentication); + } + + /** + * Builds a new {@link OidcClientRegistrationAuthenticationContext}. + * @return the {@link OidcClientRegistrationAuthenticationContext} + */ + @Override + public OidcClientRegistrationAuthenticationContext build() { + return new OidcClientRegistrationAuthenticationContext(getContext()); + } + + } + +} diff --git a/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcClientRegistrationAuthenticationProvider.java b/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcClientRegistrationAuthenticationProvider.java index b402f306d7..d2506d43c0 100644 --- a/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcClientRegistrationAuthenticationProvider.java +++ b/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcClientRegistrationAuthenticationProvider.java @@ -16,14 +16,12 @@ package org.springframework.security.oauth2.server.authorization.oidc.authentication; -import java.net.URI; -import java.net.URISyntaxException; import java.util.Collection; import java.util.Collections; import java.util.HashSet; -import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.Consumer; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -60,7 +58,6 @@ import org.springframework.security.oauth2.server.authorization.token.OAuth2Toke import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator; import org.springframework.security.oauth2.server.resource.authentication.AbstractOAuth2TokenAuthenticationToken; import org.springframework.util.Assert; -import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; /** @@ -102,6 +99,8 @@ public final class OidcClientRegistrationAuthenticationProvider implements Authe private PasswordEncoder passwordEncoder; + private Consumer authenticationValidator; + /** * Constructs an {@code OidcClientRegistrationAuthenticationProvider} using the * provided parameters. @@ -121,6 +120,7 @@ public final class OidcClientRegistrationAuthenticationProvider implements Authe this.clientRegistrationConverter = new RegisteredClientOidcClientRegistrationConverter(); this.registeredClientConverter = new OidcClientRegistrationRegisteredClientConverter(); this.passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder(); + this.authenticationValidator = new OidcClientRegistrationAuthenticationValidator(); } @Override @@ -206,20 +206,35 @@ public final class OidcClientRegistrationAuthenticationProvider implements Authe this.passwordEncoder = passwordEncoder; } + /** + * Sets the {@code Consumer} providing access to the + * {@link OidcClientRegistrationAuthenticationContext} and is responsible for + * validating specific OpenID Connect 1.0 Client Registration Request parameters + * associated in the {@link OidcClientRegistrationAuthenticationToken}. The default + * authentication validator is {@link OidcClientRegistrationAuthenticationValidator}. + * + *

+ * NOTE: The authentication validator MUST throw + * {@link OAuth2AuthenticationException} if validation fails. + * @param authenticationValidator the {@code Consumer} providing access to the + * {@link OidcClientRegistrationAuthenticationContext} and is responsible for + * validating specific OpenID Connect 1.0 Client Registration Request parameters + * @since 7.0.5 + */ + public void setAuthenticationValidator( + Consumer authenticationValidator) { + Assert.notNull(authenticationValidator, "authenticationValidator cannot be null"); + this.authenticationValidator = authenticationValidator; + } + private OidcClientRegistrationAuthenticationToken registerClient( OidcClientRegistrationAuthenticationToken clientRegistrationAuthentication, OAuth2Authorization authorization) { - if (!isValidRedirectUris(clientRegistrationAuthentication.getClientRegistration().getRedirectUris())) { - throwInvalidClientRegistration(OAuth2ErrorCodes.INVALID_REDIRECT_URI, - OidcClientMetadataClaimNames.REDIRECT_URIS); - } - - if (!isValidRedirectUris( - clientRegistrationAuthentication.getClientRegistration().getPostLogoutRedirectUris())) { - throwInvalidClientRegistration("invalid_client_metadata", - OidcClientMetadataClaimNames.POST_LOGOUT_REDIRECT_URIS); - } + OidcClientRegistrationAuthenticationContext authenticationContext = OidcClientRegistrationAuthenticationContext + .with(clientRegistrationAuthentication) + .build(); + this.authenticationValidator.accept(authenticationContext); if (!isValidTokenEndpointAuthenticationMethod(clientRegistrationAuthentication.getClientRegistration())) { throwInvalidClientRegistration("invalid_client_metadata", @@ -351,26 +366,6 @@ public final class OidcClientRegistrationAuthenticationProvider implements Authe } } - private static boolean isValidRedirectUris(List redirectUris) { - if (CollectionUtils.isEmpty(redirectUris)) { - return true; - } - - for (String redirectUri : redirectUris) { - try { - URI validRedirectUri = new URI(redirectUri); - if (validRedirectUri.getFragment() != null) { - return false; - } - } - catch (URISyntaxException ex) { - return false; - } - } - - return true; - } - private static boolean isValidTokenEndpointAuthenticationMethod(OidcClientRegistration clientRegistration) { String authenticationMethod = clientRegistration.getTokenEndpointAuthenticationMethod(); String authenticationSigningAlgorithm = clientRegistration.getTokenEndpointAuthenticationSigningAlgorithm(); diff --git a/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcClientRegistrationAuthenticationValidator.java b/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcClientRegistrationAuthenticationValidator.java new file mode 100644 index 0000000000..b803481aea --- /dev/null +++ b/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcClientRegistrationAuthenticationValidator.java @@ -0,0 +1,285 @@ +/* + * 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.oauth2.server.authorization.oidc.authentication; + +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.util.List; +import java.util.function.Consumer; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.core.log.LogMessage; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.server.authorization.oidc.OidcClientMetadataClaimNames; +import org.springframework.security.oauth2.server.authorization.oidc.OidcClientRegistration; +import org.springframework.util.CollectionUtils; + +/** + * A {@code Consumer} providing access to the + * {@link OidcClientRegistrationAuthenticationContext} containing an + * {@link OidcClientRegistrationAuthenticationToken} and is the default + * {@link OidcClientRegistrationAuthenticationProvider#setAuthenticationValidator(Consumer) + * authentication validator} used for validating specific OpenID Connect 1.0 Dynamic + * Client Registration Request parameters. + * + *

+ * The default implementation validates {@link OidcClientRegistration#getRedirectUris() + * redirect_uris}, {@link OidcClientRegistration#getPostLogoutRedirectUris() + * post_logout_redirect_uris}, {@link OidcClientRegistration#getJwkSetUrl() jwks_uri}, and + * {@link OidcClientRegistration#getScopes() scope}. If validation fails, an + * {@link OAuth2AuthenticationException} is thrown. + * + *

+ * Each validated field is backed by two public constants: + *

    + *
  • {@code DEFAULT_*_VALIDATOR} — strict validation that rejects unsafe values. This is + * the default behavior and may reject input that was previously accepted.
  • + *
  • {@code SIMPLE_*_VALIDATOR} — lenient validation preserving the behavior from prior + * releases. Use only when strictly required for backward compatibility and with full + * understanding that it may accept values that enable attacks against the authorization + * server.
  • + *
+ * + * @author addcontent + * @since 7.0.5 + * @see OidcClientRegistrationAuthenticationContext + * @see OidcClientRegistrationAuthenticationToken + * @see OidcClientRegistrationAuthenticationProvider#setAuthenticationValidator(Consumer) + */ +public final class OidcClientRegistrationAuthenticationValidator + implements Consumer { + + private static final String ERROR_URI = "https://openid.net/specs/openid-connect-registration-1_0.html#RegistrationError"; + + private static final Log LOGGER = LogFactory.getLog(OidcClientRegistrationAuthenticationValidator.class); + + /** + * The default validator for {@link OidcClientRegistration#getRedirectUris() + * redirect_uris}. Rejects URIs that contain a fragment, have no scheme (e.g. + * protocol-relative {@code //host/path}), or use an unsafe scheme + * ({@code javascript}, {@code data}, {@code vbscript}). + */ + public static final Consumer DEFAULT_REDIRECT_URI_VALIDATOR = OidcClientRegistrationAuthenticationValidator::validateRedirectUris; + + /** + * The simple validator for {@link OidcClientRegistration#getRedirectUris() + * redirect_uris} that preserves prior behavior (fragment-only check). Use only when + * backward compatibility is required; values that enable open redirect and XSS + * attacks may be accepted. + */ + public static final Consumer SIMPLE_REDIRECT_URI_VALIDATOR = OidcClientRegistrationAuthenticationValidator::validateRedirectUrisSimple; + + /** + * The default validator for {@link OidcClientRegistration#getPostLogoutRedirectUris() + * post_logout_redirect_uris}. Applies the same rules as + * {@link #DEFAULT_REDIRECT_URI_VALIDATOR}. + */ + public static final Consumer DEFAULT_POST_LOGOUT_REDIRECT_URI_VALIDATOR = OidcClientRegistrationAuthenticationValidator::validatePostLogoutRedirectUris; + + /** + * The simple validator for {@link OidcClientRegistration#getPostLogoutRedirectUris() + * post_logout_redirect_uris} that preserves prior behavior (fragment-only check). Use + * only when backward compatibility is required; values that enable XSS attacks on the + * authorization server origin may be accepted. + */ + public static final Consumer SIMPLE_POST_LOGOUT_REDIRECT_URI_VALIDATOR = OidcClientRegistrationAuthenticationValidator::validatePostLogoutRedirectUrisSimple; + + /** + * The default validator for {@link OidcClientRegistration#getJwkSetUrl() jwks_uri}. + * Rejects URIs that do not use the {@code https} scheme. + */ + public static final Consumer DEFAULT_JWK_SET_URI_VALIDATOR = OidcClientRegistrationAuthenticationValidator::validateJwkSetUri; + + /** + * The simple validator for {@link OidcClientRegistration#getJwkSetUrl() jwks_uri} + * that preserves prior behavior (no validation). Use only when backward compatibility + * is required; values that enable SSRF attacks may be accepted. + */ + public static final Consumer SIMPLE_JWK_SET_URI_VALIDATOR = OidcClientRegistrationAuthenticationValidator::validateJwkSetUriSimple; + + /** + * The default validator for {@link OidcClientRegistration#getScopes() scope}. Rejects + * any request that includes a non-empty scope value. Deployers that need to accept + * scopes during Dynamic Client Registration must configure their own validator (for + * example, by chaining on top of {@link #SIMPLE_SCOPE_VALIDATOR}). + */ + public static final Consumer DEFAULT_SCOPE_VALIDATOR = OidcClientRegistrationAuthenticationValidator::validateScope; + + /** + * The simple validator for {@link OidcClientRegistration#getScopes() scope} that + * preserves prior behavior (accepts any scope). Use only when backward compatibility + * is required; values that enable arbitrary scope injection may be accepted. + */ + public static final Consumer SIMPLE_SCOPE_VALIDATOR = OidcClientRegistrationAuthenticationValidator::validateScopeSimple; + + private final Consumer authenticationValidator = DEFAULT_REDIRECT_URI_VALIDATOR + .andThen(DEFAULT_POST_LOGOUT_REDIRECT_URI_VALIDATOR) + .andThen(DEFAULT_JWK_SET_URI_VALIDATOR) + .andThen(DEFAULT_SCOPE_VALIDATOR); + + @Override + public void accept(OidcClientRegistrationAuthenticationContext authenticationContext) { + this.authenticationValidator.accept(authenticationContext); + } + + private static void validateRedirectUris(OidcClientRegistrationAuthenticationContext authenticationContext) { + OidcClientRegistrationAuthenticationToken clientRegistrationAuthentication = authenticationContext + .getAuthentication(); + List redirectUris = clientRegistrationAuthentication.getClientRegistration().getRedirectUris(); + validateRedirectUrisStrict(redirectUris, OAuth2ErrorCodes.INVALID_REDIRECT_URI, + OidcClientMetadataClaimNames.REDIRECT_URIS); + } + + private static void validatePostLogoutRedirectUris( + OidcClientRegistrationAuthenticationContext authenticationContext) { + OidcClientRegistrationAuthenticationToken clientRegistrationAuthentication = authenticationContext + .getAuthentication(); + List postLogoutRedirectUris = clientRegistrationAuthentication.getClientRegistration() + .getPostLogoutRedirectUris(); + validateRedirectUrisStrict(postLogoutRedirectUris, "invalid_client_metadata", + OidcClientMetadataClaimNames.POST_LOGOUT_REDIRECT_URIS); + } + + private static void validateRedirectUrisStrict(List redirectUris, String errorCode, String fieldName) { + if (CollectionUtils.isEmpty(redirectUris)) { + return; + } + for (String redirectUri : redirectUris) { + URI parsed; + try { + parsed = new URI(redirectUri); + } + catch (URISyntaxException ex) { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug( + LogMessage.format("Invalid request: %s is not parseable ('%s')", fieldName, redirectUri)); + } + throwInvalidClientRegistration(errorCode, fieldName); + return; + } + if (parsed.getFragment() != null) { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug(LogMessage.format("Invalid request: %s contains a fragment ('%s')", fieldName, + redirectUri)); + } + throwInvalidClientRegistration(errorCode, fieldName); + } + String scheme = parsed.getScheme(); + if (scheme == null) { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug(LogMessage.format("Invalid request: %s has no scheme ('%s')", fieldName, redirectUri)); + } + throwInvalidClientRegistration(errorCode, fieldName); + } + if (isUnsafeScheme(scheme)) { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug( + LogMessage.format("Invalid request: %s uses unsafe scheme ('%s')", fieldName, redirectUri)); + } + throwInvalidClientRegistration(errorCode, fieldName); + } + } + } + + private static void validateRedirectUrisSimple(OidcClientRegistrationAuthenticationContext authenticationContext) { + OidcClientRegistrationAuthenticationToken clientRegistrationAuthentication = authenticationContext + .getAuthentication(); + List redirectUris = clientRegistrationAuthentication.getClientRegistration().getRedirectUris(); + validateRedirectUrisFragmentOnly(redirectUris, OAuth2ErrorCodes.INVALID_REDIRECT_URI, + OidcClientMetadataClaimNames.REDIRECT_URIS); + } + + private static void validatePostLogoutRedirectUrisSimple( + OidcClientRegistrationAuthenticationContext authenticationContext) { + OidcClientRegistrationAuthenticationToken clientRegistrationAuthentication = authenticationContext + .getAuthentication(); + List postLogoutRedirectUris = clientRegistrationAuthentication.getClientRegistration() + .getPostLogoutRedirectUris(); + validateRedirectUrisFragmentOnly(postLogoutRedirectUris, "invalid_client_metadata", + OidcClientMetadataClaimNames.POST_LOGOUT_REDIRECT_URIS); + } + + private static void validateRedirectUrisFragmentOnly(List redirectUris, String errorCode, + String fieldName) { + if (CollectionUtils.isEmpty(redirectUris)) { + return; + } + for (String redirectUri : redirectUris) { + try { + URI parsed = new URI(redirectUri); + if (parsed.getFragment() != null) { + throwInvalidClientRegistration(errorCode, fieldName); + } + } + catch (URISyntaxException ex) { + throwInvalidClientRegistration(errorCode, fieldName); + } + } + } + + private static void validateJwkSetUri(OidcClientRegistrationAuthenticationContext authenticationContext) { + OidcClientRegistrationAuthenticationToken clientRegistrationAuthentication = authenticationContext + .getAuthentication(); + URL jwkSetUrl = clientRegistrationAuthentication.getClientRegistration().getJwkSetUrl(); + if (jwkSetUrl == null) { + return; + } + if (!"https".equalsIgnoreCase(jwkSetUrl.getProtocol())) { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug(LogMessage.format("Invalid request: jwks_uri does not use https ('%s')", jwkSetUrl)); + } + throwInvalidClientRegistration("invalid_client_metadata", OidcClientMetadataClaimNames.JWKS_URI); + } + } + + private static void validateJwkSetUriSimple(OidcClientRegistrationAuthenticationContext authenticationContext) { + // No validation. Preserves prior behavior. + } + + private static void validateScope(OidcClientRegistrationAuthenticationContext authenticationContext) { + OidcClientRegistrationAuthenticationToken clientRegistrationAuthentication = authenticationContext + .getAuthentication(); + List scopes = clientRegistrationAuthentication.getClientRegistration().getScopes(); + if (!CollectionUtils.isEmpty(scopes)) { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug(LogMessage.format( + "Invalid request: scope must not be set during Dynamic Client Registration ('%s')", scopes)); + } + throwInvalidClientRegistration(OAuth2ErrorCodes.INVALID_SCOPE, OidcClientMetadataClaimNames.SCOPE); + } + } + + private static void validateScopeSimple(OidcClientRegistrationAuthenticationContext authenticationContext) { + // No validation. Preserves prior behavior. + } + + private static boolean isUnsafeScheme(String scheme) { + return "javascript".equalsIgnoreCase(scheme) || "data".equalsIgnoreCase(scheme) + || "vbscript".equalsIgnoreCase(scheme); + } + + private static void throwInvalidClientRegistration(String errorCode, String fieldName) { + OAuth2Error error = new OAuth2Error(errorCode, "Invalid Client Registration: " + fieldName, ERROR_URI); + throw new OAuth2AuthenticationException(error); + } + +} diff --git a/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientRegistrationAuthenticationProviderTests.java b/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientRegistrationAuthenticationProviderTests.java index b4fdbf8954..d59420ab6a 100644 --- a/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientRegistrationAuthenticationProviderTests.java +++ b/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientRegistrationAuthenticationProviderTests.java @@ -360,6 +360,11 @@ public class OAuth2ClientRegistrationAuthenticationProviderTests { @Test public void authenticateWhenValidAccessTokenThenReturnClientRegistration() { + this.authenticationProvider + .setAuthenticationValidator(OAuth2ClientRegistrationAuthenticationValidator.DEFAULT_REDIRECT_URI_VALIDATOR + .andThen(OAuth2ClientRegistrationAuthenticationValidator.DEFAULT_JWK_SET_URI_VALIDATOR) + .andThen(OAuth2ClientRegistrationAuthenticationValidator.SIMPLE_SCOPE_VALIDATOR)); + Jwt jwt = createJwtClientRegistration(); OAuth2AccessToken jwtAccessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, jwt.getTokenValue(), jwt.getIssuedAt(), jwt.getExpiresAt(), jwt.getClaim(OAuth2ParameterNames.SCOPE)); @@ -412,6 +417,10 @@ public class OAuth2ClientRegistrationAuthenticationProviderTests { @Test public void authenticateWhenOpenRegistrationThenReturnClientRegistration() { this.authenticationProvider.setOpenRegistrationAllowed(true); + this.authenticationProvider + .setAuthenticationValidator(OAuth2ClientRegistrationAuthenticationValidator.DEFAULT_REDIRECT_URI_VALIDATOR + .andThen(OAuth2ClientRegistrationAuthenticationValidator.DEFAULT_JWK_SET_URI_VALIDATOR) + .andThen(OAuth2ClientRegistrationAuthenticationValidator.SIMPLE_SCOPE_VALIDATOR)); // @formatter:off OAuth2ClientRegistration clientRegistration = OAuth2ClientRegistration.builder() diff --git a/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientRegistrationAuthenticationValidatorTests.java b/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientRegistrationAuthenticationValidatorTests.java new file mode 100644 index 0000000000..80956094b7 --- /dev/null +++ b/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientRegistrationAuthenticationValidatorTests.java @@ -0,0 +1,197 @@ +/* + * 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.oauth2.server.authorization.authentication; + +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; + +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.server.authorization.OAuth2ClientMetadataClaimNames; +import org.springframework.security.oauth2.server.authorization.OAuth2ClientRegistration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatNoException; + +/** + * Tests for {@link OAuth2ClientRegistrationAuthenticationValidator}. + * + * @author addcontent + */ +public class OAuth2ClientRegistrationAuthenticationValidatorTests { + + private final OAuth2ClientRegistrationAuthenticationValidator validator = new OAuth2ClientRegistrationAuthenticationValidator(); + + @Test + public void defaultRedirectUriValidatorWhenProtocolRelativeThenRejected() { + assertRejected(context("//client.example.com/path", null), OAuth2ErrorCodes.INVALID_REDIRECT_URI, + OAuth2ClientMetadataClaimNames.REDIRECT_URIS); + } + + @Test + public void defaultRedirectUriValidatorWhenJavascriptSchemeThenRejected() { + assertRejected(context("javascript:alert(document.cookie)", null), OAuth2ErrorCodes.INVALID_REDIRECT_URI, + OAuth2ClientMetadataClaimNames.REDIRECT_URIS); + } + + @Test + public void defaultRedirectUriValidatorWhenDataSchemeThenRejected() { + assertRejected(context("data:text/html,

content

", null), OAuth2ErrorCodes.INVALID_REDIRECT_URI, + OAuth2ClientMetadataClaimNames.REDIRECT_URIS); + } + + @Test + public void defaultRedirectUriValidatorWhenVbscriptSchemeThenRejected() { + assertRejected(context("vbscript:msgbox(\"content\")", null), OAuth2ErrorCodes.INVALID_REDIRECT_URI, + OAuth2ClientMetadataClaimNames.REDIRECT_URIS); + } + + @Test + public void defaultRedirectUriValidatorWhenFragmentThenRejected() { + assertRejected(context("https://client.example.com/cb#fragment", null), OAuth2ErrorCodes.INVALID_REDIRECT_URI, + OAuth2ClientMetadataClaimNames.REDIRECT_URIS); + } + + @Test + public void defaultRedirectUriValidatorWhenHttpsThenAccepted() { + assertThatNoException().isThrownBy(() -> this.validator.accept(context("https://client.example.com", null))); + } + + @Test + public void defaultRedirectUriValidatorWhenCustomSchemeForNativeAppThenAccepted() { + assertThatNoException().isThrownBy(() -> this.validator.accept(context("myapp://callback", null))); + } + + @Test + public void defaultRedirectUriValidatorWhenHttpLoopbackThenAccepted() { + assertThatNoException().isThrownBy(() -> this.validator.accept(context("http://127.0.0.1:8080", null))); + } + + @Test + public void defaultJwkSetUriValidatorWhenHttpThenRejected() { + assertRejected(context("https://client.example.com", "http://169.254.169.254/keys"), "invalid_client_metadata", + OAuth2ClientMetadataClaimNames.JWKS_URI); + } + + @Test + public void defaultJwkSetUriValidatorWhenHttpsThenAccepted() { + assertThatNoException().isThrownBy( + () -> this.validator.accept(context("https://client.example.com", "https://client.example.com/jwks"))); + } + + @Test + public void defaultJwkSetUriValidatorWhenAbsentThenAccepted() { + assertThatNoException().isThrownBy(() -> this.validator.accept(context("https://client.example.com", null))); + } + + @Test + public void defaultScopeValidatorWhenNonEmptyThenRejected() { + OAuth2ClientRegistrationAuthenticationContext context = OAuth2ClientRegistrationAuthenticationContext + .with(new OAuth2ClientRegistrationAuthenticationToken(null, + OAuth2ClientRegistration.builder() + .redirectUri("https://client.example.com") + .scope("write") + .build())) + .build(); + assertThatExceptionOfType(OAuth2AuthenticationException.class).isThrownBy(() -> this.validator.accept(context)) + .extracting(OAuth2AuthenticationException::getError) + .satisfies((error) -> assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_SCOPE)); + } + + @Test + public void defaultScopeValidatorWhenEmptyThenAccepted() { + assertThatNoException().isThrownBy(() -> this.validator.accept(context("https://client.example.com", null))); + } + + @Test + public void simpleRedirectUriValidatorWhenProtocolRelativeThenAccepted() { + OAuth2ClientRegistrationAuthenticationContext context = context("//client.example.com/path", null); + assertThatNoException().isThrownBy( + () -> OAuth2ClientRegistrationAuthenticationValidator.SIMPLE_REDIRECT_URI_VALIDATOR.accept(context)); + } + + @Test + public void simpleRedirectUriValidatorWhenJavascriptThenAccepted() { + OAuth2ClientRegistrationAuthenticationContext context = context("javascript:alert(document.cookie)", null); + assertThatNoException().isThrownBy( + () -> OAuth2ClientRegistrationAuthenticationValidator.SIMPLE_REDIRECT_URI_VALIDATOR.accept(context)); + } + + @Test + public void simpleJwkSetUriValidatorWhenHttpThenAccepted() { + OAuth2ClientRegistrationAuthenticationContext context = context("https://client.example.com", + "http://169.254.169.254/keys"); + assertThatNoException().isThrownBy( + () -> OAuth2ClientRegistrationAuthenticationValidator.SIMPLE_JWK_SET_URI_VALIDATOR.accept(context)); + } + + @Test + public void simpleScopeValidatorWhenNonEmptyThenAccepted() { + OAuth2ClientRegistrationAuthenticationContext context = OAuth2ClientRegistrationAuthenticationContext + .with(new OAuth2ClientRegistrationAuthenticationToken(null, + OAuth2ClientRegistration.builder() + .redirectUri("https://client.example.com") + .scope("write") + .build())) + .build(); + assertThatNoException() + .isThrownBy(() -> OAuth2ClientRegistrationAuthenticationValidator.SIMPLE_SCOPE_VALIDATOR.accept(context)); + } + + @Test + public void composedValidatorWhenDefaultUrisAndSimpleScopeThenAcceptsLegitimateRequest() { + Consumer composed = OAuth2ClientRegistrationAuthenticationValidator.DEFAULT_REDIRECT_URI_VALIDATOR + .andThen(OAuth2ClientRegistrationAuthenticationValidator.DEFAULT_JWK_SET_URI_VALIDATOR) + .andThen(OAuth2ClientRegistrationAuthenticationValidator.SIMPLE_SCOPE_VALIDATOR); + OAuth2ClientRegistrationAuthenticationContext context = OAuth2ClientRegistrationAuthenticationContext + .with(new OAuth2ClientRegistrationAuthenticationToken(null, + OAuth2ClientRegistration.builder() + .redirectUri("https://client.example.com") + .jwkSetUrl("https://client.example.com/jwks") + .scope("openid") + .scope("profile") + .build())) + .build(); + assertThatNoException().isThrownBy(() -> composed.accept(context)); + } + + private static OAuth2ClientRegistrationAuthenticationContext context(String redirectUri, String jwkSetUrl) { + OAuth2ClientRegistration.Builder builder = OAuth2ClientRegistration.builder(); + if (redirectUri != null) { + builder.redirectUri(redirectUri); + } + if (jwkSetUrl != null) { + builder.jwkSetUrl(jwkSetUrl); + } + return OAuth2ClientRegistrationAuthenticationContext + .with(new OAuth2ClientRegistrationAuthenticationToken(null, builder.build())) + .build(); + } + + private void assertRejected(OAuth2ClientRegistrationAuthenticationContext context, String errorCode, + String fieldName) { + assertThatExceptionOfType(OAuth2AuthenticationException.class).isThrownBy(() -> this.validator.accept(context)) + .extracting(OAuth2AuthenticationException::getError) + .satisfies((error) -> { + assertThat(error.getErrorCode()).isEqualTo(errorCode); + assertThat(error.getDescription()).contains(fieldName); + }); + } + +} diff --git a/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcClientRegistrationAuthenticationProviderTests.java b/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcClientRegistrationAuthenticationProviderTests.java index 9e73093ce6..ab30c9bb8e 100644 --- a/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcClientRegistrationAuthenticationProviderTests.java +++ b/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcClientRegistrationAuthenticationProviderTests.java @@ -561,6 +561,11 @@ public class OidcClientRegistrationAuthenticationProviderTests { @Test public void authenticateWhenTokenEndpointAuthenticationSigningAlgorithmNotProvidedThenDefaults() { + this.authenticationProvider + .setAuthenticationValidator(OidcClientRegistrationAuthenticationValidator.DEFAULT_REDIRECT_URI_VALIDATOR + .andThen(OidcClientRegistrationAuthenticationValidator.DEFAULT_POST_LOGOUT_REDIRECT_URI_VALIDATOR) + .andThen(OidcClientRegistrationAuthenticationValidator.DEFAULT_JWK_SET_URI_VALIDATOR) + .andThen(OidcClientRegistrationAuthenticationValidator.SIMPLE_SCOPE_VALIDATOR)); Jwt jwt = createJwtClientRegistration(); OAuth2AccessToken jwtAccessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, jwt.getTokenValue(), jwt.getIssuedAt(), jwt.getExpiresAt(), jwt.getClaim(OAuth2ParameterNames.SCOPE)); @@ -612,6 +617,11 @@ public class OidcClientRegistrationAuthenticationProviderTests { @Test public void authenticateWhenRegistrationAccessTokenNotGeneratedThenThrowOAuth2AuthenticationException() { + this.authenticationProvider + .setAuthenticationValidator(OidcClientRegistrationAuthenticationValidator.DEFAULT_REDIRECT_URI_VALIDATOR + .andThen(OidcClientRegistrationAuthenticationValidator.DEFAULT_POST_LOGOUT_REDIRECT_URI_VALIDATOR) + .andThen(OidcClientRegistrationAuthenticationValidator.DEFAULT_JWK_SET_URI_VALIDATOR) + .andThen(OidcClientRegistrationAuthenticationValidator.SIMPLE_SCOPE_VALIDATOR)); Jwt jwt = createJwtClientRegistration(); OAuth2AccessToken jwtAccessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, jwt.getTokenValue(), jwt.getIssuedAt(), jwt.getExpiresAt(), jwt.getClaim(OAuth2ParameterNames.SCOPE)); @@ -653,6 +663,11 @@ public class OidcClientRegistrationAuthenticationProviderTests { @Test public void authenticateWhenValidAccessTokenThenReturnClientRegistration() { + this.authenticationProvider + .setAuthenticationValidator(OidcClientRegistrationAuthenticationValidator.DEFAULT_REDIRECT_URI_VALIDATOR + .andThen(OidcClientRegistrationAuthenticationValidator.DEFAULT_POST_LOGOUT_REDIRECT_URI_VALIDATOR) + .andThen(OidcClientRegistrationAuthenticationValidator.DEFAULT_JWK_SET_URI_VALIDATOR) + .andThen(OidcClientRegistrationAuthenticationValidator.SIMPLE_SCOPE_VALIDATOR)); Jwt jwt = createJwtClientRegistration(); OAuth2AccessToken jwtAccessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, jwt.getTokenValue(), jwt.getIssuedAt(), jwt.getExpiresAt(), jwt.getClaim(OAuth2ParameterNames.SCOPE)); diff --git a/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcClientRegistrationAuthenticationValidatorTests.java b/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcClientRegistrationAuthenticationValidatorTests.java new file mode 100644 index 0000000000..a8ebf50b28 --- /dev/null +++ b/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcClientRegistrationAuthenticationValidatorTests.java @@ -0,0 +1,188 @@ +/* + * 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.oauth2.server.authorization.oidc.authentication; + +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; + +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.server.authorization.oidc.OidcClientMetadataClaimNames; +import org.springframework.security.oauth2.server.authorization.oidc.OidcClientRegistration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatNoException; + +/** + * Tests for {@link OidcClientRegistrationAuthenticationValidator}. + * + * @author addcontent + */ +public class OidcClientRegistrationAuthenticationValidatorTests { + + private final OidcClientRegistrationAuthenticationValidator validator = new OidcClientRegistrationAuthenticationValidator(); + + @Test + public void defaultRedirectUriValidatorWhenProtocolRelativeThenRejected() { + assertRejected(context("//client.example.com/path", null, null), OAuth2ErrorCodes.INVALID_REDIRECT_URI, + OidcClientMetadataClaimNames.REDIRECT_URIS); + } + + @Test + public void defaultRedirectUriValidatorWhenJavascriptSchemeThenRejected() { + assertRejected(context("javascript:alert(document.cookie)", null, null), OAuth2ErrorCodes.INVALID_REDIRECT_URI, + OidcClientMetadataClaimNames.REDIRECT_URIS); + } + + @Test + public void defaultRedirectUriValidatorWhenHttpsThenAccepted() { + assertThatNoException() + .isThrownBy(() -> this.validator.accept(context("https://client.example.com", null, null))); + } + + @Test + public void defaultPostLogoutRedirectUriValidatorWhenJavascriptSchemeThenRejected() { + assertRejected(context("https://client.example.com", "javascript:alert(document.cookie)", null), + "invalid_client_metadata", OidcClientMetadataClaimNames.POST_LOGOUT_REDIRECT_URIS); + } + + @Test + public void defaultPostLogoutRedirectUriValidatorWhenProtocolRelativeThenRejected() { + assertRejected(context("https://client.example.com", "//client.example.com/post-logout", null), + "invalid_client_metadata", OidcClientMetadataClaimNames.POST_LOGOUT_REDIRECT_URIS); + } + + @Test + public void defaultPostLogoutRedirectUriValidatorWhenHttpsThenAccepted() { + assertThatNoException().isThrownBy(() -> this.validator + .accept(context("https://client.example.com", "https://client.example.com/post-logout", null))); + } + + @Test + public void defaultJwkSetUriValidatorWhenHttpThenRejected() { + assertRejected(context("https://client.example.com", null, "http://169.254.169.254/keys"), + "invalid_client_metadata", OidcClientMetadataClaimNames.JWKS_URI); + } + + @Test + public void defaultJwkSetUriValidatorWhenHttpsThenAccepted() { + assertThatNoException().isThrownBy(() -> this.validator + .accept(context("https://client.example.com", null, "https://client.example.com/jwks"))); + } + + @Test + public void defaultScopeValidatorWhenNonEmptyThenRejected() { + OidcClientRegistrationAuthenticationContext context = OidcClientRegistrationAuthenticationContext + .with(new OidcClientRegistrationAuthenticationToken(principal(), + OidcClientRegistration.builder().redirectUri("https://client.example.com").scope("write").build())) + .build(); + assertThatExceptionOfType(OAuth2AuthenticationException.class).isThrownBy(() -> this.validator.accept(context)) + .extracting(OAuth2AuthenticationException::getError) + .satisfies((error) -> assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_SCOPE)); + } + + @Test + public void simpleRedirectUriValidatorWhenJavascriptThenAccepted() { + OidcClientRegistrationAuthenticationContext context = context("javascript:alert(document.cookie)", null, null); + assertThatNoException().isThrownBy( + () -> OidcClientRegistrationAuthenticationValidator.SIMPLE_REDIRECT_URI_VALIDATOR.accept(context)); + } + + @Test + public void simplePostLogoutRedirectUriValidatorWhenJavascriptThenAccepted() { + OidcClientRegistrationAuthenticationContext context = context("https://client.example.com", + "javascript:alert(document.cookie)", null); + assertThatNoException() + .isThrownBy(() -> OidcClientRegistrationAuthenticationValidator.SIMPLE_POST_LOGOUT_REDIRECT_URI_VALIDATOR + .accept(context)); + } + + @Test + public void simpleJwkSetUriValidatorWhenHttpThenAccepted() { + OidcClientRegistrationAuthenticationContext ctx = context("https://client.example.com", null, + "http://169.254.169.254/keys"); + assertThatNoException() + .isThrownBy(() -> OidcClientRegistrationAuthenticationValidator.SIMPLE_JWK_SET_URI_VALIDATOR.accept(ctx)); + } + + @Test + public void simpleScopeValidatorWhenNonEmptyThenAccepted() { + OidcClientRegistrationAuthenticationContext context = OidcClientRegistrationAuthenticationContext + .with(new OidcClientRegistrationAuthenticationToken(principal(), + OidcClientRegistration.builder().redirectUri("https://client.example.com").scope("write").build())) + .build(); + assertThatNoException() + .isThrownBy(() -> OidcClientRegistrationAuthenticationValidator.SIMPLE_SCOPE_VALIDATOR.accept(context)); + } + + @Test + public void composedValidatorWhenDefaultUrisAndSimpleScopeThenAcceptsLegitimateRequest() { + Consumer composed = OidcClientRegistrationAuthenticationValidator.DEFAULT_REDIRECT_URI_VALIDATOR + .andThen(OidcClientRegistrationAuthenticationValidator.DEFAULT_POST_LOGOUT_REDIRECT_URI_VALIDATOR) + .andThen(OidcClientRegistrationAuthenticationValidator.DEFAULT_JWK_SET_URI_VALIDATOR) + .andThen(OidcClientRegistrationAuthenticationValidator.SIMPLE_SCOPE_VALIDATOR); + OidcClientRegistrationAuthenticationContext context = OidcClientRegistrationAuthenticationContext + .with(new OidcClientRegistrationAuthenticationToken(principal(), + OidcClientRegistration.builder() + .redirectUri("https://client.example.com") + .postLogoutRedirectUri("https://client.example.com/post-logout") + .jwkSetUrl("https://client.example.com/jwks") + .scope("openid") + .scope("profile") + .build())) + .build(); + assertThatNoException().isThrownBy(() -> composed.accept(context)); + } + + private static Authentication principal() { + TestingAuthenticationToken principal = new TestingAuthenticationToken("user", "password"); + principal.setAuthenticated(true); + return principal; + } + + private static OidcClientRegistrationAuthenticationContext context(String redirectUri, String postLogoutRedirectUri, + String jwkSetUrl) { + OidcClientRegistration.Builder builder = OidcClientRegistration.builder(); + if (redirectUri != null) { + builder.redirectUri(redirectUri); + } + if (postLogoutRedirectUri != null) { + builder.postLogoutRedirectUri(postLogoutRedirectUri); + } + if (jwkSetUrl != null) { + builder.jwkSetUrl(jwkSetUrl); + } + return OidcClientRegistrationAuthenticationContext + .with(new OidcClientRegistrationAuthenticationToken(principal(), builder.build())) + .build(); + } + + private void assertRejected(OidcClientRegistrationAuthenticationContext context, String errorCode, + String fieldName) { + assertThatExceptionOfType(OAuth2AuthenticationException.class).isThrownBy(() -> this.validator.accept(context)) + .extracting(OAuth2AuthenticationException::getError) + .satisfies((error) -> { + assertThat(error.getErrorCode()).isEqualTo(errorCode); + assertThat(error.getDescription()).contains(fieldName); + }); + } + +}