Add authentication validator for dynamic client registration

Signed-off-by: Kelvin Mbogo <addcontent08@gmail.com>
This commit is contained in:
addcontent 2026-04-16 00:58:19 +03:00 committed by Joe Grandja
parent 4a6e0a13cd
commit 19b3cae62e
13 changed files with 1487 additions and 79 deletions

View File

@ -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,<h1>content</h1>"],
"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<List<AuthenticationProvider>> 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()

View File

@ -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,<h1>content</h1>"],
"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,<h1>content</h1>"],
"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<List<AuthenticationProvider>> 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) ->

View File

@ -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

View File

@ -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<Object, Object> context;
private OAuth2ClientRegistrationAuthenticationContext(Map<Object, Object> context) {
this.context = Collections.unmodifiableMap(new HashMap<>(context));
}
@SuppressWarnings("unchecked")
@Nullable
@Override
public <V> 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<OAuth2ClientRegistrationAuthenticationContext, Builder> {
private Builder(OAuth2ClientRegistrationAuthenticationToken authentication) {
super(authentication);
}
/**
* Builds a new {@link OAuth2ClientRegistrationAuthenticationContext}.
* @return the {@link OAuth2ClientRegistrationAuthenticationContext}
*/
@Override
public OAuth2ClientRegistrationAuthenticationContext build() {
return new OAuth2ClientRegistrationAuthenticationContext(getContext());
}
}
}

View File

@ -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<OAuth2ClientRegistrationAuthenticationContext> 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}.
*
* <p>
* <b>NOTE:</b> 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<OAuth2ClientRegistrationAuthenticationContext> 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<String> 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);
}
}

View File

@ -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).
*
* <p>
* 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.
*
* <p>
* Each validated field is backed by two public constants:
* <ul>
* <li>{@code DEFAULT_*_VALIDATOR} strict validation that rejects unsafe values. This is
* the default behavior and may reject input that was previously accepted.</li>
* <li>{@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.</li>
* </ul>
*
* @author addcontent
* @since 7.0.5
* @see OAuth2ClientRegistrationAuthenticationContext
* @see OAuth2ClientRegistrationAuthenticationToken
* @see OAuth2ClientRegistrationAuthenticationProvider#setAuthenticationValidator(Consumer)
*/
public final class OAuth2ClientRegistrationAuthenticationValidator
implements Consumer<OAuth2ClientRegistrationAuthenticationContext> {
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<OAuth2ClientRegistrationAuthenticationContext> 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<OAuth2ClientRegistrationAuthenticationContext> 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<OAuth2ClientRegistrationAuthenticationContext> 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<OAuth2ClientRegistrationAuthenticationContext> 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<OAuth2ClientRegistrationAuthenticationContext> 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<OAuth2ClientRegistrationAuthenticationContext> SIMPLE_SCOPE_VALIDATOR = OAuth2ClientRegistrationAuthenticationValidator::validateScopeSimple;
private final Consumer<OAuth2ClientRegistrationAuthenticationContext> 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<String> 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<String> 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<String> 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);
}
}

View File

@ -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<Object, Object> context;
private OidcClientRegistrationAuthenticationContext(Map<Object, Object> context) {
this.context = Collections.unmodifiableMap(new HashMap<>(context));
}
@SuppressWarnings("unchecked")
@Nullable
@Override
public <V> 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<OidcClientRegistrationAuthenticationContext, Builder> {
private Builder(OidcClientRegistrationAuthenticationToken authentication) {
super(authentication);
}
/**
* Builds a new {@link OidcClientRegistrationAuthenticationContext}.
* @return the {@link OidcClientRegistrationAuthenticationContext}
*/
@Override
public OidcClientRegistrationAuthenticationContext build() {
return new OidcClientRegistrationAuthenticationContext(getContext());
}
}
}

View File

@ -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<OidcClientRegistrationAuthenticationContext> 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}.
*
* <p>
* <b>NOTE:</b> 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<OidcClientRegistrationAuthenticationContext> 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<String> 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();

View File

@ -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.
*
* <p>
* 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.
*
* <p>
* Each validated field is backed by two public constants:
* <ul>
* <li>{@code DEFAULT_*_VALIDATOR} strict validation that rejects unsafe values. This is
* the default behavior and may reject input that was previously accepted.</li>
* <li>{@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.</li>
* </ul>
*
* @author addcontent
* @since 7.0.5
* @see OidcClientRegistrationAuthenticationContext
* @see OidcClientRegistrationAuthenticationToken
* @see OidcClientRegistrationAuthenticationProvider#setAuthenticationValidator(Consumer)
*/
public final class OidcClientRegistrationAuthenticationValidator
implements Consumer<OidcClientRegistrationAuthenticationContext> {
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<OidcClientRegistrationAuthenticationContext> 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<OidcClientRegistrationAuthenticationContext> 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<OidcClientRegistrationAuthenticationContext> 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<OidcClientRegistrationAuthenticationContext> 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<OidcClientRegistrationAuthenticationContext> 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<OidcClientRegistrationAuthenticationContext> 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<OidcClientRegistrationAuthenticationContext> 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<OidcClientRegistrationAuthenticationContext> SIMPLE_SCOPE_VALIDATOR = OidcClientRegistrationAuthenticationValidator::validateScopeSimple;
private final Consumer<OidcClientRegistrationAuthenticationContext> 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<String> redirectUris = clientRegistrationAuthentication.getClientRegistration().getRedirectUris();
validateRedirectUrisStrict(redirectUris, OAuth2ErrorCodes.INVALID_REDIRECT_URI,
OidcClientMetadataClaimNames.REDIRECT_URIS);
}
private static void validatePostLogoutRedirectUris(
OidcClientRegistrationAuthenticationContext authenticationContext) {
OidcClientRegistrationAuthenticationToken clientRegistrationAuthentication = authenticationContext
.getAuthentication();
List<String> postLogoutRedirectUris = clientRegistrationAuthentication.getClientRegistration()
.getPostLogoutRedirectUris();
validateRedirectUrisStrict(postLogoutRedirectUris, "invalid_client_metadata",
OidcClientMetadataClaimNames.POST_LOGOUT_REDIRECT_URIS);
}
private static void validateRedirectUrisStrict(List<String> 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<String> redirectUris = clientRegistrationAuthentication.getClientRegistration().getRedirectUris();
validateRedirectUrisFragmentOnly(redirectUris, OAuth2ErrorCodes.INVALID_REDIRECT_URI,
OidcClientMetadataClaimNames.REDIRECT_URIS);
}
private static void validatePostLogoutRedirectUrisSimple(
OidcClientRegistrationAuthenticationContext authenticationContext) {
OidcClientRegistrationAuthenticationToken clientRegistrationAuthentication = authenticationContext
.getAuthentication();
List<String> postLogoutRedirectUris = clientRegistrationAuthentication.getClientRegistration()
.getPostLogoutRedirectUris();
validateRedirectUrisFragmentOnly(postLogoutRedirectUris, "invalid_client_metadata",
OidcClientMetadataClaimNames.POST_LOGOUT_REDIRECT_URIS);
}
private static void validateRedirectUrisFragmentOnly(List<String> 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<String> 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);
}
}

View File

@ -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()

View File

@ -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,<h1>content</h1>", 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<OAuth2ClientRegistrationAuthenticationContext> 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);
});
}
}

View File

@ -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));

View File

@ -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<OidcClientRegistrationAuthenticationContext> 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);
});
}
}