diff --git a/config/spring-security-config.gradle b/config/spring-security-config.gradle index fc56abf254..e59f23fb09 100644 --- a/config/spring-security-config.gradle +++ b/config/spring-security-config.gradle @@ -34,6 +34,7 @@ dependencies { testCompile apachedsDependencies testCompile powerMock2Dependencies testCompile spockDependencies + testCompile 'com.squareup.okhttp3:mockwebserver' testCompile 'ch.qos.logback:logback-classic' testCompile 'io.projectreactor.ipc:reactor-netty' testCompile 'javax.annotation:jsr250-api:1.0' diff --git a/config/src/main/java/org/springframework/security/config/oauth2/client/OidcConfigurationProvider.java b/config/src/main/java/org/springframework/security/config/oauth2/client/OidcConfigurationProvider.java new file mode 100644 index 0000000000..77984669c5 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/oauth2/client/OidcConfigurationProvider.java @@ -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 + * OpenID Provider Configuration. + * + * @author Rob Winch + * @since 5.1 + */ +public final class OidcConfigurationProvider { + + /** + * Given the Issuer creates a + * {@link ClientRegistration.Builder} by making an + * OpenID Provider + * Configuration Request and using the values in the + * OpenID + * Provider Configuration Response to initialize the {@link ClientRegistration.Builder}. + * + *

+ * 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". + *

+ * + *

+ * Example usage: + *

+ *
+	 * ClientRegistration registration = OidcConfigurationProvider.issuer("https://example.com")
+	 *     .clientId("client-id")
+	 *     .clientSecret("client-secret")
+	 *     .build();
+	 * 
+ * @param issuer the Issuer + * @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 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 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 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 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() {} +} diff --git a/config/src/test/java/org/springframework/security/config/oauth2/client/OidcConfigurationProviderTests.java b/config/src/test/java/org/springframework/security/config/oauth2/client/OidcConfigurationProviderTests.java new file mode 100644 index 0000000000..bd967c58ed --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/oauth2/client/OidcConfigurationProviderTests.java @@ -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 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>(){}); + } + + @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(); + } +} diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/registration/ClientRegistration.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/registration/ClientRegistration.java index 54e730717e..080dde9d69 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/registration/ClientRegistration.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/registration/ClientRegistration.java @@ -21,6 +21,7 @@ import org.springframework.security.oauth2.core.oidc.OidcScopes; import org.springframework.util.Assert; import java.util.Arrays; +import java.util.Collection; import java.util.Collections; import java.util.LinkedHashSet; import java.util.Set; @@ -324,6 +325,20 @@ public final class ClientRegistration { 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 scope) { + if (scope != null && !scope.isEmpty()) { + this.scopes = Collections.unmodifiableSet( + new LinkedHashSet<>(scope)); + } + return this; + } + /** * Sets the uri for the authorization endpoint. *