Selaa lähdekoodia

Implement OpenID client registration endpoint

See: https://openid.net/specs/openid-connect-registration-1_0.html#ClientRegistration

Closes gh-57
Ovidiu Popa 4 vuotta sitten
vanhempi
commit
8224a0d971
25 muutettua tiedostoa jossa 2476 lisäystä ja 36 poistoa
  1. 2 0
      oauth2-authorization-server/spring-security-oauth2-authorization-server.gradle
  2. 8 5
      oauth2-authorization-server/src/main/java/org/springframework/security/config/annotation/web/configuration/OAuth2AuthorizationServerConfiguration.java
  3. 40 3
      oauth2-authorization-server/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2AuthorizationServerConfigurer.java
  4. 130 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/oidc/OidcClientMetadataClaimAccessor.java
  5. 79 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/oidc/OidcClientMetadataClaimNames.java
  6. 349 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/oidc/OidcClientRegistration.java
  7. 157 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/oidc/http/converter/OidcClientRegistrationHttpMessageConverter.java
  8. 86 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OidcClientRegistrationAuthenticationProvider.java
  9. 17 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/client/InMemoryRegisteredClientRepository.java
  10. 7 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/client/RegisteredClientRepository.java
  11. 44 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/ProviderSettings.java
  12. 194 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcClientRegistrationEndpointFilter.java
  13. 0 1
      oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/JwkSetTests.java
  14. 6 7
      oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2ClientCredentialsGrantTests.java
  15. 11 12
      oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2RefreshTokenGrantTests.java
  16. 5 6
      oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2TokenRevocationTests.java
  17. 241 0
      oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OidcClientRegistrationTests.java
  18. 6 0
      oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OidcTests.java
  19. 331 0
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/core/oidc/OidcClientRegistrationTests.java
  20. 197 0
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/core/oidc/http/converter/OidcClientRegistrationHttpMessageConverterTest.java
  21. 173 0
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OidcClientRegistrationAuthenticationProviderTests.java
  22. 75 0
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/client/InMemoryRegisteredClientRepositoryTests.java
  23. 19 2
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/ProviderSettingsTests.java
  24. 286 0
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcClientRegistrationEndpointFilterTests.java
  25. 13 0
      samples/boot/oauth2-integration/authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java

+ 2 - 0
oauth2-authorization-server/spring-security-oauth2-authorization-server.gradle

@@ -5,6 +5,7 @@ dependencies {
 	compile 'org.springframework.security:spring-security-web'
 	compile 'org.springframework.security:spring-security-oauth2-core'
 	compile 'org.springframework.security:spring-security-oauth2-jose'
+	compile 'org.springframework.security:spring-security-oauth2-resource-server'
 	compile springCoreDependency
 	compile 'com.nimbusds:nimbus-jose-jwt'
 	compile 'com.fasterxml.jackson.core:jackson-databind'
@@ -15,6 +16,7 @@ dependencies {
 	testCompile 'org.assertj:assertj-core'
 	testCompile 'org.mockito:mockito-core'
 	testCompile 'com.jayway.jsonpath:json-path'
+	testCompile 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
 
 	provided 'javax.servlet:javax.servlet-api'
 }

+ 8 - 5
oauth2-authorization-server/src/main/java/org/springframework/security/config/annotation/web/configuration/OAuth2AuthorizationServerConfiguration.java

@@ -21,6 +21,7 @@ import org.springframework.core.Ordered;
 import org.springframework.core.annotation.Order;
 import org.springframework.security.config.annotation.web.builders.HttpSecurity;
 import org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization.OAuth2AuthorizationServerConfigurer;
+import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer;
 import org.springframework.security.web.SecurityFilterChain;
 import org.springframework.security.web.util.matcher.RequestMatcher;
 
@@ -28,8 +29,8 @@ import org.springframework.security.web.util.matcher.RequestMatcher;
  * {@link Configuration} for OAuth 2.0 Authorization Server support.
  *
  * @author Joe Grandja
- * @since 0.0.1
  * @see OAuth2AuthorizationServerConfigurer
+ * @since 0.0.1
  */
 @Configuration(proxyBeanMethods = false)
 public class OAuth2AuthorizationServerConfiguration {
@@ -47,14 +48,16 @@ public class OAuth2AuthorizationServerConfiguration {
 				new OAuth2AuthorizationServerConfigurer<>();
 		RequestMatcher endpointsMatcher = authorizationServerConfigurer
 				.getEndpointsMatcher();
-
 		http
 			.requestMatcher(endpointsMatcher)
 			.authorizeRequests(authorizeRequests ->
-				authorizeRequests.anyRequest().authenticated()
-			)
-			.csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher))
+					authorizeRequests.anyRequest().authenticated()
+			).csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher))
 			.apply(authorizationServerConfigurer);
+
+		if (authorizationServerConfigurer.isOidcClientRegistrationEnabled()) {
+			http.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt);
+		}
 	}
 	// @formatter:on
 }

+ 40 - 3
oauth2-authorization-server/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2AuthorizationServerConfigurer.java

@@ -44,8 +44,10 @@ import org.springframework.security.oauth2.server.authorization.authentication.O
 import org.springframework.security.oauth2.server.authorization.authentication.OAuth2RefreshTokenAuthenticationProvider;
 import org.springframework.security.oauth2.server.authorization.authentication.OAuth2TokenIntrospectionAuthenticationProvider;
 import org.springframework.security.oauth2.server.authorization.authentication.OAuth2TokenRevocationAuthenticationProvider;
+import org.springframework.security.oauth2.server.authorization.authentication.OidcClientRegistrationAuthenticationProvider;
 import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
 import org.springframework.security.oauth2.server.authorization.config.ProviderSettings;
+import org.springframework.security.oauth2.server.authorization.oidc.web.OidcClientRegistrationEndpointFilter;
 import org.springframework.security.oauth2.server.authorization.oidc.web.OidcProviderConfigurationEndpointFilter;
 import org.springframework.security.oauth2.server.authorization.web.NimbusJwkSetEndpointFilter;
 import org.springframework.security.oauth2.server.authorization.web.OAuth2AuthorizationEndpointFilter;
@@ -69,6 +71,7 @@ import org.springframework.util.StringUtils;
  * @author Joe Grandja
  * @author Daniel Garnier-Moiroux
  * @author Gerardo Roza
+ * @author Ovidiu Popa
  * @since 0.0.1
  * @see AbstractHttpConfigurer
  * @see RegisteredClientRepository
@@ -81,6 +84,7 @@ import org.springframework.util.StringUtils;
  * @see OidcProviderConfigurationEndpointFilter
  * @see OAuth2AuthorizationServerMetadataEndpointFilter
  * @see OAuth2ClientAuthenticationFilter
+ * @see OidcClientRegistrationEndpointFilter
  */
 public final class OAuth2AuthorizationServerConfigurer<B extends HttpSecurityBuilder<B>>
 		extends AbstractHttpConfigurer<OAuth2AuthorizationServerConfigurer<B>, B> {
@@ -92,6 +96,7 @@ public final class OAuth2AuthorizationServerConfigurer<B extends HttpSecurityBui
 	private RequestMatcher jwkSetEndpointMatcher;
 	private RequestMatcher oidcProviderConfigurationEndpointMatcher;
 	private RequestMatcher authorizationServerMetadataEndpointMatcher;
+	private RequestMatcher oidcClientRegistrationEndpointMatcher;
 	private final RequestMatcher endpointsMatcher = (request) ->
 			this.authorizationEndpointMatcher.matches(request) ||
 			this.tokenEndpointMatcher.matches(request) ||
@@ -99,7 +104,8 @@ public final class OAuth2AuthorizationServerConfigurer<B extends HttpSecurityBui
 			this.tokenRevocationEndpointMatcher.matches(request) ||
 			this.jwkSetEndpointMatcher.matches(request) ||
 			this.oidcProviderConfigurationEndpointMatcher.matches(request) ||
-			this.authorizationServerMetadataEndpointMatcher.matches(request);
+			this.authorizationServerMetadataEndpointMatcher.matches(request) ||
+			this.oidcClientRegistrationEndpointMatcher.matches(request);
 
 	/**
 	 * Sets the repository of registered clients.
@@ -146,6 +152,17 @@ public final class OAuth2AuthorizationServerConfigurer<B extends HttpSecurityBui
 		return this.endpointsMatcher;
 	}
 
+	/**
+	 * Returns {@code true} if the OIDC Client Registration endpoint is enabled.
+	 * The default is {@code false}.
+	 *
+	 * @return {@code true} if the OIDC Client Registration endpoint is enabled, {@code false} otherwise
+	 */
+	public boolean isOidcClientRegistrationEnabled() {
+		ProviderSettings providerSettings = getProviderSettings(this.getBuilder());
+		return providerSettings.isOidClientRegistrationEndpointEnabled();
+	}
+
 	@Override
 	public void init(B builder) {
 		ProviderSettings providerSettings = getProviderSettings(builder);
@@ -199,6 +216,11 @@ public final class OAuth2AuthorizationServerConfigurer<B extends HttpSecurityBui
 						getAuthorizationService(builder));
 		builder.authenticationProvider(postProcess(tokenRevocationAuthenticationProvider));
 
+		OidcClientRegistrationAuthenticationProvider clientRegistrationAuthenticationProvider =
+				new OidcClientRegistrationAuthenticationProvider(
+						getAuthorizationService(builder));
+		builder.authenticationProvider(postProcess(clientRegistrationAuthenticationProvider));
+
 		ExceptionHandlingConfigurer<B> exceptionHandling = builder.getConfigurer(ExceptionHandlingConfigurer.class);
 		if (exceptionHandling != null) {
 			exceptionHandling.defaultAuthenticationEntryPointFor(
@@ -224,6 +246,9 @@ public final class OAuth2AuthorizationServerConfigurer<B extends HttpSecurityBui
 			builder.addFilterBefore(postProcess(authorizationServerMetadataEndpointFilter), AbstractPreAuthenticatedProcessingFilter.class);
 		}
 
+		RegisteredClientRepository registeredClientRepository = getRegisteredClientRepository(builder);
+		OAuth2AuthorizationService authorizationService = getAuthorizationService(builder);
+
 		JWKSource<SecurityContext> jwkSource = getJwkSource(builder);
 		NimbusJwkSetEndpointFilter jwkSetEndpointFilter = new NimbusJwkSetEndpointFilter(
 				jwkSource,
@@ -243,8 +268,8 @@ public final class OAuth2AuthorizationServerConfigurer<B extends HttpSecurityBui
 
 		OAuth2AuthorizationEndpointFilter authorizationEndpointFilter =
 				new OAuth2AuthorizationEndpointFilter(
-						getRegisteredClientRepository(builder),
-						getAuthorizationService(builder),
+						registeredClientRepository,
+						authorizationService,
 						providerSettings.authorizationEndpoint());
 		builder.addFilterBefore(postProcess(authorizationEndpointFilter), AbstractPreAuthenticatedProcessingFilter.class);
 
@@ -265,6 +290,15 @@ public final class OAuth2AuthorizationServerConfigurer<B extends HttpSecurityBui
 						authenticationManager,
 						providerSettings.tokenRevocationEndpoint());
 		builder.addFilterAfter(postProcess(tokenRevocationEndpointFilter), OAuth2TokenIntrospectionEndpointFilter.class);
+
+		if (providerSettings.isOidClientRegistrationEndpointEnabled()) {
+			OidcClientRegistrationEndpointFilter oidcClientRegistrationEndpointFilter =
+					new OidcClientRegistrationEndpointFilter(
+							registeredClientRepository,
+							authenticationManager,
+							providerSettings.oidcClientRegistrationEndpoint());
+			builder.addFilterAfter(postProcess(oidcClientRegistrationEndpointFilter), OAuth2TokenRevocationEndpointFilter.class);
+		}
 	}
 
 	private void initEndpointMatchers(ProviderSettings providerSettings) {
@@ -287,6 +321,9 @@ public final class OAuth2AuthorizationServerConfigurer<B extends HttpSecurityBui
 				OidcProviderConfigurationEndpointFilter.DEFAULT_OIDC_PROVIDER_CONFIGURATION_ENDPOINT_URI, HttpMethod.GET.name());
 		this.authorizationServerMetadataEndpointMatcher = new AntPathRequestMatcher(
 				OAuth2AuthorizationServerMetadataEndpointFilter.DEFAULT_OAUTH2_AUTHORIZATION_SERVER_METADATA_ENDPOINT_URI, HttpMethod.GET.name());
+		this.oidcClientRegistrationEndpointMatcher = new AntPathRequestMatcher(
+				providerSettings.oidcClientRegistrationEndpoint(),
+				HttpMethod.POST.name());
 	}
 
 	private static void validateProviderSettings(ProviderSettings providerSettings) {

+ 130 - 0
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/oidc/OidcClientMetadataClaimAccessor.java

@@ -0,0 +1,130 @@
+/*
+ * Copyright 2020-2021 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.core.oidc;
+
+import org.springframework.security.oauth2.core.ClaimAccessor;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+
+import java.time.Instant;
+import java.util.List;
+
+/**
+ * A {@link ClaimAccessor} for the "claims" that can be returned
+ * in the OpenID Client Registration Response.
+ *
+ * @author Ovidiu Popa
+ * @since 0.1.1
+ * @see ClaimAccessor
+ * @see OidcClientMetadataClaimNames
+ * @see OidcClientRegistration
+ * @see <a target="_blank" href="https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata">2. Client Metadata</a>
+ */
+public interface OidcClientMetadataClaimAccessor extends ClaimAccessor {
+
+	/**
+	 * Returns the redirect URI(s) that the client may use in redirect-based flows.
+	 *
+	 * @return the {@code List} of redirect URI(s)
+	 */
+	default List<String> getRedirectUris() {
+		return getClaimAsStringList(OidcClientMetadataClaimNames.REDIRECT_URIS);
+	}
+
+	/**
+	 * Returns the OAuth 2.0 {@code response_type} values that the client may use.
+	 *
+	 * @return the {@code List} of {@code response_type}
+	 */
+	default List<String> getResponseTypes() {
+		return getClaimAsStringList(OidcClientMetadataClaimNames.RESPONSE_TYPES);
+	}
+
+	/**
+	 * Returns the authorization {@code grant_types} that the client may use.
+	 *
+	 * @return the {@code List} of authorization {@code grant_types}
+	 */
+	default List<String> getGrantTypes() {
+		return getClaimAsStringList(OidcClientMetadataClaimNames.GRANT_TYPES);
+	}
+
+	/**
+	 * Returns the {@code client_name}.
+	 *
+	 * @return the {@code client_name}
+	 */
+	default String getClientName() {
+		return getClaimAsString(OidcClientMetadataClaimNames.CLIENT_NAME);
+	}
+
+	/**
+	 * Returns the scope(s) that the client may use.
+	 *
+	 * @return the scope(s)
+	 */
+	default String getScope() {
+		return getClaimAsString(OidcClientMetadataClaimNames.SCOPE);
+	}
+
+	/**
+	 * Returns the {@link ClientAuthenticationMethod authentication method} that the client may use.
+	 *
+	 * @return the {@link ClientAuthenticationMethod authentication method}
+	 */
+	default String getTokenEndpointAuthenticationMethod() {
+		return getClaimAsString(OidcClientMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHOD);
+	}
+
+	/**
+	 * Returns the {@code client_id}.
+	 *
+	 * @return the {@code client_id}
+	 */
+	default String getClientId() {
+		return getClaimAsString(OidcClientMetadataClaimNames.CLIENT_ID);
+	}
+
+	/**
+	 * Returns the {@code client_id_issued_at} timestamp.
+	 *
+	 * @return the {@code client_id_issued_at} timestamp
+	 */
+	default Instant getClientIdIssuedAt() {
+		return getClaimAsInstant(OidcClientMetadataClaimNames.CLIENT_ID_ISSUED_AT);
+	}
+
+	/**
+	 * Returns the {@code client_secret}.
+	 *
+	 * @return the {@code client_secret}
+	 */
+	default String getClientSecret() {
+		return getClaimAsString(OidcClientMetadataClaimNames.CLIENT_SECRET);
+	}
+
+	/**
+	 * Returns the {@code client_secret_expires_at} timestamp.
+	 *
+	 * @return the {@code client_secret_expires_at} timestamp
+	 */
+	default Instant getClientSecretExpiresAt() {
+		return getClaimAsInstant(OidcClientMetadataClaimNames.CLIENT_SECRET_EXPIRES_AT);
+	}
+
+
+
+
+}

+ 79 - 0
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/oidc/OidcClientMetadataClaimNames.java

@@ -0,0 +1,79 @@
+/*
+ * Copyright 2020-2021 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.core.oidc;
+
+/**
+ * The names of the "claims" defined by OpenID Client Registration 1.0 that can be returned
+ * in the OpenID Client Registration Response.
+ *
+ * @author Ovidiu Popa
+ * @since 0.1.1
+ * @see <a target="_blank" href="https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata">2. Client Metadata</a>
+ */
+public interface OidcClientMetadataClaimNames {
+
+	//request
+	/**
+	 * {@code redirect_uris} - the redirect URI(s) that the client may use in redirect-based flows
+	 */
+	String REDIRECT_URIS = "redirect_uris";
+
+	/**
+	 * {@code response_types} - the OAuth 2.0 {@code response_type} values that the client may use
+	 */
+	String RESPONSE_TYPES = "response_types";
+
+	/**
+	 * {@code grant_types} - the OAuth 2.0 authorization {@code grant_types} that the client may use
+	 */
+	String GRANT_TYPES = "grant_types";
+
+	/**
+	 * {@code client_name} - the {@code client_name}
+	 */
+	String CLIENT_NAME = "client_name";
+
+	/**
+	 * {@code scope} - the scope(s) that the client may use
+	 */
+	String SCOPE = "scope";
+
+	/**
+	 * {@code token_endpoint_auth_method} - the {@link org.springframework.security.oauth2.core.ClientAuthenticationMethod authentication method} that the client may use.
+	 */
+	String TOKEN_ENDPOINT_AUTH_METHOD = "token_endpoint_auth_method";
+
+	//response
+	/**
+	 * {@code client_id} - the {@code client_id}
+	 */
+	String CLIENT_ID = "client_id";
+
+	/**
+	 * {@code client_secret} - the {@code client_secret}
+	 */
+	String CLIENT_SECRET = "client_secret";
+
+	/**
+	 * {@code client_id_issued_at} - the timestamp when the client id was issued
+	 */
+	String CLIENT_ID_ISSUED_AT = "client_id_issued_at";
+
+	/**
+	 * {@code client_secret_expires_at} - the timestamp when the client secret expires
+	 */
+	String CLIENT_SECRET_EXPIRES_AT = "client_secret_expires_at";
+}

+ 349 - 0
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/oidc/OidcClientRegistration.java

@@ -0,0 +1,349 @@
+/*
+ * Copyright 2020-2021 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.core.oidc;
+
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.Version;
+import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponseType;
+import org.springframework.util.Assert;
+
+import java.io.Serializable;
+import java.net.URI;
+import java.net.URL;
+import java.time.Instant;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+
+/**
+ * A representation of an OpenID Client Registration Request and Response,
+ * which contains a set of claims defined by the
+ * OpenID Connect Registration 1.0 specification.
+ *
+ * @author Ovidiu Popa
+ * @since 0.1.1
+ * @see OidcClientMetadataClaimAccessor
+ * @see <a href="https://openid.net/specs/openid-connect-registration-1_0.html#ClientRegistration">3.1.  Client Registration Request</a>
+ */
+public final class OidcClientRegistration implements OidcClientMetadataClaimAccessor, Serializable {
+	private static final long serialVersionUID = Version.SERIAL_VERSION_UID;
+	private final Map<String, Object> claims;
+
+	private OidcClientRegistration(Map<String, Object> claims) {
+		this.claims = Collections.unmodifiableMap(claims);
+	}
+
+	/**
+	 * Returns the OpenID Client Registration metadata.
+	 *
+	 * @return a {@code Map} of the metadata values
+	 */
+	@Override
+	public Map<String, Object> getClaims() {
+		return this.claims;
+	}
+
+	/**
+	 * Constructs a new {@link OidcClientRegistration.Builder} with empty claims.
+	 *
+	 * @return the {@link OidcClientRegistration.Builder}
+	 */
+	public static Builder builder() {
+		return new Builder();
+	}
+
+	/**
+	 * Constructs a new {@link Builder} with the provided claims.
+	 *
+	 * @param claims the claims to initialize the builder
+	 */
+	public static Builder withClaims(Map<String, Object> claims) {
+		Assert.notEmpty(claims, "claims cannot be empty");
+		return new Builder()
+				.claims(c -> c.putAll(claims));
+	}
+
+	public static class Builder {
+
+		private final Map<String, Object> claims = new LinkedHashMap<>();
+
+		private Builder() {
+		}
+
+		/**
+		 * Add this Redirect URI to the collection of {@code redirect_uris} in the resulting
+		 * {@link OidcClientRegistration}, REQUIRED.
+		 *
+		 * @param redirectUri the OAuth 2.0 {@code redirect_uri} value that client supports
+		 * @return the {@link Builder} for further configuration
+		 */
+		public Builder redirectUri(String redirectUri) {
+			addClaimToClaimList(OidcClientMetadataClaimNames.REDIRECT_URIS, redirectUri);
+			return this;
+		}
+
+		/**
+		 * A {@code Consumer} of the Redirect URI(s) allowing the ability to add, replace, or remove.
+		 *
+		 * @param redirectUriConsumer a {@code Consumer} of the Redirect URI(s)
+		 * @return the {@link Builder} for further configuration
+		 */
+		public Builder redirectUris(Consumer<List<String>> redirectUriConsumer) {
+			acceptClaimValues(OidcClientMetadataClaimNames.REDIRECT_URIS, redirectUriConsumer);
+			return this;
+		}
+
+		/**
+		 * Add this Response Type to the collection of {@code response_types} in the resulting
+		 * {@link OidcClientRegistration}, OPTIONAL.
+		 *
+		 * @param responseType the OAuth 2.0 {@code response_type} value that client supports
+		 * @return the {@link Builder} for further configuration
+		 */
+		public Builder responseType(String responseType) {
+			addClaimToClaimList(OidcClientMetadataClaimNames.RESPONSE_TYPES, responseType);
+			return this;
+		}
+
+		/**
+		 * Add {@code Consumer}  of {@code response_types} allowing the ability to add, replace, or remove
+		 * {@link OidcClientRegistration}, OPTIONAL.
+		 *
+		 * @param responseType the OAuth 2.0 {@code response_type} value that client supports
+		 * @return the {@link Builder} for further configuration
+		 */
+		public Builder responseTypes(Consumer<List<String>>  responseType) {
+			acceptClaimValues(OidcClientMetadataClaimNames.RESPONSE_TYPES, responseType);
+			return this;
+		}
+
+		/**
+		 * Sets {@code client_name} claim in the resulting
+		 * {@link OidcClientRegistration}, OPTIONAL.
+		 *
+		 * @param clientName the OAuth 2.0 {@code client_name} of the registered client
+		 * @return the {@link Builder} for further configuration
+		 */
+		public Builder clientName(String clientName) {
+			return claim(OidcClientMetadataClaimNames.CLIENT_NAME, clientName);
+		}
+
+		/**
+		 * Sets {@code client_id} claim in the resulting
+		 * {@link OidcClientRegistration}.
+		 *
+		 * @param clientId the OAuth 2.0 {@code client_id} of the registered client
+		 * @return the {@link Builder} for further configuration
+		 */
+		public Builder clientId(String clientId) {
+			return claim(OidcClientMetadataClaimNames.CLIENT_ID, clientId);
+		}
+
+		/**
+		 * Sets {@code client_id_issued_at} claim in the resulting
+		 * {@link OidcClientRegistration}.
+		 *
+		 * @param clientIssuedAt the timestamp {@code client_id_issued_at} when the client was issued
+		 * @return the {@link Builder} for further configuration
+		 */
+		public Builder clientIdIssuedAt(Instant clientIssuedAt) {
+			return claim(OidcClientMetadataClaimNames.CLIENT_ID_ISSUED_AT, clientIssuedAt);
+		}
+
+		/**
+		 * Sets {@code client_secret} claim in the resulting
+		 * {@link OidcClientRegistration}.
+		 *
+		 * @param clientSecret the {@code client_secret} of the registered client
+		 * @return the {@link Builder} for further configuration
+		 */
+		public Builder clientSecret(String clientSecret) {
+			return claim(OidcClientMetadataClaimNames.CLIENT_SECRET, clientSecret);
+		}
+
+		/**
+		 * Sets {@code client_secret_expires_at} claim in the resulting
+		 * {@link OidcClientRegistration}.
+		 *
+		 * @param clientSecretExpiresAt the timestamp {@code client_secret_expires_at} when the client_secret expires
+		 * @return the {@link Builder} for further configuration
+		 */
+		public Builder clientSecretExpiresAt(Instant clientSecretExpiresAt) {
+			return claim(OidcClientMetadataClaimNames.CLIENT_SECRET_EXPIRES_AT, clientSecretExpiresAt);
+		}
+
+		/**
+		 * Add this Grant Type to the collection of {@code grant_types_supported} in the resulting
+		 * {@link OidcClientRegistration}, OPTIONAL.
+		 *
+		 * @param grantType the OAuth 2.0 {@code grant_type} value that client supports
+		 * @return the {@link Builder} for further configuration
+		 */
+		public Builder grantType(String grantType) {
+			addClaimToClaimList(OidcClientMetadataClaimNames.GRANT_TYPES, grantType);
+			return this;
+		}
+
+		/**
+		 * A {@code Consumer} of the Grant Type(s) allowing the ability to add, replace, or remove.
+		 *
+		 * @param grantTypesConsumer a {@code Consumer} of the Grant Type(s)
+		 * @return the {@link Builder} for further configuration
+		 */
+		public Builder grantTypes(Consumer<List<String>> grantTypesConsumer) {
+			acceptClaimValues(OidcClientMetadataClaimNames.GRANT_TYPES, grantTypesConsumer);
+			return this;
+		}
+
+		/**
+		 * Add this Scope to the collection of {@code scopes_supported} in the resulting
+		 * {@link OidcClientRegistration}, RECOMMENDED.
+		 *
+		 * @param scope the OAuth 2.0 {@code scope} value that client supports
+		 * @return the {@link Builder} for further configuration
+		 */
+		public Builder scope(String scope) {
+			claim(OidcClientMetadataClaimNames.SCOPE, scope);
+			return this;
+		}
+
+		/**
+		 * Add {@code Consumer}  of {@code scopes} allowing the ability to add, replace, or remove
+		 * {@link OidcClientRegistration}, RECOMMENDED.
+		 *
+		 * @param scopesConsumer the OAuth 2.0 {@code scope} value that client supports
+		 * @return the {@link Builder} for further configuration
+		 */
+		public Builder scopes(Consumer<List<String>>  scopesConsumer) {
+			acceptClaimValues(OidcClientMetadataClaimNames.SCOPE, scopesConsumer);
+			return this;
+		}
+
+		/**
+		 * Add this Token endpoint authentication method to the collection of {@code token_endpoint_auth_method} in the resulting
+		 * {@link OidcClientRegistration}, OPTIONAL.
+		 *
+		 * @param tokenEndpointAuthenticationMethod the OAuth 2.0 {@code token_endpoint_auth_method} value that client supports
+		 * @return the {@link Builder} for further configuration
+		 */
+		public Builder tokenEndpointAuthenticationMethod(String tokenEndpointAuthenticationMethod) {
+			claim(OidcClientMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHOD, tokenEndpointAuthenticationMethod);
+			return this;
+		}
+
+		/**
+		 * Add this claim in the resulting {@link OidcClientRegistration}.
+		 *
+		 * @param name  the claim name
+		 * @param value the claim value
+		 * @return the {@link Builder} for further configuration
+		 */
+		public Builder claim(String name, Object value) {
+			Assert.hasText(name, "name cannot be empty");
+			Assert.notNull(value, "value cannot be null");
+			this.claims.put(name, value);
+			return this;
+		}
+
+		/**
+		 * Provides access to every {@link #claim(String, Object)} declared so far with
+		 * the possibility to add, replace, or remove.
+		 *
+		 * @param claimsConsumer a {@code Consumer} of the claims
+		 * @return the {@link Builder} for further configurations
+		 */
+		public Builder claims(Consumer<Map<String, Object>> claimsConsumer) {
+			claimsConsumer.accept(this.claims);
+			return this;
+		}
+
+		public OidcClientRegistration build() {
+			this.claims.computeIfAbsent(OidcClientMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHOD,
+					k -> ClientAuthenticationMethod.BASIC.getValue());
+			// If omitted, the default is that the Client will use only the authorization_code Grant Type.
+			this.claims.computeIfAbsent(OidcClientMetadataClaimNames.GRANT_TYPES,
+					k -> Collections.singletonList(AuthorizationGrantType.AUTHORIZATION_CODE.getValue()));
+			//If omitted, the default is that the Client will use only the code Response Type.
+			this.claims.computeIfAbsent(OidcClientMetadataClaimNames.RESPONSE_TYPES,
+					k -> Collections.singletonList(OAuth2AuthorizationResponseType.CODE.getValue()));
+			validateRedirectUris();
+			validateReponseTypesClaim();
+			validateGrantTypesClaim();
+			return new OidcClientRegistration(this.claims);
+		}
+
+		private void validateRedirectUris() {
+			// redirect_uris is required
+			Assert.notNull(this.claims.get(OidcClientMetadataClaimNames.REDIRECT_URIS), "redirect_uris cannot be null");
+			Assert.isInstanceOf(List.class, this.claims.get(OidcClientMetadataClaimNames.REDIRECT_URIS), "redirect_uris must be of type list");
+			Assert.notEmpty((List<?>) this.claims.get(OidcClientMetadataClaimNames.REDIRECT_URIS), "redirect_uris must not be empty");
+			((List<?>) this.claims.get(OidcClientMetadataClaimNames.REDIRECT_URIS)).forEach(
+					url -> validateURL(url, "redirect_uri must be a valid URL")
+			);
+		}
+
+		private void validateGrantTypesClaim() {
+			Assert.isInstanceOf(List.class, this.claims.get(OidcClientMetadataClaimNames.GRANT_TYPES), "grant_types must be of type List");
+			List<?> grantTypes = (List<?>) this.claims.get(OidcClientMetadataClaimNames.GRANT_TYPES);
+			// If empty, the default is that the Client will use only the authorization_code Grant Type.
+			if (grantTypes.isEmpty()) {
+				this.claims.put(OidcClientMetadataClaimNames.GRANT_TYPES,
+						Collections.singletonList(AuthorizationGrantType.AUTHORIZATION_CODE.getValue()));
+			}
+		}
+
+		private void validateReponseTypesClaim() {
+			Assert.isInstanceOf(List.class, this.claims.get(OidcClientMetadataClaimNames.RESPONSE_TYPES), "response_types must be of type List");
+			List<?> responseTypes = (List<?>) this.claims.get(OidcClientMetadataClaimNames.RESPONSE_TYPES);
+			//If empty, the default is that the Client will use only the code Response Type.
+			if (responseTypes.isEmpty()) {
+				this.claims.put(OidcClientMetadataClaimNames.RESPONSE_TYPES, Collections.singletonList(OAuth2AuthorizationResponseType.CODE.getValue()));
+			}
+		}
+
+		private static void validateURL(Object url, String errorMessage) {
+			if (URL.class.isAssignableFrom(url.getClass())) {
+				return;
+			}
+			try {
+				new URI(url.toString()).toURL();
+			} catch (Exception ex) {
+				throw new IllegalArgumentException(errorMessage, ex);
+			}
+		}
+
+		@SuppressWarnings("unchecked")
+		private void addClaimToClaimList(String name, String value) {
+			Assert.hasText(name, "name cannot be empty");
+			Assert.notNull(value, "value cannot be null");
+			this.claims.computeIfAbsent(name, k -> new LinkedList<String>());
+			((List<String>) this.claims.get(name)).add(value);
+		}
+
+		@SuppressWarnings("unchecked")
+		private void acceptClaimValues(String name, Consumer<List<String>> valuesConsumer) {
+			Assert.hasText(name, "name cannot be empty");
+			Assert.notNull(valuesConsumer, "valuesConsumer cannot be null");
+			this.claims.computeIfAbsent(name, k -> new LinkedList<String>());
+			List<String> values = (List<String>) this.claims.get(name);
+			valuesConsumer.accept(values);
+		}
+	}
+}

+ 157 - 0
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/oidc/http/converter/OidcClientRegistrationHttpMessageConverter.java

@@ -0,0 +1,157 @@
+/*
+ * Copyright 2020-2021 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.core.oidc.http.converter;
+
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.core.convert.TypeDescriptor;
+import org.springframework.core.convert.converter.Converter;
+import org.springframework.http.HttpInputMessage;
+import org.springframework.http.HttpOutputMessage;
+import org.springframework.http.MediaType;
+import org.springframework.http.converter.AbstractHttpMessageConverter;
+import org.springframework.http.converter.GenericHttpMessageConverter;
+import org.springframework.http.converter.HttpMessageConverter;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.http.converter.HttpMessageNotWritableException;
+import org.springframework.security.oauth2.core.converter.ClaimConversionService;
+import org.springframework.security.oauth2.core.converter.ClaimTypeConverter;
+import org.springframework.security.oauth2.core.oidc.OidcClientMetadataClaimNames;
+import org.springframework.security.oauth2.core.oidc.OidcClientRegistration;
+import org.springframework.util.Assert;
+
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * A {@link HttpMessageConverter} for an {@link OidcClientRegistration OpenID Client Registration Response}.
+ *
+ * @author Ovidiu Popa
+ * @see AbstractHttpMessageConverter
+ * @see OidcClientRegistration
+ * @since 0.1.1
+ */
+public class OidcClientRegistrationHttpMessageConverter extends AbstractHttpMessageConverter<OidcClientRegistration> {
+
+	private static final ParameterizedTypeReference<Map<String, Object>> STRING_OBJECT_MAP =
+			new ParameterizedTypeReference<Map<String, Object>>() {
+			};
+
+	private Converter<Map<String, Object>, OidcClientRegistration> clientRegistrationConverter =
+			new OidcClientRegistrationConverter();
+
+	private Converter<OidcClientRegistration, Map<String, Object>> clientRegistrationParametersConverter = OidcClientRegistration::getClaims;
+	private final GenericHttpMessageConverter<Object> jsonMessageConverter = HttpMessageConverters.getJsonMessageConverter();
+
+	public OidcClientRegistrationHttpMessageConverter() {
+		super(MediaType.APPLICATION_JSON, new MediaType("application", "*+json"));
+	}
+
+	@Override
+	protected boolean supports(Class<?> clazz) {
+		return OidcClientRegistration.class.isAssignableFrom(clazz);
+	}
+
+	@Override
+	@SuppressWarnings("unchecked")
+	protected OidcClientRegistration readInternal(Class<? extends OidcClientRegistration> clazz, HttpInputMessage inputMessage)
+			throws HttpMessageNotReadableException {
+		try {
+			Map<String, Object> clientRegistrationParameters =
+					(Map<String, Object>) this.jsonMessageConverter.read(STRING_OBJECT_MAP.getType(), null, inputMessage);
+			return this.clientRegistrationConverter.convert(clientRegistrationParameters);
+		} catch (Exception ex) {
+			throw new HttpMessageNotReadableException(
+					"An error occurred reading the OpenID Client Registration Request: " + ex.getMessage(), ex, inputMessage);
+		}
+	}
+
+	@Override
+	protected void writeInternal(OidcClientRegistration oidcClientRegistration, HttpOutputMessage outputMessage)
+			throws HttpMessageNotWritableException {
+
+		try {
+			Map<String, Object> claims = clientRegistrationParametersConverter.convert(oidcClientRegistration);
+			this.jsonMessageConverter.write(
+					claims,
+					STRING_OBJECT_MAP.getType(),
+					MediaType.APPLICATION_JSON,
+					outputMessage
+			);
+		} catch (Exception ex) {
+			throw new HttpMessageNotWritableException(
+					"An error occurred writing the OpenID Client Registration response: " + ex.getMessage(), ex);
+		}
+
+	}
+
+	/**
+	 * Sets the {@link Converter} used for converting the OpenID Client Registration parameters
+	 * to an {@link OidcClientRegistration}.
+	 *
+	 * @param clientRegistrationConverter the {@link Converter} used for converting to an
+	 *                                        {@link OidcClientRegistration}
+	 */
+	public void setClientRegistrationConverter(Converter<Map<String, Object>, OidcClientRegistration> clientRegistrationConverter) {
+		Assert.notNull(clientRegistrationConverter, "clientRegistrationConverter cannot be null");
+		this.clientRegistrationConverter = clientRegistrationConverter;
+	}
+
+	/**
+	 * Sets the {@link Converter} used for converting the {@link OidcClientRegistration} to a
+	 * {@code Map} representation of the OpenID Client Registration Response.
+	 *
+	 * @param clientRegistrationParametersConverter the {@link Converter} used for converting to a
+	 *                                         {@code Map} representation of the OpenID Client Registration Response
+	 */
+	public final void setClientRegistrationParametersConverter(
+			Converter<OidcClientRegistration, Map<String, Object>> clientRegistrationParametersConverter) {
+		Assert.notNull(clientRegistrationParametersConverter, "clientRegistrationParametersConverter cannot be null");
+		this.clientRegistrationParametersConverter = clientRegistrationParametersConverter;
+	}
+
+	private static final class OidcClientRegistrationConverter implements Converter<Map<String, Object>, OidcClientRegistration> {
+		private static final ClaimConversionService CLAIM_CONVERSION_SERVICE = ClaimConversionService.getSharedInstance();
+		private static final TypeDescriptor OBJECT_TYPE_DESCRIPTOR = TypeDescriptor.valueOf(Object.class);
+		private static final TypeDescriptor STRING_TYPE_DESCRIPTOR = TypeDescriptor.valueOf(String.class);
+		private final ClaimTypeConverter claimTypeConverter;
+
+		private OidcClientRegistrationConverter() {
+			Converter<Object, ?> collectionStringConverter = getConverter(
+					TypeDescriptor.collection(Collection.class, STRING_TYPE_DESCRIPTOR));
+			Converter<Object, ?> stringConverter = getConverter(STRING_TYPE_DESCRIPTOR);
+
+			Map<String, Converter<Object, ?>> claimConverters = new HashMap<>();
+			claimConverters.put(OidcClientMetadataClaimNames.REDIRECT_URIS, collectionStringConverter);
+			claimConverters.put(OidcClientMetadataClaimNames.RESPONSE_TYPES, collectionStringConverter);
+			claimConverters.put(OidcClientMetadataClaimNames.GRANT_TYPES, collectionStringConverter);
+			claimConverters.put(OidcClientMetadataClaimNames.CLIENT_NAME, stringConverter);
+			claimConverters.put(OidcClientMetadataClaimNames.SCOPE, stringConverter);
+			claimConverters.put(OidcClientMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHOD, stringConverter);
+			this.claimTypeConverter = new ClaimTypeConverter(claimConverters);
+		}
+
+		@Override
+		public OidcClientRegistration convert(Map<String, Object> source) {
+			Map<String, Object> parsedClaims = this.claimTypeConverter.convert(source);
+			return OidcClientRegistration.withClaims(parsedClaims).build();
+		}
+
+		private static Converter<Object, ?> getConverter(TypeDescriptor targetDescriptor) {
+			return source -> CLAIM_CONVERSION_SERVICE.convert(source, OBJECT_TYPE_DESCRIPTOR, targetDescriptor);
+		}
+	}
+}

+ 86 - 0
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OidcClientRegistrationAuthenticationProvider.java

@@ -0,0 +1,86 @@
+/*
+ * Copyright 2020-2021 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 org.springframework.security.authentication.AuthenticationProvider;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.AuthenticationException;
+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.OAuth2TokenType;
+import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
+import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
+import org.springframework.util.Assert;
+
+/**
+ * An {@link AuthenticationProvider} implementation for OpenID Client Registration Endpoint.
+ *
+ * @author Ovidiu Popa
+ * @since 0.1.1
+ * @see JwtAuthenticationToken
+ * @see OAuth2AuthorizationService
+ */
+public class OidcClientRegistrationAuthenticationProvider implements AuthenticationProvider {
+
+	private static final String CLIENT_CREATE_SCOPE = "client.create";
+	private final OAuth2AuthorizationService authorizationService;
+
+	/**
+	 * Constructs an {@code OidcClientRegistrationAuthenticationProvider} using the provided parameters.
+	 *
+	 * @param authorizationService the authorization service
+	 */
+	public OidcClientRegistrationAuthenticationProvider(OAuth2AuthorizationService authorizationService) {
+		Assert.notNull(authorizationService, "authorizationService cannot be null");
+		this.authorizationService = authorizationService;
+	}
+
+	@Override
+	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
+		JwtAuthenticationToken jwtAuthenticationToken =
+				(JwtAuthenticationToken) authentication;
+
+		String tokenValue = jwtAuthenticationToken.getToken().getTokenValue();
+		OAuth2Authorization authorization = this.authorizationService.findByToken(tokenValue, OAuth2TokenType.ACCESS_TOKEN);
+
+		if (authorization == null) {
+			throw new OAuth2AuthenticationException(new OAuth2Error(OAuth2ErrorCodes.INVALID_GRANT));
+		}
+
+		OAuth2Authorization.Token<OAuth2AccessToken> authorizationAccessToken =
+				authorization.getAccessToken();
+		if (authorizationAccessToken.isInvalidated()) {
+			throw new OAuth2AuthenticationException(new OAuth2Error(OAuth2ErrorCodes.INVALID_GRANT));
+		}
+		OAuth2AccessToken accessToken = authorizationAccessToken.getToken();
+		if (!accessToken.getScopes().contains(CLIENT_CREATE_SCOPE)) {
+			throw new OAuth2AuthenticationException(new OAuth2Error(OAuth2ErrorCodes.INVALID_GRANT));
+		}
+
+		authorization = OAuth2AuthenticationProviderUtils.invalidate(authorization, accessToken);
+		this.authorizationService.save(authorization);
+
+		return jwtAuthenticationToken;
+	}
+
+	@Override
+	public boolean supports(Class<?> authentication) {
+		return JwtAuthenticationToken.class.isAssignableFrom(authentication);
+	}
+}

+ 17 - 0
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/client/InMemoryRegisteredClientRepository.java

@@ -88,4 +88,21 @@ public final class InMemoryRegisteredClientRepository implements RegisteredClien
 		Assert.hasText(clientId, "clientId cannot be empty");
 		return this.clientIdRegistrationMap.get(clientId);
 	}
+
+	@Override
+	public void saveClient(RegisteredClient registeredClient) {
+		Assert.notNull(registeredClient, "registeredClient cannot be null");
+		String id = registeredClient.getId();
+		if (idRegistrationMap.containsKey(id)) {
+			throw new IllegalArgumentException("Registered client must be unique. " +
+					"Found duplicate identifier: " + id);
+		}
+		String clientId = registeredClient.getClientId();
+		if (clientIdRegistrationMap.containsKey(clientId)) {
+			throw new IllegalArgumentException("Registered client must be unique. " +
+					"Found duplicate client identifier: " + clientId);
+		}
+		this.idRegistrationMap.put(registeredClient.getId(), registeredClient);
+		this.clientIdRegistrationMap.put(registeredClient.getClientId(), registeredClient);
+	}
 }

+ 7 - 0
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/client/RegisteredClientRepository.java

@@ -47,4 +47,11 @@ public interface RegisteredClientRepository {
 	@Nullable
 	RegisteredClient findByClientId(String clientId);
 
+	/**
+	 * Saves a new registered client
+	 *
+	 * @param registeredClient the {@link RegisteredClient} to be saved
+	 */
+	void saveClient(RegisteredClient registeredClient);
+
 }

+ 44 - 0
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/ProviderSettings.java

@@ -33,6 +33,8 @@ public class ProviderSettings extends Settings {
 	public static final String JWK_SET_ENDPOINT = PROVIDER_SETTING_BASE.concat("jwk-set-endpoint");
 	public static final String TOKEN_REVOCATION_ENDPOINT = PROVIDER_SETTING_BASE.concat("token-revocation-endpoint");
 	public static final String TOKEN_INTROSPECTION_ENDPOINT = PROVIDER_SETTING_BASE.concat("token-introspection-endpoint");
+	public static final String OIDC_CLIENT_REGISTRATION_ENDPOINT = PROVIDER_SETTING_BASE.concat("oidc-client-registration-endpoint");
+	public static final String ENABLE_OIDC_CLIENT_REGISTRATION_ENDPOINT = PROVIDER_SETTING_BASE.concat("enable-oidc-client-registration-endpoint");
 
 	/**
 	 * Constructs a {@code ProviderSettings}.
@@ -164,6 +166,46 @@ public class ProviderSettings extends Settings {
 		return setting(TOKEN_INTROSPECTION_ENDPOINT, tokenIntrospectionEndpoint);
 	}
 
+	/**
+	 * Returns the Provider's OAuth 2.0 OIDC Client Registration endpoint. The default is {@code /connect/register}.
+	 *
+	 * @return the OIDC Client Registration endpoint
+	 */
+	public String oidcClientRegistrationEndpoint() {
+		return setting(OIDC_CLIENT_REGISTRATION_ENDPOINT);
+	}
+
+	/**
+	 * Sets the Provider's OAuth 2.0 OIDC Client Registration endpoint.
+	 *
+	 * @param oidcClientRegistrationEndpoint the Token Revocation endpoint
+	 * @return the {@link ProviderSettings} for further configuration
+	 */
+	public ProviderSettings oidcClientRegistrationEndpoint(String oidcClientRegistrationEndpoint) {
+		return setting(OIDC_CLIENT_REGISTRATION_ENDPOINT, oidcClientRegistrationEndpoint);
+	}
+
+	/**
+	 * Returns {@code true} if the OIDC Client Registration endpoint is enabled.
+	 * The default is {@code false}.
+	 *
+	 * @return {@code true} if the OIDC Client Registration endpoint is enabled, {@code false} otherwise
+	 */
+	public boolean isOidClientRegistrationEndpointEnabled() {
+		return setting(ENABLE_OIDC_CLIENT_REGISTRATION_ENDPOINT);
+	}
+
+	/**
+	 * Set to {@code true} if the OIDC Client Registration Endpoint should be enabled.
+	 *
+	 * @param oidClientRegistrationEndpointEnabled {@code true} if the OIDC Client Registration endpoint should enabled
+	 * @return the {@link ProviderSettings}
+	 */
+	public ProviderSettings isOidClientRegistrationEndpointEnabled(boolean oidClientRegistrationEndpointEnabled) {
+		setting(ENABLE_OIDC_CLIENT_REGISTRATION_ENDPOINT, oidClientRegistrationEndpointEnabled);
+		return this;
+	}
+
 	protected static Map<String, Object> defaultSettings() {
 		Map<String, Object> settings = new HashMap<>();
 		settings.put(AUTHORIZATION_ENDPOINT, "/oauth2/authorize");
@@ -171,6 +213,8 @@ public class ProviderSettings extends Settings {
 		settings.put(JWK_SET_ENDPOINT, "/oauth2/jwks");
 		settings.put(TOKEN_REVOCATION_ENDPOINT, "/oauth2/revoke");
 		settings.put(TOKEN_INTROSPECTION_ENDPOINT, "/oauth2/introspect");
+		settings.put(OIDC_CLIENT_REGISTRATION_ENDPOINT, "/connect/register");
+		settings.put(ENABLE_OIDC_CLIENT_REGISTRATION_ENDPOINT, false);
 		return settings;
 	}
 }

+ 194 - 0
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcClientRegistrationEndpointFilter.java

@@ -0,0 +1,194 @@
+/*
+ * Copyright 2020-2021 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.web;
+
+import org.springframework.core.convert.converter.Converter;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.converter.HttpMessageConverter;
+import org.springframework.http.server.ServletServerHttpRequest;
+import org.springframework.http.server.ServletServerHttpResponse;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponseType;
+import org.springframework.security.oauth2.core.http.converter.OAuth2ErrorHttpMessageConverter;
+import org.springframework.security.oauth2.core.oidc.OidcClientRegistration;
+import org.springframework.security.oauth2.core.oidc.http.converter.OidcClientRegistrationHttpMessageConverter;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
+import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
+import org.springframework.security.web.util.matcher.RequestMatcher;
+import org.springframework.util.Assert;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.List;
+import java.util.UUID;
+import java.util.stream.Collectors;
+
+/**
+ * A {@code Filter} that processes OpenID Client Registration Requests.
+ * @author Ovidiu Popa
+ * @since 0.1.1
+ * @see OidcClientRegistration
+ * @see <a href="https://openid.net/specs/openid-connect-registration-1_0.html#ClientRegistration">3.1.  Client Registration Request</a>
+ */
+public class OidcClientRegistrationEndpointFilter extends OncePerRequestFilter {
+	/**
+	 * The default endpoint {@code URI} for OpenID Client Registration requests.
+	 */
+	public static final String DEFAULT_OIDC_CLIENT_REGISTRATION_ENDPOINT_URI = "/connect/register";
+	private static final String SCOPE_CLAIM_DELIMITER = " ";
+
+	private final OidcClientRegistrationHttpMessageConverter clientRegistrationHttpMessageConverter =
+			new OidcClientRegistrationHttpMessageConverter();
+	private final RegisteredClientRepository registeredClientRepository;
+	private final OidcClientRegistrationToRegisteredClientConverter oidcClientToRegisteredClientConverter =
+			new OidcClientRegistrationToRegisteredClientConverter();
+	private final RegisteredClientToOidcClientRegistrationConverter registeredClientToOidcClientConverter =
+			new RegisteredClientToOidcClientRegistrationConverter();
+	private final HttpMessageConverter<OAuth2Error> errorHttpResponseConverter =
+			new OAuth2ErrorHttpMessageConverter();
+	private final RequestMatcher requestMatcher;
+	private final AuthenticationManager authenticationManager;
+
+	/**
+	 * Constructs an {@code OidcClientRegistrationEndpointFilter} using the provided parameters.
+	 *
+	 * @param registeredClientRepository the repository of registered clients
+	 * @param authenticationManager the authentication manager
+	 */
+	public OidcClientRegistrationEndpointFilter(RegisteredClientRepository registeredClientRepository,
+			AuthenticationManager authenticationManager) {
+		this(registeredClientRepository, authenticationManager, DEFAULT_OIDC_CLIENT_REGISTRATION_ENDPOINT_URI);
+	}
+
+	/**
+	 * Constructs an {@code OidcClientRegistrationEndpointFilter} using the provided parameters.
+	 *
+	 * @param registeredClientRepository the repository of registered clients
+	 * @param authenticationManager the authentication manager
+	 * @param oidcClientRegistrationUri the endpoint {@code URI} for OIDC Client Registration requests
+	 */
+	public OidcClientRegistrationEndpointFilter(RegisteredClientRepository registeredClientRepository,
+			AuthenticationManager authenticationManager, String oidcClientRegistrationUri) {
+		Assert.notNull(registeredClientRepository, "registeredClientRepository cannot be null");
+		Assert.notNull(authenticationManager, "authenticationManager cannot be null");
+		Assert.hasText(oidcClientRegistrationUri, "oidcClientRegistrationUri cannot be empty");
+		this.registeredClientRepository = registeredClientRepository;
+		this.authenticationManager = authenticationManager;
+		this.requestMatcher = new AntPathRequestMatcher(
+				oidcClientRegistrationUri,
+				HttpMethod.POST.name()
+		);
+	}
+
+	@Override
+	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+			throws ServletException, IOException {
+
+		if (!this.requestMatcher.matches(request)) {
+			filterChain.doFilter(request, response);
+			return;
+		}
+
+		try {
+			Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+			authenticationManager.authenticate(authentication);
+			OidcClientRegistration clientRegistrationRequest =
+					this.clientRegistrationHttpMessageConverter.read(OidcClientRegistration.class, new ServletServerHttpRequest(request));
+
+			RegisteredClient registeredClient = this.oidcClientToRegisteredClientConverter
+					.convert(clientRegistrationRequest);
+			this.registeredClientRepository.saveClient(registeredClient);
+
+			OidcClientRegistration convert = this.registeredClientToOidcClientConverter
+					.convert(registeredClient);
+
+			final ServletServerHttpResponse httpResponse = new ServletServerHttpResponse(response);
+			httpResponse.setStatusCode(HttpStatus.CREATED);
+			this.clientRegistrationHttpMessageConverter.write(
+					convert, MediaType.APPLICATION_JSON, httpResponse);
+		} catch (OAuth2AuthenticationException ex) {
+			SecurityContextHolder.clearContext();
+			sendErrorResponse(response, ex.getError());
+		}
+	}
+
+	private void sendErrorResponse(HttpServletResponse response, OAuth2Error error) throws IOException {
+		ServletServerHttpResponse httpResponse = new ServletServerHttpResponse(response);
+		httpResponse.setStatusCode(HttpStatus.BAD_REQUEST);
+		this.errorHttpResponseConverter.write(error, null, httpResponse);
+	}
+
+	private static class OidcClientRegistrationToRegisteredClientConverter implements Converter<OidcClientRegistration, RegisteredClient> {
+
+		@Override
+		public RegisteredClient convert(OidcClientRegistration clientRegistration) {
+			return RegisteredClient.withId(UUID.randomUUID().toString())
+					.clientId(UUID.randomUUID().toString())
+					.clientSecret(UUID.randomUUID().toString())
+					.redirectUris(redirectUris ->
+							redirectUris.addAll(clientRegistration.getRedirectUris()))
+					.clientAuthenticationMethod(new ClientAuthenticationMethod(clientRegistration.getTokenEndpointAuthenticationMethod()))
+					.authorizationGrantTypes(grantTypes ->
+							grantTypes.addAll(this.grantTypes(clientRegistration)))
+					.scopes(scopes ->
+							scopes.addAll(Arrays.asList(clientRegistration.getScope().split(SCOPE_CLAIM_DELIMITER))))
+					.clientSettings(clientSettings -> clientSettings.requireUserConsent(true))
+					.build();
+		}
+
+		private List<AuthorizationGrantType> grantTypes(OidcClientRegistration clientRegistration) {
+			return clientRegistration.getGrantTypes().stream()
+					.map(AuthorizationGrantType::new)
+					.collect(Collectors.toList());
+		}
+	}
+
+	private static class RegisteredClientToOidcClientRegistrationConverter implements Converter<RegisteredClient, OidcClientRegistration> {
+
+		@Override
+		public OidcClientRegistration convert(RegisteredClient source) {
+			return OidcClientRegistration.builder()
+					.clientId(source.getClientId())
+					.redirectUris(uris -> uris.addAll(source.getRedirectUris()))
+					.clientIdIssuedAt(Instant.now())
+					.clientSecret(source.getClientSecret())
+					.clientSecretExpiresAt(Instant.EPOCH)
+					.responseType(OAuth2AuthorizationResponseType.CODE.getValue())
+					.grantTypes(grantTypes ->
+							grantTypes.addAll(source.getAuthorizationGrantTypes().stream().map(AuthorizationGrantType::getValue)
+									.collect(Collectors.toList()))
+					)
+					.scope(String.join(SCOPE_CLAIM_DELIMITER, source.getScopes()))
+					.tokenEndpointAuthenticationMethod(source.getClientAuthenticationMethods().iterator().next().getValue())
+					.build();
+		}
+	}
+}

+ 0 - 1
oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/JwkSetTests.java

@@ -22,7 +22,6 @@ import org.junit.Before;
 import org.junit.BeforeClass;
 import org.junit.Rule;
 import org.junit.Test;
-
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Import;

+ 6 - 7
oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2ClientCredentialsGrantTests.java

@@ -15,10 +15,6 @@
  */
 package org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization;
 
-import java.net.URLEncoder;
-import java.nio.charset.StandardCharsets;
-import java.util.Base64;
-
 import com.nimbusds.jose.jwk.JWKSet;
 import com.nimbusds.jose.jwk.source.JWKSource;
 import com.nimbusds.jose.proc.SecurityContext;
@@ -26,7 +22,6 @@ import org.junit.Before;
 import org.junit.BeforeClass;
 import org.junit.Rule;
 import org.junit.Test;
-
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Import;
@@ -37,16 +32,20 @@ import org.springframework.security.config.test.SpringTestRule;
 import org.springframework.security.oauth2.core.AuthorizationGrantType;
 import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
 import org.springframework.security.oauth2.jose.TestJwks;
+import org.springframework.security.oauth2.server.authorization.JwtEncodingContext;
 import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
+import org.springframework.security.oauth2.server.authorization.OAuth2TokenCustomizer;
 import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
 import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
 import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients;
-import org.springframework.security.oauth2.server.authorization.JwtEncodingContext;
-import org.springframework.security.oauth2.server.authorization.OAuth2TokenCustomizer;
 import org.springframework.security.oauth2.server.authorization.web.OAuth2TokenEndpointFilter;
 import org.springframework.test.web.servlet.MockMvc;
 import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
 
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;

+ 11 - 12
oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2RefreshTokenGrantTests.java

@@ -15,14 +15,6 @@
  */
 package org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization;
 
-import java.net.URLEncoder;
-import java.nio.charset.StandardCharsets;
-import java.security.Principal;
-import java.util.Base64;
-import java.util.List;
-import java.util.Set;
-import java.util.stream.Collectors;
-
 import com.nimbusds.jose.jwk.JWKSet;
 import com.nimbusds.jose.jwk.source.JWKSource;
 import com.nimbusds.jose.proc.SecurityContext;
@@ -30,7 +22,6 @@ import org.junit.Before;
 import org.junit.BeforeClass;
 import org.junit.Rule;
 import org.junit.Test;
-
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Import;
@@ -45,6 +36,7 @@ import org.springframework.security.config.test.SpringTestRule;
 import org.springframework.security.core.Authentication;
 import org.springframework.security.core.GrantedAuthority;
 import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.OAuth2TokenType;
 import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
 import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
 import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter;
@@ -52,21 +44,28 @@ import org.springframework.security.oauth2.jose.TestJwks;
 import org.springframework.security.oauth2.jose.TestKeys;
 import org.springframework.security.oauth2.jwt.Jwt;
 import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
+import org.springframework.security.oauth2.server.authorization.JwtEncodingContext;
 import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
 import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
+import org.springframework.security.oauth2.server.authorization.OAuth2TokenCustomizer;
 import org.springframework.security.oauth2.server.authorization.TestOAuth2Authorizations;
-import org.springframework.security.oauth2.core.OAuth2TokenType;
 import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
 import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
 import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients;
-import org.springframework.security.oauth2.server.authorization.JwtEncodingContext;
-import org.springframework.security.oauth2.server.authorization.OAuth2TokenCustomizer;
 import org.springframework.security.oauth2.server.authorization.web.OAuth2TokenEndpointFilter;
 import org.springframework.test.web.servlet.MockMvc;
 import org.springframework.test.web.servlet.MvcResult;
 import org.springframework.util.LinkedMultiValueMap;
 import org.springframework.util.MultiValueMap;
 
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.Principal;
+import java.util.Base64;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.hamcrest.CoreMatchers.containsString;
 import static org.mockito.ArgumentMatchers.any;

+ 5 - 6
oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2TokenRevocationTests.java

@@ -15,10 +15,6 @@
  */
 package org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization;
 
-import java.net.URLEncoder;
-import java.nio.charset.StandardCharsets;
-import java.util.Base64;
-
 import com.nimbusds.jose.jwk.JWKSet;
 import com.nimbusds.jose.jwk.source.JWKSource;
 import com.nimbusds.jose.proc.SecurityContext;
@@ -27,7 +23,6 @@ import org.junit.BeforeClass;
 import org.junit.Rule;
 import org.junit.Test;
 import org.mockito.ArgumentCaptor;
-
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Import;
@@ -38,12 +33,12 @@ import org.springframework.security.config.test.SpringTestRule;
 import org.springframework.security.oauth2.core.AbstractOAuth2Token;
 import org.springframework.security.oauth2.core.OAuth2AccessToken;
 import org.springframework.security.oauth2.core.OAuth2RefreshToken;
+import org.springframework.security.oauth2.core.OAuth2TokenType;
 import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames2;
 import org.springframework.security.oauth2.jose.TestJwks;
 import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
 import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
 import org.springframework.security.oauth2.server.authorization.TestOAuth2Authorizations;
-import org.springframework.security.oauth2.core.OAuth2TokenType;
 import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
 import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
 import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients;
@@ -53,6 +48,10 @@ import org.springframework.test.web.servlet.MockMvc;
 import org.springframework.util.LinkedMultiValueMap;
 import org.springframework.util.MultiValueMap;
 
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.ArgumentMatchers.isNull;

+ 241 - 0
oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OidcClientRegistrationTests.java

@@ -0,0 +1,241 @@
+/*
+ * Copyright 2020-2021 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.config.annotation.web.configurers.oauth2.server.authorization;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.source.JWKSource;
+import com.nimbusds.jose.proc.SecurityContext;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Rule;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Import;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.converter.HttpMessageConverter;
+import org.springframework.mock.http.client.MockClientHttpResponse;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
+import org.springframework.security.config.test.SpringTestRule;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.OAuth2AccessToken;
+import org.springframework.security.oauth2.core.OAuth2TokenType;
+import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
+import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponseType;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter;
+import org.springframework.security.oauth2.core.oidc.OidcClientRegistration;
+import org.springframework.security.oauth2.core.oidc.http.converter.OidcClientRegistrationHttpMessageConverter;
+import org.springframework.security.oauth2.jose.TestJwks;
+import org.springframework.security.oauth2.jose.TestKeys;
+import org.springframework.security.oauth2.jwt.JwtDecoder;
+import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
+import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
+import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients;
+import org.springframework.security.oauth2.server.authorization.config.ProviderSettings;
+import org.springframework.security.oauth2.server.authorization.web.OAuth2TokenEndpointFilter;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * Integration tests for OpenID Connect 1.0 Client Registration Endpoint.
+ *
+ * @author Ovidiu Popa
+ * @since 0.1.1
+ */
+public class OidcClientRegistrationTests {
+	private static final OidcClientRegistration.Builder OIDC_CLIENT_REGISTRATION = OidcClientRegistration.builder()
+			.redirectUri("https://localhost:8080/client")
+			.responseType(OAuth2AuthorizationResponseType.CODE.getValue())
+			.grantType(AuthorizationGrantType.AUTHORIZATION_CODE.getValue())
+			.tokenEndpointAuthenticationMethod(ClientAuthenticationMethod.BASIC.getValue())
+			.scope("test");
+
+	private static final HttpMessageConverter<OAuth2AccessTokenResponse> accessTokenHttpResponseConverter =
+			new OAuth2AccessTokenResponseHttpMessageConverter();
+
+	private static final OidcClientRegistrationHttpMessageConverter clientRegistrationHttpMessageConverter =
+			new OidcClientRegistrationHttpMessageConverter();
+
+	private static final OAuth2TokenType ACCESS_TOKEN_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.ACCESS_TOKEN);
+
+	private static RegisteredClientRepository registeredClientRepository;
+	private static OAuth2AuthorizationService authorizationService;
+	private static JWKSource<SecurityContext> jwkSource;
+	private static NimbusJwtDecoder jwtDecoder;
+
+	@Rule
+	public final SpringTestRule spring = new SpringTestRule();
+
+	@Autowired
+	private MockMvc mvc;
+
+	@BeforeClass
+	public static void init() {
+		registeredClientRepository = mock(RegisteredClientRepository.class);
+		authorizationService = mock(OAuth2AuthorizationService.class);
+		JWKSet jwkSet = new JWKSet(TestJwks.DEFAULT_RSA_JWK);
+		jwkSource = (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
+		jwtDecoder = NimbusJwtDecoder.withPublicKey(TestKeys.DEFAULT_PUBLIC_KEY).build();
+	}
+
+	@Before
+	public void setup() {
+		reset(registeredClientRepository);
+		reset(authorizationService);
+	}
+
+	@Test
+	public void requestWhenAuthenticatedThenResponseIncludesRegisteredClientDetails() throws Exception {
+		this.spring.register(AuthorizationServerConfigurationEnabledClientRegistration.class).autowire();
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient2()
+				.scope("client.create").build();
+		when(registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+				.thenReturn(registeredClient);
+		// get access token
+		MvcResult mvcResult = this.mvc.perform(post(OAuth2TokenEndpointFilter.DEFAULT_TOKEN_ENDPOINT_URI)
+				.param(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.CLIENT_CREDENTIALS.getValue())
+				.param(OAuth2ParameterNames.SCOPE, "client.create")
+				.header(HttpHeaders.AUTHORIZATION, "Basic " + encodeBasicAuth(
+						registeredClient.getClientId(), registeredClient.getClientSecret())))
+				.andExpect(status().isOk())
+				.andExpect(jsonPath("$.access_token").isNotEmpty())
+				.andExpect(jsonPath("$.scope").value("client.create"))
+				.andReturn();
+
+		//assert get access token
+		verify(registeredClientRepository).findByClientId(eq(registeredClient.getClientId()));
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		verify(authorizationService).save(authorizationCaptor.capture());
+		OAuth2Authorization authorization = authorizationCaptor.getValue();
+		MockHttpServletResponse servletResponse = mvcResult.getResponse();
+		MockClientHttpResponse httpResponse = new MockClientHttpResponse(
+				servletResponse.getContentAsByteArray(), HttpStatus.valueOf(servletResponse.getStatus()));
+		OAuth2AccessTokenResponse accessTokenResponse = accessTokenHttpResponseConverter.read(OAuth2AccessTokenResponse.class, httpResponse);
+		String tokenValue = accessTokenResponse.getAccessToken().getTokenValue();
+
+		// prepare register client request
+		when(authorizationService.findByToken(
+				eq(authorization.getToken(OAuth2AccessToken.class).getToken().getTokenValue()),
+				eq(ACCESS_TOKEN_TOKEN_TYPE)))
+				.thenReturn(authorization);
+		doNothing().when(registeredClientRepository).saveClient(any(RegisteredClient.class));
+		mvcResult = this.mvc.perform(post("/connect/register")
+				.header(HttpHeaders.AUTHORIZATION, "Bearer " + tokenValue)
+				.contentType(MediaType.APPLICATION_JSON)
+				.content(convertToByteArray(OIDC_CLIENT_REGISTRATION.build())))
+				.andExpect(status().isCreated()).andReturn();
+
+		servletResponse = mvcResult.getResponse();
+		httpResponse = new MockClientHttpResponse(
+				servletResponse.getContentAsByteArray(), HttpStatus.valueOf(servletResponse.getStatus()));
+
+		OidcClientRegistration result = clientRegistrationHttpMessageConverter.read(OidcClientRegistration.class, httpResponse);
+
+
+		assertThat(result).isNotNull();
+		assertThat(result.getClaimAsString("client_id")).isNotEmpty();
+		assertThat(result.getClaimAsString("client_id_issued_at")).isNotEmpty();
+		assertThat(result.getClaimAsString("client_secret")).isNotEmpty();
+		assertThat(result.getClaimAsString("client_secret_expires_at")).isNotNull().isEqualTo("0.0");
+		assertThat(result.getRedirectUris()).isNotEmpty().containsExactly("https://localhost:8080/client");
+		assertThat(result.getResponseTypes()).isNotEmpty().containsExactly(OAuth2AuthorizationResponseType.CODE.getValue());
+		assertThat(result.getGrantTypes()).isNotEmpty().containsExactly(AuthorizationGrantType.AUTHORIZATION_CODE.getValue());
+		assertThat(result.getTokenEndpointAuthenticationMethod()).isNotEmpty().isEqualTo(ClientAuthenticationMethod.BASIC.getValue());
+		assertThat(result.getScope()).isNotEmpty().isEqualTo("test");
+	}
+
+	private static String encodeBasicAuth(String clientId, String secret) throws Exception {
+		clientId = URLEncoder.encode(clientId, StandardCharsets.UTF_8.name());
+		secret = URLEncoder.encode(secret, StandardCharsets.UTF_8.name());
+		String credentialsString = clientId + ":" + secret;
+		byte[] encodedBytes = Base64.getEncoder().encode(credentialsString.getBytes(StandardCharsets.UTF_8));
+		return new String(encodedBytes, StandardCharsets.UTF_8);
+	}
+
+	private static byte[] convertToByteArray(OidcClientRegistration clientRegistration) throws JsonProcessingException {
+		ObjectMapper objectMapper = new ObjectMapper();
+
+		return objectMapper
+				.writerFor(Map.class)
+				.writeValueAsBytes(clientRegistration.getClaims());
+	}
+
+	@EnableWebSecurity
+	@Import(OAuth2AuthorizationServerConfiguration.class)
+	static class AuthorizationServerConfiguration {
+
+		@Bean
+		RegisteredClientRepository registeredClientRepository() {
+			return registeredClientRepository;
+		}
+
+		@Bean
+		OAuth2AuthorizationService authorizationService() {
+			return authorizationService;
+		}
+
+		@Bean
+		JWKSource<SecurityContext> jwkSource() {
+			return jwkSource;
+		}
+
+
+	}
+
+	@EnableWebSecurity
+	@Import(OAuth2AuthorizationServerConfiguration.class)
+	static class AuthorizationServerConfigurationEnabledClientRegistration extends AuthorizationServerConfiguration{
+
+		@Bean
+		JwtDecoder jwtDecoder() {
+			return jwtDecoder;
+		}
+
+		@Bean
+		ProviderSettings providerSettings() {
+			return new ProviderSettings().isOidClientRegistrationEndpointEnabled(true);
+		}
+	}
+}

+ 6 - 0
oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OidcTests.java

@@ -56,6 +56,7 @@ import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames
 import org.springframework.security.oauth2.jose.TestJwks;
 import org.springframework.security.oauth2.jose.TestKeys;
 import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtDecoder;
 import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
 import org.springframework.security.oauth2.server.authorization.JwtEncodingContext;
 import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
@@ -273,6 +274,11 @@ public class OidcTests {
 				}
 			};
 		}
+
+		@Bean
+		JwtDecoder jwtDecoder(){
+			return jwtDecoder;
+		}
 	}
 
 	@EnableWebSecurity

+ 331 - 0
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/core/oidc/OidcClientRegistrationTests.java

@@ -0,0 +1,331 @@
+/*
+ * Copyright 2020-2021 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.core.oidc;
+
+import org.junit.Test;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponseType;
+
+import java.net.URL;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests for {@link OidcClientRegistration}
+ *
+ * @author Ovidiu Popa
+ * @since 0.1.1
+ */
+public class OidcClientRegistrationTests {
+
+	private final OidcClientRegistration.Builder clientRegistrationBuilder =
+			OidcClientRegistration.builder();
+
+	@Test
+	public void buildWhenAllRequiredClaimsAndAdditionalClaimsThenCreated() {
+		OidcClientRegistration clientRegistration = OidcClientRegistration.builder()
+				.redirectUri("http://client.example.com")
+				.grantType(AuthorizationGrantType.AUTHORIZATION_CODE.getValue())
+				.grantType(AuthorizationGrantType.CLIENT_CREDENTIALS.getValue())
+				.responseType(OAuth2AuthorizationResponseType.CODE.getValue())
+				.scope("test read")
+				.tokenEndpointAuthenticationMethod(ClientAuthenticationMethod.BASIC.getValue())
+				.build();
+
+		assertThat(clientRegistration.getRedirectUris())
+				.containsOnly("http://client.example.com");
+		assertThat(clientRegistration.getGrantTypes())
+				.contains(
+						AuthorizationGrantType.AUTHORIZATION_CODE.getValue(),
+						AuthorizationGrantType.CLIENT_CREDENTIALS.getValue()
+				);
+		assertThat(clientRegistration.getResponseTypes())
+				.contains(OAuth2AuthorizationResponseType.CODE.getValue());
+		assertThat(clientRegistration.getScope())
+				.isEqualTo("test read");
+		assertThat(clientRegistration.getTokenEndpointAuthenticationMethod())
+				.isEqualTo(ClientAuthenticationMethod.BASIC.getValue());
+
+	}
+
+	@Test
+	public void buildWhenAllRequiredClaimsThenCreated() {
+		OidcClientRegistration clientRegistration = OidcClientRegistration.builder()
+				.redirectUri("http://client.example.com")
+				.build();
+
+		assertThat(clientRegistration.getRedirectUris())
+				.containsOnly("http://client.example.com");
+		assertThat(clientRegistration.getGrantTypes())
+				.containsOnly(AuthorizationGrantType.AUTHORIZATION_CODE.getValue());
+		assertThat(clientRegistration.getResponseTypes())
+				.containsOnly(OAuth2AuthorizationResponseType.CODE.getValue());
+		assertThat(clientRegistration.getScope())
+				.isNull();
+		assertThat(clientRegistration.getTokenEndpointAuthenticationMethod())
+				.isEqualTo(ClientAuthenticationMethod.BASIC.getValue());
+	}
+
+	@Test
+	public void buildWhenAllRequiredClaimsAndAuthorizationGrantTypeButMissingResponseTypeThenCreated() {
+		OidcClientRegistration clientRegistration = OidcClientRegistration.builder()
+				.redirectUri("http://client.example.com")
+				.grantType(AuthorizationGrantType.AUTHORIZATION_CODE.getValue())
+				.build();
+
+		assertThat(clientRegistration.getRedirectUris())
+				.containsOnly("http://client.example.com");
+		assertThat(clientRegistration.getGrantTypes())
+				.containsOnly(AuthorizationGrantType.AUTHORIZATION_CODE.getValue());
+		assertThat(clientRegistration.getResponseTypes())
+				.containsOnly(OAuth2AuthorizationResponseType.CODE.getValue());
+	}
+
+	@Test
+	public void buildWhenAllRequiredClaimsAndEmptyGrantTypeListButMissingResponseTypeThenCreated() {
+		OidcClientRegistration clientRegistration = OidcClientRegistration.builder()
+				.redirectUri("http://client.example.com")
+				.grantTypes(List::clear)
+				.build();
+
+		assertThat(clientRegistration.getRedirectUris())
+				.containsOnly("http://client.example.com");
+		assertThat(clientRegistration.getGrantTypes())
+				.containsOnly(AuthorizationGrantType.AUTHORIZATION_CODE.getValue());
+		assertThat(clientRegistration.getResponseTypes())
+				.containsOnly(OAuth2AuthorizationResponseType.CODE.getValue());
+	}
+
+	@Test
+	public void buildWhenAllRequiredClaimsAndResponseTypeButMissingAuthorizationGrantTypeThenCreated() {
+		OidcClientRegistration clientRegistration = OidcClientRegistration.builder()
+				.redirectUri("http://client.example.com")
+				.responseType(OAuth2AuthorizationResponseType.CODE.getValue())
+				.build();
+
+		assertThat(clientRegistration.getRedirectUris())
+				.containsOnly("http://client.example.com");
+		assertThat(clientRegistration.getGrantTypes())
+				.containsOnly(AuthorizationGrantType.AUTHORIZATION_CODE.getValue());
+		assertThat(clientRegistration.getResponseTypes())
+				.containsOnly(OAuth2AuthorizationResponseType.CODE.getValue());
+	}
+
+	@Test
+	public void buildWhenAllRequiredClaimsAndEmptyResponseTypeListButMissingAuthorizationGrantTypeThenCreated() {
+		OidcClientRegistration clientRegistration = OidcClientRegistration.builder()
+				.redirectUri("http://client.example.com")
+				.responseTypes(List::clear)
+				.build();
+
+		assertThat(clientRegistration.getRedirectUris())
+				.containsOnly("http://client.example.com");
+		assertThat(clientRegistration.getGrantTypes())
+				.containsOnly(AuthorizationGrantType.AUTHORIZATION_CODE.getValue());
+		assertThat(clientRegistration.getResponseTypes())
+				.containsOnly(OAuth2AuthorizationResponseType.CODE.getValue());
+	}
+
+	@Test
+	public void buildWhenAllRequiredClaimsAndEmptyScopeThenCreated() {
+		OidcClientRegistration clientRegistration = OidcClientRegistration.builder()
+				.redirectUri("http://client.example.com")
+				.build();
+
+		assertThat(clientRegistration.getRedirectUris())
+				.containsOnly("http://client.example.com");
+		assertThat(clientRegistration.getScope())
+				.isNull();
+	}
+
+	@Test
+	public void buildWhenAllRequiredClaimsAndEmptyTokenEndpointAuthMethodThenCreated() {
+		OidcClientRegistration clientRegistration = OidcClientRegistration.builder()
+				.redirectUri("http://client.example.com")
+				.build();
+
+		assertThat(clientRegistration.getRedirectUris())
+				.containsOnly("http://client.example.com");
+		assertThat(clientRegistration.getTokenEndpointAuthenticationMethod())
+				.isEqualTo(ClientAuthenticationMethod.BASIC.getValue());
+	}
+
+	@Test
+	public void buildWhenClaimsProvidedThenCreated() {
+		Map<String, Object> claims = new HashMap<>();
+		claims.put(OidcClientMetadataClaimNames.REDIRECT_URIS, Collections.singletonList("http://client.example.com"));
+		claims.put(OidcClientMetadataClaimNames.GRANT_TYPES, Arrays.asList(
+				AuthorizationGrantType.AUTHORIZATION_CODE.getValue(),
+				AuthorizationGrantType.CLIENT_CREDENTIALS.getValue()
+		));
+		claims.put(OidcClientMetadataClaimNames.RESPONSE_TYPES,
+				Collections.singletonList(OAuth2AuthorizationResponseType.CODE.getValue()));
+		claims.put(OidcClientMetadataClaimNames.SCOPE, "test read");
+		claims.put(OidcClientMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHOD, ClientAuthenticationMethod.BASIC.getValue());
+
+		OidcClientRegistration clientRegistration = OidcClientRegistration.withClaims(claims).build();
+
+		assertThat(clientRegistration.getRedirectUris())
+				.containsOnly("http://client.example.com");
+		assertThat(clientRegistration.getGrantTypes())
+				.contains(
+						AuthorizationGrantType.AUTHORIZATION_CODE.getValue(),
+						AuthorizationGrantType.CLIENT_CREDENTIALS.getValue()
+				);
+		assertThat(clientRegistration.getResponseTypes())
+				.contains(OAuth2AuthorizationResponseType.CODE.getValue());
+		assertThat(clientRegistration.getScope())
+				.isEqualTo("test read");
+		assertThat(clientRegistration.getTokenEndpointAuthenticationMethod())
+				.isEqualTo(ClientAuthenticationMethod.BASIC.getValue());
+	}
+
+	@Test
+	public void buildWhenRedirectUriProvidedWithUrlThenCreated() {
+		Map<String, Object> claims = new HashMap<>();
+		claims.put(OidcClientMetadataClaimNames.REDIRECT_URIS, Arrays.asList(
+				url("http://client.example.com"),
+				url("http://client.example.com/authorized")
+				)
+		);
+		claims.put(OidcClientMetadataClaimNames.GRANT_TYPES, Arrays.asList(
+				AuthorizationGrantType.AUTHORIZATION_CODE.getValue(),
+				AuthorizationGrantType.CLIENT_CREDENTIALS.getValue()
+		));
+		claims.put(OidcClientMetadataClaimNames.RESPONSE_TYPES,
+				Collections.singletonList(OAuth2AuthorizationResponseType.CODE.getValue()));
+		claims.put(OidcClientMetadataClaimNames.SCOPE, "test read");
+		claims.put(OidcClientMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHOD, ClientAuthenticationMethod.BASIC.getValue());
+
+		OidcClientRegistration clientRegistration = OidcClientRegistration.withClaims(claims).build();
+
+		assertThat(clientRegistration.getRedirectUris())
+				.contains("http://client.example.com", "http://client.example.com/authorized");
+		assertThat(clientRegistration.getGrantTypes())
+				.contains(
+						AuthorizationGrantType.AUTHORIZATION_CODE.getValue(),
+						AuthorizationGrantType.CLIENT_CREDENTIALS.getValue()
+				);
+		assertThat(clientRegistration.getResponseTypes())
+				.contains(OAuth2AuthorizationResponseType.CODE.getValue());
+		assertThat(clientRegistration.getScope())
+				.isEqualTo("test read");
+		assertThat(clientRegistration.getTokenEndpointAuthenticationMethod())
+				.isEqualTo(ClientAuthenticationMethod.BASIC.getValue());
+	}
+
+	@Test
+	public void withClaimsNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> OidcClientRegistration.withClaims(null))
+				.isInstanceOf(IllegalArgumentException.class);
+	}
+
+	@Test
+	public void withClaimsEmptyThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> OidcClientRegistration.withClaims(Collections.emptyMap()))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("claims cannot be empty");
+	}
+
+	@Test
+	public void buildWhenNullRedirectUriThenThrowIllegalArgumentException() {
+		OidcClientRegistration.Builder builder = this.clientRegistrationBuilder
+				.redirectUris((claims) -> claims.remove(OidcClientMetadataClaimNames.REDIRECT_URIS));
+
+		assertThatThrownBy(builder::build)
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("redirect_uris must not be empty");
+	}
+
+	@Test
+	public void buildWhenNullRedirectUriClaimThenThrowIllegalArgumentException() {
+		Map<String, Object> claims = new HashMap<>();
+		claims.put(OidcClientMetadataClaimNames.REDIRECT_URIS, null);
+		OidcClientRegistration.Builder builder = OidcClientRegistration.withClaims(claims);
+
+		assertThatThrownBy(builder::build)
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("redirect_uris cannot be null");
+	}
+
+	@Test
+	public void buildWhenEmptyRedirectUriListThenThrowIllegalArgumentException() {
+		OidcClientRegistration.Builder builder = this.clientRegistrationBuilder
+				.redirectUris(List::clear);
+
+		assertThatThrownBy(builder::build)
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("redirect_uris must not be empty");
+	}
+
+	@Test
+	public void buildWhenRedirectUriNotOfTypeListThenThrowIllegalArgumentException() {
+		OidcClientRegistration.Builder builder = this.clientRegistrationBuilder
+				.claims(claims -> claims.put(OidcClientMetadataClaimNames.REDIRECT_URIS, "http://client.example.com"));
+
+		assertThatThrownBy(builder::build)
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessageContaining("redirect_uris must be of type list");
+	}
+
+	@Test
+	public void buildWhenRedirectUriNotUrlThenThrowIllegalArgumentException() {
+		OidcClientRegistration.Builder builder = this.clientRegistrationBuilder
+				.redirectUri("not url");
+
+		assertThatThrownBy(builder::build)
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("redirect_uri must be a valid URL");
+	}
+
+	@Test
+	public void buildWhenResponseTypesNotOfTypeListThenThrowIllegalArgumentException() {
+		OidcClientRegistration.Builder builder = this.clientRegistrationBuilder
+				.redirectUri("http://client.example.com")
+				.claims(claims -> claims.put(OidcClientMetadataClaimNames.RESPONSE_TYPES, OAuth2AuthorizationResponseType.CODE.getValue()));
+
+		assertThatThrownBy(builder::build)
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessageContaining("response_types must be of type List");
+	}
+
+	@Test
+	public void buildWhenGrantTypesNotOfTypeListThenThrowIllegalArgumentException() {
+		OidcClientRegistration.Builder builder = this.clientRegistrationBuilder
+				.redirectUri("http://client.example.com")
+				.claims(claims -> claims.put(OidcClientMetadataClaimNames.GRANT_TYPES, AuthorizationGrantType.AUTHORIZATION_CODE.getValue()));
+
+		assertThatThrownBy(builder::build)
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessageContaining("grant_types must be of type List");
+	}
+
+	private static URL url(String urlString) {
+		try {
+			return new URL(urlString);
+		} catch (Exception ex) {
+			throw new IllegalArgumentException("urlString must be a valid URL and valid URI");
+		}
+	}
+
+}

+ 197 - 0
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/core/oidc/http/converter/OidcClientRegistrationHttpMessageConverterTest.java

@@ -0,0 +1,197 @@
+/*
+ * Copyright 2020-2021 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.core.oidc.http.converter;
+
+import org.junit.Test;
+import org.springframework.core.convert.converter.Converter;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.http.converter.HttpMessageNotWritableException;
+import org.springframework.mock.http.MockHttpOutputMessage;
+import org.springframework.mock.http.client.MockClientHttpResponse;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponseType;
+import org.springframework.security.oauth2.core.oidc.OidcClientRegistration;
+
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * @author Ovidiu Popa
+ * @since 0.1.1
+ */
+public class OidcClientRegistrationHttpMessageConverterTest {
+	private final OidcClientRegistrationHttpMessageConverter messageConverter =
+			new OidcClientRegistrationHttpMessageConverter();
+
+	@Test
+	public void supportsWhenOidcClientRegistrationThenTrue() {
+		assertThat(this.messageConverter.supports(OidcClientRegistration.class)).isTrue();
+	}
+
+	@Test
+	public void setClientRegistrationReadConverterWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> this.messageConverter.setClientRegistrationConverter(null))
+				.withMessageContaining("clientRegistrationConverter cannot be null");
+	}
+
+	@Test
+	public void setClientRegistrationWriteConverterWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> this.messageConverter.setClientRegistrationParametersConverter(null))
+				.withMessageContaining("clientRegistrationParametersConverter cannot be null");
+	}
+
+	@Test
+	public void readInternalWhenRequiredParametersThenSuccess() {
+		// @formatter:off
+		String clientRegistrationResponse = "{\n"
+				+ "		\"redirect_uris\": [\n"
+				+ "			\"https://client.example.org/callback\"\n"
+				+ "		]\n"
+				+ "}\n";
+		// @formatter:on
+
+		MockClientHttpResponse response = new MockClientHttpResponse(clientRegistrationResponse.getBytes(), HttpStatus.OK);
+		OidcClientRegistration clientRegistration = this.messageConverter
+				.readInternal(OidcClientRegistration.class, response);
+
+		assertThat(clientRegistration.getRedirectUris())
+				.containsOnly("https://client.example.org/callback");
+		assertThat(clientRegistration.getGrantTypes())
+				.containsOnly(
+						AuthorizationGrantType.AUTHORIZATION_CODE.getValue()
+				);
+		assertThat(clientRegistration.getResponseTypes())
+				.contains(OAuth2AuthorizationResponseType.CODE.getValue());
+		assertThat(clientRegistration.getScope())
+				.isNull();
+		assertThat(clientRegistration.getTokenEndpointAuthenticationMethod())
+				.isEqualTo(ClientAuthenticationMethod.BASIC.getValue());
+	}
+
+	@Test
+	public void readInternalWhenValidParametersThenSuccess() {
+		// @formatter:off
+		String clientRegistrationResponse = "{\n"
+				+"		\"redirect_uris\": [\n"
+				+ "			\"https://client.example.org/callback\"\n"
+				+ "		],\n"
+				+"		\"grant_types\": [\n"
+				+"			\"client_credentials\",\n"
+				+"			\"authorization_code\"\n"
+				+"		],\n"
+				+"		\"response_types\":[\n"
+				+"			\"code\"\n"
+				+"		],\n"
+				+"		\"client_name\": \"My Example\",\n"
+				+"		\"scope\": \"read write\",\n"
+				+"		\"token_endpoint_auth_method\": \"basic\"\n"
+				+"}\n";
+		// @formatter:on
+		MockClientHttpResponse response = new MockClientHttpResponse(clientRegistrationResponse.getBytes(), HttpStatus.OK);
+
+		OidcClientRegistration clientRegistration = this.messageConverter
+				.readInternal(OidcClientRegistration.class, response);
+		assertThat(clientRegistration.getRedirectUris())
+				.containsOnly("https://client.example.org/callback");
+		assertThat(clientRegistration.getGrantTypes())
+				.contains(
+						AuthorizationGrantType.AUTHORIZATION_CODE.getValue(),
+						AuthorizationGrantType.CLIENT_CREDENTIALS.getValue()
+				);
+		assertThat(clientRegistration.getResponseTypes())
+				.contains(OAuth2AuthorizationResponseType.CODE.getValue());
+		assertThat(clientRegistration.getScope())
+				.isEqualTo("read write");
+		assertThat(clientRegistration.getTokenEndpointAuthenticationMethod())
+				.isEqualTo(ClientAuthenticationMethod.BASIC.getValue());
+	}
+
+	@Test
+	public void readInternalWhenFailingConverterThenThrowException() {
+		String errorMessage = "this is not a valid converter";
+		this.messageConverter.setClientRegistrationConverter(source -> {
+			throw new RuntimeException(errorMessage);
+		});
+		MockClientHttpResponse response = new MockClientHttpResponse("{}".getBytes(), HttpStatus.OK);
+
+		assertThatExceptionOfType(HttpMessageNotReadableException.class)
+				.isThrownBy(() -> this.messageConverter.readInternal(OidcClientRegistration.class, response))
+				.withMessageContaining("An error occurred reading the OpenID Client Registration Request")
+				.withMessageContaining(errorMessage);
+	}
+
+	@Test
+	public void readInternalWhenInvalidClientRegistrationThenThrowException() {
+		String clientRegistrationResponse = "{ \"redirect_uris\": null }";
+		MockClientHttpResponse response = new MockClientHttpResponse(clientRegistrationResponse.getBytes(), HttpStatus.OK);
+
+		assertThatExceptionOfType(HttpMessageNotReadableException.class)
+				.isThrownBy(() -> this.messageConverter.readInternal(OidcClientRegistration.class, response))
+				.withMessageContaining("An error occurred reading the OpenID Client Registration Request")
+				.withMessageContaining("redirect_uris cannot be null");
+	}
+
+	@Test
+	public void writeInternalWhenClientRegistrationThenSuccess() {
+		OidcClientRegistration clientRegistration = OidcClientRegistration.builder()
+				.redirectUri("http://client.example.com/callback")
+				.grantType(AuthorizationGrantType.AUTHORIZATION_CODE.getValue())
+				.grantType(AuthorizationGrantType.CLIENT_CREDENTIALS.getValue())
+				.responseType(OAuth2AuthorizationResponseType.CODE.getValue())
+				.scope("test read")
+				.tokenEndpointAuthenticationMethod(ClientAuthenticationMethod.BASIC.getValue())
+				.build();
+		MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
+
+		this.messageConverter.writeInternal(clientRegistration, outputMessage);
+		String clientRegistrationResponse = outputMessage.getBodyAsString();
+		assertThat(clientRegistrationResponse).contains("\"redirect_uris\":[\"http://client.example.com/callback\"]");
+		assertThat(clientRegistrationResponse).contains("\"grant_types\":[\"authorization_code\",\"client_credentials\"]");
+		assertThat(clientRegistrationResponse).contains("\"response_types\":[\"code\"]");
+		assertThat(clientRegistrationResponse).contains("\"scope\":\"test read\"");
+		assertThat(clientRegistrationResponse).contains("\"token_endpoint_auth_method\":\"basic\"");
+	}
+
+	@Test
+	public void writeInternalWhenWriteFailsThenThrowsException() {
+		String errorMessage = "this is not a valid converter";
+		Converter<OidcClientRegistration, Map<String, Object>> failingConverter =
+				source -> {
+					throw new RuntimeException(errorMessage);
+				};
+		this.messageConverter.setClientRegistrationParametersConverter(failingConverter);
+
+		OidcClientRegistration clientRegistration =
+				OidcClientRegistration.builder()
+						.redirectUri("http://client.example.com")
+						.build();
+
+		MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
+
+		assertThatThrownBy(() -> this.messageConverter.writeInternal(clientRegistration, outputMessage))
+				.isInstanceOf(HttpMessageNotWritableException.class)
+				.hasMessageContaining("An error occurred writing the OpenID Client Registration response")
+				.hasMessageContaining(errorMessage);
+	}
+}

+ 173 - 0
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OidcClientRegistrationAuthenticationProviderTests.java

@@ -0,0 +1,173 @@
+/*
+ * Copyright 2020-2021 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 org.junit.Before;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.AuthorityUtils;
+import org.springframework.security.oauth2.core.OAuth2AccessToken;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.core.OAuth2TokenType;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
+import org.springframework.security.oauth2.server.authorization.TestOAuth2Authorizations;
+import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
+
+import java.time.Instant;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * @author Ovidiu Popa
+ * @since 0.1.1
+ */
+public class OidcClientRegistrationAuthenticationProviderTests {
+
+	private OAuth2AuthorizationService authorizationService;
+	private OidcClientRegistrationAuthenticationProvider authenticationProvider;
+
+	@Before
+	public void setUp() {
+		this.authorizationService = mock(OAuth2AuthorizationService.class);
+		this.authenticationProvider = new OidcClientRegistrationAuthenticationProvider(this.authorizationService);
+	}
+
+	@Test
+	public void constructorWhenAuthorizationServiceNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new OidcClientRegistrationAuthenticationProvider(null))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("authorizationService cannot be null");
+	}
+
+	@Test
+	public void supportsWhenTypeJwtAuthenticationTokenThenReturnTrue() {
+		assertThat(this.authenticationProvider.supports(JwtAuthenticationToken.class)).isTrue();
+	}
+
+	@Test
+	public void authenticateWhenAccessTokenNotFoundThenThrowOAuth2AuthenticationException() {
+		JwtAuthenticationToken authentication = buildJwtAuthenticationToken("client-registration-token",  "SCOPE_client.create");
+
+		when(authorizationService.findByToken(
+				eq("client-registration-token"), eq(OAuth2TokenType.ACCESS_TOKEN)))
+				.thenReturn(null);
+
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.isInstanceOf(OAuth2AuthenticationException.class)
+				.extracting(ex -> ((OAuth2AuthenticationException) ex).getError())
+				.extracting("errorCode")
+				.isEqualTo(OAuth2ErrorCodes.INVALID_GRANT);
+
+	}
+
+	@Test
+	public void authenticateWhenAccessTokenInvalidatedThenThrowOAuth2AuthenticationException() {
+
+		JwtAuthenticationToken authentication = buildJwtAuthenticationToken("client-registration-token",  "SCOPE_client.create");
+
+		OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER,
+				"client-registration-token", Instant.now().minusSeconds(120), Instant.now().plusSeconds(1000));
+
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization()
+				.token(accessToken, (metadata) -> metadata.put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, true))
+				.build();
+
+		when(authorizationService.findByToken(
+				eq("client-registration-token"), eq(OAuth2TokenType.ACCESS_TOKEN)))
+				.thenReturn(authorization);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.isInstanceOf(OAuth2AuthenticationException.class)
+				.extracting(ex -> ((OAuth2AuthenticationException) ex).getError())
+				.extracting("errorCode")
+				.isEqualTo(OAuth2ErrorCodes.INVALID_GRANT);
+	}
+
+	@Test
+	public void authenticateWhenAccessTokenWithoutClientCreateScopeThenThrowOAuth2AuthenticationException() {
+
+		JwtAuthenticationToken authentication = buildJwtAuthenticationToken("client-registration-token",  "SCOPE_scope1");
+
+		OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER,
+				"client-registration-token", Instant.now().minusSeconds(120), Instant.now().plusSeconds(1000),
+				new HashSet<>(Collections.singletonList("scope1")));
+
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization()
+				.token(accessToken)
+				.build();
+
+		when(authorizationService.findByToken(
+				eq("client-registration-token"), eq(OAuth2TokenType.ACCESS_TOKEN)))
+				.thenReturn(authorization);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.isInstanceOf(OAuth2AuthenticationException.class)
+				.extracting(ex -> ((OAuth2AuthenticationException) ex).getError())
+				.extracting("errorCode")
+				.isEqualTo(OAuth2ErrorCodes.INVALID_GRANT);
+	}
+
+	@Test
+	public void authenticateWhenValidAccessTokenThenInvalidated() {
+		JwtAuthenticationToken authentication = buildJwtAuthenticationToken("client-registration-token", "SCOPE_client.create");
+
+		OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER,
+				"client-registration-token", Instant.now().minusSeconds(120), Instant.now().plusSeconds(1000),
+				new HashSet<>(Collections.singletonList("client.create")));
+
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization()
+				.token(accessToken)
+				.build();
+
+		when(authorizationService.findByToken(
+				eq("client-registration-token"), eq(OAuth2TokenType.ACCESS_TOKEN)))
+				.thenReturn(authorization);
+
+		authenticationProvider.authenticate(authentication);
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		verify(authorizationService).save(authorizationCaptor.capture());
+
+		OAuth2Authorization capturedAuthorization = authorizationCaptor.getValue();
+
+		assertThat(capturedAuthorization.getAccessToken()).isNotNull();
+		assertThat(capturedAuthorization.getAccessToken().isInvalidated()).isTrue();
+	}
+
+	private static JwtAuthenticationToken buildJwtAuthenticationToken(String tokenValue, String... authorities) {
+		Jwt jwt = Jwt.withTokenValue(tokenValue)
+				.header("alg", "none")
+				.claim("sub", "client")
+				.build();
+		List<GrantedAuthority> grantedAuthorities = AuthorityUtils.createAuthorityList(authorities);
+		JwtAuthenticationToken jwtAuthenticationToken = new JwtAuthenticationToken(jwt, grantedAuthorities);
+		jwtAuthenticationToken.setAuthenticated(true);
+		return jwtAuthenticationToken;
+	}
+}

+ 75 - 0
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/client/InMemoryRegisteredClientRepositoryTests.java

@@ -16,6 +16,8 @@
 package org.springframework.security.oauth2.server.authorization.client;
 
 import org.junit.Test;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
 
 import java.util.Arrays;
 import java.util.Collections;
@@ -112,4 +114,77 @@ public class InMemoryRegisteredClientRepositoryTests {
 	public void findByClientIdWhenNullThenThrowIllegalArgumentException() {
 		assertThatThrownBy(() -> this.clients.findByClientId(null)).isInstanceOf(IllegalArgumentException.class);
 	}
+
+	@Test
+	public void saveNullRegisteredClientThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> this.clients.saveClient(null))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessageContaining("registeredClient cannot be null");
+	}
+
+	@Test
+	public void saveRegisteredClientThenReturnsSavedRegisteredClientWhenSearchedById() {
+		RegisteredClient registeredClient = RegisteredClient.withId("new-client")
+				.clientId("new-client")
+				.clientSecret("secret")
+				.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
+				.clientAuthenticationMethod(ClientAuthenticationMethod.BASIC)
+				.redirectUri("https://newclient.com")
+				.scope("scope1").build();
+
+		this.clients.saveClient(registeredClient);
+
+		RegisteredClient savedClient = this.clients.findById("new-client");
+
+		assertThat(savedClient).isNotNull().isEqualTo(registeredClient);
+	}
+
+	@Test
+	public void saveRegisteredClientThenReturnsSavedRegisteredClientWhenSearchedByClientId() {
+		RegisteredClient registeredClient = RegisteredClient.withId("id1")
+				.clientId("new-client-id")
+				.clientSecret("secret")
+				.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
+				.clientAuthenticationMethod(ClientAuthenticationMethod.BASIC)
+				.redirectUri("https://newclient.com")
+				.scope("scope1").build();
+
+		this.clients.saveClient(registeredClient);
+
+		RegisteredClient savedClient = this.clients.findByClientId("new-client-id");
+
+		assertThat(savedClient).isNotNull().isEqualTo(registeredClient);
+	}
+
+	@Test
+	public void saveRegisteredClientWithExistingIdThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> {
+			RegisteredClient registeredClient = RegisteredClient.withId("registration-1")
+					.clientId("new-client")
+					.clientSecret("secret")
+					.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
+					.clientAuthenticationMethod(ClientAuthenticationMethod.BASIC)
+					.redirectUri("https://newclient.com")
+					.scope("scope1").build();
+
+			this.clients.saveClient(registeredClient);
+		}).isInstanceOf(IllegalArgumentException.class)
+		.hasMessageContaining("Registered client must be unique. Found duplicate identifier");
+	}
+
+	@Test
+	public void saveRegisteredClientWithExistingClientIdThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> {
+			RegisteredClient registeredClient = RegisteredClient.withId("new-client")
+					.clientId("client-1")
+					.clientSecret("secret")
+					.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
+					.clientAuthenticationMethod(ClientAuthenticationMethod.BASIC)
+					.redirectUri("https://newclient.com")
+					.scope("scope1").build();
+
+			this.clients.saveClient(registeredClient);
+		}).isInstanceOf(IllegalArgumentException.class)
+		.hasMessageContaining("Registered client must be unique. Found duplicate client identifier");
+	}
 }

+ 19 - 2
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/ProviderSettingsTests.java

@@ -37,6 +37,8 @@ public class ProviderSettingsTests {
 		assertThat(providerSettings.jwkSetEndpoint()).isEqualTo("/oauth2/jwks");
 		assertThat(providerSettings.tokenRevocationEndpoint()).isEqualTo("/oauth2/revoke");
 		assertThat(providerSettings.tokenIntrospectionEndpoint()).isEqualTo("/oauth2/introspect");
+		assertThat(providerSettings.oidcClientRegistrationEndpoint()).isEqualTo("/connect/register");
+		assertThat(providerSettings.isOidClientRegistrationEndpointEnabled()).isFalse();
 	}
 
 	@Test
@@ -47,6 +49,7 @@ public class ProviderSettingsTests {
 		String tokenRevocationEndpoint = "/oauth2/v1/revoke";
 		String tokenIntrospectionEndpoint = "/oauth2/v1/introspect";
 		String issuer = "https://example.com:9000";
+		String oidcClientRegistrationEndpoint = "/connect/v1/register";
 
 		ProviderSettings providerSettings = new ProviderSettings()
 				.issuer(issuer)
@@ -54,7 +57,10 @@ public class ProviderSettingsTests {
 				.tokenEndpoint(tokenEndpoint)
 				.jwkSetEndpoint(jwkSetEndpoint)
 				.tokenRevocationEndpoint(tokenRevocationEndpoint)
-				.tokenIntrospectionEndpoint(tokenIntrospectionEndpoint);
+				.tokenIntrospectionEndpoint(tokenIntrospectionEndpoint)
+				.tokenRevocationEndpoint(tokenRevocationEndpoint)
+				.isOidClientRegistrationEndpointEnabled(true)
+				.oidcClientRegistrationEndpoint(oidcClientRegistrationEndpoint);
 
 		assertThat(providerSettings.issuer()).isEqualTo(issuer);
 		assertThat(providerSettings.authorizationEndpoint()).isEqualTo(authorizationEndpoint);
@@ -62,6 +68,8 @@ public class ProviderSettingsTests {
 		assertThat(providerSettings.jwkSetEndpoint()).isEqualTo(jwkSetEndpoint);
 		assertThat(providerSettings.tokenRevocationEndpoint()).isEqualTo(tokenRevocationEndpoint);
 		assertThat(providerSettings.tokenIntrospectionEndpoint()).isEqualTo(tokenIntrospectionEndpoint);
+		assertThat(providerSettings.oidcClientRegistrationEndpoint()).isEqualTo(oidcClientRegistrationEndpoint);
+		assertThat(providerSettings.isOidClientRegistrationEndpointEnabled()).isTrue();
 	}
 
 	@Test
@@ -70,7 +78,7 @@ public class ProviderSettingsTests {
 				.setting("name1", "value1")
 				.settings(settings -> settings.put("name2", "value2"));
 
-		assertThat(providerSettings.settings()).hasSize(7);
+		assertThat(providerSettings.settings()).hasSize(9);
 		assertThat(providerSettings.<String>setting("name1")).isEqualTo("value1");
 		assertThat(providerSettings.<String>setting("name2")).isEqualTo("value2");
 	}
@@ -115,6 +123,15 @@ public class ProviderSettingsTests {
 				.withMessage("value cannot be null");
 	}
 
+	@Test
+	public void oidcClientRegistrationEndpointWhenNullThenThrowIllegalArgumentException() {
+		ProviderSettings settings = new ProviderSettings();
+		assertThatThrownBy(() -> settings.oidcClientRegistrationEndpoint(null))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("value cannot be null");
+	}
+
+
 	@Test
 	public void jwksEndpointWhenNullThenThrowIllegalArgumentException() {
 		ProviderSettings settings = new ProviderSettings();

+ 286 - 0
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcClientRegistrationEndpointFilterTests.java

@@ -0,0 +1,286 @@
+/*
+ * Copyright 2020-2021 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.web;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.mockito.AdditionalAnswers;
+import org.mockito.ArgumentCaptor;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.converter.HttpMessageConverter;
+import org.springframework.mock.http.client.MockClientHttpResponse;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.AuthorityUtils;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+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.OAuth2AuthorizationResponseType;
+import org.springframework.security.oauth2.core.http.converter.OAuth2ErrorHttpMessageConverter;
+import org.springframework.security.oauth2.core.oidc.OidcClientMetadataClaimNames;
+import org.springframework.security.oauth2.core.oidc.OidcClientRegistration;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
+import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
+
+import javax.servlet.FilterChain;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+/**
+ * Tests for {@link OidcClientRegistrationEndpointFilter}
+ *
+ * @author Ovidiu Popa
+ * @since 0.1.1
+ */
+public class OidcClientRegistrationEndpointFilterTests {
+
+	private static final OidcClientRegistration.Builder OIDC_CLIENT_REGISTRATION = OidcClientRegistration.builder()
+			.redirectUri("https://localhost:8080/client")
+			.responseType("code")
+			.grantType("authorization_code")
+			.tokenEndpointAuthenticationMethod("basic")
+			.scope("test");
+	private final HttpMessageConverter<OAuth2Error> errorHttpResponseConverter =
+			new OAuth2ErrorHttpMessageConverter();
+	private static RegisteredClientRepository registeredClientRepository;
+	private static AuthenticationManager authenticationManager;
+
+	@BeforeClass
+	public static void init() {
+		registeredClientRepository = mock(RegisteredClientRepository.class);
+		authenticationManager = mock(AuthenticationManager.class);
+	}
+
+	@Before
+	public void setup() {
+		reset(registeredClientRepository);
+		reset(authenticationManager);
+	}
+
+	@After
+	public void tearDown() {
+		SecurityContextHolder.clearContext();
+	}
+
+	@Test
+	public void constructorWhenRegisteredClientRepositoryNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new OidcClientRegistrationEndpointFilter(null,
+				authenticationManager))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("registeredClientRepository cannot be null");
+	}
+
+	@Test
+	public void constructorWhenAuthenticationManagerNullThenThrowIllegalArgumentException() {
+
+		assertThatThrownBy(() -> new OidcClientRegistrationEndpointFilter(registeredClientRepository, null))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("authenticationManager cannot be null");
+	}
+
+	@Test
+	public void constructorWhenOidcClientRegistrationUriNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new OidcClientRegistrationEndpointFilter(registeredClientRepository, authenticationManager, null))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("oidcClientRegistrationUri cannot be empty");
+	}
+
+	@Test
+	public void constructorWhenOidcClientRegistrationUriEmptyThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new OidcClientRegistrationEndpointFilter(registeredClientRepository, authenticationManager, ""))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("oidcClientRegistrationUri cannot be empty");
+	}
+
+	@Test
+	public void doFilterWhenNotClientRegistrationRequestThenNotProcessed() throws Exception {
+		OidcClientRegistrationEndpointFilter filter =
+				new OidcClientRegistrationEndpointFilter(registeredClientRepository, authenticationManager);
+
+		String requestUri = "/path";
+		MockHttpServletRequest request = new MockHttpServletRequest("POST", requestUri);
+		request.setServletPath(requestUri);
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		filter.doFilter(request, response, filterChain);
+
+		verify(filterChain).doFilter(any(HttpServletRequest.class), any(HttpServletResponse.class));
+	}
+
+	@Test
+	public void doFilterWhenClientRegistrationRequestGetThenNotProcessed() throws Exception {
+
+		OidcClientRegistrationEndpointFilter filter =
+				new OidcClientRegistrationEndpointFilter(registeredClientRepository, authenticationManager);
+
+		String requestUri = OidcClientRegistrationEndpointFilter.DEFAULT_OIDC_CLIENT_REGISTRATION_ENDPOINT_URI;
+		MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri);
+		request.setServletPath(requestUri);
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		filter.doFilter(request, response, filterChain);
+
+		verify(filterChain).doFilter(any(HttpServletRequest.class), any(HttpServletResponse.class));
+	}
+
+	@Test
+	public void doFilterWhenAuthenticationManagerThrowsOAuth2AuthenticationExceptionThenBadRequest() throws Exception {
+
+		setSecurityContext("client-registration-token", true, "SCOPE_client.create");
+
+		when(authenticationManager.authenticate(any(JwtAuthenticationToken.class)))
+				.thenThrow(new OAuth2AuthenticationException(new OAuth2Error(OAuth2ErrorCodes.INVALID_GRANT)));
+
+		OidcClientRegistrationEndpointFilter filter =
+				new OidcClientRegistrationEndpointFilter(registeredClientRepository, authenticationManager);
+
+		String requestUri = OidcClientRegistrationEndpointFilter.DEFAULT_OIDC_CLIENT_REGISTRATION_ENDPOINT_URI;
+		MockHttpServletRequest request = new MockHttpServletRequest("POST", requestUri);
+		request.setServletPath(requestUri);
+
+		request.setContent(convertToByteArray(OIDC_CLIENT_REGISTRATION.build()));
+
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		filter.doFilter(request, response, filterChain);
+
+		verifyNoInteractions(filterChain);
+
+		assertThat(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value());
+		OAuth2Error error = readError(response);
+		assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_GRANT);
+	}
+
+	@Test
+	@SuppressWarnings("unchecked")
+	public void doFilterWhenClientRegistrationRequestThenClientRegistrationResponse() throws Exception {
+
+		doNothing().when(registeredClientRepository).saveClient(any(RegisteredClient.class));
+		when(authenticationManager.authenticate(any(JwtAuthenticationToken.class))).then(AdditionalAnswers.returnsFirstArg());
+		setSecurityContext("client-registration-token", true, "SCOPE_client.create");
+
+		OidcClientRegistrationEndpointFilter filter =
+				new OidcClientRegistrationEndpointFilter(registeredClientRepository, authenticationManager);
+
+		String requestUri = OidcClientRegistrationEndpointFilter.DEFAULT_OIDC_CLIENT_REGISTRATION_ENDPOINT_URI;
+		MockHttpServletRequest request = new MockHttpServletRequest("POST", requestUri);
+		request.setServletPath(requestUri);
+
+		request.setContent(convertToByteArray(OIDC_CLIENT_REGISTRATION.build()));
+
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		filter.doFilter(request, response, filterChain);
+
+		verifyNoInteractions(filterChain);
+
+		verify(authenticationManager).authenticate(any());
+
+		ArgumentCaptor<RegisteredClient> registeredClientCaptor = ArgumentCaptor.forClass(RegisteredClient.class);
+		verify(registeredClientRepository).saveClient(registeredClientCaptor.capture());
+
+		RegisteredClient registeredClient = registeredClientCaptor.getValue();
+
+		assertThat(response.getStatus()).isEqualTo(HttpStatus.CREATED.value());
+		assertThat(response.getContentType()).isEqualTo(MediaType.APPLICATION_JSON_VALUE);
+
+		ObjectMapper objectMapper = new ObjectMapper();
+		Map<String, Object> clientRegistrationResponse = objectMapper.readerFor(Map.class)
+				.readValue(response.getContentAsString());
+
+		assertThat(clientRegistrationResponse.get(OidcClientMetadataClaimNames.CLIENT_ID))
+				.isEqualTo(registeredClient.getClientId());
+		assertThat((String) clientRegistrationResponse.get(OidcClientMetadataClaimNames.CLIENT_SECRET))
+				.isEqualTo(registeredClient.getClientSecret());
+		assertThat((List<String>) clientRegistrationResponse.get(OidcClientMetadataClaimNames.REDIRECT_URIS))
+				.containsAll(registeredClient.getRedirectUris());
+		assertThat(clientRegistrationResponse.get(OidcClientMetadataClaimNames.CLIENT_ID_ISSUED_AT))
+				.isNotNull();
+		assertThat(clientRegistrationResponse.get(OidcClientMetadataClaimNames.CLIENT_SECRET_EXPIRES_AT))
+				.isEqualTo(0.0);
+		assertThat((List<String>) clientRegistrationResponse.get(OidcClientMetadataClaimNames.RESPONSE_TYPES))
+				.contains(OAuth2AuthorizationResponseType.CODE.getValue());
+		assertThat((List<String>) clientRegistrationResponse.get(OidcClientMetadataClaimNames.GRANT_TYPES))
+				.containsAll(grantTypes(registeredClient));
+
+		assertThat(clientRegistrationResponse.get(OidcClientMetadataClaimNames.SCOPE))
+				.isEqualTo(String.join(" ", registeredClient.getScopes()));
+		assertThat(clientRegistrationResponse.get(OidcClientMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHOD))
+				.isEqualTo(registeredClient.getClientAuthenticationMethods().iterator().next().getValue());
+	}
+
+	private List<String> grantTypes(RegisteredClient registeredClient) {
+		return registeredClient.getAuthorizationGrantTypes().stream()
+				.map(AuthorizationGrantType::getValue)
+				.collect(Collectors.toList());
+	}
+
+	private static void setSecurityContext(String tokenValue, boolean authenticated, String... authorities) {
+		Jwt jwt = Jwt.withTokenValue(tokenValue)
+				.header("alg", "none")
+				.claim("sub", "client")
+				.build();
+		List<GrantedAuthority> grantedAuthorities = AuthorityUtils.createAuthorityList(authorities);
+		JwtAuthenticationToken jwtAuthenticationToken = new JwtAuthenticationToken(jwt, grantedAuthorities);
+		jwtAuthenticationToken.setAuthenticated(authenticated);
+		SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
+		securityContext.setAuthentication(jwtAuthenticationToken);
+		SecurityContextHolder.setContext(securityContext);
+	}
+
+	private static byte[] convertToByteArray(OidcClientRegistration clientRegistration) throws JsonProcessingException {
+		ObjectMapper objectMapper = new ObjectMapper();
+
+		return objectMapper
+				.writerFor(Map.class)
+				.writeValueAsBytes(clientRegistration.getClaims());
+	}
+
+	private OAuth2Error readError(MockHttpServletResponse response) throws Exception {
+		MockClientHttpResponse httpResponse = new MockClientHttpResponse(
+				response.getContentAsByteArray(), HttpStatus.valueOf(response.getStatus()));
+		return this.errorHttpResponseConverter.read(OAuth2Error.class, httpResponse);
+	}
+}

+ 13 - 0
samples/boot/oauth2-integration/authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java

@@ -21,6 +21,11 @@ import com.nimbusds.jose.jwk.JWKSet;
 import com.nimbusds.jose.jwk.RSAKey;
 import com.nimbusds.jose.jwk.source.JWKSource;
 import com.nimbusds.jose.proc.SecurityContext;
+import org.springframework.security.oauth2.core.OAuth2TokenValidator;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtDecoder;
+import org.springframework.security.oauth2.jwt.JwtValidators;
+import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
 import sample.jose.Jwks;
 
 import org.springframework.context.annotation.Bean;
@@ -85,4 +90,12 @@ public class AuthorizationServerConfig {
 	public ProviderSettings providerSettings() {
 		return new ProviderSettings().issuer("http://auth-server:9000");
 	}
+
+	@Bean
+	public JwtDecoder jwtDecoder(ProviderSettings providerSettings){
+		OAuth2TokenValidator<Jwt> jwtValidator = JwtValidators.createDefaultWithIssuer(providerSettings.issuer());
+		NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withJwkSetUri("http://auth-server:9000"+providerSettings.jwkSetEndpoint()).build();
+		jwtDecoder.setJwtValidator(jwtValidator);
+		return jwtDecoder;
+	}
 }