Эх сурвалжийг харах

Implement OpenID Provider Configuration endpoint

- See https://openid.net/specs/openid-connect-discovery-1_0.html
  sections 3 and 4.
- We introduce here a "ProviderSettings" construct to configure
  the authorization server, starting with endpoint paths (e.g.
  token endpoint, jwk set endpont, ...)

Closes gh-55
Daniel Garnier-Moiroux 4 жил өмнө
parent
commit
6a5e277a11
14 өөрчлөгдсөн 2140 нэмэгдсэн , 5 устгасан
  1. 70 5
      oauth2-authorization-server/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2AuthorizationServerConfigurer.java
  2. 79 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToSetStringConverter2.java
  3. 161 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/http/converter/OidcProviderConfigurationHttpMessageConverter.java
  4. 331 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/oidc/OidcProviderConfiguration.java
  5. 118 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/oidc/OidcProviderMetadataClaimAccessor.java
  6. 73 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/oidc/OidcProviderMetadataClaimNames.java
  7. 155 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/ProviderSettings.java
  8. 94 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OidcProviderConfigurationEndpointFilter.java
  9. 139 0
      oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OidcTests.java
  10. 76 0
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/core/converter/ObjectToSetStringConverter2Test.java
  11. 208 0
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/core/http/converter/OidcProviderConfigurationHttpMessageConverterTests.java
  12. 398 0
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/core/oidc/OidcProviderConfigurationTests.java
  13. 126 0
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/ProviderSettingsTests.java
  14. 112 0
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OidcProviderConfigurationEndpointFilterTests.java

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

@@ -34,11 +34,13 @@ 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.OAuth2TokenRevocationAuthenticationProvider;
 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.web.JwkSetEndpointFilter;
 import org.springframework.security.oauth2.server.authorization.web.OAuth2AuthorizationEndpointFilter;
 import org.springframework.security.oauth2.server.authorization.web.OAuth2ClientAuthenticationFilter;
 import org.springframework.security.oauth2.server.authorization.web.OAuth2TokenEndpointFilter;
 import org.springframework.security.oauth2.server.authorization.web.OAuth2TokenRevocationEndpointFilter;
+import org.springframework.security.oauth2.server.authorization.web.OidcProviderConfigurationEndpointFilter;
 import org.springframework.security.web.AuthenticationEntryPoint;
 import org.springframework.security.web.access.intercept.FilterSecurityInterceptor;
 import org.springframework.security.web.authentication.DelegatingAuthenticationEntryPoint;
@@ -52,6 +54,9 @@ import org.springframework.security.web.util.matcher.RequestMatcher;
 import org.springframework.util.Assert;
 import org.springframework.util.StringUtils;
 
+import java.net.MalformedURLException;
+import java.net.URI;
+import java.net.URISyntaxException;
 import java.util.Arrays;
 import java.util.LinkedHashMap;
 import java.util.List;
@@ -85,6 +90,8 @@ public final class OAuth2AuthorizationServerConfigurer<B extends HttpSecurityBui
 			OAuth2TokenRevocationEndpointFilter.DEFAULT_TOKEN_REVOCATION_ENDPOINT_URI, HttpMethod.POST.name());
 	private final RequestMatcher jwkSetEndpointMatcher = new AntPathRequestMatcher(
 			JwkSetEndpointFilter.DEFAULT_JWK_SET_ENDPOINT_URI, HttpMethod.GET.name());
+	private final RequestMatcher oidcProviderConfigurationEndpointMatcher = new AntPathRequestMatcher(
+			OidcProviderConfigurationEndpointFilter.DEFAULT_OIDC_PROVIDER_CONFIGURATION_ENDPOINT_URI, HttpMethod.GET.name());
 
 	/**
 	 * Sets the repository of registered clients.
@@ -122,18 +129,33 @@ public final class OAuth2AuthorizationServerConfigurer<B extends HttpSecurityBui
 		return this;
 	}
 
+	/**
+	 * Sets the provider settings.
+	 *
+	 * @param providerSettings the provider settings
+	 * @return the {@link OAuth2AuthorizationServerConfigurer} for further configuration
+	 */
+	public OAuth2AuthorizationServerConfigurer<B> providerSettings(ProviderSettings providerSettings) {
+		Assert.notNull(providerSettings, "providerSettings cannot be null");
+		this.getBuilder().setSharedObject(ProviderSettings.class, providerSettings);
+		return this;
+	}
+
 	/**
 	 * Returns a {@code List} of {@link RequestMatcher}'s for the authorization server endpoints.
 	 *
 	 * @return a {@code List} of {@link RequestMatcher}'s for the authorization server endpoints
 	 */
 	public List<RequestMatcher> getEndpointMatchers() {
+		// TODO: use ProviderSettings instead
 		return Arrays.asList(this.authorizationEndpointMatcher, this.tokenEndpointMatcher,
-				this.tokenRevocationEndpointMatcher, this.jwkSetEndpointMatcher);
+				this.tokenRevocationEndpointMatcher, this.jwkSetEndpointMatcher, this.oidcProviderConfigurationEndpointMatcher);
 	}
 
 	@Override
 	public void init(B builder) {
+		ProviderSettings providerSettings = getProviderSettings(builder);
+		validateProviderSettings(providerSettings);
 		OAuth2ClientAuthenticationProvider clientAuthenticationProvider =
 				new OAuth2ClientAuthenticationProvider(
 						getRegisteredClientRepository(builder),
@@ -186,7 +208,14 @@ public final class OAuth2AuthorizationServerConfigurer<B extends HttpSecurityBui
 
 	@Override
 	public void configure(B builder) {
-		JwkSetEndpointFilter jwkSetEndpointFilter = new JwkSetEndpointFilter(getKeySource(builder));
+		if (getProviderSettings(builder).issuer() != null) {
+			OidcProviderConfigurationEndpointFilter oidcProviderConfigurationEndpointFilter = new OidcProviderConfigurationEndpointFilter(getProviderSettings(builder));
+			builder.addFilterBefore(postProcess(oidcProviderConfigurationEndpointFilter), AbstractPreAuthenticatedProcessingFilter.class);
+		}
+
+		JwkSetEndpointFilter jwkSetEndpointFilter = new JwkSetEndpointFilter(
+				getKeySource(builder),
+				getProviderSettings(builder).jwkSetEndpoint());
 		builder.addFilterBefore(postProcess(jwkSetEndpointFilter), AbstractPreAuthenticatedProcessingFilter.class);
 
 		AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);
@@ -200,18 +229,21 @@ public final class OAuth2AuthorizationServerConfigurer<B extends HttpSecurityBui
 		OAuth2AuthorizationEndpointFilter authorizationEndpointFilter =
 				new OAuth2AuthorizationEndpointFilter(
 						getRegisteredClientRepository(builder),
-						getAuthorizationService(builder));
+						getAuthorizationService(builder),
+						getProviderSettings(builder).authorizationEndpoint());
 		builder.addFilterBefore(postProcess(authorizationEndpointFilter), AbstractPreAuthenticatedProcessingFilter.class);
 
 		OAuth2TokenEndpointFilter tokenEndpointFilter =
 				new OAuth2TokenEndpointFilter(
 						authenticationManager,
-						getAuthorizationService(builder));
+						getAuthorizationService(builder),
+						getProviderSettings(builder).tokenEndpoint());
 		builder.addFilterAfter(postProcess(tokenEndpointFilter), FilterSecurityInterceptor.class);
 
 		OAuth2TokenRevocationEndpointFilter tokenRevocationEndpointFilter =
 				new OAuth2TokenRevocationEndpointFilter(
-						authenticationManager);
+						authenticationManager,
+						getProviderSettings(builder).tokenRevocationEndpoint());
 		builder.addFilterAfter(postProcess(tokenRevocationEndpointFilter), OAuth2TokenEndpointFilter.class);
 	}
 
@@ -263,4 +295,37 @@ public final class OAuth2AuthorizationServerConfigurer<B extends HttpSecurityBui
 	private static <B extends HttpSecurityBuilder<B>> CryptoKeySource getKeySourceBean(B builder) {
 		return builder.getSharedObject(ApplicationContext.class).getBean(CryptoKeySource.class);
 	}
+
+	private static <B extends HttpSecurityBuilder<B>> ProviderSettings getProviderSettings(B builder) {
+		ProviderSettings providerSettings = builder.getSharedObject(ProviderSettings.class);
+		if (providerSettings == null) {
+			providerSettings = getProviderSettingsBean(builder);
+			if (providerSettings == null) {
+				providerSettings = new ProviderSettings();
+			}
+			builder.setSharedObject(ProviderSettings.class, providerSettings);
+		}
+		return providerSettings;
+	}
+
+	private static <B extends HttpSecurityBuilder<B>> ProviderSettings getProviderSettingsBean(B builder) {
+		Map<String, ProviderSettings> providerSettingsMap = BeanFactoryUtils.beansOfTypeIncludingAncestors(
+				builder.getSharedObject(ApplicationContext.class), ProviderSettings.class);
+		if (providerSettingsMap.size() > 1) {
+			throw new NoUniqueBeanDefinitionException(ProviderSettings.class, providerSettingsMap.size(),
+					"Expected single matching bean of type '" + ProviderSettings.class.getName() + "' but found " +
+							providerSettingsMap.size() + ": " + StringUtils.collectionToCommaDelimitedString(providerSettingsMap.keySet()));
+		}
+		return (!providerSettingsMap.isEmpty() ? providerSettingsMap.values().iterator().next() : null);
+	}
+
+	private void validateProviderSettings(ProviderSettings providerSettings) {
+		if (providerSettings.issuer() != null) {
+			try {
+				new URI(providerSettings.issuer()).toURL();
+			} catch (MalformedURLException | URISyntaxException e) {
+				throw new IllegalArgumentException("issuer must be a valid URL");
+			}
+		}
+	}
 }

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

@@ -0,0 +1,79 @@
+/*
+ * Copyright 2020 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.converter;
+
+import org.springframework.core.convert.TypeDescriptor;
+import org.springframework.core.convert.converter.ConditionalGenericConverter;
+import org.springframework.core.convert.converter.GenericConverter;
+import org.springframework.util.ClassUtils;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+/**
+ * TODO
+ * This class is temporary and will be removed after upgrading to Spring Security 5.5.0 GA.
+ *
+ * @author Daniel Garnier-Moiroux
+ * @since 0.1.0
+ * @see <a target="_blank" href="https://github.com/spring-projects/spring-security/pull/9146">Issue gh-9146</a>
+ */
+final public class ObjectToSetStringConverter2 implements ConditionalGenericConverter {
+
+	@Override
+	public Set<GenericConverter.ConvertiblePair> getConvertibleTypes() {
+		Set<GenericConverter.ConvertiblePair> convertibleTypes = new LinkedHashSet<>();
+		convertibleTypes.add(new GenericConverter.ConvertiblePair(Object.class, Set.class));
+		return convertibleTypes;
+	}
+
+	@Override
+	public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) {
+		if (targetType.getElementTypeDescriptor() == null
+				|| targetType.getElementTypeDescriptor().getType().equals(String.class) || sourceType == null
+				|| ClassUtils.isAssignable(sourceType.getType(), targetType.getElementTypeDescriptor().getType())) {
+			return true;
+		}
+		return false;
+	}
+
+	@Override
+	public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) {
+		if (source == null) {
+			return null;
+		}
+		if (source instanceof Set) {
+			Set<?> sourceList = (Set<?>) source;
+			for (Object entry: sourceList) {
+				if (entry instanceof String) {
+					return source;
+				}
+			}
+		}
+		if (source instanceof Collection) {
+			Collection<String> results = new LinkedHashSet<>();
+			for (Object object : ((Collection<?>) source)) {
+				if (object != null) {
+					results.add(object.toString());
+				}
+			}
+			return results;
+		}
+		return Collections.singleton(source.toString());
+	}
+}

+ 161 - 0
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/http/converter/OidcProviderConfigurationHttpMessageConverter.java

@@ -0,0 +1,161 @@
+/*
+ * Copyright 2020 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.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.converter.ObjectToSetStringConverter2;
+import org.springframework.security.oauth2.core.oidc.OidcProviderConfiguration;
+import org.springframework.security.oauth2.core.oidc.OidcProviderMetadataClaimNames;
+import org.springframework.util.Assert;
+
+import java.io.IOException;
+import java.net.URL;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+
+/**
+ * A {@link HttpMessageConverter} for an {@link OidcProviderConfiguration OpenID Provider Configuration Metadata}.
+ *
+ * @author Daniel Garnier-Moiroux
+ * @since 0.1.0
+ * @see AbstractHttpMessageConverter
+ * @see OidcProviderConfiguration
+ */
+public class OidcProviderConfigurationHttpMessageConverter
+		extends AbstractHttpMessageConverter<OidcProviderConfiguration> {
+	private static final ParameterizedTypeReference<Map<String, Object>> STRING_OBJECT_MAP =
+			new ParameterizedTypeReference<Map<String, Object>>() {
+			};
+
+	private final GenericHttpMessageConverter<Object> jsonMessageConverter = HttpMessageConverters.getJsonMessageConverter();
+
+	private Converter<Map<String, Object>, OidcProviderConfiguration> providerConfigurationConverter = new OidcProviderConfigurationConverter();
+	private Converter<OidcProviderConfiguration, Map<String, Object>> providerConfigurationParametersConverter = OidcProviderConfiguration::getClaims;
+
+	public OidcProviderConfigurationHttpMessageConverter() {
+		super(MediaType.APPLICATION_JSON, new MediaType("application", "*+json"));
+	}
+
+	@Override
+	protected boolean supports(Class<?> clazz) {
+		return OidcProviderConfiguration.class.isAssignableFrom(clazz);
+	}
+
+	@Override
+	@SuppressWarnings("unchecked")
+	protected OidcProviderConfiguration readInternal(Class<? extends OidcProviderConfiguration> clazz, HttpInputMessage inputMessage)
+			throws HttpMessageNotReadableException {
+		try {
+			Map<String, Object> providerConfigurationParameters = (Map<String, Object>) this.jsonMessageConverter.read(STRING_OBJECT_MAP.getType(), null, inputMessage);
+			return this.providerConfigurationConverter.convert(providerConfigurationParameters);
+		} catch (Exception ex) {
+			throw new HttpMessageNotReadableException(
+					"An error occurred reading the OpenID Provider Configuration: " + ex.getMessage(), ex, inputMessage);
+		}
+	}
+
+	@Override
+	protected void writeInternal(OidcProviderConfiguration providerConfiguration, HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException {
+		try {
+			Map<String, Object> providerConfigurationResponseParameters =
+					this.providerConfigurationParametersConverter.convert(providerConfiguration);
+			this.jsonMessageConverter.write(
+					providerConfigurationResponseParameters,
+					STRING_OBJECT_MAP.getType(),
+					MediaType.APPLICATION_JSON,
+					outputMessage
+			);
+		} catch (Exception ex) {
+			throw new HttpMessageNotWritableException(
+					"An error occurred writing the OpenID Provider Configuration: " + ex.getMessage(), ex);
+		}
+	}
+
+	/**
+	 * Sets the {@link Converter} used for converting the {@link OidcProviderConfiguration} to a
+	 * {@code Map} representation of the OpenID Provider Configuration.
+	 *
+	 * @param providerConfigurationParametersConverter the {@link Converter} used for converting to a
+	 * {@code Map} representation of the OpenID Provider Configuration
+	 */
+	public final void setProviderConfigurationParametersConverter(
+			Converter<OidcProviderConfiguration, Map<String, Object>> providerConfigurationParametersConverter) {
+		Assert.notNull(providerConfigurationParametersConverter, "providerConfigurationParametersConverter cannot be null");
+		this.providerConfigurationParametersConverter = providerConfigurationParametersConverter;
+	}
+
+	/**
+	 * Sets the {@link Converter} used for converting the OpenID Provider Configuration parameters
+	 * to an {@link OidcProviderConfiguration}.
+	 *
+	 * @param providerConfigurationConverter the {@link Converter} used for converting to an
+	 * {@link OidcProviderConfiguration}
+	 */
+	public final void setProviderConfigurationConverter(Converter<Map<String, Object>, OidcProviderConfiguration> providerConfigurationConverter) {
+		Assert.notNull(providerConfigurationConverter, "providerConfigurationConverter cannot be null");
+		this.providerConfigurationConverter = providerConfigurationConverter;
+	}
+
+	private static final class OidcProviderConfigurationConverter implements Converter<Map<String, Object>, OidcProviderConfiguration> {
+		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 static final TypeDescriptor URL_TYPE_DESCRIPTOR = TypeDescriptor.valueOf(URL.class);
+		private final ClaimTypeConverter claimTypeConverter;
+
+		OidcProviderConfigurationConverter() {
+			CLAIM_CONVERSION_SERVICE.addConverter(new ObjectToSetStringConverter2());
+			Map<String, Converter<Object, ?>> claimNameToConverter = new HashMap<>();
+			Converter<Object, ?> setStringConverter = getConverter(TypeDescriptor.collection(Set.class, STRING_TYPE_DESCRIPTOR));
+			Converter<Object, ?> urlConverter = getConverter(URL_TYPE_DESCRIPTOR);
+
+			claimNameToConverter.put(OidcProviderMetadataClaimNames.ISSUER, urlConverter);
+			claimNameToConverter.put(OidcProviderMetadataClaimNames.AUTHORIZATION_ENDPOINT, urlConverter);
+			claimNameToConverter.put(OidcProviderMetadataClaimNames.TOKEN_ENDPOINT, urlConverter);
+			claimNameToConverter.put(OidcProviderMetadataClaimNames.JWKS_URI, urlConverter);
+			claimNameToConverter.put(OidcProviderMetadataClaimNames.GRANT_TYPES_SUPPORTED, setStringConverter);
+			claimNameToConverter.put(OidcProviderMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED, setStringConverter);
+			claimNameToConverter.put(OidcProviderMetadataClaimNames.SUBJECT_TYPES_SUPPORTED, setStringConverter);
+			claimNameToConverter.put(OidcProviderMetadataClaimNames.SCOPES_SUPPORTED, setStringConverter);
+			claimNameToConverter.put(OidcProviderMetadataClaimNames.RESPONSE_TYPES_SUPPORTED, setStringConverter);
+			this.claimTypeConverter = new ClaimTypeConverter(claimNameToConverter);
+		}
+
+		@Override
+		public OidcProviderConfiguration convert(Map<String, Object> source) {
+			Map<String, Object> parsedClaims = this.claimTypeConverter.convert(source);
+			return OidcProviderConfiguration.withClaims(parsedClaims).build();
+		}
+
+		private static Converter<Object, ?> getConverter(TypeDescriptor targetDescriptor) {
+			return (source) -> CLAIM_CONVERSION_SERVICE.convert(source, OBJECT_TYPE_DESCRIPTOR, targetDescriptor);
+		}
+	}
+}

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

@@ -0,0 +1,331 @@
+/*
+ * Copyright 2020 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.server.authorization.Version;
+import org.springframework.util.Assert;
+
+import java.io.Serializable;
+import java.net.URI;
+import java.net.URL;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Consumer;
+
+/**
+ * A representation of an OpenID Provider Configuration Response,
+ * which is returned from an Issuer's Discovery Endpoint,
+ * and contains a set of claims about the OpenID Provider's configuration.
+ * The claims are defined by the OpenID Connect Discovery 1.0 specification.
+ *
+ * @author Daniel Garnier-Moiroux
+ * @since 0.1.0
+ * @see OidcProviderMetadataClaimAccessor
+ * @see <a target="_blank" href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse">4.2. OpenID Provider Configuration Response</a>
+ */
+public class OidcProviderConfiguration implements OidcProviderMetadataClaimAccessor, Serializable {
+	private static final long serialVersionUID = Version.SERIAL_VERSION_UID;
+
+	private final Map<String, Object> claims;
+
+	private OidcProviderConfiguration(Map<String, Object> claims) {
+		Assert.notEmpty(claims, "claims cannot be empty");
+		this.claims = Collections.unmodifiableMap(new LinkedHashMap<>(claims));
+	}
+
+	/**
+	 * Returns the OpenID Provider Configuration metadata.
+	 *
+	 * @return a {@code Map} of the metadata values
+	 */
+	@Override
+	public Map<String, Object> getClaims() {
+		return this.claims;
+	}
+
+	/**
+	 * Constructs a new empty {@link Builder}.
+	 *
+	 * @return the {@link Builder}
+	 */
+	public static Builder withClaims() {
+		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));
+	}
+
+	/**
+	 * Helps configure an {@link OidcProviderConfiguration}
+	 *
+	 * @author Daniel Garnier-Moiroux
+	 * @since 0.1.0
+	 * @see <a target="_blank" href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata">OpenID Connect Discovery 1.0</a>
+	 * for required claims
+	 */
+	public static final class Builder {
+		private final Map<String, Object> claims = new LinkedHashMap<>();
+
+		private Builder() {
+		}
+
+		/**
+		 * Use this {@code issuer} in the resulting {@link OidcProviderConfiguration}, REQUIRED.
+		 *
+		 * @param issuer the issuer URI
+		 * @return the {@link Builder} for further configuration
+		 */
+		public Builder issuer(String issuer) {
+			return claim(OidcProviderMetadataClaimNames.ISSUER, issuer);
+		}
+
+		/**
+		 * Use this {@code authorization_endpoint} in the resulting {@link OidcProviderConfiguration}, REQUIRED.
+		 *
+		 * @param authorizationEndpoint the URL of the OpenID Provider's OAuth 2.0 Authorization Endpoint
+		 * @return the {@link Builder} for further configuration
+		 */
+		public Builder authorizationEndpoint(String authorizationEndpoint) {
+			return claim(OidcProviderMetadataClaimNames.AUTHORIZATION_ENDPOINT, authorizationEndpoint);
+		}
+
+		/**
+		 * Use this {@code token_endpoint} in the resulting {@link OidcProviderConfiguration}, REQUIRED.
+		 *
+		 * @param tokenEndpoint the URL of the OpenID Provider's OAuth 2.0 Token Endpoint
+		 * @return the {@link Builder} for further configuration
+		 */
+		public Builder tokenEndpoint(String tokenEndpoint) {
+			return claim(OidcProviderMetadataClaimNames.TOKEN_ENDPOINT, tokenEndpoint);
+		}
+
+		/**
+		 * Use this {@code jwks_uri} in the resulting {@link OidcProviderConfiguration}, REQUIRED.
+		 *
+		 * @param jwksUri the URL of the OpenID Provider's JSON Web Key Set document
+		 * @return the {@link Builder} for further configuration
+		 */
+		public Builder jwksUri(String jwksUri) {
+			return claim(OidcProviderMetadataClaimNames.JWKS_URI, jwksUri);
+		}
+
+		/**
+		 * Add this Response Type to the collection of {@code response_types_supported} in the resulting
+		 * {@link OidcProviderConfiguration}, REQUIRED.
+		 *
+		 * @param responseType the OAuth 2.0 {@code response_type} values that the OpenID Provider supports
+		 * @return the {@link Builder} for further configuration
+		 */
+		public Builder responseType(String responseType) {
+			addClaimToClaimSet(OidcProviderMetadataClaimNames.RESPONSE_TYPES_SUPPORTED, responseType);
+			return this;
+		}
+
+		/**
+		 * A {@code Consumer} of the Response Type(s) allowing the ability to add, replace, or remove.
+		 *
+		 * @param responseTypesConsumer a {@code Consumer} of the Response Type(s)
+		 * @return the {@link Builder} for further configuration
+		 */
+		public Builder responseTypes(Consumer<Set<String>> responseTypesConsumer) {
+			applyToClaim(OidcProviderMetadataClaimNames.RESPONSE_TYPES_SUPPORTED, responseTypesConsumer);
+			return this;
+		}
+
+		/**
+		 * Add this Subject Type to the collection of {@code subject_types_supported} in the resulting
+		 * {@link OidcProviderConfiguration}, REQUIRED.
+		 *
+		 * @param subjectType the Subject Identifiers that the OpenID Provider supports
+		 * @return the {@link Builder} for further configuration
+		 */
+		public Builder subjectType(String subjectType) {
+			addClaimToClaimSet(OidcProviderMetadataClaimNames.SUBJECT_TYPES_SUPPORTED, subjectType);
+			return this;
+		}
+
+		/**
+		 * A {@code Consumer} of the Subject Types(s) allowing the ability to add, replace, or remove.
+		 *
+		 * @param subjectTypesConsumer a {@code Consumer} of the Subject Types(s)
+		 * @return the {@link Builder} for further configuration
+		 */
+		public Builder subjectTypes(Consumer<Set<String>> subjectTypesConsumer) {
+			applyToClaim(OidcProviderMetadataClaimNames.SUBJECT_TYPES_SUPPORTED, subjectTypesConsumer);
+			return this;
+		}
+
+		/**
+		 * Add this Scope to the collection of {@code scopes_supported} in the resulting
+		 * {@link OidcProviderConfiguration}, RECOMMENDED.
+		 *
+		 * @param scope the OAuth 2.0 {@code scopes} values that the OpenID Provider supports
+		 * @return the {@link Builder} for further configuration
+		 */
+		public Builder scope(String scope) {
+			addClaimToClaimSet(OidcProviderMetadataClaimNames.SCOPES_SUPPORTED, scope);
+			return this;
+		}
+
+		/**
+		 * A {@code Consumer} of the Scopes(s) allowing the ability to add, replace, or remove.
+		 *
+		 * @param scopesConsumer a {@code Consumer} of the Scopes(s)
+		 * @return the {@link Builder} for further configuration
+		 */
+		public Builder scopes(Consumer<Set<String>> scopesConsumer) {
+			applyToClaim(OidcProviderMetadataClaimNames.SCOPES_SUPPORTED, scopesConsumer);
+			return this;
+		}
+
+		/**
+		 * Add this Grant Type to the collection of {@code grant_types_supported} in the resulting
+		 * {@link OidcProviderConfiguration}, OPTIONAL.
+		 *
+		 * @param grantType the OAuth 2.0 {@code grant_type} values that the OpenID Provider supports
+		 * @return the {@link Builder} for further configuration
+		 */
+		public Builder grantType(String grantType) {
+			addClaimToClaimSet(OidcProviderMetadataClaimNames.GRANT_TYPES_SUPPORTED, 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<Set<String>> grantTypesConsumer) {
+			applyToClaim(OidcProviderMetadataClaimNames.GRANT_TYPES_SUPPORTED, grantTypesConsumer);
+			return this;
+		}
+
+		/**
+		 * Add this Authentication Method to the collection of {@code token_endpoint_auth_methods_supported}
+		 * in the resulting {@link OidcProviderConfiguration}, OPTIONAL.
+		 *
+		 * @param authenticationMethod the OAuth 2.0 Authentication Method supported by the Token endpoint
+		 * @return the {@link Builder} for further configuration
+		 */
+		public Builder tokenEndpointAuthenticationMethod(String authenticationMethod) {
+			addClaimToClaimSet(OidcProviderMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED, authenticationMethod);
+			return this;
+		}
+
+		/**
+		 * A {@code Consumer} of the Token Endpoint Authentication Method(s) allowing the ability to add, replace, or remove.
+		 *
+		 * @param authenticationMethodsConsumer a {@code Consumer} of the Token Endpoint Authentication Method(s)
+		 * @return the {@link Builder} for further configuration
+		 */
+		public Builder tokenEndpointAuthenticationMethods(Consumer<Set<String>> authenticationMethodsConsumer) {
+			applyToClaim(OidcProviderMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED, authenticationMethodsConsumer);
+			return this;
+		}
+
+		/**
+		 * Use this claim in the resulting {@link OidcProviderConfiguration}
+		 *
+		 * @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 the consumer
+		 * @return the {@link Builder} for further configurations
+		 */
+		public Builder claims(Consumer<Map<String, Object>> claimsConsumer) {
+			claimsConsumer.accept(this.claims);
+			return this;
+		}
+
+		/**
+		 * Validate the claims and build the {@link OidcProviderConfiguration}. The following claims are REQUIRED:
+		 * - issuer
+		 * - authorization_endpoint
+		 * - token_endpoint
+		 * - jwks_uri
+		 * - response_types_supported
+		 * - subject_types_supported
+		 *
+		 * @return The constructed {@link OidcProviderConfiguration}
+		 */
+		public OidcProviderConfiguration build() {
+			validateClaims();
+			return new OidcProviderConfiguration(this.claims);
+		}
+
+		private void validateClaims() {
+			Assert.notNull(this.claims.get(OidcProviderMetadataClaimNames.ISSUER), "issuer cannot be null");
+			validateURL(this.claims.get(OidcProviderMetadataClaimNames.ISSUER), "issuer must be a valid URL");
+			Assert.notNull(this.claims.get(OidcProviderMetadataClaimNames.AUTHORIZATION_ENDPOINT), "authorizationEndpoint cannot be null");
+			validateURL(this.claims.get(OidcProviderMetadataClaimNames.AUTHORIZATION_ENDPOINT), "authorizationEndpoint must be a valid URL");
+			Assert.notNull(this.claims.get(OidcProviderMetadataClaimNames.TOKEN_ENDPOINT), "tokenEndpoint cannot be null");
+			validateURL(this.claims.get(OidcProviderMetadataClaimNames.TOKEN_ENDPOINT), "tokenEndpoint must be a valid URL");
+			Assert.notNull(this.claims.get(OidcProviderMetadataClaimNames.JWKS_URI), "jwkSetUri cannot be null");
+			validateURL(this.claims.get(OidcProviderMetadataClaimNames.JWKS_URI), "jwkSetUri must be a valid URL");
+			Assert.notEmpty((Set<?>) this.claims.get(OidcProviderMetadataClaimNames.SUBJECT_TYPES_SUPPORTED), "subjectTypes cannot be empty");
+			Assert.notEmpty((Set<?>) this.claims.get(OidcProviderMetadataClaimNames.RESPONSE_TYPES_SUPPORTED), "responseTypes cannot be empty");
+		}
+
+		private void validateURL(Object url, String errorMessage) {
+			if (url.getClass().isAssignableFrom(URL.class)) return;
+
+			try {
+				new URI(url.toString()).toURL();
+			} catch (Exception e) {
+				throw new IllegalArgumentException(errorMessage);
+			}
+
+		}
+
+		@SuppressWarnings("unchecked")
+		private void addClaimToClaimSet(String name, String value) {
+			this.claims.putIfAbsent(name, new LinkedHashSet<String>());
+			((Set<String>) this.claims.get(name)).add(value);
+		}
+
+		@SuppressWarnings("unchecked")
+		private void applyToClaim(String name, Consumer<Set<String>> consumer) {
+			this.claims.putIfAbsent(name, new LinkedHashSet<String>());
+			Set<String> values = (Set<String>) this.claims.get(name);
+			consumer.accept(values);
+		}
+	}
+}

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

@@ -0,0 +1,118 @@
+/*
+ * Copyright 2020 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 java.net.URL;
+import java.util.List;
+
+/**
+ * A {@link ClaimAccessor} for the "claims" that can be returned
+ * in the OpenID Provider Configuration Response.
+ *
+ * @author Daniel Garnier-Moiroux
+ * @since 0.1.0
+ * @see ClaimAccessor
+ * @see OidcProviderMetadataClaimNames
+ * @see OidcProviderConfiguration
+ * @see <a target="_blank" href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata">3. OpenID Provider Metadata</a>
+ */
+public interface OidcProviderMetadataClaimAccessor extends ClaimAccessor {
+
+	/**
+	 * Returns the URL the OpenID Provider asserts as its Issuer Identifier {@code (issuer)}.
+	 *
+	 * @return the URL the OpenID Provider asserts as its Issuer Identifier
+	 */
+	default URL getIssuer() {
+		return this.getClaimAsURL(OidcProviderMetadataClaimNames.ISSUER);
+	}
+
+	/**
+	 * Returns the URL of the OAuth 2.0 Authorization Endpoint {@code (authorization_endpoint)}.
+	 *
+	 * @return the URL of the OAuth 2.0 Authorization Endpoint
+	 */
+	default URL getAuthorizationEndpoint() {
+		return this.getClaimAsURL(OidcProviderMetadataClaimNames.AUTHORIZATION_ENDPOINT);
+	}
+
+	/**
+	 * Returns the URL of the OAuth 2.0 Token Endpoint {@code (token_endpoint)}.
+	 *
+	 * @return the URL of the OAuth 2.0 Token Endpoint
+	 */
+	default URL getTokenEndpoint() {
+		return this.getClaimAsURL(OidcProviderMetadataClaimNames.TOKEN_ENDPOINT);
+	}
+
+	/**
+	 * Returns the client authentication methods supported by the OAuth 2.0 Token Endpoint {@code (token_endpoint_auth_methods_supported)}.
+	 *
+	 * @return the client authentication methods supported by the OAuth 2.0 Token Endpoint
+	 */
+	default List<String> getTokenEndpointAuthenticationMethods() {
+		return this.getClaimAsStringList(OidcProviderMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED);
+	}
+
+	/**
+	 * Returns the URL of the JSON Web Key Set {@code (jwks_uri)}.
+	 *
+	 * @return the URL of the JSON Web Key Set
+	 */
+	default URL getJwksUri() {
+		return this.getClaimAsURL(OidcProviderMetadataClaimNames.JWKS_URI);
+	}
+
+	/**
+	 * Returns the OAuth 2.0 {@code response_type} values supported {@code (response_types_supported)}.
+	 *
+	 * @return the OAuth 2.0 {@code response_type} values supported
+	 */
+	default List<String> getResponseTypes() {
+		return this.getClaimAsStringList(OidcProviderMetadataClaimNames.RESPONSE_TYPES_SUPPORTED);
+	}
+
+	/**
+	 * Returns the OAuth 2.0 {@code grant_type} values supported {@code (grant_types_supported)}.
+	 *
+	 * @return the OAuth 2.0 {@code grant_type} values supported
+	 */
+	default List<String> getGrantTypes() {
+		return this.getClaimAsStringList(OidcProviderMetadataClaimNames.GRANT_TYPES_SUPPORTED);
+	}
+
+	/**
+	 * Returns the Subject Identifier types supported {@code (subject_types_supported)}.
+	 *
+	 * @return the Subject Identifier types supported
+	 */
+	default List<String> getSubjectTypes() {
+		return this.getClaimAsStringList(OidcProviderMetadataClaimNames.SUBJECT_TYPES_SUPPORTED);
+	}
+
+	/**
+	 * Returns the OAuth 2.0 {@code scope} values supported {@code (scopes_supported)}.
+	 *
+	 * @return the OAuth 2.0 {@code scope} values supported
+	 */
+	default List<String> getScopes() {
+		return this.getClaimAsStringList(OidcProviderMetadataClaimNames.SCOPES_SUPPORTED);
+	}
+
+}

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

@@ -0,0 +1,73 @@
+/*
+ * Copyright 2020 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 the OpenID Connect Discovery 1.0 that can be returned
+ * in the OpenID Provider Configuration Response.
+ *
+ * @author Daniel Garnier-Moiroux
+ * @since 0.1.0
+ * @see <a target="_blank" href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata">3. OpenID Provider Metadata</a>
+ */
+public interface OidcProviderMetadataClaimNames {
+
+	/**
+	 * {@code issuer} - the URL the OpenID Provider asserts as its Issuer Identifier
+	 */
+	String ISSUER = "issuer";
+
+	/**
+	 * {@code authorization_endpoint} - the URL of the OAuth 2.0 Authorization Endpoint
+	 */
+	String AUTHORIZATION_ENDPOINT = "authorization_endpoint";
+
+	/**
+	 * {@code token_endpoint} - the URL of the OAuth 2.0 Token Endpoint
+	 */
+	String TOKEN_ENDPOINT = "token_endpoint";
+
+	/**
+	 * {@code token_endpoint_auth_methods_supported} - the client authentication methods supported by the OAuth 2.0 Token Endpoint
+	 */
+	String TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED = "token_endpoint_auth_methods_supported";
+
+	/**
+	 * {@code jwks_uri} - the URL of the JSON Web Key Set
+	 */
+	String JWKS_URI = "jwks_uri";
+
+	/**
+	 * {@code response_types_supported} - the OAuth 2.0 {@code response_type} values supported
+	 */
+	String RESPONSE_TYPES_SUPPORTED = "response_types_supported";
+
+	/**
+	 * {@code grant_types_supported} - the OAuth 2.0 {@code grant_type} values supported
+	 */
+	String GRANT_TYPES_SUPPORTED = "grant_types_supported";
+
+	/**
+	 * {@code subject_types_supported} - the Subject Identifier types supported
+	 */
+	String SUBJECT_TYPES_SUPPORTED = "subject_types_supported";
+
+	/**
+	 * {@code scopes_supported} - the OAuth 2.0 {@code scope} values supported
+	 */
+	String SCOPES_SUPPORTED = "scopes_supported";
+
+}

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

@@ -0,0 +1,155 @@
+/*
+ * Copyright 2020 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.config;
+
+
+import org.springframework.util.Assert;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * A facility for OpenID Connect Provider Configuration settings.
+ *
+ * @author Daniel Garnier-Moiroux
+ * @since 0.1.0
+ * @see Settings
+ * @see <a target="_blank" href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata">OpenID Connect Discovery 1.0</a>
+ */
+public class ProviderSettings extends Settings {
+	private static final String PROVIDER_SETTING_BASE = "setting.provider.";
+	public static final String ISSUER = PROVIDER_SETTING_BASE.concat("issuer");
+	public static final String AUTHORIZATION_ENDPOINT = PROVIDER_SETTING_BASE.concat("authorization-endpoint");
+	public static final String TOKEN_ENDPOINT = PROVIDER_SETTING_BASE.concat("token-endpoint");
+	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");
+
+	/**
+	 * Constructs a {@code ProviderSettings}.
+	 */
+	public ProviderSettings() {
+		super(defaultSettings());
+	}
+
+	/**
+	 * Returns the URL for the OpenID Issuer.
+	 *
+	 * @return the URL for the OpenID Issuer
+	 */
+	public String issuer() {
+		return setting(ISSUER);
+	}
+
+	/**
+	 * Sets the URL the Provider uses as its Issuer Identity.
+	 *
+	 * @param issuer the URL the Provider uses as its Issuer Identity.
+	 * @return the {@link ProviderSettings} for further configuration
+	 */
+	public ProviderSettings issuer(String issuer) {
+		Assert.notNull(issuer, "issuer cannot be null");
+		return setting(ISSUER, issuer);
+	}
+
+	/**
+	 * Returns the provider's OAuth 2.0 Authorization endpoint. The default is {@code /oauth2/authorize}.
+	 *
+	 * @return the Authorization endpoint
+	 */
+	public String authorizationEndpoint() {
+		return setting(AUTHORIZATION_ENDPOINT);
+	}
+
+	/**
+	 * Sets the provider's OAuth 2.0 Authorization endpoint.
+	 *
+	 * @param authorizationEndpoint the Authorization endpoint
+	 * @return the {@link ProviderSettings} for further configuration
+	 */
+	public ProviderSettings authorizationEndpoint(String authorizationEndpoint) {
+		Assert.hasText(authorizationEndpoint, "authorizationEndpoint cannot be empty");
+		return setting(AUTHORIZATION_ENDPOINT, authorizationEndpoint);
+	}
+
+	/**
+	 * Returns the provider's OAuth 2.0 Token endpoint. The default is {@code /oauth2/token}.
+	 *
+	 * @return the Token endpoint
+	 */
+	public String tokenEndpoint() {
+		return setting(TOKEN_ENDPOINT);
+	}
+
+	/**
+	 * Sets the provider's OAuth 2.0 Token endpoint.
+	 *
+	 * @param tokenEndpoint the Token endpoint
+	 * @return the {@link ProviderSettings} for further configuration
+	 */
+	public ProviderSettings tokenEndpoint(String tokenEndpoint) {
+		Assert.hasText(tokenEndpoint, "tokenEndpoint cannot be empty");
+		return setting(TOKEN_ENDPOINT, tokenEndpoint);
+	}
+
+	/**
+	 * Returns the provider's JWK Set endpoint. The default is {@code /oauth2/jwks}.
+	 *
+	 * @return the JWK Set endpoint
+	 */
+	public String jwkSetEndpoint() {
+		return setting(JWK_SET_ENDPOINT);
+	}
+
+	/**
+	 * Sets the provider's OAuth 2.0 JWK Set endpoint.
+	 *
+	 * @param jwkSetEndpoint the JWK Set endpoint
+	 * @return the {@link ProviderSettings} for further configuration
+	 */
+	public ProviderSettings jwkSetEndpoint(String jwkSetEndpoint) {
+		Assert.hasText(jwkSetEndpoint, "jwkSetEndpoint cannot be empty");
+		return setting(JWK_SET_ENDPOINT, jwkSetEndpoint);
+	}
+
+	/**
+	 * Returns the provider's Token Revocation endpoint. The default is {@code /oauth2/revoke}.
+	 *
+	 * @return the Token Revocation endpoint
+	 */
+	public String tokenRevocationEndpoint() {
+		return setting(TOKEN_REVOCATION_ENDPOINT);
+	}
+
+	/**
+	 * Sets the provider's OAuth 2.0 Token Revocation endpoint.
+	 *
+	 * @param tokenRevocationEndpoint the Token Revocation endpoint
+	 * @return the {@link ProviderSettings} for further configuration
+	 */
+	public ProviderSettings tokenRevocationEndpoint(String tokenRevocationEndpoint) {
+		Assert.hasText(tokenRevocationEndpoint, "tokenRevocationEndpoint cannot be empty");
+		return setting(TOKEN_REVOCATION_ENDPOINT, tokenRevocationEndpoint);
+	}
+
+	protected static Map<String, Object> defaultSettings() {
+		Map<String, Object> settings = new HashMap<>();
+		settings.put(AUTHORIZATION_ENDPOINT, "/oauth2/authorize");
+		settings.put(TOKEN_ENDPOINT, "/oauth2/token");
+		settings.put(JWK_SET_ENDPOINT, "/oauth2/jwks");
+		settings.put(TOKEN_REVOCATION_ENDPOINT, "/oauth2/revoke");
+		return settings;
+	}
+}

+ 94 - 0
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OidcProviderConfigurationEndpointFilter.java

@@ -0,0 +1,94 @@
+/*
+ * Copyright 2020 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.web;
+
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.http.server.ServletServerHttpResponse;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponseType;
+import org.springframework.security.oauth2.core.http.converter.OidcProviderConfigurationHttpMessageConverter;
+import org.springframework.security.oauth2.core.oidc.OidcProviderConfiguration;
+import org.springframework.security.oauth2.core.oidc.OidcScopes;
+import org.springframework.security.oauth2.server.authorization.config.ProviderSettings;
+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 org.springframework.web.util.UriComponentsBuilder;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+/**
+ * A {@code Filter} that processes OpenID Provider Configuration Request.
+ *
+ * @author Daniel Garnier-Moiroux
+ * @since 0.1.0
+ * @see ProviderSettings
+ * @see <a target="_blank" href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig">OpenID Connect Discovery 1.0</a>
+ */
+public class OidcProviderConfigurationEndpointFilter extends OncePerRequestFilter {
+	/**
+	 * The default endpoint {@code URI} for OpenID Provider Configuration requests.
+	 */
+	public static final String DEFAULT_OIDC_PROVIDER_CONFIGURATION_ENDPOINT_URI = "/.well-known/openid-configuration";
+
+	private final RequestMatcher requestMatcher;
+	private final ProviderSettings providerSettings;
+	private final OidcProviderConfigurationHttpMessageConverter providerConfigurationHttpMessageConverter = new OidcProviderConfigurationHttpMessageConverter();
+
+	public OidcProviderConfigurationEndpointFilter(ProviderSettings providerSettings) {
+		Assert.notNull(providerSettings, "providerSettings cannot be null");
+		this.requestMatcher = new AntPathRequestMatcher(
+				DEFAULT_OIDC_PROVIDER_CONFIGURATION_ENDPOINT_URI,
+				HttpMethod.GET.name()
+		);
+		this.providerSettings = providerSettings;
+	}
+
+	@Override
+	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+			throws ServletException, IOException {
+		if (!this.requestMatcher.matches(request)) {
+			filterChain.doFilter(request, response);
+			return;
+		}
+
+		OidcProviderConfiguration providerConfiguration = OidcProviderConfiguration.withClaims()
+				.issuer(this.providerSettings.issuer())
+				.authorizationEndpoint(asUrl(this.providerSettings.issuer(), this.providerSettings.authorizationEndpoint()))
+				.tokenEndpoint(asUrl(this.providerSettings.issuer(), this.providerSettings.tokenEndpoint()))
+				.jwksUri(asUrl(this.providerSettings.issuer(), this.providerSettings.jwkSetEndpoint()))
+				.subjectType("public")
+				.responseType(OAuth2AuthorizationResponseType.CODE.getValue())
+				.scope(OidcScopes.OPENID)
+				.grantType(AuthorizationGrantType.AUTHORIZATION_CODE.getValue())
+				.grantType(AuthorizationGrantType.CLIENT_CREDENTIALS.getValue())
+				.tokenEndpointAuthenticationMethod("client_secret_basic") // TODO: move this ClientAuthenticationMethod
+				.build();
+
+		ServletServerHttpResponse resp = new ServletServerHttpResponse(response);
+		this.providerConfigurationHttpMessageConverter.write(providerConfiguration, MediaType.APPLICATION_JSON, resp);
+	}
+
+	private String asUrl(String issuer, String endpoint) {
+		return UriComponentsBuilder.fromUriString(issuer).path(endpoint).build().toUriString();
+	}
+}

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

@@ -0,0 +1,139 @@
+/*
+ * Copyright 2020 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 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;
+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.crypto.key.CryptoKeySource;
+import org.springframework.security.crypto.key.StaticKeyGeneratingCryptoKeySource;
+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.web.OidcProviderConfigurationEndpointFilter;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * Integration tests for the OpenID Connect.
+ *
+ * @author Daniel Garnier-Moiroux
+ */
+public class OidcTests {
+	private static final String issuerUrl = "https://example.com/issuer1";
+
+	@Rule
+	public final SpringTestRule spring = new SpringTestRule();
+
+	@Autowired
+	private MockMvc mvc;
+
+	@Test
+	public void requestWhenIssuerSetAndOpenIDProviderConfigurationRequestThenReturnProviderConfigurationResponse() throws Exception {
+		this.spring.register(AuthorizationServerConfigurationWithIssuer.class).autowire();
+
+		this.mvc.perform(MockMvcRequestBuilders.get(OidcProviderConfigurationEndpointFilter.DEFAULT_OIDC_PROVIDER_CONFIGURATION_ENDPOINT_URI))
+				.andExpect(status().is2xxSuccessful())
+				.andExpect(jsonPath("issuer").value(issuerUrl))
+				.andReturn();
+	}
+
+	@Test
+	public void requestWhenIssuerNotSetAndOpenIDProviderConfigurationRequestThenRedirectsToLogin() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		MvcResult mvcResult = this.mvc.perform(get(OidcProviderConfigurationEndpointFilter.DEFAULT_OIDC_PROVIDER_CONFIGURATION_ENDPOINT_URI))
+				.andExpect(status().is3xxRedirection())
+				.andReturn();
+		assertThat(mvcResult.getResponse().getRedirectedUrl()).endsWith("/login");
+	}
+
+	@Test
+	public void requestWhenIssuerNotValidUrlThenThrowException() {
+		assertThatThrownBy(
+				() -> this.spring.register(AuthorizationServerConfigurationWithInvalidUrlIssuer.class).autowire()
+		);
+	}
+
+	@Test
+	public void requestWhenIssuerNotValidUriThenThrowException() {
+		assertThatThrownBy(
+				() -> this.spring.register(AuthorizationServerConfigurationWithInvalidUriIssuer.class).autowire()
+		);
+	}
+
+	@EnableWebSecurity
+	@Import(OAuth2AuthorizationServerConfiguration.class)
+	static class AuthorizationServerConfiguration {
+
+		@Bean
+		RegisteredClientRepository registeredClientRepository() {
+			return mock(RegisteredClientRepository.class);
+		}
+
+		@Bean
+		CryptoKeySource keySource() {
+			return new StaticKeyGeneratingCryptoKeySource();
+		}
+
+		@Bean
+		ProviderSettings providerSettings() {
+			return new ProviderSettings();
+		}
+	}
+
+	@EnableWebSecurity
+	@Import(OAuth2AuthorizationServerConfiguration.class)
+	static class AuthorizationServerConfigurationWithIssuer extends AuthorizationServerConfiguration {
+		@Bean
+		@Override
+		ProviderSettings providerSettings() {
+			return new ProviderSettings().issuer(issuerUrl);
+		}
+	}
+
+	@EnableWebSecurity
+	@Import(OAuth2AuthorizationServerConfiguration.class)
+	static class AuthorizationServerConfigurationWithInvalidUrlIssuer extends AuthorizationServerConfiguration {
+		@Bean
+		@Override
+		ProviderSettings providerSettings() {
+			return new ProviderSettings().issuer("urn:example");
+		}
+	}
+
+	@EnableWebSecurity
+	@Import(OAuth2AuthorizationServerConfiguration.class)
+	static class AuthorizationServerConfigurationWithInvalidUriIssuer extends AuthorizationServerConfiguration {
+		@Bean
+		@Override
+		ProviderSettings providerSettings() {
+			return new ProviderSettings().issuer("https://not a valid uri");
+		}
+	}
+}

+ 76 - 0
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/core/converter/ObjectToSetStringConverter2Test.java

@@ -0,0 +1,76 @@
+/*
+ * Copyright 2020 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.converter;
+
+import org.junit.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * TODO
+ * This class is temporary and will be removed after upgrading to Spring Security 5.5.0 GA.
+ * These tests will probably be folded into tests for {@link ClaimConversionService}.
+ *
+ * Tests for {@link ObjectToSetStringConverter2}.
+ *
+ * @author Daniel Garnier-Moiroux
+ */
+public class ObjectToSetStringConverter2Test {
+	@Test
+	@SuppressWarnings("unchecked")
+	public void convertFromNullThenReturnNull() {
+		ObjectToSetStringConverter2 converter = new ObjectToSetStringConverter2();
+		Set<String> result = (Set<String>) converter.convert(null, null, null);
+		assertThat(result).isNull();
+	}
+
+	@Test
+	@SuppressWarnings("unchecked")
+	public void convertFromStringThenReturnSet() {
+		ObjectToSetStringConverter2 converter = new ObjectToSetStringConverter2();
+		Set<String> result = (Set<String>) converter.convert("Hello", null, null);
+		assertThat(result).containsExactly("Hello");
+	}
+
+	@Test
+	@SuppressWarnings("unchecked")
+	public void convertFromSetThenReturnSet() {
+		ObjectToSetStringConverter2 converter = new ObjectToSetStringConverter2();
+		Set<String> result = (Set<String>) converter.convert(new HashSet<>(Arrays.asList("Hello", "world")), null, null);
+		assertThat(result).containsExactlyInAnyOrder("Hello", "world");
+	}
+
+	@Test
+	@SuppressWarnings("unchecked")
+	public void convertFromCollectionThenReturnSet() {
+		ObjectToSetStringConverter2 converter = new ObjectToSetStringConverter2();
+		Set<String> result = (Set<String>) converter.convert(Arrays.asList("Hello", "world"), null, null);
+		assertThat(result).containsExactlyInAnyOrder("Hello", "world");
+	}
+
+	@Test
+	@SuppressWarnings("unchecked")
+	public void convertFromEmptyCollectionThenReturnEmptySet() {
+		ObjectToSetStringConverter2 converter = new ObjectToSetStringConverter2();
+		Set<String> result = (Set<String>) converter.convert(Collections.emptyList(), null, null);
+		assertThat(result).isEmpty();
+	}
+}

+ 208 - 0
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/core/http/converter/OidcProviderConfigurationHttpMessageConverterTests.java

@@ -0,0 +1,208 @@
+/*
+ * Copyright 2020 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.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.oidc.OidcProviderConfiguration;
+
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.Arrays;
+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;
+
+/**
+ * Tests for {@link OidcProviderConfigurationHttpMessageConverter}
+ *
+ * @author Daniel Garnier-Moiroux
+ */
+public class OidcProviderConfigurationHttpMessageConverterTests {
+	private final OidcProviderConfigurationHttpMessageConverter messageConverter = new OidcProviderConfigurationHttpMessageConverter();
+
+	@Test
+	public void supportsWhenOidcProviderConfigurationThenTrue() {
+		assertThat(this.messageConverter.supports(OidcProviderConfiguration.class)).isTrue();
+	}
+
+	@Test
+	public void setProviderConfigurationParametersConverterWhenConverterIsNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.messageConverter.setProviderConfigurationParametersConverter(null));
+	}
+
+	@Test
+	public void setProviderConfigurationConverterWhenConverterIsNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.messageConverter.setProviderConfigurationConverter(null));
+	}
+
+	@Test
+	public void readInternalWhenSuccessfulProviderConfigurationOnlyRequiredParametersThenReadOidcProviderConfiguration() throws Exception {
+		// @formatter:off
+		String providerConfigurationResponse = "{\n"
+				+ "		\"issuer\": \"https://example.com/issuer1\",\n"
+				+ "		\"authorization_endpoint\": \"https://example.com/issuer1/oauth2/authorize\",\n"
+				+ "		\"token_endpoint\": \"https://example.com/issuer1/oauth2/token\",\n"
+				+ "		\"jwks_uri\": \"https://example.com/issuer1/oauth2/jwks\",\n"
+				+ "		\"response_types_supported\": [\"code\"],\n"
+				+ "		\"subject_types_supported\": [\"public\"]\n"
+				+ "}\n";
+		// @formatter:on
+		MockClientHttpResponse response = new MockClientHttpResponse(providerConfigurationResponse.getBytes(), HttpStatus.OK);
+		OidcProviderConfiguration providerConfiguration = this.messageConverter
+				.readInternal(OidcProviderConfiguration.class, response);
+
+		assertThat(providerConfiguration.getIssuer()).isEqualTo(new URL("https://example.com/issuer1"));
+		assertThat(providerConfiguration.getAuthorizationEndpoint()).isEqualTo(new URL("https://example.com/issuer1/oauth2/authorize"));
+		assertThat(providerConfiguration.getTokenEndpoint()).isEqualTo(new URL("https://example.com/issuer1/oauth2/token"));
+		assertThat(providerConfiguration.getJwksUri()).isEqualTo(new URL("https://example.com/issuer1/oauth2/jwks"));
+		assertThat(providerConfiguration.getResponseTypes()).containsExactly("code");
+		assertThat(providerConfiguration.getSubjectTypes()).containsExactly("public");
+		assertThat(providerConfiguration.getScopes()).isNull();
+		assertThat(providerConfiguration.getGrantTypes()).isNull();
+		assertThat(providerConfiguration.getTokenEndpointAuthenticationMethods()).isNull();
+	}
+
+	@Test
+	public void readInternalWhenSuccessfulProviderConfigurationThenReadOidcProviderConfiguration() throws Exception {
+		// @formatter:off
+		String providerConfigurationResponse = "{\n"
+				+ "		\"issuer\": \"https://example.com/issuer1\",\n"
+				+ "		\"authorization_endpoint\": \"https://example.com/issuer1/oauth2/authorize\",\n"
+				+ "		\"token_endpoint\": \"https://example.com/issuer1/oauth2/token\",\n"
+				+ "		\"jwks_uri\": \"https://example.com/issuer1/oauth2/jwks\",\n"
+				+ "		\"scopes_supported\": [\"openid\"],\n"
+				+ "		\"response_types_supported\": [\"code\"],\n"
+				+ "		\"grant_types_supported\": [\"authorization_code\", \"client_credentials\"],\n"
+				+ "		\"subject_types_supported\": [\"public\"],\n"
+				+ "		\"token_endpoint_auth_methods_supported\": [\"basic\"],\n"
+				+ "		\"custom_claim\": \"value\",\n"
+				+ "		\"custom_collection_claim\": [\"value1\", \"value2\"]\n"
+				+ "}\n";
+		// @formatter:on
+		MockClientHttpResponse response = new MockClientHttpResponse(providerConfigurationResponse.getBytes(), HttpStatus.OK);
+		OidcProviderConfiguration providerConfiguration = this.messageConverter
+				.readInternal(OidcProviderConfiguration.class, response);
+
+		assertThat(providerConfiguration.getIssuer()).isEqualTo(new URL("https://example.com/issuer1"));
+		assertThat(providerConfiguration.getAuthorizationEndpoint()).isEqualTo(new URL("https://example.com/issuer1/oauth2/authorize"));
+		assertThat(providerConfiguration.getTokenEndpoint()).isEqualTo(new URL("https://example.com/issuer1/oauth2/token"));
+		assertThat(providerConfiguration.getJwksUri()).isEqualTo(new URL("https://example.com/issuer1/oauth2/jwks"));
+		assertThat(providerConfiguration.getScopes()).containsExactly("openid");
+		assertThat(providerConfiguration.getResponseTypes()).containsExactly("code");
+		assertThat(providerConfiguration.getGrantTypes()).containsExactlyInAnyOrder("authorization_code", "client_credentials");
+		assertThat(providerConfiguration.getSubjectTypes()).containsExactly("public");
+		assertThat(providerConfiguration.getTokenEndpointAuthenticationMethods()).containsExactly("basic");
+		assertThat(providerConfiguration.getClaimAsString("custom_claim")).isEqualTo("value");
+		assertThat(providerConfiguration.getClaimAsStringList("custom_collection_claim")).containsExactlyInAnyOrder("value1", "value2");
+	}
+
+	@Test
+	public void readInternalWhenFailingConverterThenThrowException() {
+		String errorMessage = "this is not a valid converter";
+		this.messageConverter.setProviderConfigurationConverter(source -> {
+			throw new RuntimeException(errorMessage);
+		});
+		MockClientHttpResponse response = new MockClientHttpResponse("{}".getBytes(), HttpStatus.OK);
+
+		assertThatExceptionOfType(HttpMessageNotReadableException.class)
+				.isThrownBy(() -> this.messageConverter.readInternal(OidcProviderConfiguration.class, response))
+				.withMessageContaining("An error occurred reading the OpenID Provider Configuration")
+				.withMessageContaining(errorMessage);
+	}
+
+	@Test
+	public void readInternalWhenInvalidProviderConfigurationThenThrowException() {
+		String providerConfigurationResponse = "{ \"issuer\": null }";
+		MockClientHttpResponse response = new MockClientHttpResponse(providerConfigurationResponse.getBytes(), HttpStatus.OK);
+
+		assertThatExceptionOfType(HttpMessageNotReadableException.class)
+				.isThrownBy(() -> this.messageConverter.readInternal(OidcProviderConfiguration.class, response))
+				.withMessageContaining("An error occurred reading the OpenID Provider Configuration")
+				.withMessageContaining("issuer cannot be null");
+	}
+
+	@Test
+	public void writeInternalWhenOidcProviderConfigurationThenWriteTokenResponse() throws Exception {
+		OidcProviderConfiguration providerConfiguration =
+				OidcProviderConfiguration.withClaims()
+						.issuer("https://example.com/issuer1")
+						.authorizationEndpoint("https://example.com/issuer1/oauth2/authorize")
+						.tokenEndpoint("https://example.com/issuer1/oauth2/token")
+						.jwksUri("https://example.com/issuer1/oauth2/jwks")
+						.scope("openid")
+						.responseType("code")
+						.grantType("authorization_code")
+						.grantType("client_credentials")
+						.subjectType("public")
+						.tokenEndpointAuthenticationMethod("basic")
+						.claim("custom_claim", "value")
+						.claim("custom_collection_claim", Arrays.asList("value1", "value2"))
+						.build();
+		MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
+
+		this.messageConverter.writeInternal(providerConfiguration, outputMessage);
+
+		String providerConfigurationResponse = outputMessage.getBodyAsString();
+		assertThat(providerConfigurationResponse).contains("\"issuer\":\"https://example.com/issuer1\"");
+		assertThat(providerConfigurationResponse).contains("\"authorization_endpoint\":\"https://example.com/issuer1/oauth2/authorize\"");
+		assertThat(providerConfigurationResponse).contains("\"token_endpoint\":\"https://example.com/issuer1/oauth2/token\"");
+		assertThat(providerConfigurationResponse).contains("\"jwks_uri\":\"https://example.com/issuer1/oauth2/jwks\"");
+		assertThat(providerConfigurationResponse).contains("\"scopes_supported\":[\"openid\"]");
+		assertThat(providerConfigurationResponse).contains("\"response_types_supported\":[\"code\"]");
+		assertThat(providerConfigurationResponse).contains("\"grant_types_supported\":[\"authorization_code\",\"client_credentials\"]");
+		assertThat(providerConfigurationResponse).contains("\"subject_types_supported\":[\"public\"]");
+		assertThat(providerConfigurationResponse).contains("\"token_endpoint_auth_methods_supported\":[\"basic\"]");
+		assertThat(providerConfigurationResponse).contains("\"custom_claim\":\"value\"");
+		assertThat(providerConfigurationResponse).contains("\"custom_collection_claim\":[\"value1\",\"value2\"]");
+	}
+
+	@Test
+	@SuppressWarnings("unchecked")
+	public void writeInternalWhenWriteFailsThenThrowsException() throws MalformedURLException {
+		String errorMessage = "this is not a valid converter";
+		Converter<OidcProviderConfiguration, Map<String, Object>> failingConverter =
+				source -> {
+					throw new RuntimeException(errorMessage);
+				};
+		this.messageConverter.setProviderConfigurationParametersConverter(failingConverter);
+
+		OidcProviderConfiguration providerConfiguration =
+				OidcProviderConfiguration.withClaims()
+						.issuer("https://example.com")
+						.authorizationEndpoint("https://example.com")
+						.tokenEndpoint("https://example.com")
+						.jwksUri("https://example.com")
+						.responseType("code")
+						.subjectType("public")
+						.build();
+
+		MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
+
+		assertThatThrownBy(() -> this.messageConverter.writeInternal(providerConfiguration, outputMessage))
+				.isInstanceOf(HttpMessageNotWritableException.class)
+				.hasMessageContaining("An error occurred writing the OpenID Provider Configuration")
+				.hasMessageContaining(errorMessage);
+	}
+}

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

@@ -0,0 +1,398 @@
+/*
+ * Copyright 2020 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 java.net.MalformedURLException;
+import java.net.URL;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests for {@link OidcProviderConfiguration}.
+ *
+ * @author Daniel Garnier-Moiroux
+ */
+public class OidcProviderConfigurationTests {
+	private final OidcProviderConfiguration.Builder minimalConfigurationBuilder =
+			OidcProviderConfiguration.withClaims()
+					.issuer("https://example.com/issuer1")
+					.authorizationEndpoint("https://example.com/issuer1/oauth2/authorize")
+					.tokenEndpoint("https://example.com/issuer1/oauth2/token")
+					.jwksUri("https://example.com/issuer1/oauth2/jwks")
+					.scope("openid")
+					.responseType("code")
+					.subjectType("public");
+
+	@Test
+	public void buildWhenAllRequiredClaimsAndAdditionalClaimsThenCreated() {
+		OidcProviderConfiguration providerConfiguration = OidcProviderConfiguration.withClaims()
+				.issuer("https://example.com/issuer1")
+				.authorizationEndpoint("https://example.com/issuer1/oauth2/authorize")
+				.tokenEndpoint("https://example.com/issuer1/oauth2/token")
+				.jwksUri("https://example.com/issuer1/oauth2/jwks")
+				.scope("openid")
+				.responseType("code")
+				.grantType("authorization_code")
+				.grantType("client_credentials")
+				.subjectType("public")
+				.tokenEndpointAuthenticationMethod("basic")
+				.claim("a-claim", "a-value")
+				.build();
+
+		assertThat(providerConfiguration.getIssuer()).isEqualTo(url("https://example.com/issuer1"));
+		assertThat(providerConfiguration.getAuthorizationEndpoint()).isEqualTo(url("https://example.com/issuer1/oauth2/authorize"));
+		assertThat(providerConfiguration.getTokenEndpoint()).isEqualTo(url("https://example.com/issuer1/oauth2/token"));
+		assertThat(providerConfiguration.getJwksUri()).isEqualTo(url("https://example.com/issuer1/oauth2/jwks"));
+		assertThat(providerConfiguration.getScopes()).containsExactly("openid");
+		assertThat(providerConfiguration.getResponseTypes()).containsExactly("code");
+		assertThat(providerConfiguration.getGrantTypes()).containsExactlyInAnyOrder("authorization_code", "client_credentials");
+		assertThat(providerConfiguration.getSubjectTypes()).containsExactly("public");
+		assertThat(providerConfiguration.getTokenEndpointAuthenticationMethods()).containsExactly("basic");
+		assertThat(providerConfiguration.getClaimAsString("a-claim")).isEqualTo("a-value");
+	}
+
+	@Test
+	public void buildWhenOnlyRequiredClaimsThenCreated() {
+		OidcProviderConfiguration providerConfiguration = OidcProviderConfiguration.withClaims()
+				.issuer("https://example.com/issuer1")
+				.authorizationEndpoint("https://example.com/issuer1/oauth2/authorize")
+				.tokenEndpoint("https://example.com/issuer1/oauth2/token")
+				.jwksUri("https://example.com/issuer1/oauth2/jwks")
+				.scope("openid")
+				.responseType("code")
+				.subjectType("public")
+				.build();
+
+		assertThat(providerConfiguration.getIssuer()).isEqualTo(url("https://example.com/issuer1"));
+		assertThat(providerConfiguration.getAuthorizationEndpoint()).isEqualTo(url("https://example.com/issuer1/oauth2/authorize"));
+		assertThat(providerConfiguration.getTokenEndpoint()).isEqualTo(url("https://example.com/issuer1/oauth2/token"));
+		assertThat(providerConfiguration.getJwksUri()).isEqualTo(url("https://example.com/issuer1/oauth2/jwks"));
+		assertThat(providerConfiguration.getScopes()).containsExactly("openid");
+		assertThat(providerConfiguration.getResponseTypes()).containsExactly("code");
+		assertThat(providerConfiguration.getGrantTypes()).isNull();
+		assertThat(providerConfiguration.getSubjectTypes()).containsExactly("public");
+		assertThat(providerConfiguration.getTokenEndpointAuthenticationMethods()).isNull();
+	}
+
+	@Test
+	public void buildFromClaimsThenCreated() {
+		HashMap<String, Object> claims = new HashMap<>();
+		claims.put(OidcProviderMetadataClaimNames.ISSUER, "https://example.com/issuer1");
+		claims.put(OidcProviderMetadataClaimNames.AUTHORIZATION_ENDPOINT, "https://example.com/issuer1/oauth2/authorize");
+		claims.put(OidcProviderMetadataClaimNames.TOKEN_ENDPOINT, "https://example.com/issuer1/oauth2/token");
+		claims.put(OidcProviderMetadataClaimNames.JWKS_URI, "https://example.com/issuer1/oauth2/jwks");
+		claims.put(OidcProviderMetadataClaimNames.SCOPES_SUPPORTED, Collections.singleton("openid"));
+		claims.put(OidcProviderMetadataClaimNames.RESPONSE_TYPES_SUPPORTED, Collections.singleton("code"));
+		claims.put(OidcProviderMetadataClaimNames.SUBJECT_TYPES_SUPPORTED, Collections.singleton("public"));
+		claims.put("some-claim", "some-value");
+
+		OidcProviderConfiguration providerConfiguration = OidcProviderConfiguration.withClaims(claims).build();
+
+		assertThat(providerConfiguration.getIssuer()).isEqualTo(url("https://example.com/issuer1"));
+		assertThat(providerConfiguration.getAuthorizationEndpoint()).isEqualTo(url("https://example.com/issuer1/oauth2/authorize"));
+		assertThat(providerConfiguration.getTokenEndpoint()).isEqualTo(url("https://example.com/issuer1/oauth2/token"));
+		assertThat(providerConfiguration.getJwksUri()).isEqualTo(url("https://example.com/issuer1/oauth2/jwks"));
+		assertThat(providerConfiguration.getScopes()).containsExactly("openid");
+		assertThat(providerConfiguration.getResponseTypes()).containsExactly("code");
+		assertThat(providerConfiguration.getGrantTypes()).isNull();
+		assertThat(providerConfiguration.getSubjectTypes()).containsExactly("public");
+		assertThat(providerConfiguration.getTokenEndpointAuthenticationMethods()).isNull();
+		assertThat(providerConfiguration.getClaimAsString("some-claim")).isEqualTo("some-value");
+	}
+
+	@Test
+	public void buildFromClaimsWhenUsingUrlsThenCreated() {
+		HashMap<String, Object> claims = new HashMap<>();
+		claims.put(OidcProviderMetadataClaimNames.ISSUER, url("https://example.com/issuer1"));
+		claims.put(OidcProviderMetadataClaimNames.AUTHORIZATION_ENDPOINT, url("https://example.com/issuer1/oauth2/authorize"));
+		claims.put(OidcProviderMetadataClaimNames.TOKEN_ENDPOINT, url("https://example.com/issuer1/oauth2/token"));
+		claims.put(OidcProviderMetadataClaimNames.JWKS_URI, url("https://example.com/issuer1/oauth2/jwks"));
+		claims.put(OidcProviderMetadataClaimNames.SCOPES_SUPPORTED, Collections.singleton("openid"));
+		claims.put(OidcProviderMetadataClaimNames.RESPONSE_TYPES_SUPPORTED, Collections.singleton("code"));
+		claims.put(OidcProviderMetadataClaimNames.SUBJECT_TYPES_SUPPORTED, Collections.singleton("public"));
+		claims.put("some-claim", "some-value");
+
+		OidcProviderConfiguration providerConfiguration = OidcProviderConfiguration.withClaims(claims).build();
+
+		assertThat(providerConfiguration.getIssuer()).isEqualTo(url("https://example.com/issuer1"));
+		assertThat(providerConfiguration.getAuthorizationEndpoint()).isEqualTo(url("https://example.com/issuer1/oauth2/authorize"));
+		assertThat(providerConfiguration.getTokenEndpoint()).isEqualTo(url("https://example.com/issuer1/oauth2/token"));
+		assertThat(providerConfiguration.getJwksUri()).isEqualTo(url("https://example.com/issuer1/oauth2/jwks"));
+		assertThat(providerConfiguration.getScopes()).containsExactly("openid");
+		assertThat(providerConfiguration.getResponseTypes()).containsExactly("code");
+		assertThat(providerConfiguration.getGrantTypes()).isNull();
+		assertThat(providerConfiguration.getSubjectTypes()).containsExactly("public");
+		assertThat(providerConfiguration.getTokenEndpointAuthenticationMethods()).isNull();
+		assertThat(providerConfiguration.getClaimAsString("some-claim")).isEqualTo("some-value");
+	}
+
+	@Test
+	public void withClaimsWhenNullThenThrowsException() {
+		assertThatThrownBy(() -> OidcProviderConfiguration.withClaims(null))
+				.isInstanceOf(IllegalArgumentException.class);
+	}
+
+	@Test
+	public void  withClaimsWhenMissingRequiredClaimsThenThrowsException() {
+		assertThatThrownBy(() -> OidcProviderConfiguration.withClaims(Collections.emptyMap()))
+				.isInstanceOf(IllegalArgumentException.class);
+	}
+
+	@Test
+	public void buildWhenCalledTwiceThenGeneratesTwoConfigurations() {
+		OidcProviderConfiguration first = minimalConfigurationBuilder
+				.grantType("client_credentials")
+				.build();
+
+		OidcProviderConfiguration second = minimalConfigurationBuilder
+				.claims((claims) ->
+						{
+							LinkedHashSet<String> newGrantTypes = new LinkedHashSet<>();
+							newGrantTypes.add("authorization_code");
+							newGrantTypes.add("implicit");
+							claims.put(OidcProviderMetadataClaimNames.GRANT_TYPES_SUPPORTED, newGrantTypes);
+						}
+				)
+				.build();
+
+		assertThat(first.getGrantTypes()).containsExactly("client_credentials");
+		assertThat(second.getGrantTypes()).containsExactlyInAnyOrder("authorization_code", "implicit");
+	}
+
+	@Test
+	public void buildWhenMissingIssuerThenThrowsException() {
+		OidcProviderConfiguration.Builder builder = minimalConfigurationBuilder
+				.claims((claims) -> claims.remove(OidcProviderMetadataClaimNames.ISSUER));
+
+		assertThatThrownBy(builder::build)
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("issuer cannot be null");
+	}
+
+	@Test
+	public void buildWhenIssuerIsNotAnUrlThenThrowsException() {
+		OidcProviderConfiguration.Builder builder = minimalConfigurationBuilder
+				.claims((claims) -> claims.put(OidcProviderMetadataClaimNames.ISSUER, "not an url"));
+
+		assertThatThrownBy(builder::build)
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessageStartingWith("issuer must be a valid URL");
+	}
+
+	@Test
+	public void buildWhenMissingAuthorizationEndpointThenThrowsException() {
+		OidcProviderConfiguration.Builder builder = minimalConfigurationBuilder
+				.claims((claims) -> claims.remove(OidcProviderMetadataClaimNames.AUTHORIZATION_ENDPOINT));
+
+		assertThatThrownBy(builder::build)
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("authorizationEndpoint cannot be null");
+	}
+
+	@Test
+	public void buildWhenAuthorizationEndpointIsNotAnUrlThenThrowsException() {
+		OidcProviderConfiguration.Builder builder = minimalConfigurationBuilder
+				.claims((claims) -> claims.put(OidcProviderMetadataClaimNames.AUTHORIZATION_ENDPOINT, "not an url"));
+
+		assertThatThrownBy(builder::build)
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessageStartingWith("authorizationEndpoint must be a valid URL");
+	}
+
+	@Test
+	public void buildWhenMissingTokenEndpointThenThrowsException() {
+		OidcProviderConfiguration.Builder builder = minimalConfigurationBuilder
+				.claims((claims) -> claims.remove(OidcProviderMetadataClaimNames.TOKEN_ENDPOINT));
+
+		assertThatThrownBy(builder::build)
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("tokenEndpoint cannot be null");
+	}
+
+	@Test
+	public void buildWhenTokenEndpointIsNotAnUrlThenThrowsException() {
+		OidcProviderConfiguration.Builder builder = minimalConfigurationBuilder
+				.claims((claims) -> claims.put(OidcProviderMetadataClaimNames.TOKEN_ENDPOINT, "not an url"));
+
+		assertThatThrownBy(builder::build)
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessageStartingWith("tokenEndpoint must be a valid URL");
+	}
+
+	@Test
+	public void buildWhenMissingJwksUriThenThrowsException() {
+		OidcProviderConfiguration.Builder builder = minimalConfigurationBuilder
+				.claims((claims) -> claims.remove(OidcProviderMetadataClaimNames.JWKS_URI));
+
+		assertThatThrownBy(builder::build)
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("jwkSetUri cannot be null");
+	}
+
+	@Test
+	public void buildWheJwksUriIsNotAnUrlThenThrowsException() {
+		OidcProviderConfiguration.Builder builder = minimalConfigurationBuilder
+				.claims((claims) -> claims.put(OidcProviderMetadataClaimNames.JWKS_URI, "not an url"));
+
+		assertThatThrownBy(builder::build)
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessageStartingWith("jwkSetUri must be a valid URL");
+	}
+
+	@Test
+	public void buildWhenMissingResponseTypesThenThrowsException() {
+		OidcProviderConfiguration.Builder builder = minimalConfigurationBuilder
+				.claims((claims) -> claims.remove(OidcProviderMetadataClaimNames.RESPONSE_TYPES_SUPPORTED));
+
+		assertThatThrownBy(builder::build)
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("responseTypes cannot be empty");
+	}
+
+	@Test
+	public void buildWhenMissingSubjectTypesThenThrowsException() {
+		OidcProviderConfiguration.Builder builder = minimalConfigurationBuilder
+				.claims((claims) -> claims.remove(OidcProviderMetadataClaimNames.SUBJECT_TYPES_SUPPORTED));
+
+		assertThatThrownBy(builder::build)
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("subjectTypes cannot be empty");
+	}
+
+	@Test
+	public void responseTypesWhenAddingOrRemovingThenCorrectValues() {
+		OidcProviderConfiguration configuration = minimalConfigurationBuilder
+				.responseType("should-be-removed")
+				.responseTypes(responseTypes -> {
+					responseTypes.clear();
+					responseTypes.add("some-response-type");
+				})
+				.build();
+
+		assertThat(configuration.getResponseTypes()).containsExactly("some-response-type");
+	}
+
+	@Test
+	public void responseTypesWhenNotPresentAndAddingThenCorrectValues() {
+		OidcProviderConfiguration configuration = minimalConfigurationBuilder
+				.claims(claims -> claims.remove(OidcProviderMetadataClaimNames.RESPONSE_TYPES_SUPPORTED))
+				.responseTypes(responseTypes -> responseTypes.add("some-response-type"))
+				.build();
+
+		assertThat(configuration.getResponseTypes()).containsExactly("some-response-type");
+	}
+
+	@Test
+	public void subjectTypesWhenAddingOrRemovingThenCorrectValues() {
+		OidcProviderConfiguration configuration = minimalConfigurationBuilder
+				.subjectType("should-be-removed")
+				.subjectTypes(subjectTypes -> {
+					subjectTypes.clear();
+					subjectTypes.add("some-subject-type");
+				})
+				.build();
+
+		assertThat(configuration.getSubjectTypes()).containsExactly("some-subject-type");
+	}
+
+	@Test
+	public void scopesWhenAddingOrRemovingThenCorrectValues() {
+		OidcProviderConfiguration configuration = minimalConfigurationBuilder
+				.scope("should-be-removed")
+				.scopes(scopes -> {
+					scopes.clear();
+					scopes.add("some-scope");
+				})
+				.build();
+
+		assertThat(configuration.getScopes()).containsExactly("some-scope");
+	}
+
+	@Test
+	public void grantTypesWhenAddingOrRemovingThenCorrectValues() {
+		OidcProviderConfiguration configuration = minimalConfigurationBuilder
+				.grantType("should-be-removed")
+				.grantTypes(grantTypes -> {
+					grantTypes.clear();
+					grantTypes.add("some-grant-type");
+				})
+				.build();
+
+		assertThat(configuration.getGrantTypes()).containsExactly("some-grant-type");
+	}
+
+	@Test
+	public void tokenEndpointAuthenticationMethodsWhenAddingOrRemovingThenCorrectValues() {
+		OidcProviderConfiguration configuration = minimalConfigurationBuilder
+				.tokenEndpointAuthenticationMethod("should-be-removed")
+				.tokenEndpointAuthenticationMethods(authMethods -> {
+					authMethods.clear();
+					authMethods.add("some-authentication-method");
+				})
+				.build();
+
+		assertThat(configuration.getTokenEndpointAuthenticationMethods()).containsExactly("some-authentication-method");
+	}
+
+	@Test
+	public void claimWhenNameIsNullThenThrowIllegalArgumentException() {
+		OidcProviderConfiguration.Builder builder = OidcProviderConfiguration.withClaims();
+		assertThatThrownBy(() -> builder.claim(null, "value"))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("name cannot be empty");
+	}
+
+	@Test
+	public void claimWhenValueIsNullThenThrowIllegalArgumentException() {
+		OidcProviderConfiguration.Builder builder = OidcProviderConfiguration.withClaims();
+		assertThatThrownBy(() -> builder.claim("claim-name", null))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("value cannot be null");
+	}
+
+	@Test
+	public void claimsWhenRemovingAClaimThenIsNotPresent() {
+		OidcProviderConfiguration configuration =
+				minimalConfigurationBuilder
+						.grantType("some-grant-type")
+						.claims((claims) -> claims.remove(OidcProviderMetadataClaimNames.GRANT_TYPES_SUPPORTED))
+						.build();
+		assertThat(configuration.getGrantTypes()).isNull();
+	}
+
+	@Test
+	public void claimsWhenAddingAClaimThenIsPresent() {
+		OidcProviderConfiguration configuration =
+				minimalConfigurationBuilder
+						.claims((claims) -> claims.put(OidcProviderMetadataClaimNames.GRANT_TYPES_SUPPORTED, "authorization_code"))
+						.build();
+		assertThat(configuration.getGrantTypes()).containsExactly("authorization_code");
+	}
+
+	private static URL url(String urlString) {
+		try {
+			return new URL(urlString);
+		} catch (MalformedURLException e) {
+			throw new IllegalArgumentException("urlString must be a valid URL and valid URI");
+		}
+	}
+}

+ 126 - 0
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/ProviderSettingsTests.java

@@ -0,0 +1,126 @@
+/*
+ * Copyright 2020 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.config;
+
+import org.junit.Test;
+
+import java.net.MalformedURLException;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests for {@link ProviderSettings}.
+ *
+ * @author Daniel Garnier-Moiroux
+ */
+public class ProviderSettingsTests {
+	@Test
+	public void constructorWhenDefaultThenDefaultsAreSetAndIssuerIsNotSet() {
+		ProviderSettings providerSettings = new ProviderSettings();
+
+		assertThat(providerSettings.issuer()).isNull();
+		assertThat(providerSettings.authorizationEndpoint()).isEqualTo("/oauth2/authorize");
+		assertThat(providerSettings.tokenEndpoint()).isEqualTo("/oauth2/token");
+		assertThat(providerSettings.jwkSetEndpoint()).isEqualTo("/oauth2/jwks");
+		assertThat(providerSettings.tokenRevocationEndpoint()).isEqualTo("/oauth2/revoke");
+	}
+
+	@Test
+	public void settingsWhenProvidedThenSet() throws MalformedURLException {
+		String authorizationEndpoint = "/my-endpoints/authorize";
+		String tokenEndpoint = "/my-endpoints/token";
+		String jwksEndpoint = "/my-endpoints/jwks";
+		String tokenRevocationEndpoint = "/my-endpoints/revoke";
+		String issuer = "https://example.com/9000";
+
+		ProviderSettings providerSettings = new ProviderSettings()
+				.issuer(issuer)
+				.authorizationEndpoint(authorizationEndpoint)
+				.tokenEndpoint(tokenEndpoint)
+				.jwkSetEndpoint(jwksEndpoint)
+				.tokenRevocationEndpoint(tokenRevocationEndpoint);
+
+		assertThat(providerSettings.issuer()).isEqualTo(issuer);
+		assertThat(providerSettings.authorizationEndpoint()).isEqualTo(authorizationEndpoint);
+		assertThat(providerSettings.tokenEndpoint()).isEqualTo(tokenEndpoint);
+		assertThat(providerSettings.jwkSetEndpoint()).isEqualTo(jwksEndpoint);
+		assertThat(providerSettings.tokenRevocationEndpoint()).isEqualTo(tokenRevocationEndpoint);
+	}
+
+	@Test
+	public void settingWhenCalledThenReturnTokenSettings() {
+		ProviderSettings providerSettings = new ProviderSettings()
+				.setting("name1", "value1")
+				.settings(settings -> settings.put("name2", "value2"));
+
+		assertThat(providerSettings.settings()).hasSize(6);
+		assertThat(providerSettings.<String>setting("name1")).isEqualTo("value1");
+		assertThat(providerSettings.<String>setting("name2")).isEqualTo("value2");
+	}
+
+	@Test
+	public void issuerWhenNullThenThrowsIllegalArgumentException() {
+		ProviderSettings settings = new ProviderSettings();
+		assertThatThrownBy(() -> settings.issuer(null))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("issuer cannot be null");
+	}
+
+	@Test
+	public void authorizationEndpointWhenNullThenThrowsIllegalArgumentException() {
+		ProviderSettings settings = new ProviderSettings();
+		assertThatThrownBy(() -> settings.authorizationEndpoint(null))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("authorizationEndpoint cannot be empty");
+		assertThatThrownBy(() -> settings.authorizationEndpoint(""))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("authorizationEndpoint cannot be empty");
+	}
+
+	@Test
+	public void tokenEndpointWhenNullThenThrowsIllegalArgumentException() {
+		ProviderSettings settings = new ProviderSettings();
+		assertThatThrownBy(() -> settings.tokenEndpoint(null))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("tokenEndpoint cannot be empty");
+		assertThatThrownBy(() -> settings.tokenEndpoint(""))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("tokenEndpoint cannot be empty");
+	}
+
+	@Test
+	public void tokenRevocationEndpointWhenNullThenThrowsIllegalArgumentException() {
+		ProviderSettings settings = new ProviderSettings();
+		assertThatThrownBy(() -> settings.tokenRevocationEndpoint(null))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("tokenRevocationEndpoint cannot be empty");
+		assertThatThrownBy(() -> settings.tokenRevocationEndpoint(""))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("tokenRevocationEndpoint cannot be empty");
+	}
+
+	@Test
+	public void jwkSetEndpointWhenNullThenThrowsIllegalArgumentException() {
+		ProviderSettings settings = new ProviderSettings();
+		assertThatThrownBy(() -> settings.jwkSetEndpoint(null))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("jwkSetEndpoint cannot be empty");
+		assertThatThrownBy(() -> settings.jwkSetEndpoint(""))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("jwkSetEndpoint cannot be empty");
+	}
+}

+ 112 - 0
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OidcProviderConfigurationEndpointFilterTests.java

@@ -0,0 +1,112 @@
+/*
+ * Copyright 2020 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.web;
+
+import org.junit.Test;
+import org.springframework.http.MediaType;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.security.oauth2.server.authorization.config.ProviderSettings;
+
+import javax.servlet.FilterChain;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+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.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+
+/**
+ * Tests for {@link OidcProviderConfigurationEndpointFilter}.
+ *
+ * @author Daniel Garnier-Moiroux
+ */
+public class OidcProviderConfigurationEndpointFilterTests {
+	@Test
+	public void constructorWhenProviderSettingsNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new OidcProviderConfigurationEndpointFilter(null))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("providerSettings cannot be null");
+	}
+
+	@Test
+	public void doFilterWhenRequestDoesNotMatchThenNotProcessed() throws Exception {
+		OidcProviderConfigurationEndpointFilter filter = new OidcProviderConfigurationEndpointFilter(new ProviderSettings());
+		String requestUri = "/path";
+		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 doFilterWhenSuccessThenConfigurationResponse() throws Exception {
+		String authorizationEndpoint = "/my-endpoints/authorize";
+		String tokenEndpoint = "/my-endpoints/token";
+		String jwksEndpoint = "/my-endpoints/jwks";
+
+		ProviderSettings providerSettings = new ProviderSettings()
+				.issuer("https://example.com/issuer1")
+				.authorizationEndpoint(authorizationEndpoint)
+				.tokenEndpoint(tokenEndpoint)
+				.jwkSetEndpoint(jwksEndpoint);
+		OidcProviderConfigurationEndpointFilter filter = new OidcProviderConfigurationEndpointFilter(providerSettings);
+
+		MockHttpServletRequest request = new MockHttpServletRequest("GET", org.springframework.security.oauth2.server.authorization.web.OidcProviderConfigurationEndpointFilter.DEFAULT_OIDC_PROVIDER_CONFIGURATION_ENDPOINT_URI);
+		request.setServletPath(org.springframework.security.oauth2.server.authorization.web.OidcProviderConfigurationEndpointFilter.DEFAULT_OIDC_PROVIDER_CONFIGURATION_ENDPOINT_URI);
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		filter.doFilter(request, response, filterChain);
+
+		verifyNoInteractions(filterChain);
+
+		assertThat(response.getContentType()).isEqualTo(MediaType.APPLICATION_JSON_VALUE);
+		String providerConfigurationResponse = response.getContentAsString();
+		assertThat(providerConfigurationResponse).contains("\"issuer\":\"https://example.com/issuer1\"");
+		assertThat(providerConfigurationResponse).contains("\"authorization_endpoint\":\"https://example.com/issuer1/my-endpoints/authorize\"");
+		assertThat(providerConfigurationResponse).contains("\"token_endpoint\":\"https://example.com/issuer1/my-endpoints/token\"");
+		assertThat(providerConfigurationResponse).contains("\"jwks_uri\":\"https://example.com/issuer1/my-endpoints/jwks\"");
+		assertThat(providerConfigurationResponse).contains("\"scopes_supported\":[\"openid\"]");
+		assertThat(providerConfigurationResponse).contains("\"response_types_supported\":[\"code\"]");
+		assertThat(providerConfigurationResponse).contains("\"grant_types_supported\":[\"authorization_code\",\"client_credentials\"]");
+		assertThat(providerConfigurationResponse).contains("\"subject_types_supported\":[\"public\"]");
+		assertThat(providerConfigurationResponse).contains("\"token_endpoint_auth_methods_supported\":[\"client_secret_basic\"]");
+	}
+
+
+	@Test
+	public void  doFilterWhenProviderSettingsWithInvalidIssuerThenThrowIllegalArgumentException() {
+		ProviderSettings providerSettings = new ProviderSettings()
+				.issuer("https://this is an invalid URL");
+		OidcProviderConfigurationEndpointFilter filter = new OidcProviderConfigurationEndpointFilter(providerSettings);
+
+		MockHttpServletRequest request = new MockHttpServletRequest("GET", org.springframework.security.oauth2.server.authorization.web.OidcProviderConfigurationEndpointFilter.DEFAULT_OIDC_PROVIDER_CONFIGURATION_ENDPOINT_URI);
+		request.setServletPath(org.springframework.security.oauth2.server.authorization.web.OidcProviderConfigurationEndpointFilter.DEFAULT_OIDC_PROVIDER_CONFIGURATION_ENDPOINT_URI);
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		assertThatThrownBy(() -> filter.doFilter(request, response, filterChain))
+				.isInstanceOf(IllegalArgumentException.class);
+	}
+}