Skip to content

Commit

Permalink
Allow an expected issuer to be successfully validated
Browse files Browse the repository at this point in the history
Closes gh-14633
  • Loading branch information
heruan committed Aug 31, 2024
1 parent add5c56 commit a349ff4
Show file tree
Hide file tree
Showing 2 changed files with 174 additions and 10 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,57 @@ private ClientRegistrations() {
* Provider Configuration.
*/
public static ClientRegistration.Builder fromOidcIssuerLocation(String issuer) {
return fromOidcIssuerLocation(issuer, issuer);
}

/**
* Creates a {@link ClientRegistration.Builder} using the provided <a href=
* "https://openid.net/specs/openid-connect-core-1_0.html#IssuerIdentifier">Issuer</a>
* 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>
* This is a specialized overload of {@link #fromOidcIssuerLocation(String)} that
* allows to fetch metadata from a different issuer than the one that is expected to
* be declared in the metadata, for example when the application can only communicate
* with the issuer using a backend URL.
* </p>
*
* <p>
* For example, if the issuer provided is "https://backend-issuer.com" and the
* expected metadata issuer is "https://frontend-issuer.com", then an "OpenID Provider
* Configuration Request" will be made to
* "https://backend-issuer.com/.well-known/openid-configuration" and the returning
* metadata is expected to declare "https://frontend-issuer.com" in the issuer field.
* The result is expected to be an "OpenID Provider Configuration Response".
* </p>
*
* <p>
* Example usage:
* </p>
* <pre>
* ClientRegistration registration = ClientRegistrations
* .fromOidcIssuerLocation("https://backend-issuer.com", "https://frontend-issuer.com")
* .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 expectedIssuer the expected <a href=
* "https://openid.net/specs/openid-connect-core-1_0.html#IssuerIdentifier">Issuer</a>
* to use for metadata validation
* @return a {@link ClientRegistration.Builder} that was initialized by the OpenID
* Provider Configuration.
*/
public static ClientRegistration.Builder fromOidcIssuerLocation(String issuer, String expectedIssuer) {
Assert.hasText(issuer, "issuer cannot be empty");
return getBuilder(issuer, oidc(URI.create(issuer)));
Assert.hasText(expectedIssuer, "expectedIssuer cannot be empty");
return getBuilder(issuer, oidc(URI.create(issuer), URI.create(expectedIssuer)));
}

/**
Expand Down Expand Up @@ -147,12 +196,68 @@ public static ClientRegistration.Builder fromOidcIssuerLocation(String issuer) {
* described endpoints
*/
public static ClientRegistration.Builder fromIssuerLocation(String issuer) {
return fromIssuerLocation(issuer, issuer);
}

/**
* Creates a {@link ClientRegistration.Builder} using the provided <a href=
* "https://openid.net/specs/openid-connect-core-1_0.html#IssuerIdentifier">Issuer</a>
* 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 is a specialized overload of {@link #fromIssuerLocation(String)} that allows
* to fetch metadata from a different issuer than the one that is expected to be
* declared in the metadata, for example when the application can only communicate
* with the issuer using a backend URL.
* </p>
*
* <p>
* Example usage:
* </p>
* <pre>
* ClientRegistration registration = ClientRegistrations
* .fromIssuerLocation("https://backend-example.com", "https://frontend-example.com")
* .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 expectedIssuer the expected <a href=
* "https://openid.net/specs/openid-connect-core-1_0.html#IssuerIdentifier">Issuer</a>
* to use for metadata validation
* @return a {@link ClientRegistration.Builder} that was initialized by one of the
* described endpoints
*/
public static ClientRegistration.Builder fromIssuerLocation(String issuer, String expectedIssuer) {
Assert.hasText(issuer, "issuer cannot be empty");
Assert.hasText(expectedIssuer, "expectedIssuer cannot be empty");
URI uri = URI.create(issuer);
return getBuilder(issuer, oidc(uri), oidcRfc8414(uri), oauth(uri));
URI expectedUri = URI.create(expectedIssuer);
return getBuilder(issuer, oidc(uri, expectedUri), oidcRfc8414(uri, expectedUri), oauth(uri, expectedUri));
}

private static Supplier<ClientRegistration.Builder> oidc(URI issuer) {
private static Supplier<ClientRegistration.Builder> oidc(URI issuer, URI expectedIssuer) {
// @formatter:off
URI uri = UriComponentsBuilder.fromUri(issuer)
.replacePath(issuer.getPath() + OIDC_METADATA_PATH)
Expand All @@ -162,7 +267,7 @@ private static Supplier<ClientRegistration.Builder> oidc(URI issuer) {
RequestEntity<Void> request = RequestEntity.get(uri).build();
Map<String, Object> configuration = rest.exchange(request, typeReference).getBody();
OIDCProviderMetadata metadata = parse(configuration, OIDCProviderMetadata::parse);
ClientRegistration.Builder builder = withProviderConfiguration(metadata, issuer.toASCIIString())
ClientRegistration.Builder builder = withProviderConfiguration(metadata, expectedIssuer.toASCIIString())
.jwkSetUri(metadata.getJWKSetURI().toASCIIString());
if (metadata.getUserInfoEndpointURI() != null) {
builder.userInfoUri(metadata.getUserInfoEndpointURI().toASCIIString());
Expand All @@ -171,30 +276,30 @@ private static Supplier<ClientRegistration.Builder> oidc(URI issuer) {
};
}

private static Supplier<ClientRegistration.Builder> oidcRfc8414(URI issuer) {
private static Supplier<ClientRegistration.Builder> oidcRfc8414(URI issuer, URI expectedIssuer) {
// @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, expectedIssuer);
}

private static Supplier<ClientRegistration.Builder> oauth(URI issuer) {
private static Supplier<ClientRegistration.Builder> oauth(URI issuer, URI expectedIssuer) {
// @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, expectedIssuer);
}

private static Supplier<ClientRegistration.Builder> getRfc8414Builder(URI issuer, URI uri) {
private static Supplier<ClientRegistration.Builder> getRfc8414Builder(URI issuer, URI uri, URI expectedIssuer) {
return () -> {
RequestEntity<Void> request = RequestEntity.get(uri).build();
Map<String, Object> configuration = rest.exchange(request, typeReference).getBody();
AuthorizationServerMetadata metadata = parse(configuration, AuthorizationServerMetadata::parse);
ClientRegistration.Builder builder = withProviderConfiguration(metadata, issuer.toASCIIString());
ClientRegistration.Builder builder = withProviderConfiguration(metadata, expectedIssuer.toASCIIString());
URI jwkSetUri = metadata.getJWKSetURI();
if (jwkSetUri != null) {
builder.jwkSetUri(jwkSetUri.toASCIIString());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
import static org.assertj.core.api.Assertions.assertThatNoException;

/**
* @author Rob Winch
Expand Down Expand Up @@ -440,6 +441,35 @@ public void issuerWhenOpenIdConfigurationDoesNotMatchThenMeaningfulErrorMessage(
// @formatter:on
}

@Test
public void expectedIssuerWhenOpenIdConfigurationDoesMatchThenSuccess() throws Exception {
this.issuer = createIssuerFromServer("");
this.response.put("issuer", "https://expected-issuer.com");
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);
// @formatter:off
assertThatNoException()
.isThrownBy(() -> ClientRegistrations.fromOidcIssuerLocation(this.issuer, "https://expected-issuer.com"));
// @formatter:on
}

@Test
public void expectedIssuerWhenOpenIdConfigurationDoesNotMatchThenMeaningfulErrorMessage() throws Exception {
this.issuer = createIssuerFromServer("");
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);
// @formatter:off
assertThatIllegalStateException()
.isThrownBy(() -> ClientRegistrations.fromOidcIssuerLocation(this.issuer, "https://expected-issuer.com"))
.withMessageContaining("The Issuer \"https://example.com\" provided in the configuration metadata did "
+ "not match the requested issuer \"https://expected-issuer.com\"");
// @formatter:on
}

@Test
public void issuerWhenOAuth2ConfigurationDoesNotMatchThenMeaningfulErrorMessage() throws Exception {
this.issuer = createIssuerFromServer("");
Expand All @@ -455,6 +485,35 @@ public void issuerWhenOAuth2ConfigurationDoesNotMatchThenMeaningfulErrorMessage(
// @formatter:on
}

@Test
public void expectedIssuerWhenOAuth2ConfigurationDoesMatchThenSuccess() throws Exception {
this.issuer = createIssuerFromServer("");
this.response.put("issuer", "https://expected-issuer.com");
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);
// @formatter:off
assertThatNoException()
.isThrownBy(() -> ClientRegistrations.fromIssuerLocation(this.issuer, "https://expected-issuer.com"));
// @formatter:on
}

@Test
public void expectedIssuerWhenOAuth2ConfigurationDoesNotMatchThenMeaningfulErrorMessage() throws Exception {
this.issuer = createIssuerFromServer("");
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);
// @formatter:off
assertThatIllegalStateException()
.isThrownBy(() -> ClientRegistrations.fromIssuerLocation(this.issuer, "https://expected-issuer.com"))
.withMessageContaining("The Issuer \"https://example.com\" provided in the configuration metadata "
+ "did not match the requested issuer \"https://expected-issuer.com\"");
// @formatter:on
}

private ClientRegistration.Builder registration(String path) throws Exception {
this.issuer = createIssuerFromServer(path);
this.response.put("issuer", this.issuer);
Expand Down

0 comments on commit a349ff4

Please sign in to comment.