Skip to content

Commit 9b2bf11

Browse files
committed
Provide ability to customize HTTP client in ClientRegistrations and JwtDecoders
Closes gh-19014 Signed-off-by: Evgeniy Cheban <mister.cheban@gmail.com>
1 parent 82241b6 commit 9b2bf11

7 files changed

Lines changed: 295 additions & 13 deletions

File tree

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
*
4848
* @author Joe Grandja
4949
* @author Michael Sosa
50+
* @author Evgeniy Cheban
5051
* @since 5.0
5152
* @see <a target="_blank" href="https://tools.ietf.org/html/rfc6749#section-2">Section 2
5253
* Client Registration</a>
@@ -386,6 +387,8 @@ public static final class Builder implements Serializable {
386387

387388
private @Nullable String issuerUri;
388389

390+
private @Nullable String trustedIssuer;
391+
389392
private Map<String, Object> configurationMetadata = Collections.emptyMap();
390393

391394
private @Nullable String clientName;
@@ -597,6 +600,18 @@ public Builder issuerUri(@Nullable String issuerUri) {
597600
return this;
598601
}
599602

603+
/**
604+
* Allows overriding of default issuer validation that requires the requested
605+
* issuer to match the issuer received in the metadata configuration.
606+
* @param trustedIssuer the issuer to use
607+
* @return the {@link Builder}
608+
* @since 7.2
609+
*/
610+
public Builder trustedIssuer(@Nullable String trustedIssuer) {
611+
this.trustedIssuer = trustedIssuer;
612+
return this;
613+
}
614+
600615
/**
601616
* Sets the metadata describing the provider's configuration.
602617
* @param configurationMetadata the metadata describing the provider's
@@ -645,6 +660,7 @@ else if (AuthorizationGrantType.AUTHORIZATION_CODE.equals(this.authorizationGran
645660
}
646661
this.validateAuthorizationGrantTypes();
647662
this.validateScopes();
663+
this.validateIssuer();
648664
return this.create();
649665
}
650666

@@ -742,6 +758,15 @@ private static boolean withinTheRangeOf(int c, int min, int max) {
742758
return c >= min && c <= max;
743759
}
744760

761+
private void validateIssuer() {
762+
if (this.issuerUri == null || this.trustedIssuer == null) {
763+
return;
764+
}
765+
Assert.state(this.issuerUri.equals(this.trustedIssuer),
766+
() -> "The Issuer \"%s\" provided in the configuration metadata did not match the requested issuer \"%s\""
767+
.formatted(this.trustedIssuer, this.issuerUri));
768+
}
769+
745770
}
746771

747772
/**

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

Lines changed: 107 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames;
3838
import org.springframework.util.Assert;
3939
import org.springframework.web.client.HttpClientErrorException;
40+
import org.springframework.web.client.RestOperations;
4041
import org.springframework.web.client.RestTemplate;
4142
import org.springframework.web.util.UriComponents;
4243
import org.springframework.web.util.UriComponentsBuilder;
@@ -148,8 +149,50 @@ public static ClientRegistration.Builder fromOidcConfiguration(Map<String, Objec
148149
* Provider Configuration.
149150
*/
150151
public static ClientRegistration.Builder fromOidcIssuerLocation(String issuer) {
152+
return fromOidcIssuerLocation(issuer, rest);
153+
}
154+
155+
/**
156+
* Creates a {@link ClientRegistration.Builder} using the provided <a href=
157+
* "https://openid.net/specs/openid-connect-core-1_0.html#IssuerIdentifier">Issuer</a>
158+
* by making an <a href=
159+
* "https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationRequest">OpenID
160+
* Provider Configuration Request</a> and using the values in the <a href=
161+
* "https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse">OpenID
162+
* Provider Configuration Response</a> to initialize the
163+
* {@link ClientRegistration.Builder}.
164+
*
165+
* <p>
166+
* For example, if the issuer provided is "https://example.com", then an "OpenID
167+
* Provider Configuration Request" will be made to
168+
* "https://example.com/.well-known/openid-configuration". The result is expected to
169+
* be an "OpenID Provider Configuration Response".
170+
* </p>
171+
*
172+
* This method uses the provided {@link RestOperations} to query oidc issuer
173+
* configuration.
174+
*
175+
* <p>
176+
* Example usage:
177+
* </p>
178+
* <pre>
179+
* RestTemplate rest = new RestTemplate();
180+
* ClientRegistration registration = ClientRegistrations.fromOidcIssuerLocation("https://example.com", rest)
181+
* .clientId("client-id")
182+
* .clientSecret("client-secret")
183+
* .build();
184+
* </pre>
185+
* @param issuer the <a href=
186+
* "https://openid.net/specs/openid-connect-core-1_0.html#IssuerIdentifier">Issuer</a>
187+
* @param restOperations the {@link RestOperations} to use
188+
* @return a {@link ClientRegistration.Builder} that was initialized by the OpenID
189+
* Provider Configuration.
190+
* @since 7.2
191+
*/
192+
public static ClientRegistration.Builder fromOidcIssuerLocation(String issuer, RestOperations restOperations) {
151193
Assert.hasText(issuer, "issuer cannot be empty");
152-
return getBuilder(issuer, oidc(issuer));
194+
Assert.notNull(restOperations, "restOperations cannot be null");
195+
return getBuilder(issuer, oidc(issuer, restOperations));
153196
}
154197

155198
/**
@@ -191,11 +234,65 @@ public static ClientRegistration.Builder fromOidcIssuerLocation(String issuer) {
191234
* described endpoints
192235
*/
193236
public static ClientRegistration.Builder fromIssuerLocation(String issuer) {
237+
return fromIssuerLocation(issuer, rest);
238+
}
239+
240+
/**
241+
* Creates a {@link ClientRegistration.Builder} using the provided <a href=
242+
* "https://openid.net/specs/openid-connect-core-1_0.html#IssuerIdentifier">Issuer</a>
243+
* by querying three different discovery endpoints serially, using the values in the
244+
* first successful response to initialize. If an endpoint returns anything other than
245+
* a 200 or a 4xx, the method will exit without attempting subsequent endpoints.
246+
*
247+
* <p>
248+
* The three endpoints are computed as follows, given that the {@code issuer} is
249+
* composed of a {@code host} and a {@code path}:
250+
* </p>
251+
*
252+
* <ol>
253+
* <li>{@code host/.well-known/openid-configuration/path}, as defined in
254+
* <a href="https://tools.ietf.org/html/rfc8414#section-5">RFC 8414's Compatibility
255+
* Notes</a>.</li>
256+
* <li>{@code issuer/.well-known/openid-configuration}, as defined in <a href=
257+
* "https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationRequest">
258+
* OpenID Provider Configuration</a>.</li>
259+
* <li>{@code host/.well-known/oauth-authorization-server/path}, as defined in
260+
* <a href="https://tools.ietf.org/html/rfc8414#section-3.1">Authorization Server
261+
* Metadata Request</a>.</li>
262+
* </ol>
263+
*
264+
* Note that the second endpoint is the equivalent of calling
265+
* {@link ClientRegistrations#fromOidcIssuerLocation(String)}.
266+
*
267+
* <p>
268+
* This method uses the provided {@link RestOperations} to query issuer configuration.
269+
* </p>
270+
*
271+
* <p>
272+
* Example usage:
273+
* </p>
274+
* <pre>
275+
* RestTemplate rest = new RestTemplate();
276+
* ClientRegistration registration = ClientRegistrations.fromIssuerLocation("https://example.com", rest)
277+
* .clientId("client-id")
278+
* .clientSecret("client-secret")
279+
* .build();
280+
* </pre>
281+
* @param issuer the <a href=
282+
* "https://openid.net/specs/openid-connect-core-1_0.html#IssuerIdentifier">Issuer</a>
283+
* @param restOperations the {@link RestOperations} to use
284+
* @return a {@link ClientRegistration.Builder} that was initialized by one of the
285+
* described endpoints
286+
* @since 7.2
287+
*/
288+
public static ClientRegistration.Builder fromIssuerLocation(String issuer, RestOperations restOperations) {
194289
Assert.hasText(issuer, "issuer cannot be empty");
195-
return getBuilder(issuer, oidc(issuer), oidcRfc8414(issuer), oauth(issuer));
290+
Assert.notNull(restOperations, "restOperations cannot be null");
291+
return getBuilder(issuer, oidc(issuer, restOperations), oidcRfc8414(issuer, restOperations),
292+
oauth(issuer, restOperations));
196293
}
197294

198-
static Supplier<ClientRegistration.Builder> oidc(String issuer) {
295+
static Supplier<ClientRegistration.Builder> oidc(String issuer, RestOperations rest) {
199296
UriComponents uri = oidcUri(issuer);
200297
// @formatter:on
201298
return () -> {
@@ -220,10 +317,10 @@ static UriComponents oidcUri(String issuer) {
220317
.build();
221318
}
222319

223-
static Supplier<ClientRegistration.Builder> oidcRfc8414(String issuer) {
320+
static Supplier<ClientRegistration.Builder> oidcRfc8414(String issuer, RestOperations rest) {
224321
UriComponents uri = oidcRfc8414Uri(issuer);
225322
// @formatter:on
226-
return getRfc8414Builder(issuer, uri);
323+
return getRfc8414Builder(issuer, uri, rest);
227324
}
228325

229326
static UriComponents oidcRfc8414Uri(String issuer) {
@@ -234,9 +331,9 @@ static UriComponents oidcRfc8414Uri(String issuer) {
234331
.build();
235332
}
236333

237-
static Supplier<ClientRegistration.Builder> oauth(String issuer) {
334+
static Supplier<ClientRegistration.Builder> oauth(String issuer, RestOperations rest) {
238335
UriComponents uri = oauthUri(issuer);
239-
return getRfc8414Builder(issuer, uri);
336+
return getRfc8414Builder(issuer, uri, rest);
240337
}
241338

242339
static UriComponents oauthUri(String issuer) {
@@ -248,7 +345,8 @@ static UriComponents oauthUri(String issuer) {
248345
// @formatter:on
249346
}
250347

251-
private static Supplier<ClientRegistration.Builder> getRfc8414Builder(String issuer, UriComponents uri) {
348+
private static Supplier<ClientRegistration.Builder> getRfc8414Builder(String issuer, UriComponents uri,
349+
RestOperations rest) {
252350
return () -> {
253351
RequestEntity<Void> request = RequestEntity.get(uri.toUriString()).build();
254352
Map<String, Object> configuration = rest.exchange(request, typeReference).getBody();
@@ -317,9 +415,6 @@ private static <T> T parse(Map<String, Object> body, ThrowingFunction<JSONObject
317415
private static ClientRegistration.Builder withProviderConfiguration(AuthorizationServerMetadata metadata,
318416
String issuer) {
319417
String metadataIssuer = metadata.getIssuer().getValue();
320-
Assert.state(issuer.equals(metadataIssuer),
321-
() -> "The Issuer \"" + metadataIssuer + "\" provided in the configuration metadata did "
322-
+ "not match the requested issuer \"" + issuer + "\"");
323418
String name = URI.create(issuer).getHost();
324419
ClientAuthenticationMethod method = getClientAuthenticationMethod(metadata.getTokenEndpointAuthMethods());
325420
URI authorizationEndpointURI = metadata.getAuthorizationEndpointURI();
@@ -336,6 +431,7 @@ private static ClientRegistration.Builder withProviderConfiguration(Authorizatio
336431
.authorizationUri((authorizationEndpointURI != null) ? authorizationEndpointURI.toASCIIString() : null)
337432
.providerConfigurationMetadata(configurationMetadata)
338433
.issuerUri(issuer)
434+
.trustedIssuer(metadataIssuer)
339435
.clientName(issuer);
340436
if (tokenEndpointURI != null) {
341437
builder.tokenUri(tokenEndpointURI.toASCIIString());

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,13 @@
3737

3838
import static org.assertj.core.api.Assertions.assertThat;
3939
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
40+
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
4041

4142
/**
4243
* Tests for {@link ClientRegistration}.
4344
*
4445
* @author Joe Grandja
46+
* @author Evgeniy Cheban
4547
*/
4648
public class ClientRegistrationTests {
4749

@@ -731,6 +733,32 @@ void buildWhenInvalidGrantTypeForPkceThenPkceDisabled(AuthorizationGrantType inv
731733
assertThat(clientRegistration.getClientSettings().isRequireProofKey()).isFalse();
732734
}
733735

736+
@Test
737+
void buildWhenTrustedIssuerNotMatchThenException() {
738+
ClientRegistration.Builder builder = ClientRegistration.withRegistrationId(REGISTRATION_ID)
739+
.clientId(CLIENT_ID)
740+
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
741+
.tokenUri(TOKEN_URI)
742+
.issuerUri("https://example.com")
743+
.trustedIssuer("https://local.example.com");
744+
assertThatIllegalStateException().isThrownBy(builder::build).withMessage("""
745+
The Issuer "https://local.example.com" provided in the configuration metadata did \
746+
not match the requested issuer "https://example.com"\
747+
""");
748+
}
749+
750+
@Test
751+
void buildWhenTrustedIssuerMatchesThenBuilds() {
752+
ClientRegistration registration = ClientRegistration.withRegistrationId(REGISTRATION_ID)
753+
.clientId(CLIENT_ID)
754+
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
755+
.tokenUri(TOKEN_URI)
756+
.issuerUri("https://example.com")
757+
.trustedIssuer("https://example.com")
758+
.build();
759+
assertThat(registration.getProviderDetails().getIssuerUri()).isEqualTo("https://example.com");
760+
}
761+
734762
static List<AuthorizationGrantType> invalidPkceGrantTypes() {
735763
return Arrays.stream(AuthorizationGrantType.class.getFields())
736764
.filter((field) -> Modifier.isFinal(field.getModifiers())

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

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import org.springframework.http.MediaType;
3535
import org.springframework.security.oauth2.core.AuthorizationGrantType;
3636
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
37+
import org.springframework.web.client.RestTemplate;
3738
import org.springframework.web.util.UriComponents;
3839

3940
import static org.assertj.core.api.Assertions.assertThat;
@@ -435,7 +436,7 @@ public void issuerWhenOpenIdConfigurationDoesNotMatchThenMeaningfulErrorMessage(
435436
this.server.enqueue(mockResponse);
436437
// @formatter:off
437438
assertThatIllegalStateException()
438-
.isThrownBy(() -> ClientRegistrations.fromOidcIssuerLocation(this.issuer))
439+
.isThrownBy(() -> ClientRegistrations.fromOidcIssuerLocation(this.issuer).clientId("client-id").build())
439440
.withMessageContaining("The Issuer \"https://example.com\" provided in the configuration metadata did "
440441
+ "not match the requested issuer \"" + this.issuer + "\"");
441442
// @formatter:on
@@ -450,7 +451,7 @@ public void issuerWhenOAuth2ConfigurationDoesNotMatchThenMeaningfulErrorMessage(
450451
this.server.enqueue(mockResponse);
451452
// @formatter:off
452453
assertThatIllegalStateException()
453-
.isThrownBy(() -> ClientRegistrations.fromIssuerLocation(this.issuer))
454+
.isThrownBy(() -> ClientRegistrations.fromIssuerLocation(this.issuer).clientId("client-id").build())
454455
.withMessageContaining("The Issuer \"https://example.com\" provided in the configuration metadata "
455456
+ "did not match the requested issuer \"" + this.issuer + "\"");
456457
// @formatter:on
@@ -601,6 +602,37 @@ public MockResponse dispatch(RecordedRequest request) {
601602
.withMessageContaining("404");
602603
}
603604

605+
@Test
606+
public void issuerWhenOidcUsingCustomRestOperationsThenSuccess() {
607+
this.issuer = createIssuerFromServer("issuer1");
608+
this.response.put("issuer", this.issuer);
609+
String body = this.mapper.writeValueAsString(this.response);
610+
MockResponse mockResponse = new MockResponse().setBody(body)
611+
.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
612+
this.server.enqueue(mockResponse);
613+
RestTemplate rest = new RestTemplate();
614+
ClientRegistration registration = ClientRegistrations.fromOidcIssuerLocation(this.issuer, rest)
615+
.clientId("client-id")
616+
.clientSecret("client-secret")
617+
.build();
618+
assertThat(registration.getProviderDetails().getIssuerUri()).isEqualTo(this.issuer);
619+
}
620+
621+
@Test
622+
public void issuerWhenUsingCustomRestOperationsThenSuccess() {
623+
this.issuer = createIssuerFromServer("issuer1");
624+
this.response.put("issuer", this.issuer);
625+
String body = this.mapper.writeValueAsString(this.response);
626+
MockResponse mockResponse = new MockResponse().setBody(body)
627+
.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
628+
this.server.enqueue(mockResponse);
629+
RestTemplate rest = new RestTemplate();
630+
ClientRegistration registration = ClientRegistrations.fromIssuerLocation(this.issuer, rest)
631+
.clientId("client-id")
632+
.build();
633+
assertThat(registration.getProviderDetails().getIssuerUri()).isEqualTo(this.issuer);
634+
}
635+
604636
private ClientRegistration.Builder registration(String path) throws Exception {
605637
this.issuer = createIssuerFromServer(path);
606638
this.response.put("issuer", this.issuer);

oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtDecoderProviderConfigurationUtils.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@ static int getReadTimeout() {
101101
}
102102

103103
static Map<String, Object> getConfigurationForOidcIssuerLocation(String oidcIssuerLocation) {
104+
return getConfigurationForOidcIssuerLocation(oidcIssuerLocation, rest);
105+
}
106+
107+
static Map<String, Object> getConfigurationForOidcIssuerLocation(String oidcIssuerLocation, RestOperations rest) {
104108
return getConfiguration(oidcIssuerLocation, rest, oidc(oidcIssuerLocation));
105109
}
106110

0 commit comments

Comments
 (0)