Skip to content

Commit 0598d47

Browse files
committed
Add ClientRegistration from OpenID Connect Discovery
Fixes: gh-4413
1 parent e82a1d1 commit 0598d47

File tree

4 files changed

+343
-0
lines changed

4 files changed

+343
-0
lines changed

config/spring-security-config.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ dependencies {
3434
testCompile apachedsDependencies
3535
testCompile powerMock2Dependencies
3636
testCompile spockDependencies
37+
testCompile 'com.squareup.okhttp3:mockwebserver'
3738
testCompile 'ch.qos.logback:logback-classic'
3839
testCompile 'io.projectreactor.ipc:reactor-netty'
3940
testCompile 'javax.annotation:jsr250-api:1.0'
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/*
2+
* Copyright 2002-2018 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.security.config.oauth2.client;
18+
19+
import java.net.URI;
20+
import java.util.Arrays;
21+
import java.util.List;
22+
23+
import org.springframework.security.oauth2.client.registration.ClientRegistration;
24+
import org.springframework.security.oauth2.core.AuthorizationGrantType;
25+
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
26+
import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames;
27+
import org.springframework.web.client.RestTemplate;
28+
29+
import com.nimbusds.oauth2.sdk.GrantType;
30+
import com.nimbusds.oauth2.sdk.ParseException;
31+
import com.nimbusds.oauth2.sdk.Scope;
32+
import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata;
33+
34+
/**
35+
* Allows creating a {@link ClientRegistration.Builder} from an
36+
* <a href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig">OpenID Provider Configuration</a>.
37+
*
38+
* @author Rob Winch
39+
* @since 5.1
40+
*/
41+
public final class OidcConfigurationProvider {
42+
43+
/**
44+
* Given the <a href="http://openid.net/specs/openid-connect-core-1_0.html#IssuerIdentifier">Issuer</a> creates a
45+
* {@link ClientRegistration.Builder} by making an
46+
* <a href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationRequest">OpenID Provider
47+
* Configuration Request</a> and using the values in the
48+
* <a href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse">OpenID
49+
* Provider Configuration Response</a> to initialize the {@link ClientRegistration.Builder}.
50+
*
51+
* <p>
52+
* For example if the issuer provided is "https://example.com", then an "OpenID Provider Configuration Request" will
53+
* be made to "https://example.com/.well-known/openid-configuration". The result is expected to be an "OpenID
54+
* Provider Configuration Response".
55+
* </p>
56+
*
57+
* <p>
58+
* Example usage:
59+
* </p>
60+
* <pre>
61+
* ClientRegistration registration = OidcConfigurationProvider.issuer("https://example.com")
62+
* .clientId("client-id")
63+
* .clientSecret("client-secret")
64+
* .build();
65+
* </pre>
66+
* @param issuer the <a href="http://openid.net/specs/openid-connect-core-1_0.html#IssuerIdentifier">Issuer</a>
67+
* @return a {@link ClientRegistration.Builder} that was initialized by the OpenID Provider Configuration.
68+
*/
69+
public static ClientRegistration.Builder issuer(String issuer) {
70+
RestTemplate rest = new RestTemplate();
71+
String openidConfiguration = rest.getForObject(issuer + "/.well-known/openid-configuration", String.class);
72+
OIDCProviderMetadata metadata = parse(openidConfiguration);
73+
String name = URI.create(issuer).getHost();
74+
List<com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod> metadataAuthMethods = metadata.getTokenEndpointAuthMethods();
75+
// if null, the default includes client_secret_basic
76+
if (metadataAuthMethods != null && !metadataAuthMethods.contains(com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod.CLIENT_SECRET_BASIC)) {
77+
throw new IllegalArgumentException("Only ClientAuthenticationMethod.BASIC is supported. The issuer \"" + issuer + "\" returned a configuration of " + metadataAuthMethods);
78+
}
79+
List<GrantType> grantTypes = metadata.getGrantTypes();
80+
// If null, the default includes authorization_code
81+
if (grantTypes != null && !grantTypes.contains(GrantType.AUTHORIZATION_CODE)) {
82+
throw new IllegalArgumentException("Only AuthorizationGrantType.AUTHORIZATION_CODE is supported. The issuer \"" + issuer + "\" returned a configuration of " + grantTypes);
83+
}
84+
List<String> scopes = getScopes(metadata);
85+
return ClientRegistration.withRegistrationId(name)
86+
.userNameAttributeName(IdTokenClaimNames.SUB)
87+
.scope(scopes)
88+
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
89+
.clientAuthenticationMethod(ClientAuthenticationMethod.BASIC)
90+
.redirectUriTemplate("{baseUrl}/{action}/oauth2/code/{registrationId}")
91+
.authorizationUri(metadata.getAuthorizationEndpointURI().toASCIIString())
92+
.jwkSetUri(metadata.getJWKSetURI().toASCIIString())
93+
.userInfoUri(metadata.getUserInfoEndpointURI().toASCIIString())
94+
.tokenUri(metadata.getTokenEndpointURI().toASCIIString())
95+
.clientName(issuer);
96+
}
97+
98+
private static List<String> getScopes(OIDCProviderMetadata metadata) {
99+
Scope scope = metadata.getScopes();
100+
if (scope == null) {
101+
// If null, default to "openid" which must be supported
102+
return Arrays.asList("openid");
103+
} else {
104+
return scope.toStringList();
105+
}
106+
}
107+
108+
private static OIDCProviderMetadata parse(String body) {
109+
try {
110+
return OIDCProviderMetadata.parse(body);
111+
}
112+
catch (ParseException e) {
113+
throw new RuntimeException(e);
114+
}
115+
}
116+
117+
private OidcConfigurationProvider() {}
118+
}
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
/*
2+
* Copyright 2002-2018 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.security.config.oauth2.client;
18+
19+
import com.fasterxml.jackson.core.type.TypeReference;
20+
import com.fasterxml.jackson.databind.ObjectMapper;
21+
import okhttp3.mockwebserver.MockResponse;
22+
import okhttp3.mockwebserver.MockWebServer;
23+
import org.junit.After;
24+
import org.junit.Before;
25+
import org.junit.Test;
26+
import org.springframework.http.HttpHeaders;
27+
import org.springframework.http.MediaType;
28+
import org.springframework.security.oauth2.client.registration.ClientRegistration;
29+
import org.springframework.security.oauth2.core.AuthorizationGrantType;
30+
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
31+
32+
import java.util.Arrays;
33+
import java.util.Map;
34+
35+
import static org.assertj.core.api.Assertions.*;
36+
37+
/**
38+
* @author Rob Winch
39+
* @since 5.1
40+
*/
41+
public class OidcConfigurationProviderTests {
42+
43+
/**
44+
* Contains all optional parameters that are found in ClientRegistration
45+
*/
46+
private static final String DEFAULT_RESPONSE =
47+
"{\n"
48+
+ " \"authorization_endpoint\": \"https://example.com/o/oauth2/v2/auth\", \n"
49+
+ " \"claims_supported\": [\n"
50+
+ " \"aud\", \n"
51+
+ " \"email\", \n"
52+
+ " \"email_verified\", \n"
53+
+ " \"exp\", \n"
54+
+ " \"family_name\", \n"
55+
+ " \"given_name\", \n"
56+
+ " \"iat\", \n"
57+
+ " \"iss\", \n"
58+
+ " \"locale\", \n"
59+
+ " \"name\", \n"
60+
+ " \"picture\", \n"
61+
+ " \"sub\"\n"
62+
+ " ], \n"
63+
+ " \"code_challenge_methods_supported\": [\n"
64+
+ " \"plain\", \n"
65+
+ " \"S256\"\n"
66+
+ " ], \n"
67+
+ " \"id_token_signing_alg_values_supported\": [\n"
68+
+ " \"RS256\"\n"
69+
+ " ], \n"
70+
+ " \"issuer\": \"https://example.com\", \n"
71+
+ " \"jwks_uri\": \"https://example.com/oauth2/v3/certs\", \n"
72+
+ " \"response_types_supported\": [\n"
73+
+ " \"code\", \n"
74+
+ " \"token\", \n"
75+
+ " \"id_token\", \n"
76+
+ " \"code token\", \n"
77+
+ " \"code id_token\", \n"
78+
+ " \"token id_token\", \n"
79+
+ " \"code token id_token\", \n"
80+
+ " \"none\"\n"
81+
+ " ], \n"
82+
+ " \"revocation_endpoint\": \"https://example.com/o/oauth2/revoke\", \n"
83+
+ " \"scopes_supported\": [\n"
84+
+ " \"openid\", \n"
85+
+ " \"email\", \n"
86+
+ " \"profile\"\n"
87+
+ " ], \n"
88+
+ " \"subject_types_supported\": [\n"
89+
+ " \"public\"\n"
90+
+ " ], \n"
91+
+ " \"grant_types_supported\" : [\"authorization_code\"], \n"
92+
+ " \"token_endpoint\": \"https://example.com/oauth2/v4/token\", \n"
93+
+ " \"token_endpoint_auth_methods_supported\": [\n"
94+
+ " \"client_secret_post\", \n"
95+
+ " \"client_secret_basic\"\n"
96+
+ " ], \n"
97+
+ " \"userinfo_endpoint\": \"https://example.com/oauth2/v3/userinfo\"\n"
98+
+ "}";
99+
100+
private MockWebServer server;
101+
102+
private ObjectMapper mapper = new ObjectMapper();
103+
104+
private Map<String, Object> response;
105+
106+
private String issuer;
107+
108+
@Before
109+
public void setup() throws Exception {
110+
this.server = new MockWebServer();
111+
this.server.start();
112+
this.response = this.mapper.readValue(DEFAULT_RESPONSE, new TypeReference<Map<String, Object>>(){});
113+
}
114+
115+
@After
116+
public void cleanup() throws Exception {
117+
this.server.shutdown();
118+
}
119+
120+
@Test
121+
public void issuerWhenAllInformationThenSuccess() throws Exception {
122+
ClientRegistration registration = registration("");
123+
ClientRegistration.ProviderDetails provider = registration.getProviderDetails();
124+
125+
assertThat(registration.getClientAuthenticationMethod()).isEqualTo(ClientAuthenticationMethod.BASIC);
126+
assertThat(registration.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.AUTHORIZATION_CODE);
127+
assertThat(registration.getRegistrationId()).isEqualTo(this.server.getHostName());
128+
assertThat(registration.getClientName()).isEqualTo(this.issuer);
129+
assertThat(registration.getScopes()).containsOnly("openid", "email", "profile");
130+
assertThat(provider.getAuthorizationUri()).isEqualTo("https://example.com/o/oauth2/v2/auth");
131+
assertThat(provider.getTokenUri()).isEqualTo("https://example.com/oauth2/v4/token");
132+
assertThat(provider.getJwkSetUri()).isEqualTo("https://example.com/oauth2/v3/certs");
133+
assertThat(provider.getUserInfoEndpoint().getUri()).isEqualTo("https://example.com/oauth2/v3/userinfo");
134+
}
135+
136+
/**
137+
* https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata
138+
*
139+
* RECOMMENDED. JSON array containing a list of the OAuth 2.0 [RFC6749] scope values that this server supports. The
140+
* server MUST support the openid scope value.
141+
* @throws Exception
142+
*/
143+
@Test
144+
public void issuerWhenScopesNullThenScopesDefaulted() throws Exception {
145+
this.response.remove("scopes_supported");
146+
147+
ClientRegistration registration = registration("");
148+
149+
assertThat(registration.getScopes()).containsOnly("openid");
150+
}
151+
152+
@Test
153+
public void issuerWhenGrantTypesSupportedNullThenDefaulted() throws Exception {
154+
this.response.remove("grant_types_supported");
155+
156+
ClientRegistration registration = registration("");
157+
158+
assertThat(registration.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.AUTHORIZATION_CODE);
159+
}
160+
161+
/**
162+
* We currently only support authorization_code, so verify we have a meaningful error until we add support.
163+
* @throws Exception
164+
*/
165+
@Test
166+
public void issuerWhenGrantTypesSupportedInvalidThenException() throws Exception {
167+
this.response.put("grant_types_supported", Arrays.asList("implicit"));
168+
169+
assertThatThrownBy(() -> registration(""))
170+
.isInstanceOf(IllegalArgumentException.class)
171+
.hasMessageContaining("Only AuthorizationGrantType.AUTHORIZATION_CODE is supported. The issuer \"" + this.issuer + "\" returned a configuration of [implicit]");
172+
}
173+
174+
@Test
175+
public void issuerWhenTokenEndpointAuthMethodsNullThenDefaulted() throws Exception {
176+
this.response.remove("token_endpoint_auth_methods_supported");
177+
178+
ClientRegistration registration = registration("");
179+
180+
assertThat(registration.getClientAuthenticationMethod()).isEqualTo(ClientAuthenticationMethod.BASIC);
181+
}
182+
183+
/**
184+
* We currently only support client_secret_basic, so verify we have a meaningful error until we add support.
185+
* @throws Exception
186+
*/
187+
@Test
188+
public void issuerWhenTokenEndpointAuthMethodsInvalidThenException() throws Exception {
189+
this.response.put("token_endpoint_auth_methods_supported", Arrays.asList("client_secret_post"));
190+
191+
assertThatThrownBy(() -> registration(""))
192+
.isInstanceOf(IllegalArgumentException.class)
193+
.hasMessageContaining("Only ClientAuthenticationMethod.BASIC is supported. The issuer \"" + this.issuer + "\" returned a configuration of [client_secret_post]");
194+
}
195+
196+
private ClientRegistration registration(String path) throws Exception {
197+
String body = this.mapper.writeValueAsString(this.response);
198+
MockResponse mockResponse = new MockResponse()
199+
.setBody(body)
200+
.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
201+
this.server.enqueue(mockResponse);
202+
this.issuer = this.server.url(path).toString();
203+
204+
return OidcConfigurationProvider.issuer(this.issuer)
205+
.clientId("client-id")
206+
.clientSecret("client-secret")
207+
.build();
208+
}
209+
}

oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/registration/ClientRegistration.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import org.springframework.util.Assert;
2222

2323
import java.util.Arrays;
24+
import java.util.Collection;
2425
import java.util.Collections;
2526
import java.util.LinkedHashSet;
2627
import java.util.Set;
@@ -324,6 +325,20 @@ public Builder scope(String... scope) {
324325
return this;
325326
}
326327

328+
/**
329+
* Sets the scope(s) used for the client.
330+
*
331+
* @param scope the scope(s) used for the client
332+
* @return the {@link Builder}
333+
*/
334+
public Builder scope(Collection<String> scope) {
335+
if (scope != null && !scope.isEmpty()) {
336+
this.scopes = Collections.unmodifiableSet(
337+
new LinkedHashSet<>(scope));
338+
}
339+
return this;
340+
}
341+
327342
/**
328343
* Sets the uri for the authorization endpoint.
329344
*

0 commit comments

Comments
 (0)