Skip to content

Commit a349ff4

Browse files
committed
Allow an expected issuer to be successfully validated
Closes gh-14633
1 parent add5c56 commit a349ff4

File tree

2 files changed

+174
-10
lines changed

2 files changed

+174
-10
lines changed

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

Lines changed: 115 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,57 @@ private ClientRegistrations() {
104104
* Provider Configuration.
105105
*/
106106
public static ClientRegistration.Builder fromOidcIssuerLocation(String issuer) {
107+
return fromOidcIssuerLocation(issuer, issuer);
108+
}
109+
110+
/**
111+
* Creates a {@link ClientRegistration.Builder} using the provided <a href=
112+
* "https://openid.net/specs/openid-connect-core-1_0.html#IssuerIdentifier">Issuer</a>
113+
* by making an <a href=
114+
* "https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationRequest">OpenID
115+
* Provider Configuration Request</a> and using the values in the <a href=
116+
* "https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse">OpenID
117+
* Provider Configuration Response</a> to initialize the
118+
* {@link ClientRegistration.Builder}.
119+
*
120+
* <p>
121+
* This is a specialized overload of {@link #fromOidcIssuerLocation(String)} that
122+
* allows to fetch metadata from a different issuer than the one that is expected to
123+
* be declared in the metadata, for example when the application can only communicate
124+
* with the issuer using a backend URL.
125+
* </p>
126+
*
127+
* <p>
128+
* For example, if the issuer provided is "https://backend-issuer.com" and the
129+
* expected metadata issuer is "https://frontend-issuer.com", then an "OpenID Provider
130+
* Configuration Request" will be made to
131+
* "https://backend-issuer.com/.well-known/openid-configuration" and the returning
132+
* metadata is expected to declare "https://frontend-issuer.com" in the issuer field.
133+
* The result is expected to be an "OpenID Provider Configuration Response".
134+
* </p>
135+
*
136+
* <p>
137+
* Example usage:
138+
* </p>
139+
* <pre>
140+
* ClientRegistration registration = ClientRegistrations
141+
* .fromOidcIssuerLocation("https://backend-issuer.com", "https://frontend-issuer.com")
142+
* .clientId("client-id")
143+
* .clientSecret("client-secret")
144+
* .build();
145+
* </pre>
146+
* @param issuer the <a href=
147+
* "https://openid.net/specs/openid-connect-core-1_0.html#IssuerIdentifier">Issuer</a>
148+
* @param expectedIssuer the expected <a href=
149+
* "https://openid.net/specs/openid-connect-core-1_0.html#IssuerIdentifier">Issuer</a>
150+
* to use for metadata validation
151+
* @return a {@link ClientRegistration.Builder} that was initialized by the OpenID
152+
* Provider Configuration.
153+
*/
154+
public static ClientRegistration.Builder fromOidcIssuerLocation(String issuer, String expectedIssuer) {
107155
Assert.hasText(issuer, "issuer cannot be empty");
108-
return getBuilder(issuer, oidc(URI.create(issuer)));
156+
Assert.hasText(expectedIssuer, "expectedIssuer cannot be empty");
157+
return getBuilder(issuer, oidc(URI.create(issuer), URI.create(expectedIssuer)));
109158
}
110159

111160
/**
@@ -147,12 +196,68 @@ public static ClientRegistration.Builder fromOidcIssuerLocation(String issuer) {
147196
* described endpoints
148197
*/
149198
public static ClientRegistration.Builder fromIssuerLocation(String issuer) {
199+
return fromIssuerLocation(issuer, issuer);
200+
}
201+
202+
/**
203+
* Creates a {@link ClientRegistration.Builder} using the provided <a href=
204+
* "https://openid.net/specs/openid-connect-core-1_0.html#IssuerIdentifier">Issuer</a>
205+
* by querying three different discovery endpoints serially, using the values in the
206+
* first successful response to initialize. If an endpoint returns anything other than
207+
* a 200 or a 4xx, the method will exit without attempting subsequent endpoints.
208+
*
209+
* The three endpoints are computed as follows, given that the {@code issuer} is
210+
* composed of a {@code host} and a {@code path}:
211+
*
212+
* <ol>
213+
* <li>{@code host/.well-known/openid-configuration/path}, as defined in
214+
* <a href="https://tools.ietf.org/html/rfc8414#section-5">RFC 8414's Compatibility
215+
* Notes</a>.</li>
216+
* <li>{@code issuer/.well-known/openid-configuration}, as defined in <a href=
217+
* "https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationRequest">
218+
* OpenID Provider Configuration</a>.</li>
219+
* <li>{@code host/.well-known/oauth-authorization-server/path}, as defined in
220+
* <a href="https://tools.ietf.org/html/rfc8414#section-3.1">Authorization Server
221+
* Metadata Request</a>.</li>
222+
* </ol>
223+
*
224+
* Note that the second endpoint is the equivalent of calling
225+
* {@link ClientRegistrations#fromOidcIssuerLocation(String)}.
226+
*
227+
* <p>
228+
* This is a specialized overload of {@link #fromIssuerLocation(String)} that allows
229+
* to fetch metadata from a different issuer than the one that is expected to be
230+
* declared in the metadata, for example when the application can only communicate
231+
* with the issuer using a backend URL.
232+
* </p>
233+
*
234+
* <p>
235+
* Example usage:
236+
* </p>
237+
* <pre>
238+
* ClientRegistration registration = ClientRegistrations
239+
* .fromIssuerLocation("https://backend-example.com", "https://frontend-example.com")
240+
* .clientId("client-id")
241+
* .clientSecret("client-secret")
242+
* .build();
243+
* </pre>
244+
* @param issuer the <a href=
245+
* "https://openid.net/specs/openid-connect-core-1_0.html#IssuerIdentifier">Issuer</a>
246+
* @param expectedIssuer the expected <a href=
247+
* "https://openid.net/specs/openid-connect-core-1_0.html#IssuerIdentifier">Issuer</a>
248+
* to use for metadata validation
249+
* @return a {@link ClientRegistration.Builder} that was initialized by one of the
250+
* described endpoints
251+
*/
252+
public static ClientRegistration.Builder fromIssuerLocation(String issuer, String expectedIssuer) {
150253
Assert.hasText(issuer, "issuer cannot be empty");
254+
Assert.hasText(expectedIssuer, "expectedIssuer cannot be empty");
151255
URI uri = URI.create(issuer);
152-
return getBuilder(issuer, oidc(uri), oidcRfc8414(uri), oauth(uri));
256+
URI expectedUri = URI.create(expectedIssuer);
257+
return getBuilder(issuer, oidc(uri, expectedUri), oidcRfc8414(uri, expectedUri), oauth(uri, expectedUri));
153258
}
154259

155-
private static Supplier<ClientRegistration.Builder> oidc(URI issuer) {
260+
private static Supplier<ClientRegistration.Builder> oidc(URI issuer, URI expectedIssuer) {
156261
// @formatter:off
157262
URI uri = UriComponentsBuilder.fromUri(issuer)
158263
.replacePath(issuer.getPath() + OIDC_METADATA_PATH)
@@ -162,7 +267,7 @@ private static Supplier<ClientRegistration.Builder> oidc(URI issuer) {
162267
RequestEntity<Void> request = RequestEntity.get(uri).build();
163268
Map<String, Object> configuration = rest.exchange(request, typeReference).getBody();
164269
OIDCProviderMetadata metadata = parse(configuration, OIDCProviderMetadata::parse);
165-
ClientRegistration.Builder builder = withProviderConfiguration(metadata, issuer.toASCIIString())
270+
ClientRegistration.Builder builder = withProviderConfiguration(metadata, expectedIssuer.toASCIIString())
166271
.jwkSetUri(metadata.getJWKSetURI().toASCIIString());
167272
if (metadata.getUserInfoEndpointURI() != null) {
168273
builder.userInfoUri(metadata.getUserInfoEndpointURI().toASCIIString());
@@ -171,30 +276,30 @@ private static Supplier<ClientRegistration.Builder> oidc(URI issuer) {
171276
};
172277
}
173278

174-
private static Supplier<ClientRegistration.Builder> oidcRfc8414(URI issuer) {
279+
private static Supplier<ClientRegistration.Builder> oidcRfc8414(URI issuer, URI expectedIssuer) {
175280
// @formatter:off
176281
URI uri = UriComponentsBuilder.fromUri(issuer)
177282
.replacePath(OIDC_METADATA_PATH + issuer.getPath())
178283
.build(Collections.emptyMap());
179284
// @formatter:on
180-
return getRfc8414Builder(issuer, uri);
285+
return getRfc8414Builder(issuer, uri, expectedIssuer);
181286
}
182287

183-
private static Supplier<ClientRegistration.Builder> oauth(URI issuer) {
288+
private static Supplier<ClientRegistration.Builder> oauth(URI issuer, URI expectedIssuer) {
184289
// @formatter:off
185290
URI uri = UriComponentsBuilder.fromUri(issuer)
186291
.replacePath(OAUTH_METADATA_PATH + issuer.getPath())
187292
.build(Collections.emptyMap());
188293
// @formatter:on
189-
return getRfc8414Builder(issuer, uri);
294+
return getRfc8414Builder(issuer, uri, expectedIssuer);
190295
}
191296

192-
private static Supplier<ClientRegistration.Builder> getRfc8414Builder(URI issuer, URI uri) {
297+
private static Supplier<ClientRegistration.Builder> getRfc8414Builder(URI issuer, URI uri, URI expectedIssuer) {
193298
return () -> {
194299
RequestEntity<Void> request = RequestEntity.get(uri).build();
195300
Map<String, Object> configuration = rest.exchange(request, typeReference).getBody();
196301
AuthorizationServerMetadata metadata = parse(configuration, AuthorizationServerMetadata::parse);
197-
ClientRegistration.Builder builder = withProviderConfiguration(metadata, issuer.toASCIIString());
302+
ClientRegistration.Builder builder = withProviderConfiguration(metadata, expectedIssuer.toASCIIString());
198303
URI jwkSetUri = metadata.getJWKSetURI();
199304
if (jwkSetUri != null) {
200305
builder.jwkSetUri(jwkSetUri.toASCIIString());

oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/registration/ClientRegistrationsTests.java

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
import static org.assertj.core.api.Assertions.assertThat;
3939
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
4040
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
41+
import static org.assertj.core.api.Assertions.assertThatNoException;
4142

4243
/**
4344
* @author Rob Winch
@@ -440,6 +441,35 @@ public void issuerWhenOpenIdConfigurationDoesNotMatchThenMeaningfulErrorMessage(
440441
// @formatter:on
441442
}
442443

444+
@Test
445+
public void expectedIssuerWhenOpenIdConfigurationDoesMatchThenSuccess() throws Exception {
446+
this.issuer = createIssuerFromServer("");
447+
this.response.put("issuer", "https://expected-issuer.com");
448+
String body = this.mapper.writeValueAsString(this.response);
449+
MockResponse mockResponse = new MockResponse().setBody(body)
450+
.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
451+
this.server.enqueue(mockResponse);
452+
// @formatter:off
453+
assertThatNoException()
454+
.isThrownBy(() -> ClientRegistrations.fromOidcIssuerLocation(this.issuer, "https://expected-issuer.com"));
455+
// @formatter:on
456+
}
457+
458+
@Test
459+
public void expectedIssuerWhenOpenIdConfigurationDoesNotMatchThenMeaningfulErrorMessage() throws Exception {
460+
this.issuer = createIssuerFromServer("");
461+
String body = this.mapper.writeValueAsString(this.response);
462+
MockResponse mockResponse = new MockResponse().setBody(body)
463+
.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
464+
this.server.enqueue(mockResponse);
465+
// @formatter:off
466+
assertThatIllegalStateException()
467+
.isThrownBy(() -> ClientRegistrations.fromOidcIssuerLocation(this.issuer, "https://expected-issuer.com"))
468+
.withMessageContaining("The Issuer \"https://example.com\" provided in the configuration metadata did "
469+
+ "not match the requested issuer \"https://expected-issuer.com\"");
470+
// @formatter:on
471+
}
472+
443473
@Test
444474
public void issuerWhenOAuth2ConfigurationDoesNotMatchThenMeaningfulErrorMessage() throws Exception {
445475
this.issuer = createIssuerFromServer("");
@@ -455,6 +485,35 @@ public void issuerWhenOAuth2ConfigurationDoesNotMatchThenMeaningfulErrorMessage(
455485
// @formatter:on
456486
}
457487

488+
@Test
489+
public void expectedIssuerWhenOAuth2ConfigurationDoesMatchThenSuccess() throws Exception {
490+
this.issuer = createIssuerFromServer("");
491+
this.response.put("issuer", "https://expected-issuer.com");
492+
String body = this.mapper.writeValueAsString(this.response);
493+
MockResponse mockResponse = new MockResponse().setBody(body)
494+
.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
495+
this.server.enqueue(mockResponse);
496+
// @formatter:off
497+
assertThatNoException()
498+
.isThrownBy(() -> ClientRegistrations.fromIssuerLocation(this.issuer, "https://expected-issuer.com"));
499+
// @formatter:on
500+
}
501+
502+
@Test
503+
public void expectedIssuerWhenOAuth2ConfigurationDoesNotMatchThenMeaningfulErrorMessage() throws Exception {
504+
this.issuer = createIssuerFromServer("");
505+
String body = this.mapper.writeValueAsString(this.response);
506+
MockResponse mockResponse = new MockResponse().setBody(body)
507+
.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
508+
this.server.enqueue(mockResponse);
509+
// @formatter:off
510+
assertThatIllegalStateException()
511+
.isThrownBy(() -> ClientRegistrations.fromIssuerLocation(this.issuer, "https://expected-issuer.com"))
512+
.withMessageContaining("The Issuer \"https://example.com\" provided in the configuration metadata "
513+
+ "did not match the requested issuer \"https://expected-issuer.com\"");
514+
// @formatter:on
515+
}
516+
458517
private ClientRegistration.Builder registration(String path) throws Exception {
459518
this.issuer = createIssuerFromServer(path);
460519
this.response.put("issuer", this.issuer);

0 commit comments

Comments
 (0)