Skip to content

Commit 4fc99aa

Browse files
committed
Add ClientRegistration.clientSettings.requireProofKey
Setting ClientRegistration.clientSettings.requireProofKey=true will enable PKCE for clients using authorization_code grant type. Closes gh-16386
2 parents c2a5709 + 85d7cc1 commit 4fc99aa

File tree

15 files changed

+354
-11
lines changed

15 files changed

+354
-11
lines changed

build.gradle

+4
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,10 @@ nohttp {
110110
source.builtBy(project(':spring-security-config').tasks.withType(RncToXsd))
111111
}
112112

113+
tasks.named('checkstyleNohttp') {
114+
maxHeapSize = '1g'
115+
}
116+
113117
tasks.register('cloneRepository', IncludeRepoTask) {
114118
repository = project.getProperties().get("repositoryName")
115119
ref = project.getProperties().get("ref")

docs/modules/ROOT/pages/reactive/oauth2/client/authorization-grants.adoc

+4
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ If the client is running in an untrusted environment (eg. native application or
7979
. `client-secret` is omitted (or empty)
8080
. `client-authentication-method` is set to "none" (`ClientAuthenticationMethod.NONE`)
8181

82+
or
83+
84+
. When `ClientRegistration.clientSettings.requireProofKey` is `true` (in this case `ClientRegistration.authorizationGrantType` must be `authorization_code`)
85+
8286
[TIP]
8387
====
8488
If the OAuth 2.0 Provider supports PKCE for https://tools.ietf.org/html/rfc6749#section-2.1[Confidential Clients], you may (optionally) configure it using `DefaultServerOAuth2AuthorizationRequestResolver.setAuthorizationRequestCustomizer(OAuth2AuthorizationRequestCustomizers.withPkce())`.

docs/modules/ROOT/pages/reactive/oauth2/client/core.adoc

+5
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ public final class ClientRegistration {
3939
4040
}
4141
}
42+
43+
public static final class ClientSettings {
44+
private boolean requireProofKey; // <17>
45+
}
4246
}
4347
----
4448
<1> `registrationId`: The ID that uniquely identifies the `ClientRegistration`.
@@ -64,6 +68,7 @@ The name may be used in certain scenarios, such as when displaying the name of t
6468
<15> `(userInfoEndpoint)authenticationMethod`: The authentication method used when sending the access token to the UserInfo Endpoint.
6569
The supported values are *header*, *form* and *query*.
6670
<16> `userNameAttributeName`: The name of the attribute returned in the UserInfo Response that references the Name or Identifier of the end-user.
71+
<17> [[oauth2Client-client-registration-requireProofKey]]`requireProofKey`: If `true` or if `authorizationGrantType` is `none`, then PKCE will be enabled by default.
6772

6873
A `ClientRegistration` can be initially configured using discovery of an OpenID Connect Provider's https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig[Configuration endpoint] or an Authorization Server's https://tools.ietf.org/html/rfc8414#section-3[Metadata endpoint].
6974

docs/modules/ROOT/pages/servlet/oauth2/client/authorization-grants.adoc

+6-1
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,14 @@ spring:
7777
Public Clients are supported by using https://tools.ietf.org/html/rfc7636[Proof Key for Code Exchange] (PKCE).
7878
If the client is running in an untrusted environment (such as a native application or web browser-based application) and is therefore incapable of maintaining the confidentiality of its credentials, PKCE is automatically used when the following conditions are true:
7979

80-
. `client-secret` is omitted (or empty)
80+
. `client-secret` is omitted (or empty) and
8181
. `client-authentication-method` is set to `none` (`ClientAuthenticationMethod.NONE`)
8282

83+
or
84+
85+
. When `ClientRegistration.clientSettings.requireProofKey` is `true` (in this case `ClientRegistration.authorizationGrantType` must be `authorization_code`)
86+
87+
8388
[TIP]
8489
====
8590
If the OAuth 2.0 Provider supports PKCE for https://tools.ietf.org/html/rfc6749#section-2.1[Confidential Clients], you may (optionally) configure it using `DefaultOAuth2AuthorizationRequestResolver.setAuthorizationRequestCustomizer(OAuth2AuthorizationRequestCustomizers.withPkce())`.

docs/modules/ROOT/pages/servlet/oauth2/client/core.adoc

+5
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ public final class ClientRegistration {
4040
4141
}
4242
}
43+
44+
public static final class ClientSettings {
45+
private boolean requireProofKey; // <17>
46+
}
4347
}
4448
----
4549
<1> `registrationId`: The ID that uniquely identifies the `ClientRegistration`.
@@ -65,6 +69,7 @@ This information is available only if the Spring Boot property `spring.security.
6569
<15> `(userInfoEndpoint)authenticationMethod`: The authentication method used when sending the access token to the UserInfo Endpoint.
6670
The supported values are *header*, *form*, and *query*.
6771
<16> `userNameAttributeName`: The name of the attribute returned in the UserInfo Response that references the Name or Identifier of the end-user.
72+
<17> [[oauth2Client-client-registration-requireProofKey]]`requireProofKey`: If `true` or if `authorizationGrantType` is `none`, then PKCE will be enabled by default.
6873

6974
You can initially configure a `ClientRegistration` by using discovery of an OpenID Connect Provider's https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig[Configuration endpoint] or an Authorization Server's https://tools.ietf.org/html/rfc8414#section-3[Metadata endpoint].
7075

docs/modules/ROOT/pages/whats-new.adoc

+4
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,7 @@ Below are the highlights of the release, or you can view https://github.com/spri
1010

1111
The `security.security.reached.filter.section` key name was corrected to `spring.security.reached.filter.section`.
1212
Note that this may affect reports that operate on this key name.
13+
14+
== OAuth
15+
16+
* https://github.com/spring-projects/spring-security/pull/16386[gh-16386] - Enable PKCE for confidential clients using `ClientRegistration.clientSettings.requireProofKey=true` for xref:servlet/oauth2/client/core.adoc#oauth2Client-client-registration-requireProofKey[servlet] and xref:reactive/oauth2/client/core.adoc#oauth2Client-client-registration-requireProofKey[reactive] applications

oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/jackson2/ClientRegistrationDeserializer.java

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2020 the original author or authors.
2+
* Copyright 2002-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.

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

+107-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2022 the original author or authors.
2+
* Copyright 2002-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -26,6 +26,7 @@
2626
import java.util.LinkedHashSet;
2727
import java.util.List;
2828
import java.util.Map;
29+
import java.util.Objects;
2930
import java.util.Set;
3031

3132
import org.apache.commons.logging.Log;
@@ -71,6 +72,8 @@ public final class ClientRegistration implements Serializable {
7172

7273
private String clientName;
7374

75+
private ClientSettings clientSettings;
76+
7477
private ClientRegistration() {
7578
}
7679

@@ -162,6 +165,14 @@ public String getClientName() {
162165
return this.clientName;
163166
}
164167

168+
/**
169+
* Returns the {@link ClientSettings client configuration settings}.
170+
* @return the {@link ClientSettings}
171+
*/
172+
public ClientSettings getClientSettings() {
173+
return this.clientSettings;
174+
}
175+
165176
@Override
166177
public String toString() {
167178
// @formatter:off
@@ -175,6 +186,7 @@ public String toString() {
175186
+ '\'' + ", scopes=" + this.scopes
176187
+ ", providerDetails=" + this.providerDetails
177188
+ ", clientName='" + this.clientName + '\''
189+
+ ", clientSettings='" + this.clientSettings + '\''
178190
+ '}';
179191
// @formatter:on
180192
}
@@ -367,6 +379,8 @@ public static final class Builder implements Serializable {
367379

368380
private String clientName;
369381

382+
private ClientSettings clientSettings = ClientSettings.builder().build();
383+
370384
private Builder(String registrationId) {
371385
this.registrationId = registrationId;
372386
}
@@ -391,6 +405,7 @@ private Builder(ClientRegistration clientRegistration) {
391405
this.configurationMetadata = new HashMap<>(configurationMetadata);
392406
}
393407
this.clientName = clientRegistration.clientName;
408+
this.clientSettings = clientRegistration.clientSettings;
394409
}
395410

396411
/**
@@ -594,6 +609,17 @@ public Builder clientName(String clientName) {
594609
return this;
595610
}
596611

612+
/**
613+
* Sets the {@link ClientSettings client configuration settings}.
614+
* @param clientSettings the client configuration settings
615+
* @return the {@link Builder}
616+
*/
617+
public Builder clientSettings(ClientSettings clientSettings) {
618+
Assert.notNull(clientSettings, "clientSettings cannot be null");
619+
this.clientSettings = clientSettings;
620+
return this;
621+
}
622+
597623
/**
598624
* Builds a new {@link ClientRegistration}.
599625
* @return a {@link ClientRegistration}
@@ -627,12 +653,13 @@ private ClientRegistration create() {
627653
clientRegistration.providerDetails = createProviderDetails(clientRegistration);
628654
clientRegistration.clientName = StringUtils.hasText(this.clientName) ? this.clientName
629655
: this.registrationId;
656+
clientRegistration.clientSettings = this.clientSettings;
630657
return clientRegistration;
631658
}
632659

633660
private ClientAuthenticationMethod deduceClientAuthenticationMethod(ClientRegistration clientRegistration) {
634661
if (AuthorizationGrantType.AUTHORIZATION_CODE.equals(this.authorizationGrantType)
635-
&& !StringUtils.hasText(this.clientSecret)) {
662+
&& (!StringUtils.hasText(this.clientSecret))) {
636663
return ClientAuthenticationMethod.NONE;
637664
}
638665
return ClientAuthenticationMethod.CLIENT_SECRET_BASIC;
@@ -685,6 +712,12 @@ private void validateAuthorizationGrantTypes() {
685712
"AuthorizationGrantType: %s does not match the pre-defined constant %s and won't match a valid OAuth2AuthorizedClientProvider",
686713
this.authorizationGrantType, authorizationGrantType));
687714
}
715+
if (!AuthorizationGrantType.AUTHORIZATION_CODE.equals(this.authorizationGrantType)
716+
&& this.clientSettings.isRequireProofKey()) {
717+
throw new IllegalStateException(
718+
"clientSettings.isRequireProofKey=true is only valid with authorizationGrantType=AUTHORIZATION_CODE. Got authorizationGrantType="
719+
+ this.authorizationGrantType);
720+
}
688721
}
689722
}
690723

@@ -709,4 +742,76 @@ private static boolean withinTheRangeOf(int c, int min, int max) {
709742

710743
}
711744

745+
/**
746+
* A facility for client configuration settings.
747+
*
748+
* @author DingHao
749+
* @since 6.5
750+
*/
751+
public static final class ClientSettings {
752+
753+
private boolean requireProofKey;
754+
755+
private ClientSettings() {
756+
757+
}
758+
759+
public boolean isRequireProofKey() {
760+
return this.requireProofKey;
761+
}
762+
763+
@Override
764+
public boolean equals(Object o) {
765+
if (this == o) {
766+
return true;
767+
}
768+
if (!(o instanceof ClientSettings that)) {
769+
return false;
770+
}
771+
return this.requireProofKey == that.requireProofKey;
772+
}
773+
774+
@Override
775+
public int hashCode() {
776+
return Objects.hashCode(this.requireProofKey);
777+
}
778+
779+
@Override
780+
public String toString() {
781+
return "ClientSettings{" + "requireProofKey=" + this.requireProofKey + '}';
782+
}
783+
784+
public static Builder builder() {
785+
return new Builder();
786+
}
787+
788+
public static final class Builder {
789+
790+
private boolean requireProofKey;
791+
792+
private Builder() {
793+
}
794+
795+
/**
796+
* Set to {@code true} if the client is required to provide a proof key
797+
* challenge and verifier when performing the Authorization Code Grant flow.
798+
* @param requireProofKey {@code true} if the client is required to provide a
799+
* proof key challenge and verifier, {@code false} otherwise
800+
* @return the {@link Builder} for further configuration
801+
*/
802+
public Builder requireProofKey(boolean requireProofKey) {
803+
this.requireProofKey = requireProofKey;
804+
return this;
805+
}
806+
807+
public ClientSettings build() {
808+
ClientSettings clientSettings = new ClientSettings();
809+
clientSettings.requireProofKey = this.requireProofKey;
810+
return clientSettings;
811+
}
812+
813+
}
814+
815+
}
816+
712817
}

oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/DefaultOAuth2AuthorizationRequestResolver.java

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2022 the original author or authors.
2+
* Copyright 2002-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -183,7 +183,8 @@ private OAuth2AuthorizationRequest.Builder getBuilder(ClientRegistration clientR
183183
// value.
184184
applyNonce(builder);
185185
}
186-
if (ClientAuthenticationMethod.NONE.equals(clientRegistration.getClientAuthenticationMethod())) {
186+
if (ClientAuthenticationMethod.NONE.equals(clientRegistration.getClientAuthenticationMethod())
187+
|| clientRegistration.getClientSettings().isRequireProofKey()) {
187188
DEFAULT_PKCE_APPLIER.accept(builder);
188189
}
189190
return builder;

oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/server/DefaultServerOAuth2AuthorizationRequestResolver.java

+2-1
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,8 @@ private OAuth2AuthorizationRequest.Builder getBuilder(ClientRegistration clientR
196196
// value.
197197
applyNonce(builder);
198198
}
199-
if (ClientAuthenticationMethod.NONE.equals(clientRegistration.getClientAuthenticationMethod())) {
199+
if (ClientAuthenticationMethod.NONE.equals(clientRegistration.getClientAuthenticationMethod())
200+
|| clientRegistration.getClientSettings().isRequireProofKey()) {
200201
DEFAULT_PKCE_APPLIER.accept(builder);
201202
}
202203
return builder;

oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/jackson2/OAuth2AuthorizedClientMixinTests.java

+69-1
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,71 @@ public void deserializeWhenRequiredAttributesOnlyThenDeserializes() throws Excep
214214
assertThat(authorizedClient.getRefreshToken()).isNull();
215215
}
216216

217+
@Test
218+
void deserializeWhenClientSettingsPropertyDoesNotExistThenDefaulted() throws JsonProcessingException {
219+
// ClientRegistration.clientSettings was added later, so old values will be
220+
// serialized without that property
221+
// this test checks for passivity
222+
ClientRegistration clientRegistration = this.clientRegistrationBuilder.build();
223+
ClientRegistration.ProviderDetails providerDetails = clientRegistration.getProviderDetails();
224+
ClientRegistration.ProviderDetails.UserInfoEndpoint userInfoEndpoint = providerDetails.getUserInfoEndpoint();
225+
String scopes = "";
226+
if (!CollectionUtils.isEmpty(clientRegistration.getScopes())) {
227+
scopes = StringUtils.collectionToDelimitedString(clientRegistration.getScopes(), ",", "\"", "\"");
228+
}
229+
String configurationMetadata = "\"@class\": \"java.util.Collections$UnmodifiableMap\"";
230+
if (!CollectionUtils.isEmpty(providerDetails.getConfigurationMetadata())) {
231+
configurationMetadata += "," + providerDetails.getConfigurationMetadata()
232+
.keySet()
233+
.stream()
234+
.map((key) -> "\"" + key + "\": \"" + providerDetails.getConfigurationMetadata().get(key) + "\"")
235+
.collect(Collectors.joining(","));
236+
}
237+
// @formatter:off
238+
String json = "{\n" +
239+
" \"@class\": \"org.springframework.security.oauth2.client.registration.ClientRegistration\",\n" +
240+
" \"registrationId\": \"" + clientRegistration.getRegistrationId() + "\",\n" +
241+
" \"clientId\": \"" + clientRegistration.getClientId() + "\",\n" +
242+
" \"clientSecret\": \"" + clientRegistration.getClientSecret() + "\",\n" +
243+
" \"clientAuthenticationMethod\": {\n" +
244+
" \"value\": \"" + clientRegistration.getClientAuthenticationMethod().getValue() + "\"\n" +
245+
" },\n" +
246+
" \"authorizationGrantType\": {\n" +
247+
" \"value\": \"" + clientRegistration.getAuthorizationGrantType().getValue() + "\"\n" +
248+
" },\n" +
249+
" \"redirectUri\": \"" + clientRegistration.getRedirectUri() + "\",\n" +
250+
" \"scopes\": [\n" +
251+
" \"java.util.Collections$UnmodifiableSet\",\n" +
252+
" [" + scopes + "]\n" +
253+
" ],\n" +
254+
" \"providerDetails\": {\n" +
255+
" \"@class\": \"org.springframework.security.oauth2.client.registration.ClientRegistration$ProviderDetails\",\n" +
256+
" \"authorizationUri\": \"" + providerDetails.getAuthorizationUri() + "\",\n" +
257+
" \"tokenUri\": \"" + providerDetails.getTokenUri() + "\",\n" +
258+
" \"userInfoEndpoint\": {\n" +
259+
" \"@class\": \"org.springframework.security.oauth2.client.registration.ClientRegistration$ProviderDetails$UserInfoEndpoint\",\n" +
260+
" \"uri\": " + ((userInfoEndpoint.getUri() != null) ? "\"" + userInfoEndpoint.getUri() + "\"" : null) + ",\n" +
261+
" \"authenticationMethod\": {\n" +
262+
" \"value\": \"" + userInfoEndpoint.getAuthenticationMethod().getValue() + "\"\n" +
263+
" },\n" +
264+
" \"userNameAttributeName\": " + ((userInfoEndpoint.getUserNameAttributeName() != null) ? "\"" + userInfoEndpoint.getUserNameAttributeName() + "\"" : null) + "\n" +
265+
" },\n" +
266+
" \"jwkSetUri\": " + ((providerDetails.getJwkSetUri() != null) ? "\"" + providerDetails.getJwkSetUri() + "\"" : null) + ",\n" +
267+
" \"issuerUri\": " + ((providerDetails.getIssuerUri() != null) ? "\"" + providerDetails.getIssuerUri() + "\"" : null) + ",\n" +
268+
" \"configurationMetadata\": {\n" +
269+
" " + configurationMetadata + "\n" +
270+
" }\n" +
271+
" },\n" +
272+
" \"clientName\": \"" + clientRegistration.getClientName() + "\"\n" +
273+
"}";
274+
// @formatter:on
275+
// validate the test input
276+
assertThat(json).doesNotContain("clientSettings");
277+
ClientRegistration registration = this.mapper.readValue(json, ClientRegistration.class);
278+
// the default value of requireProofKey is false
279+
assertThat(registration.getClientSettings().isRequireProofKey()).isFalse();
280+
}
281+
217282
private static String asJson(OAuth2AuthorizedClient authorizedClient) {
218283
// @formatter:off
219284
return "{\n" +
@@ -276,7 +341,10 @@ private static String asJson(ClientRegistration clientRegistration) {
276341
" " + configurationMetadata + "\n" +
277342
" }\n" +
278343
" },\n" +
279-
" \"clientName\": \"" + clientRegistration.getClientName() + "\"\n" +
344+
" \"clientName\": \"" + clientRegistration.getClientName() + "\",\n" +
345+
" \"clientSettings\": {\n" +
346+
" \"requireProofKey\": " + clientRegistration.getClientSettings().isRequireProofKey() + "\n" +
347+
" }\n" +
280348
"}";
281349
// @formatter:on
282350
}

0 commit comments

Comments
 (0)