Skip to content

Commit f5b7706

Browse files
rhamedyjzheaux
authored andcommitted
Support for OAuth 2.0 Authorization Server Metadata
Added support for OAuth 2.0 Authorization Server Metadata as per the RFC 8414 specification. Updated the existing implementation of OpenId to comply with the Compatibility Section of RFC 8414 specification. Fixes: gh-6500
1 parent b6e8997 commit f5b7706

File tree

4 files changed

+726
-56
lines changed

4 files changed

+726
-56
lines changed

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

Lines changed: 204 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -19,28 +19,41 @@
1919
import com.nimbusds.oauth2.sdk.GrantType;
2020
import com.nimbusds.oauth2.sdk.ParseException;
2121
import com.nimbusds.oauth2.sdk.Scope;
22+
import com.nimbusds.oauth2.sdk.as.AuthorizationServerMetadata;
2223
import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata;
2324
import org.springframework.security.oauth2.core.AuthorizationGrantType;
2425
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
2526
import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames;
2627
import org.springframework.security.oauth2.core.oidc.OidcScopes;
28+
import org.springframework.util.Assert;
2729
import org.springframework.web.client.RestTemplate;
30+
import org.springframework.web.util.UriComponentsBuilder;
2831

2932
import java.net.URI;
3033
import java.util.Collections;
34+
import java.util.HashMap;
3135
import java.util.LinkedHashMap;
3236
import java.util.List;
3337
import java.util.Map;
3438

3539
/**
3640
* Allows creating a {@link ClientRegistration.Builder} from an
37-
* <a href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig">OpenID Provider Configuration</a>.
41+
* <a href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig">OpenID Provider Configuration</a>
42+
* or <a href="https://tools.ietf.org/html/rfc8414#section-3">Authorization Server Metadata</a> based on
43+
* provided issuer.
3844
*
3945
* @author Rob Winch
4046
* @author Josh Cummings
47+
* @author Rafiullah Hamedy
4148
* @since 5.1
4249
*/
4350
public final class ClientRegistrations {
51+
private static final String OIDC_METADATA_PATH = "/.well-known/openid-configuration";
52+
private static final String OAUTH2_METADATA_PATH = "/.well-known/oauth-authorization-server";
53+
54+
enum ProviderType {
55+
OIDCV1, OIDC, OAUTH2;
56+
}
4457

4558
/**
4659
* Creates a {@link ClientRegistration.Builder} using the provided
@@ -50,6 +63,12 @@ public final class ClientRegistrations {
5063
* <a href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse">OpenID
5164
* Provider Configuration Response</a> to initialize the {@link ClientRegistration.Builder}.
5265
*
66+
* When deployed in legacy environments using OpenID Connect Discovery 1.0 and if the provided issuer has
67+
* a path i.e. /issuer1 then as per <a href="https://tools.ietf.org/html/rfc8414#section-5">Compatibility Notes</a>
68+
* first make an <a href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationRequest">OpenID Provider
69+
* Configuration Request</a> using path /.well-known/openid-configuration/issuer1 and only if the retrieval
70+
* fail then a subsequent request to path /issuer1/.well-known/openid-configuration should be made.
71+
*
5372
* <p>
5473
* For example, if the issuer provided is "https://example.com", then an "OpenID Provider Configuration Request" will
5574
* be made to "https://example.com/.well-known/openid-configuration". The result is expected to be an "OpenID
@@ -69,19 +88,86 @@ public final class ClientRegistrations {
6988
* @return a {@link ClientRegistration.Builder} that was initialized by the OpenID Provider Configuration.
7089
*/
7190
public static ClientRegistration.Builder fromOidcIssuerLocation(String issuer) {
72-
String openidConfiguration = getOpenidConfiguration(issuer);
73-
OIDCProviderMetadata metadata = parse(openidConfiguration);
91+
Map<ProviderType, String> configuration = getIssuerConfiguration(issuer, OIDC_METADATA_PATH);
92+
OIDCProviderMetadata metadata = parse(configuration.get(ProviderType.OIDCV1), OIDCProviderMetadata::parse);
93+
return withProviderConfiguration(metadata, issuer)
94+
.userInfoUri(metadata.getUserInfoEndpointURI().toASCIIString());
95+
}
96+
97+
/**
98+
* Unlike <strong>fromOidcIssuerLocation</strong> the <strong>fromIssuerLocation</strong> queries three different endpoints and uses the
99+
* returned response from whichever that returns successfully. When <strong>fromIssuerLocation</strong> is invoked with an issuer
100+
* the following sequence of actions take place
101+
*
102+
* <ol>
103+
* <li>
104+
* The first request is made against <i>{host}/.well-known/openid-configuration/issuer1</i> where issuer is equal to
105+
* <strong>issuer1</strong>. See <a href="https://tools.ietf.org/html/rfc8414#section-5">Compatibility Notes</a> of RFC 8414
106+
* specification for more details.
107+
* </li>
108+
* <li>
109+
* If the first attempt request returned non-Success (i.e. 200 status code) response then based on <strong>Compatibility Notes</strong> of
110+
* <strong>RFC 8414</strong> a fallback <a href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationRequest">
111+
* OpenID Provider Configuration Request</a> is made to <i>{host}/issuer1/.well-known/openid-configuration</i>
112+
* </li>
113+
* <li>
114+
* If the second attempted request returns a non-Success (i.e. 200 status code) response then based a final
115+
* <a href="https://tools.ietf.org/html/rfc8414#section-3.1">Authorization Server Metadata Request</a> is being made to
116+
* <i>{host}/.well-known/oauth-authorization-server/issuer1</i>.
117+
* </li>
118+
* </ol>
119+
*
120+
*
121+
* As explained above, <strong>fromIssuerLocation</strong> would behave the exact same way as <strong>fromOidcIssuerLocation</strong> and that is
122+
* because <strong>fromIssuerLocation</strong> does the exact same processing as <strong>fromOidcIssuerLocation</strong> behind the scene. Use of
123+
* <strong>fromIssuerLocation</strong> is encouraged due to the fact that it is well-aligned with RFC 8414 specification and more specifically
124+
* it queries latest OIDC metadata endpoint with a fallback to legacy OIDC v1 discovery endpoint.
125+
*
126+
* The <strong>fromIssuerLocation</strong> is based on <a href="https://tools.ietf.org/html/rfc8414">RFC 8414</a> specification.
127+
*
128+
* <p>
129+
* Example usage:
130+
* </p>
131+
* <pre>
132+
* ClientRegistration registration = ClientRegistrations.fromIssuerLocation("https://example.com")
133+
* .clientId("client-id")
134+
* .clientSecret("client-secret")
135+
* .build();
136+
* </pre>
137+
*
138+
* @param issuer
139+
* @return a {@link ClientRegistration.Builder} that was initialized by the Authorization Sever Metadata Provider
140+
*/
141+
public static ClientRegistration.Builder fromIssuerLocation(String issuer) {
142+
Map<ProviderType, String> configuration = getIssuerConfiguration(issuer, OIDC_METADATA_PATH, OAUTH2_METADATA_PATH);
143+
144+
if (configuration.containsKey(ProviderType.OAUTH2)) {
145+
AuthorizationServerMetadata metadata = parse(configuration.get(ProviderType.OAUTH2), AuthorizationServerMetadata::parse);
146+
ClientRegistration.Builder builder = withProviderConfiguration(metadata, issuer);
147+
return builder;
148+
} else {
149+
String response = configuration.getOrDefault(ProviderType.OIDC, configuration.get(ProviderType.OIDCV1));
150+
OIDCProviderMetadata metadata = parse(response, OIDCProviderMetadata::parse);
151+
ClientRegistration.Builder builder = withProviderConfiguration(metadata, issuer)
152+
.userInfoUri(metadata.getUserInfoEndpointURI().toASCIIString());
153+
return builder;
154+
}
155+
}
156+
157+
private static ClientRegistration.Builder withProviderConfiguration(AuthorizationServerMetadata metadata, String issuer) {
74158
String metadataIssuer = metadata.getIssuer().getValue();
75159
if (!issuer.equals(metadataIssuer)) {
76-
throw new IllegalStateException("The Issuer \"" + metadataIssuer + "\" provided in the OpenID Configuration did not match the requested issuer \"" + issuer + "\"");
160+
throw new IllegalStateException("The Issuer \"" + metadataIssuer + "\" provided in the configuration metadata did "
161+
+ "not match the requested issuer \"" + issuer + "\"");
77162
}
78163

79164
String name = URI.create(issuer).getHost();
80165
ClientAuthenticationMethod method = getClientAuthenticationMethod(issuer, metadata.getTokenEndpointAuthMethods());
81166
List<GrantType> grantTypes = metadata.getGrantTypes();
82167
// If null, the default includes authorization_code
83168
if (grantTypes != null && !grantTypes.contains(GrantType.AUTHORIZATION_CODE)) {
84-
throw new IllegalArgumentException("Only AuthorizationGrantType.AUTHORIZATION_CODE is supported. The issuer \"" + issuer + "\" returned a configuration of " + grantTypes);
169+
throw new IllegalArgumentException("Only AuthorizationGrantType.AUTHORIZATION_CODE is supported. The issuer \"" + issuer +
170+
"\" returned a configuration of " + grantTypes);
85171
}
86172
List<String> scopes = getScopes(metadata);
87173
Map<String, Object> configurationMetadata = new LinkedHashMap<>(metadata.toJSONObject());
@@ -95,21 +181,118 @@ public static ClientRegistration.Builder fromOidcIssuerLocation(String issuer) {
95181
.authorizationUri(metadata.getAuthorizationEndpointURI().toASCIIString())
96182
.jwkSetUri(metadata.getJWKSetURI().toASCIIString())
97183
.providerConfigurationMetadata(configurationMetadata)
98-
.userInfoUri(metadata.getUserInfoEndpointURI().toASCIIString())
99184
.tokenUri(metadata.getTokenEndpointURI().toASCIIString())
100185
.clientName(issuer);
101186
}
102187

103-
private static String getOpenidConfiguration(String issuer) {
188+
/**
189+
* When the length of paths is equal to one (1) then it's a request for OpenId v1 discovery endpoint
190+
* hence the request is made to <strong>{host}/issuer1/.well-known/openid-configuration</strong>.
191+
* Otherwise, all three (3) metadata endpoints are queried one after another.
192+
*
193+
* @param issuer
194+
* @param paths
195+
* @throws IllegalArgumentException if the paths is null or empty or if none of the providers
196+
* responded to given issuer and paths requests
197+
* @return Map<String, Object> - Configuration Metadata from the given issuer
198+
*/
199+
private static Map<ProviderType, String> getIssuerConfiguration(String issuer, String... paths) {
200+
Assert.notEmpty(paths, "paths cannot be empty or null.");
201+
202+
Map<ProviderType, String> providersUrl = buildIssuerConfigurationUrls(issuer, paths);
203+
Map<ProviderType, String> providerResponse = new HashMap<>();
204+
205+
if (providersUrl.containsKey(ProviderType.OIDC)) {
206+
providerResponse = mapResponse(providersUrl, ProviderType.OIDC);
207+
}
208+
209+
// Fallback to OpenId v1 Discovery Endpoint based on RFC 8414 Compatibility Notes
210+
if (providerResponse.isEmpty() && providersUrl.containsKey(ProviderType.OIDCV1)) {
211+
providerResponse = mapResponse(providersUrl, ProviderType.OIDCV1);
212+
}
213+
214+
if (providerResponse.isEmpty() && providersUrl.containsKey(ProviderType.OAUTH2)) {
215+
providerResponse = mapResponse(providersUrl, ProviderType.OAUTH2);
216+
}
217+
218+
if (providerResponse.isEmpty()) {
219+
throw new IllegalArgumentException("Unable to resolve Configuration with the provided Issuer of \"" + issuer + "\"");
220+
}
221+
return providerResponse;
222+
}
223+
224+
private static Map<ProviderType, String> mapResponse(Map<ProviderType, String> providersUrl, ProviderType providerType) {
225+
Map<ProviderType, String> providerResponse = new HashMap<>();
226+
String response = makeIssuerRequest(providersUrl.get(providerType));
227+
if (response != null) {
228+
providerResponse.put(providerType, response);
229+
}
230+
return providerResponse;
231+
}
232+
233+
private static String makeIssuerRequest(String uri) {
104234
RestTemplate rest = new RestTemplate();
105235
try {
106-
return rest.getForObject(issuer + "/.well-known/openid-configuration", String.class);
107-
} catch(RuntimeException e) {
108-
throw new IllegalArgumentException("Unable to resolve the OpenID Configuration with the provided Issuer of \"" + issuer + "\"", e);
236+
return rest.getForObject(uri, String.class);
237+
} catch(RuntimeException ex) {
238+
return null;
239+
}
240+
}
241+
242+
/**
243+
* When invoked with a path then make a
244+
* <a href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationRequest">
245+
* OpenID Provider Configuration Request</a> by querying the OpenId Connection Discovery 1.0 endpoint
246+
* and the url would look as follow <strong>{host}/issuer1/.well-known/openid-configuration</strong>
247+
*
248+
* <p>
249+
* When more than one path is provided then query all the three (3) endpoints for metadata configuration
250+
* as per <a href="https://tools.ietf.org/html/rfc8414#section-5">Section 5</a> of RF 8414 specification
251+
* and the URLs would look as follow
252+
* </p>
253+
*
254+
* <ol>
255+
* <li>
256+
* <strong>{host}/.well-known/openid-configuration/issuer1</strong> - OpenID as per RFC 8414
257+
* </li>
258+
* <li>
259+
* <strong>{host}/issuer1/.well-known/openid-configuration</strong> - OpenID Connect 1.0 Discovery Compatibility as per RFC 8414
260+
* </li>
261+
* <li>
262+
* <strong>/.well-known/oauth-authorization-server/issuer1</strong> - OAuth2 Authorization Server Metadata as per RFC 8414
263+
* </li>
264+
* </ol>
265+
*
266+
* @param issuer
267+
* @param paths
268+
* @throws IllegalArgumentException throws exception if paths length is not 1 or 3, 1 for <strong>fromOidcLocationIssuer</strong>
269+
* and 3 for the newly introduced <strong>fromIssuerLocation</strong> to support querying 3 different metadata provider endpoints
270+
* @return Map<ProviderType, String> key-value map of provider with its request url
271+
*/
272+
private static Map<ProviderType, String> buildIssuerConfigurationUrls(String issuer, String... paths) {
273+
Assert.isTrue(paths.length != 1 || paths.length != 3, "paths length can either be 1 or 3");
274+
275+
Map<ProviderType, String> providersUrl = new HashMap<>();
276+
277+
URI issuerURI = URI.create(issuer);
278+
279+
if (paths.length == 1) {
280+
providersUrl.put(ProviderType.OIDCV1,
281+
UriComponentsBuilder.fromUri(issuerURI).replacePath(issuerURI.getPath() + paths[0]).toUriString());
282+
} else {
283+
providersUrl.put(ProviderType.OIDC,
284+
UriComponentsBuilder.fromUri(issuerURI).replacePath(paths[0] + issuerURI.getPath()).toUriString());
285+
providersUrl.put(ProviderType.OIDCV1,
286+
UriComponentsBuilder.fromUri(issuerURI).replacePath(issuerURI.getPath() + paths[0]).toUriString());
287+
providersUrl.put(ProviderType.OAUTH2,
288+
UriComponentsBuilder.fromUri(issuerURI).replacePath(paths[1] + issuerURI.getPath()).toUriString());
109289
}
290+
291+
return providersUrl;
110292
}
111293

112-
private static ClientAuthenticationMethod getClientAuthenticationMethod(String issuer, List<com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod> metadataAuthMethods) {
294+
private static ClientAuthenticationMethod getClientAuthenticationMethod(String issuer,
295+
List<com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod> metadataAuthMethods) {
113296
if (metadataAuthMethods == null || metadataAuthMethods.contains(com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod.CLIENT_SECRET_BASIC)) {
114297
// If null, the default includes client_secret_basic
115298
return ClientAuthenticationMethod.BASIC;
@@ -120,10 +303,11 @@ private static ClientAuthenticationMethod getClientAuthenticationMethod(String i
120303
if (metadataAuthMethods.contains(com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod.NONE)) {
121304
return ClientAuthenticationMethod.NONE;
122305
}
123-
throw new IllegalArgumentException("Only ClientAuthenticationMethod.BASIC, ClientAuthenticationMethod.POST and ClientAuthenticationMethod.NONE are supported. The issuer \"" + issuer + "\" returned a configuration of " + metadataAuthMethods);
306+
throw new IllegalArgumentException("Only ClientAuthenticationMethod.BASIC, ClientAuthenticationMethod.POST and "
307+
+ "ClientAuthenticationMethod.NONE are supported. The issuer \"" + issuer + "\" returned a configuration of " + metadataAuthMethods);
124308
}
125309

126-
private static List<String> getScopes(OIDCProviderMetadata metadata) {
310+
private static List<String> getScopes(AuthorizationServerMetadata metadata) {
127311
Scope scope = metadata.getScopes();
128312
if (scope == null) {
129313
// If null, default to "openid" which must be supported
@@ -133,15 +317,18 @@ private static List<String> getScopes(OIDCProviderMetadata metadata) {
133317
}
134318
}
135319

136-
private static OIDCProviderMetadata parse(String body) {
320+
private static <T> T parse(String body, ThrowingFunction<String, T, ParseException> parser) {
137321
try {
138-
return OIDCProviderMetadata.parse(body);
139-
}
140-
catch (ParseException e) {
322+
return parser.apply(body);
323+
} catch (ParseException e) {
141324
throw new RuntimeException(e);
142325
}
143326
}
144327

328+
private interface ThrowingFunction<S, T, E extends Throwable> {
329+
T apply(S src) throws E;
330+
}
331+
145332
private ClientRegistrations() {}
146333

147334
}

0 commit comments

Comments
 (0)