-
Notifications
You must be signed in to change notification settings - Fork 6k
Add support to ClientRegistrations for using a metadata resolver #11321
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,5 @@ | ||
/* | ||
* Copyright 2002-2021 the original author or authors. | ||
* Copyright 2002-2022 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. | ||
|
@@ -21,6 +21,7 @@ | |
import java.util.LinkedHashMap; | ||
import java.util.List; | ||
import java.util.Map; | ||
import java.util.function.Function; | ||
import java.util.function.Supplier; | ||
|
||
import com.nimbusds.oauth2.sdk.ParseException; | ||
|
@@ -48,6 +49,7 @@ | |
* @author Rob Winch | ||
* @author Josh Cummings | ||
* @author Rafiullah Hamedy | ||
* @author Steve Riesenberg | ||
* @since 5.1 | ||
*/ | ||
public final class ClientRegistrations { | ||
|
@@ -61,6 +63,11 @@ public final class ClientRegistrations { | |
private static final ParameterizedTypeReference<Map<String, Object>> typeReference = new ParameterizedTypeReference<Map<String, Object>>() { | ||
}; | ||
|
||
private static final Function<URI, Map<String, Object>> DEFAULT_METADATA_RESOLVER = (metadataUri) -> { | ||
RequestEntity<Void> request = RequestEntity.get(metadataUri).build(); | ||
return rest.exchange(request, typeReference).getBody(); | ||
}; | ||
|
||
private ClientRegistrations() { | ||
} | ||
|
||
|
@@ -100,6 +107,66 @@ public static ClientRegistration.Builder fromOidcIssuerLocation(String issuer) { | |
return getBuilder(issuer, oidc(URI.create(issuer))); | ||
} | ||
|
||
/** | ||
* Creates a {@link ClientRegistration.Builder} using the provided <a href= | ||
* "https://openid.net/specs/openid-connect-core-1_0.html#IssuerIdentifier">Issuer</a> | ||
* and {@link Function metadata resolver} to obtain the values used to initialize the | ||
* {@link ClientRegistration.Builder}. | ||
* | ||
* <p> | ||
* This method differs from {@link #fromOidcIssuerLocation(String)} in that the | ||
* <a href= | ||
* "https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse">OpenID | ||
* Provider Configuration Response</a> is provided by the metadata resolver, which | ||
* allows flexibility in determining how the metadata is obtained. For example, you | ||
* can use any HTTP client configured with user-defined proxy settings, obtain | ||
* metadata from a file on the filesystem, etc. | ||
* </p> | ||
* | ||
* <p> | ||
* The default metadata resolver used by {@link #fromOidcIssuerLocation(String)} uses | ||
* a {@code RestTemplate} to make an <a href= | ||
* "https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationRequest">OpenID | ||
* Provider Configuration Request</a> to the metadata endpoint. 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". The | ||
* default metadata resolver is as follows: | ||
* </p> | ||
* <pre> | ||
* RestTemplate rest = new RestTemplate(); | ||
* ParameterizedTypeReference<Map<String, Object>> typeReference = new ParameterizedTypeReference<Map<String, Object>>() { | ||
* }; | ||
* Function<URI, Map<String, Object>> metadataResolver = (metadataUri) -> { | ||
* RequestEntity<Void> request = RequestEntity.get(metadataUri).build(); | ||
* return rest.exchange(request, typeReference).getBody(); | ||
* }; | ||
* </pre> | ||
* | ||
* <p> | ||
* Example usage: | ||
* </p> | ||
* <pre> | ||
* ClientRegistration registration = | ||
* ClientRegistrations.fromOidcIssuerMetadata("https://example.com", metadataResolver) | ||
* .clientId("client-id") | ||
* .clientSecret("client-secret") | ||
* .build(); | ||
* </pre> | ||
* @param issuer the <a href= | ||
* "https://openid.net/specs/openid-connect-core-1_0.html#IssuerIdentifier">Issuer</a> | ||
* @param metadataResolver the {@code Function} used to resolve metadata in an | ||
* application-specific way | ||
* @return a {@link ClientRegistration.Builder} that was initialized by the OpenID | ||
* Provider Configuration. | ||
* @since 5.8 | ||
*/ | ||
public static ClientRegistration.Builder fromOidcIssuerMetadata(String issuer, | ||
Function<URI, Map<String, Object>> metadataResolver) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think I'd prefer a new non-static component. What happens when there is another behavioral aspect that someone wants to customize? It seems to me that optional configuration like this is meant for setters, not constructors/static factory methods. It also feels a little odd passing behavior as a parameter. Consider, hypothetically, introducing a method that takes a parameters object This is just my preference, though, and you may choose to disregard it. If you want to address this comment, here are a couple of ways that come to mind:
I realize that the team may have differing opinions about 1 and whether behavior should go into a builder, but for completeness, I'm listing it as an option that I personally like. I also realize 2 has been discussed in the past and rejected as not needed. That said, the request to customize the resolution strategy has come up so often that I think there's value in revisiting that decision. One nice thing about these solutions is that they allow the configuration to be more conservative if that's desired. That is, it seems like the contract is as wide as it is because we are trying to make sure that we don't need to overload the method once again down the road. It would be nicer to have a method called There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for the feedback @jzheaux. I see your point regarding...
I agree that in certain scenarios it doesn't make sense from a design standpoint, however, there are exceptions to this principle. Regardless, your points have swayed me away from the overloaded method strategy and it feels more that the behaviour should be replaced as a whole within the utility component by exposing a setter. So I'm proposing we expose a private static final Function<URI, Map<String, Object>> DEFAULT_METADATA_RESOLVER = (metadataUri) -> {
RequestEntity<Void> request = RequestEntity.get(metadataUri).build();
return rest.exchange(request, typeReference).getBody();
};
private static Function<URI, Map<String, Object>> metadataResolver = DEFAULT_METADATA_RESOLVER;
public static void setMetadataResolver(Function<URI, Map<String, Object>> metadataResolver) {
ClientRegistrations.metadataResolver = metadataResolver;
} With this design, the consuming application would need to call I realize this may not be the "cleanest" design but it is by far the most simple compared to creating a new component or exposing a new builder. This simple design can address the Spring Boot issues that arise when the What are your thoughts on this approach? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. One issue that might come up with this approach is integrating with Spring Boot. In the past, we've run into issues when trying to configure Also related to Boot's concern about configuring
My concern here is that it would deviate from the general guidance we've given so far about If there is just one application context, the code can refer to
I don't have a strong opinion here, but my preference is |
||
Assert.hasText(issuer, "issuer cannot be empty"); | ||
return getBuilder(issuer, oidc(URI.create(issuer), metadataResolver)); | ||
} | ||
|
||
/** | ||
* Creates a {@link ClientRegistration.Builder} using the provided <a href= | ||
* "https://openid.net/specs/openid-connect-core-1_0.html#IssuerIdentifier">Issuer</a> | ||
|
@@ -144,15 +211,100 @@ public static ClientRegistration.Builder fromIssuerLocation(String issuer) { | |
return getBuilder(issuer, oidc(uri), oidcRfc8414(uri), oauth(uri)); | ||
} | ||
|
||
/** | ||
* Creates a {@link ClientRegistration.Builder} using the provided <a href= | ||
* "https://openid.net/specs/openid-connect-core-1_0.html#IssuerIdentifier">Issuer</a> | ||
* and {@link Function metadata resolver} by querying three different discovery | ||
* endpoints serially, using the values in the first successful response to | ||
* initialize. If an endpoint returns anything other than a 200 or a 4xx, the method | ||
* will exit without attempting subsequent endpoints. | ||
* | ||
* The three endpoints are computed as follows, given that the {@code issuer} is | ||
* composed of a {@code host} and a {@code path}: | ||
* | ||
* <ol> | ||
* <li>{@code host/.well-known/openid-configuration/path}, as defined in | ||
* <a href="https://tools.ietf.org/html/rfc8414#section-5">RFC 8414's Compatibility | ||
* Notes</a>.</li> | ||
* <li>{@code issuer/.well-known/openid-configuration}, as defined in <a href= | ||
* "https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationRequest"> | ||
* OpenID Provider Configuration</a>.</li> | ||
* <li>{@code host/.well-known/oauth-authorization-server/path}, as defined in | ||
* <a href="https://tools.ietf.org/html/rfc8414#section-3.1">Authorization Server | ||
* Metadata Request</a>.</li> | ||
* </ol> | ||
* | ||
* Note that the second endpoint is the equivalent of calling | ||
* {@link ClientRegistrations#fromOidcIssuerLocation(String)}. | ||
* | ||
* <p> | ||
* This method differs from {@link ClientRegistrations#fromIssuerLocation(String)} in | ||
* that the <a href= | ||
* "https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse">OpenID | ||
* Provider Configuration Response</a> or | ||
* <a href="https://datatracker.ietf.org/doc/html/rfc8414#section-3.2">Authorization | ||
* Server Metadata Response</a> is provided by the metadata resolver, which allows | ||
* flexibility in determining how the metadata is obtained. For example, you can use | ||
* any HTTP client configured with user-defined proxy settings, obtain metadata from a | ||
* file on the filesystem, etc. | ||
* </p> | ||
* | ||
* <p> | ||
* The default metadata resolver used by {@link #fromIssuerLocation(String)} uses a | ||
* {@code RestTemplate} to make an "OpenID Provider Configuration Request" or | ||
* "Authorization Server Metadata Request" to the metadata endpoint. The default | ||
* metadata resolver is as follows: | ||
* </p> | ||
* <pre> | ||
* RestTemplate rest = new RestTemplate(); | ||
* ParameterizedTypeReference<Map<String, Object>> typeReference = new ParameterizedTypeReference<Map<String, Object>>() { | ||
* }; | ||
* Function<URI, Map<String, Object>> metadataResolver = (metadataUri) -> { | ||
* RequestEntity<Void> request = RequestEntity.get(metadataUri).build(); | ||
* return rest.exchange(request, typeReference).getBody(); | ||
* }; | ||
* </pre> | ||
* | ||
* <p> | ||
* Example usage: | ||
* </p> | ||
* <pre> | ||
* ClientRegistration registration = | ||
* ClientRegistrations.fromIssuerMetadata("https://example.com", metadataResolver) | ||
* .clientId("client-id") | ||
* .clientSecret("client-secret") | ||
* .build(); | ||
* </pre> | ||
* @param issuer the <a href= | ||
* "https://openid.net/specs/openid-connect-core-1_0.html#IssuerIdentifier">Issuer</a> | ||
* @param metadataResolver the {@code Function} used to resolve metadata in an | ||
* application-specific way | ||
* @return a {@link ClientRegistration.Builder} that was initialized by one of the | ||
* described endpoints | ||
* @since 5.8 | ||
*/ | ||
public static ClientRegistration.Builder fromIssuerMetadata(String issuer, | ||
Function<URI, Map<String, Object>> metadataResolver) { | ||
Assert.hasText(issuer, "issuer cannot be empty"); | ||
URI uri = URI.create(issuer); | ||
return getBuilder(issuer, oidc(uri, metadataResolver), oidcRfc8414(uri, metadataResolver), | ||
oauth(uri, metadataResolver)); | ||
} | ||
|
||
private static Supplier<ClientRegistration.Builder> oidc(URI issuer) { | ||
return oidc(issuer, DEFAULT_METADATA_RESOLVER); | ||
} | ||
|
||
private static Supplier<ClientRegistration.Builder> oidc(URI issuer, | ||
Function<URI, Map<String, Object>> metadataResolver) { | ||
// @formatter:off | ||
URI uri = UriComponentsBuilder.fromUri(issuer) | ||
.replacePath(issuer.getPath() + OIDC_METADATA_PATH) | ||
.build(Collections.emptyMap()); | ||
// @formatter:on | ||
return () -> { | ||
RequestEntity<Void> request = RequestEntity.get(uri).build(); | ||
Map<String, Object> configuration = rest.exchange(request, typeReference).getBody(); | ||
Map<String, Object> configuration = metadataResolver.apply(uri); | ||
|
||
OIDCProviderMetadata metadata = parse(configuration, OIDCProviderMetadata::parse); | ||
ClientRegistration.Builder builder = withProviderConfiguration(metadata, issuer.toASCIIString()) | ||
.jwkSetUri(metadata.getJWKSetURI().toASCIIString()); | ||
|
@@ -164,27 +316,38 @@ private static Supplier<ClientRegistration.Builder> oidc(URI issuer) { | |
} | ||
|
||
private static Supplier<ClientRegistration.Builder> oidcRfc8414(URI issuer) { | ||
return oidcRfc8414(issuer, DEFAULT_METADATA_RESOLVER); | ||
} | ||
|
||
private static Supplier<ClientRegistration.Builder> oidcRfc8414(URI issuer, | ||
Function<URI, Map<String, Object>> metadataResolver) { | ||
// @formatter:off | ||
URI uri = UriComponentsBuilder.fromUri(issuer) | ||
.replacePath(OIDC_METADATA_PATH + issuer.getPath()) | ||
.build(Collections.emptyMap()); | ||
// @formatter:on | ||
return getRfc8414Builder(issuer, uri); | ||
return getRfc8414Builder(issuer, uri, metadataResolver); | ||
} | ||
|
||
private static Supplier<ClientRegistration.Builder> oauth(URI issuer) { | ||
return oauth(issuer, DEFAULT_METADATA_RESOLVER); | ||
} | ||
|
||
private static Supplier<ClientRegistration.Builder> oauth(URI issuer, | ||
Function<URI, Map<String, Object>> metadataResolver) { | ||
// @formatter:off | ||
URI uri = UriComponentsBuilder.fromUri(issuer) | ||
.replacePath(OAUTH_METADATA_PATH + issuer.getPath()) | ||
.build(Collections.emptyMap()); | ||
// @formatter:on | ||
return getRfc8414Builder(issuer, uri); | ||
return getRfc8414Builder(issuer, uri, metadataResolver); | ||
} | ||
|
||
private static Supplier<ClientRegistration.Builder> getRfc8414Builder(URI issuer, URI uri) { | ||
private static Supplier<ClientRegistration.Builder> getRfc8414Builder(URI issuer, URI uri, | ||
Function<URI, Map<String, Object>> metadataResolver) { | ||
return () -> { | ||
RequestEntity<Void> request = RequestEntity.get(uri).build(); | ||
Map<String, Object> configuration = rest.exchange(request, typeReference).getBody(); | ||
Map<String, Object> configuration = metadataResolver.apply(uri); | ||
|
||
AuthorizationServerMetadata metadata = parse(configuration, AuthorizationServerMetadata::parse); | ||
ClientRegistration.Builder builder = withProviderConfiguration(metadata, issuer.toASCIIString()); | ||
URI jwkSetUri = metadata.getJWKSetURI(); | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Generally speaking, "resolver" is a conventional word in Spring Security that is reserved for constructing an object based on the
HttpServletRequest
. There have been exceptions, however I'd personally like that we try and be more consistent with this convention. @rwinch and @jgrandja are free to correct me here as I'm drawing from a conversation that the three of us had a couple of years ago and maybe I'm remembering it incorrectly.It may make more sense to call this
DEFAULT_METADATA_CONVERTER
and use Spring'sConverter
class. Another possibility is to call itDEFAULT_METADATA_FETCHER
or similar.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We'll consider changing the name if it makes more sense.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's consider
metadataRetriever
instead ofmetadataResolver