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); + }); + } + +}