mirror of
				https://github.com/spring-projects/spring-security.git
				synced 2025-10-31 14:48:54 +00:00 
			
		
		
		
	Add ClientRegistration from OpenID Connect Discovery
Fixes: gh-4413
This commit is contained in:
		
							parent
							
								
									e82a1d151c
								
							
						
					
					
						commit
						0598d47732
					
				| @ -34,6 +34,7 @@ dependencies { | |||||||
| 	testCompile apachedsDependencies | 	testCompile apachedsDependencies | ||||||
| 	testCompile powerMock2Dependencies | 	testCompile powerMock2Dependencies | ||||||
| 	testCompile spockDependencies | 	testCompile spockDependencies | ||||||
|  | 	testCompile 'com.squareup.okhttp3:mockwebserver' | ||||||
| 	testCompile 'ch.qos.logback:logback-classic' | 	testCompile 'ch.qos.logback:logback-classic' | ||||||
| 	testCompile 'io.projectreactor.ipc:reactor-netty' | 	testCompile 'io.projectreactor.ipc:reactor-netty' | ||||||
| 	testCompile 'javax.annotation:jsr250-api:1.0' | 	testCompile 'javax.annotation:jsr250-api:1.0' | ||||||
|  | |||||||
| @ -0,0 +1,118 @@ | |||||||
|  | /* | ||||||
|  |  * Copyright 2002-2018 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 | ||||||
|  |  * | ||||||
|  |  *      http://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.oauth2.client; | ||||||
|  | 
 | ||||||
|  | import java.net.URI; | ||||||
|  | import java.util.Arrays; | ||||||
|  | import java.util.List; | ||||||
|  | 
 | ||||||
|  | import org.springframework.security.oauth2.client.registration.ClientRegistration; | ||||||
|  | import org.springframework.security.oauth2.core.AuthorizationGrantType; | ||||||
|  | import org.springframework.security.oauth2.core.ClientAuthenticationMethod; | ||||||
|  | import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames; | ||||||
|  | import org.springframework.web.client.RestTemplate; | ||||||
|  | 
 | ||||||
|  | import com.nimbusds.oauth2.sdk.GrantType; | ||||||
|  | import com.nimbusds.oauth2.sdk.ParseException; | ||||||
|  | import com.nimbusds.oauth2.sdk.Scope; | ||||||
|  | import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Allows creating a {@link ClientRegistration.Builder} from an | ||||||
|  |  * <a href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig">OpenID Provider Configuration</a>. | ||||||
|  |  * | ||||||
|  |  * @author Rob Winch | ||||||
|  |  * @since 5.1 | ||||||
|  |  */ | ||||||
|  | public final class OidcConfigurationProvider { | ||||||
|  | 
 | ||||||
|  | 	/** | ||||||
|  | 	 * Given the <a href="http://openid.net/specs/openid-connect-core-1_0.html#IssuerIdentifier">Issuer</a> creates a | ||||||
|  | 	 * {@link ClientRegistration.Builder} by making an | ||||||
|  | 	 * <a href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationRequest">OpenID Provider | ||||||
|  | 	 * Configuration Request</a> and using the values in the | ||||||
|  | 	 * <a href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse">OpenID | ||||||
|  | 	 * Provider Configuration Response</a> to initialize the {@link ClientRegistration.Builder}. | ||||||
|  | 	 * | ||||||
|  | 	 * <p> | ||||||
|  | 	 * For example if the issuer provided is "https://example.com", then an "OpenID Provider Configuration Request" will | ||||||
|  | 	 * be made to "https://example.com/.well-known/openid-configuration". The result is expected to be an "OpenID | ||||||
|  | 	 * Provider Configuration Response". | ||||||
|  | 	 * </p> | ||||||
|  | 	 * | ||||||
|  | 	 * <p> | ||||||
|  | 	 * Example usage: | ||||||
|  | 	 * </p> | ||||||
|  | 	 * <pre> | ||||||
|  | 	 * ClientRegistration registration = OidcConfigurationProvider.issuer("https://example.com") | ||||||
|  | 	 *     .clientId("client-id") | ||||||
|  | 	 *     .clientSecret("client-secret") | ||||||
|  | 	 *     .build(); | ||||||
|  | 	 * </pre> | ||||||
|  | 	 * @param issuer the <a href="http://openid.net/specs/openid-connect-core-1_0.html#IssuerIdentifier">Issuer</a> | ||||||
|  | 	 * @return a {@link ClientRegistration.Builder} that was initialized by the OpenID Provider Configuration. | ||||||
|  | 	 */ | ||||||
|  | 	public static ClientRegistration.Builder issuer(String issuer) { | ||||||
|  | 		RestTemplate rest = new RestTemplate(); | ||||||
|  | 		String openidConfiguration = rest.getForObject(issuer + "/.well-known/openid-configuration", String.class); | ||||||
|  | 		OIDCProviderMetadata metadata = parse(openidConfiguration); | ||||||
|  | 		String name = URI.create(issuer).getHost(); | ||||||
|  | 		List<com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod> metadataAuthMethods = metadata.getTokenEndpointAuthMethods(); | ||||||
|  | 			// if null, the default includes client_secret_basic | ||||||
|  | 		if (metadataAuthMethods != null && !metadataAuthMethods.contains(com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod.CLIENT_SECRET_BASIC)) { | ||||||
|  | 			throw new IllegalArgumentException("Only ClientAuthenticationMethod.BASIC is supported. The issuer \"" + issuer + "\" returned a configuration of " + metadataAuthMethods); | ||||||
|  | 		} | ||||||
|  | 		List<GrantType> grantTypes = metadata.getGrantTypes(); | ||||||
|  | 			// If null, the default includes authorization_code | ||||||
|  | 		if (grantTypes != null && !grantTypes.contains(GrantType.AUTHORIZATION_CODE)) { | ||||||
|  | 			throw new IllegalArgumentException("Only AuthorizationGrantType.AUTHORIZATION_CODE is supported. The issuer \"" + issuer + "\" returned a configuration of " + grantTypes); | ||||||
|  | 		} | ||||||
|  | 		List<String> scopes = getScopes(metadata); | ||||||
|  | 		return ClientRegistration.withRegistrationId(name) | ||||||
|  | 				.userNameAttributeName(IdTokenClaimNames.SUB) | ||||||
|  | 				.scope(scopes) | ||||||
|  | 				.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) | ||||||
|  | 				.clientAuthenticationMethod(ClientAuthenticationMethod.BASIC) | ||||||
|  | 				.redirectUriTemplate("{baseUrl}/{action}/oauth2/code/{registrationId}") | ||||||
|  | 				.authorizationUri(metadata.getAuthorizationEndpointURI().toASCIIString()) | ||||||
|  | 				.jwkSetUri(metadata.getJWKSetURI().toASCIIString()) | ||||||
|  | 				.userInfoUri(metadata.getUserInfoEndpointURI().toASCIIString()) | ||||||
|  | 				.tokenUri(metadata.getTokenEndpointURI().toASCIIString()) | ||||||
|  | 				.clientName(issuer); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	private static List<String> getScopes(OIDCProviderMetadata metadata) { | ||||||
|  | 		Scope scope = metadata.getScopes(); | ||||||
|  | 		if (scope == null) { | ||||||
|  | 			// If null, default to "openid" which must be supported | ||||||
|  | 			return Arrays.asList("openid"); | ||||||
|  | 		} else { | ||||||
|  | 			return scope.toStringList(); | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	private static OIDCProviderMetadata parse(String body) { | ||||||
|  | 		try { | ||||||
|  | 			return OIDCProviderMetadata.parse(body); | ||||||
|  | 		} | ||||||
|  | 		catch (ParseException e) { | ||||||
|  | 			throw new RuntimeException(e); | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	private OidcConfigurationProvider() {} | ||||||
|  | } | ||||||
| @ -0,0 +1,209 @@ | |||||||
|  | /* | ||||||
|  |  * Copyright 2002-2018 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 | ||||||
|  |  * | ||||||
|  |  *      http://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.oauth2.client; | ||||||
|  | 
 | ||||||
|  | import com.fasterxml.jackson.core.type.TypeReference; | ||||||
|  | import com.fasterxml.jackson.databind.ObjectMapper; | ||||||
|  | import okhttp3.mockwebserver.MockResponse; | ||||||
|  | import okhttp3.mockwebserver.MockWebServer; | ||||||
|  | import org.junit.After; | ||||||
|  | import org.junit.Before; | ||||||
|  | import org.junit.Test; | ||||||
|  | import org.springframework.http.HttpHeaders; | ||||||
|  | import org.springframework.http.MediaType; | ||||||
|  | import org.springframework.security.oauth2.client.registration.ClientRegistration; | ||||||
|  | import org.springframework.security.oauth2.core.AuthorizationGrantType; | ||||||
|  | import org.springframework.security.oauth2.core.ClientAuthenticationMethod; | ||||||
|  | 
 | ||||||
|  | import java.util.Arrays; | ||||||
|  | import java.util.Map; | ||||||
|  | 
 | ||||||
|  | import static org.assertj.core.api.Assertions.*; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * @author Rob Winch | ||||||
|  |  * @since 5.1 | ||||||
|  |  */ | ||||||
|  | public class OidcConfigurationProviderTests { | ||||||
|  | 
 | ||||||
|  | 	/** | ||||||
|  | 	 * Contains all optional parameters that are found in ClientRegistration | ||||||
|  | 	 */ | ||||||
|  | 	private static final String DEFAULT_RESPONSE = | ||||||
|  | 			"{\n" | ||||||
|  | 			+ "    \"authorization_endpoint\": \"https://example.com/o/oauth2/v2/auth\", \n" | ||||||
|  | 			+ "    \"claims_supported\": [\n" | ||||||
|  | 			+ "        \"aud\", \n" | ||||||
|  | 			+ "        \"email\", \n" | ||||||
|  | 			+ "        \"email_verified\", \n" | ||||||
|  | 			+ "        \"exp\", \n" | ||||||
|  | 			+ "        \"family_name\", \n" | ||||||
|  | 			+ "        \"given_name\", \n" | ||||||
|  | 			+ "        \"iat\", \n" | ||||||
|  | 			+ "        \"iss\", \n" | ||||||
|  | 			+ "        \"locale\", \n" | ||||||
|  | 			+ "        \"name\", \n" | ||||||
|  | 			+ "        \"picture\", \n" | ||||||
|  | 			+ "        \"sub\"\n" | ||||||
|  | 			+ "    ], \n" | ||||||
|  | 			+ "    \"code_challenge_methods_supported\": [\n" | ||||||
|  | 			+ "        \"plain\", \n" | ||||||
|  | 			+ "        \"S256\"\n" | ||||||
|  | 			+ "    ], \n" | ||||||
|  | 			+ "    \"id_token_signing_alg_values_supported\": [\n" | ||||||
|  | 			+ "        \"RS256\"\n" | ||||||
|  | 			+ "    ], \n" | ||||||
|  | 			+ "    \"issuer\": \"https://example.com\", \n" | ||||||
|  | 			+ "    \"jwks_uri\": \"https://example.com/oauth2/v3/certs\", \n" | ||||||
|  | 			+ "    \"response_types_supported\": [\n" | ||||||
|  | 			+ "        \"code\", \n" | ||||||
|  | 			+ "        \"token\", \n" | ||||||
|  | 			+ "        \"id_token\", \n" | ||||||
|  | 			+ "        \"code token\", \n" | ||||||
|  | 			+ "        \"code id_token\", \n" | ||||||
|  | 			+ "        \"token id_token\", \n" | ||||||
|  | 			+ "        \"code token id_token\", \n" | ||||||
|  | 			+ "        \"none\"\n" | ||||||
|  | 			+ "    ], \n" | ||||||
|  | 			+ "    \"revocation_endpoint\": \"https://example.com/o/oauth2/revoke\", \n" | ||||||
|  | 			+ "    \"scopes_supported\": [\n" | ||||||
|  | 			+ "        \"openid\", \n" | ||||||
|  | 			+ "        \"email\", \n" | ||||||
|  | 			+ "        \"profile\"\n" | ||||||
|  | 			+ "    ], \n" | ||||||
|  | 			+ "    \"subject_types_supported\": [\n" | ||||||
|  | 			+ "        \"public\"\n" | ||||||
|  | 			+ "    ], \n" | ||||||
|  | 			+ "    \"grant_types_supported\" : [\"authorization_code\"], \n" | ||||||
|  | 			+ "    \"token_endpoint\": \"https://example.com/oauth2/v4/token\", \n" | ||||||
|  | 			+ "    \"token_endpoint_auth_methods_supported\": [\n" | ||||||
|  | 			+ "        \"client_secret_post\", \n" | ||||||
|  | 			+ "        \"client_secret_basic\"\n" | ||||||
|  | 			+ "    ], \n" | ||||||
|  | 			+ "    \"userinfo_endpoint\": \"https://example.com/oauth2/v3/userinfo\"\n" | ||||||
|  | 			+ "}"; | ||||||
|  | 
 | ||||||
|  | 	private MockWebServer server; | ||||||
|  | 
 | ||||||
|  | 	private ObjectMapper mapper = new ObjectMapper(); | ||||||
|  | 
 | ||||||
|  | 	private Map<String, Object> response; | ||||||
|  | 
 | ||||||
|  | 	private String issuer; | ||||||
|  | 
 | ||||||
|  | 	@Before | ||||||
|  | 	public void setup() throws Exception { | ||||||
|  | 		this.server = new MockWebServer(); | ||||||
|  | 		this.server.start(); | ||||||
|  | 		this.response = this.mapper.readValue(DEFAULT_RESPONSE, new TypeReference<Map<String, Object>>(){}); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	@After | ||||||
|  | 	public void cleanup() throws Exception { | ||||||
|  | 		this.server.shutdown(); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	@Test | ||||||
|  | 	public void issuerWhenAllInformationThenSuccess() throws Exception { | ||||||
|  | 		ClientRegistration registration = registration(""); | ||||||
|  | 		ClientRegistration.ProviderDetails provider = registration.getProviderDetails(); | ||||||
|  | 
 | ||||||
|  | 		assertThat(registration.getClientAuthenticationMethod()).isEqualTo(ClientAuthenticationMethod.BASIC); | ||||||
|  | 		assertThat(registration.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.AUTHORIZATION_CODE); | ||||||
|  | 		assertThat(registration.getRegistrationId()).isEqualTo(this.server.getHostName()); | ||||||
|  | 		assertThat(registration.getClientName()).isEqualTo(this.issuer); | ||||||
|  | 		assertThat(registration.getScopes()).containsOnly("openid", "email", "profile"); | ||||||
|  | 		assertThat(provider.getAuthorizationUri()).isEqualTo("https://example.com/o/oauth2/v2/auth"); | ||||||
|  | 		assertThat(provider.getTokenUri()).isEqualTo("https://example.com/oauth2/v4/token"); | ||||||
|  | 		assertThat(provider.getJwkSetUri()).isEqualTo("https://example.com/oauth2/v3/certs"); | ||||||
|  | 		assertThat(provider.getUserInfoEndpoint().getUri()).isEqualTo("https://example.com/oauth2/v3/userinfo"); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/** | ||||||
|  | 	 * https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata | ||||||
|  | 	 * | ||||||
|  | 	 * RECOMMENDED. JSON array containing a list of the OAuth 2.0 [RFC6749] scope values that this server supports. The | ||||||
|  | 	 * server MUST support the openid scope value. | ||||||
|  | 	 * @throws Exception | ||||||
|  | 	 */ | ||||||
|  | 	@Test | ||||||
|  | 	public void issuerWhenScopesNullThenScopesDefaulted() throws Exception { | ||||||
|  | 		this.response.remove("scopes_supported"); | ||||||
|  | 
 | ||||||
|  | 		ClientRegistration registration = registration(""); | ||||||
|  | 
 | ||||||
|  | 		assertThat(registration.getScopes()).containsOnly("openid"); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	@Test | ||||||
|  | 	public void issuerWhenGrantTypesSupportedNullThenDefaulted() throws Exception { | ||||||
|  | 		this.response.remove("grant_types_supported"); | ||||||
|  | 
 | ||||||
|  | 		ClientRegistration registration = registration(""); | ||||||
|  | 
 | ||||||
|  | 		assertThat(registration.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.AUTHORIZATION_CODE); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/** | ||||||
|  | 	 * We currently only support authorization_code, so verify we have a meaningful error until we add support. | ||||||
|  | 	 * @throws Exception | ||||||
|  | 	 */ | ||||||
|  | 	@Test | ||||||
|  | 	public void issuerWhenGrantTypesSupportedInvalidThenException() throws Exception { | ||||||
|  | 		this.response.put("grant_types_supported", Arrays.asList("implicit")); | ||||||
|  | 
 | ||||||
|  | 		assertThatThrownBy(() -> registration("")) | ||||||
|  | 				.isInstanceOf(IllegalArgumentException.class) | ||||||
|  | 				.hasMessageContaining("Only AuthorizationGrantType.AUTHORIZATION_CODE is supported. The issuer \"" + this.issuer + "\" returned a configuration of [implicit]"); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	@Test | ||||||
|  | 	public void issuerWhenTokenEndpointAuthMethodsNullThenDefaulted() throws Exception { | ||||||
|  | 		this.response.remove("token_endpoint_auth_methods_supported"); | ||||||
|  | 
 | ||||||
|  | 		ClientRegistration registration = registration(""); | ||||||
|  | 
 | ||||||
|  | 		assertThat(registration.getClientAuthenticationMethod()).isEqualTo(ClientAuthenticationMethod.BASIC); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/** | ||||||
|  | 	 * We currently only support client_secret_basic, so verify we have a meaningful error until we add support. | ||||||
|  | 	 * @throws Exception | ||||||
|  | 	 */ | ||||||
|  | 	@Test | ||||||
|  | 	public void issuerWhenTokenEndpointAuthMethodsInvalidThenException() throws Exception { | ||||||
|  | 		this.response.put("token_endpoint_auth_methods_supported", Arrays.asList("client_secret_post")); | ||||||
|  | 
 | ||||||
|  | 		assertThatThrownBy(() -> registration("")) | ||||||
|  | 				.isInstanceOf(IllegalArgumentException.class) | ||||||
|  | 				.hasMessageContaining("Only ClientAuthenticationMethod.BASIC is supported. The issuer \"" + this.issuer + "\" returned a configuration of [client_secret_post]"); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	private ClientRegistration registration(String path) throws Exception { | ||||||
|  | 		String body = this.mapper.writeValueAsString(this.response); | ||||||
|  | 		MockResponse mockResponse = new MockResponse() | ||||||
|  | 				.setBody(body) | ||||||
|  | 				.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); | ||||||
|  | 		this.server.enqueue(mockResponse); | ||||||
|  | 		this.issuer = this.server.url(path).toString(); | ||||||
|  | 
 | ||||||
|  | 		return OidcConfigurationProvider.issuer(this.issuer) | ||||||
|  | 			.clientId("client-id") | ||||||
|  | 			.clientSecret("client-secret") | ||||||
|  | 			.build(); | ||||||
|  | 	} | ||||||
|  | } | ||||||
| @ -21,6 +21,7 @@ import org.springframework.security.oauth2.core.oidc.OidcScopes; | |||||||
| import org.springframework.util.Assert; | import org.springframework.util.Assert; | ||||||
| 
 | 
 | ||||||
| import java.util.Arrays; | import java.util.Arrays; | ||||||
|  | import java.util.Collection; | ||||||
| import java.util.Collections; | import java.util.Collections; | ||||||
| import java.util.LinkedHashSet; | import java.util.LinkedHashSet; | ||||||
| import java.util.Set; | import java.util.Set; | ||||||
| @ -324,6 +325,20 @@ public final class ClientRegistration { | |||||||
| 			return this; | 			return this; | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
|  | 		/** | ||||||
|  | 		 * Sets the scope(s) used for the client. | ||||||
|  | 		 * | ||||||
|  | 		 * @param scope the scope(s) used for the client | ||||||
|  | 		 * @return the {@link Builder} | ||||||
|  | 		 */ | ||||||
|  | 		public Builder scope(Collection<String> scope) { | ||||||
|  | 			if (scope != null && !scope.isEmpty()) { | ||||||
|  | 				this.scopes = Collections.unmodifiableSet( | ||||||
|  | 						new LinkedHashSet<>(scope)); | ||||||
|  | 			} | ||||||
|  | 			return this; | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
| 		/** | 		/** | ||||||
| 		 * Sets the uri for the authorization endpoint. | 		 * Sets the uri for the authorization endpoint. | ||||||
| 		 * | 		 * | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user