4 changed files with 343 additions and 0 deletions
@ -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(); |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue