Skip to content

Commit beb5a44

Browse files
yybmionrwinch
authored andcommitted
Add SpEL support for nested username extraction in OAuth2
- Add usernameExpression property with SpEL evaluation support - Auto-convert userNameAttributeName to SpEL for backward compatibility - Use SimpleEvaluationContext for secure expression evaluation - Pass evaluated username to OAuth2UserAuthority for gh-15012 compatibility - Add Builder pattern to DefaultOAuth2User - Add Builder pattern to OAuth2UserAuthority - Add Builder pattern to OidcUserAuthority with inherance support - Add Builder pattern to DefaultOidcUser with inherance support - Support nested property access (e.g., "data.username") - Add usernameExpression property to ClientRegistration documentation - Update What's New section Fixes gh-16390 Signed-off-by: yybmion <[email protected]>
1 parent bb08484 commit beb5a44

File tree

16 files changed

+837
-115
lines changed

16 files changed

+837
-115
lines changed

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,13 @@ public final class ClientRegistration {
3636
private String uri; <14>
3737
private AuthenticationMethod authenticationMethod; <15>
3838
private String userNameAttributeName; <16>
39+
private String usernameExpression; <17>
3940
4041
}
4142
}
4243
4344
public static final class ClientSettings {
44-
private boolean requireProofKey; // <17>
45+
private boolean requireProofKey; // <18>
4546
}
4647
}
4748
----
@@ -67,8 +68,9 @@ The name may be used in certain scenarios, such as when displaying the name of t
6768
<14> `(userInfoEndpoint)uri`: The UserInfo Endpoint URI used to access the claims/attributes of the authenticated end-user.
6869
<15> `(userInfoEndpoint)authenticationMethod`: The authentication method used when sending the access token to the UserInfo Endpoint.
6970
The supported values are *header*, *form* and *query*.
70-
<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.
71+
<16> `userNameAttributeName`: The name of the attribute returned in the UserInfo Response that references the Name or Identifier of the end-user. *Deprecated* - use `usernameExpression` instead.
72+
<17> `usernameExpression`: A SpEL expression used to extract the username from the UserInfo Response. Supports accessing nested attributes (e.g., `"data.username"`) and complex expressions (e.g., `"preferred_username ?: email"`).
73+
<18> [[oauth2Client-client-registration-requireProofKey]]`requireProofKey`: If `true` or if `authorizationGrantType` is `none`, then PKCE will be enabled by default.
7274

7375
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].
7476

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

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,18 +31,19 @@ public final class ClientRegistration {
3131
private UserInfoEndpoint userInfoEndpoint;
3232
private String jwkSetUri; <11>
3333
private String issuerUri; <12>
34-
private Map<String, Object> configurationMetadata; <13>
34+
private Map<String, Object> configurationMetadata; <13>
3535
3636
public class UserInfoEndpoint {
3737
private String uri; <14>
38-
private AuthenticationMethod authenticationMethod; <15>
38+
private AuthenticationMethod authenticationMethod; <15>
3939
private String userNameAttributeName; <16>
40+
private String usernameExpression; <17>
4041
4142
}
4243
}
4344
4445
public static final class ClientSettings {
45-
private boolean requireProofKey; // <17>
46+
private boolean requireProofKey; // <18>
4647
}
4748
}
4849
----
@@ -68,8 +69,9 @@ This information is available only if the Spring Boot property `spring.security.
6869
<14> `(userInfoEndpoint)uri`: The UserInfo Endpoint URI used to access the claims and attributes of the authenticated end-user.
6970
<15> `(userInfoEndpoint)authenticationMethod`: The authentication method used when sending the access token to the UserInfo Endpoint.
7071
The supported values are *header*, *form*, and *query*.
71-
<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.
72+
<16> `userNameAttributeName`: The name of the attribute returned in the UserInfo Response that references the Name or Identifier of the end-user. Deprecated - use usernameExpression instead.
73+
<17> `usernameExpression`: A SpEL expression used to extract the username from the UserInfo Response. Supports accessing nested attributes (e.g., "data.username") and complex expressions (e.g., "preferred_username ?: email").
74+
<18> [[oauth2Client-client-registration-requireProofKey]]`requireProofKey`: If `true` or if `authorizationGrantType` is `none`, then PKCE will be enabled by default.
7375

7476
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].
7577

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ http.csrf((csrf) -> csrf.spa());
3636
* Removed `ApacheDsContainer` and related Apache DS support in favor of UnboundID
3737

3838
== OAuth 2.0
39-
39+
* OAuth2 Client now supports SpEL expressions for extracting usernames from nested UserInfo responses, eliminating the need for custom `OAuth2UserService` implementations in many cases. This is particularly useful for APIs like Twitter API v2 that return nested user data.
4040
* Removed support for password grant
4141
* Added OAuth2 Support for xref:features/integrations/rest/http-interface.adoc[HTTP Interface Integration]
4242
* Added support for custom `JwkSource` in `NimbusJwtDecoder`, allowing usage of Nimbus's `JwkSourceBuilder` API

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

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
*
4747
* @author Joe Grandja
4848
* @author Michael Sosa
49+
* @author Yoobin Yoon
4950
* @since 5.0
5051
* @see <a target="_blank" href="https://tools.ietf.org/html/rfc6749#section-2">Section 2
5152
* Client Registration</a>
@@ -298,8 +299,11 @@ public class UserInfoEndpoint implements Serializable {
298299

299300
private AuthenticationMethod authenticationMethod = AuthenticationMethod.HEADER;
300301

302+
@Deprecated
301303
private String userNameAttributeName;
302304

305+
private String usernameExpression;
306+
303307
UserInfoEndpoint() {
304308
}
305309

@@ -321,15 +325,23 @@ public AuthenticationMethod getAuthenticationMethod() {
321325
}
322326

323327
/**
324-
* Returns the attribute name used to access the user's name from the user
325-
* info response.
326-
* @return the attribute name used to access the user's name from the user
327-
* info response
328+
* @deprecated Use {@link #getUsernameExpression()} instead
328329
*/
330+
@Deprecated
329331
public String getUserNameAttributeName() {
330332
return this.userNameAttributeName;
331333
}
332334

335+
/**
336+
* Returns the SpEL expression used to extract the username from user info
337+
* response.
338+
* @return the SpEL expression for username extraction
339+
* @since 7.0
340+
*/
341+
public String getUsernameExpression() {
342+
return this.usernameExpression;
343+
}
344+
333345
}
334346

335347
}
@@ -369,8 +381,11 @@ public static final class Builder implements Serializable {
369381

370382
private AuthenticationMethod userInfoAuthenticationMethod = AuthenticationMethod.HEADER;
371383

384+
@Deprecated
372385
private String userNameAttributeName;
373386

387+
private String usernameExpression;
388+
374389
private String jwkSetUri;
375390

376391
private String issuerUri;
@@ -398,6 +413,7 @@ private Builder(ClientRegistration clientRegistration) {
398413
this.userInfoUri = clientRegistration.providerDetails.userInfoEndpoint.uri;
399414
this.userInfoAuthenticationMethod = clientRegistration.providerDetails.userInfoEndpoint.authenticationMethod;
400415
this.userNameAttributeName = clientRegistration.providerDetails.userInfoEndpoint.userNameAttributeName;
416+
this.usernameExpression = clientRegistration.providerDetails.userInfoEndpoint.usernameExpression;
401417
this.jwkSetUri = clientRegistration.providerDetails.jwkSetUri;
402418
this.issuerUri = clientRegistration.providerDetails.issuerUri;
403419
Map<String, Object> configurationMetadata = clientRegistration.providerDetails.configurationMetadata;
@@ -551,14 +567,43 @@ public Builder userInfoAuthenticationMethod(AuthenticationMethod userInfoAuthent
551567
}
552568

553569
/**
554-
* Sets the attribute name used to access the user's name from the user info
555-
* response.
556-
* @param userNameAttributeName the attribute name used to access the user's name
557-
* from the user info response
570+
* Sets the username attribute name. This method automatically converts the
571+
* attribute name to a SpEL expression for backward compatibility.
572+
*
573+
* <p>
574+
* This is a convenience method that internally calls
575+
* {@link #usernameExpression(String)} with the attribute name wrapped in bracket
576+
* notation.
577+
* @param userNameAttributeName the username attribute name
558578
* @return the {@link Builder}
559579
*/
560580
public Builder userNameAttributeName(String userNameAttributeName) {
561581
this.userNameAttributeName = userNameAttributeName;
582+
if (userNameAttributeName != null) {
583+
this.usernameExpression = "['" + userNameAttributeName + "']";
584+
}
585+
return this;
586+
}
587+
588+
/**
589+
* Sets the SpEL expression used to extract the username from user info response.
590+
*
591+
* <p>
592+
* Examples:
593+
* <ul>
594+
* <li>Simple attribute: {@code "['username']"} or {@code "username"}</li>
595+
* <li>Nested attribute: {@code "data.username"}</li>
596+
* <li>Complex expression: {@code "user_info?.name ?: 'anonymous'"}</li>
597+
* <li>Array access: {@code "users[0].name"}</li>
598+
* <li>Conditional:
599+
* {@code "preferred_username != null ? preferred_username : email"}</li>
600+
* </ul>
601+
* @param usernameExpression the SpEL expression for username extraction
602+
* @return the {@link Builder}
603+
* @since 7.0
604+
*/
605+
public Builder usernameExpression(String usernameExpression) {
606+
this.usernameExpression = usernameExpression;
562607
return this;
563608
}
564609

@@ -668,7 +713,10 @@ private ProviderDetails createProviderDetails(ClientRegistration clientRegistrat
668713
providerDetails.tokenUri = this.tokenUri;
669714
providerDetails.userInfoEndpoint.uri = this.userInfoUri;
670715
providerDetails.userInfoEndpoint.authenticationMethod = this.userInfoAuthenticationMethod;
716+
717+
providerDetails.userInfoEndpoint.usernameExpression = this.usernameExpression;
671718
providerDetails.userInfoEndpoint.userNameAttributeName = this.userNameAttributeName;
719+
672720
providerDetails.jwkSetUri = this.jwkSetUri;
673721
providerDetails.issuerUri = this.issuerUri;
674722
providerDetails.configurationMetadata = Collections.unmodifiableMap(this.configurationMetadata);

oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/userinfo/DefaultOAuth2UserService.java

Lines changed: 73 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,12 @@
2020
import java.util.LinkedHashSet;
2121
import java.util.Map;
2222

23+
import org.springframework.context.expression.MapAccessor;
2324
import org.springframework.core.ParameterizedTypeReference;
2425
import org.springframework.core.convert.converter.Converter;
26+
import org.springframework.expression.ExpressionParser;
27+
import org.springframework.expression.spel.standard.SpelExpressionParser;
28+
import org.springframework.expression.spel.support.SimpleEvaluationContext;
2529
import org.springframework.http.RequestEntity;
2630
import org.springframework.http.ResponseEntity;
2731
import org.springframework.security.core.GrantedAuthority;
@@ -47,16 +51,17 @@
4751
* An implementation of an {@link OAuth2UserService} that supports standard OAuth 2.0
4852
* Provider's.
4953
* <p>
50-
* For standard OAuth 2.0 Provider's, the attribute name used to access the user's name
51-
* from the UserInfo response is required and therefore must be available via
52-
* {@link ClientRegistration.ProviderDetails.UserInfoEndpoint#getUserNameAttributeName()
53-
* UserInfoEndpoint.getUserNameAttributeName()}.
54+
* For standard OAuth 2.0 Provider's, the username expression used to extract the user's
55+
* name from the UserInfo response is required and therefore must be available via
56+
* {@link ClientRegistration.ProviderDetails.UserInfoEndpoint#getUsernameExpression()
57+
* UserInfoEndpoint.getUsernameExpression()}.
5458
* <p>
5559
* <b>NOTE:</b> Attribute names are <b>not</b> standardized between providers and
5660
* therefore will vary. Please consult the provider's API documentation for the set of
5761
* supported user attribute names.
5862
*
5963
* @author Joe Grandja
64+
* @author Yoobin Yoon
6065
* @since 5.0
6166
* @see OAuth2UserService
6267
* @see OAuth2UserRequest
@@ -71,6 +76,10 @@ public class DefaultOAuth2UserService implements OAuth2UserService<OAuth2UserReq
7176

7277
private static final String INVALID_USER_INFO_RESPONSE_ERROR_CODE = "invalid_user_info_response";
7378

79+
private static final String INVALID_USERNAME_EXPRESSION_ERROR_CODE = "invalid_username_expression";
80+
81+
private static final ExpressionParser expressionParser = new SpelExpressionParser();
82+
7483
private static final ParameterizedTypeReference<Map<String, Object>> PARAMETERIZED_RESPONSE_TYPE = new ParameterizedTypeReference<>() {
7584
};
7685

@@ -90,13 +99,67 @@ public DefaultOAuth2UserService() {
9099
@Override
91100
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
92101
Assert.notNull(userRequest, "userRequest cannot be null");
93-
String userNameAttributeName = getUserNameAttributeName(userRequest);
102+
String usernameExpression = getUsernameExpression(userRequest);
94103
RequestEntity<?> request = this.requestEntityConverter.convert(userRequest);
95104
ResponseEntity<Map<String, Object>> response = getResponse(userRequest, request);
96105
OAuth2AccessToken token = userRequest.getAccessToken();
97106
Map<String, Object> attributes = this.attributesConverter.convert(userRequest).convert(response.getBody());
98-
Collection<GrantedAuthority> authorities = getAuthorities(token, attributes, userNameAttributeName);
99-
return new DefaultOAuth2User(authorities, attributes, userNameAttributeName);
107+
108+
String evaluatedUsername = evaluateUsername(attributes, usernameExpression);
109+
110+
Collection<GrantedAuthority> authorities = getAuthorities(token, attributes, evaluatedUsername);
111+
112+
return DefaultOAuth2User.withUsername(evaluatedUsername)
113+
.authorities(authorities)
114+
.attributes(attributes)
115+
.build();
116+
}
117+
118+
private String getUsernameExpression(OAuth2UserRequest userRequest) {
119+
if (!StringUtils
120+
.hasText(userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri())) {
121+
OAuth2Error oauth2Error = new OAuth2Error(MISSING_USER_INFO_URI_ERROR_CODE,
122+
"Missing required UserInfo Uri in UserInfoEndpoint for Client Registration: "
123+
+ userRequest.getClientRegistration().getRegistrationId(),
124+
null);
125+
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
126+
}
127+
String usernameExpression = userRequest.getClientRegistration()
128+
.getProviderDetails()
129+
.getUserInfoEndpoint()
130+
.getUsernameExpression();
131+
if (!StringUtils.hasText(usernameExpression)) {
132+
OAuth2Error oauth2Error = new OAuth2Error(MISSING_USER_NAME_ATTRIBUTE_ERROR_CODE,
133+
"Missing required \"user name\" attribute name in UserInfoEndpoint for Client Registration: "
134+
+ userRequest.getClientRegistration().getRegistrationId(),
135+
null);
136+
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
137+
}
138+
return usernameExpression;
139+
}
140+
141+
private String evaluateUsername(Map<String, Object> attributes, String usernameExpression) {
142+
Object value = null;
143+
144+
try {
145+
SimpleEvaluationContext context = SimpleEvaluationContext.forPropertyAccessors(new MapAccessor())
146+
.withRootObject(attributes)
147+
.build();
148+
value = expressionParser.parseExpression(usernameExpression).getValue(context);
149+
}
150+
catch (Exception ex) {
151+
OAuth2Error oauth2Error = new OAuth2Error(INVALID_USERNAME_EXPRESSION_ERROR_CODE,
152+
"Invalid username expression or SPEL expression: " + usernameExpression, null);
153+
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString(), ex);
154+
}
155+
156+
if (value == null) {
157+
OAuth2Error oauth2Error = new OAuth2Error(INVALID_USER_INFO_RESPONSE_ERROR_CODE,
158+
"An error occurred while attempting to retrieve the UserInfo Resource: username cannot be null",
159+
null);
160+
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
161+
}
162+
return value.toString();
100163
}
101164

102165
/**
@@ -164,33 +227,11 @@ private ResponseEntity<Map<String, Object>> getResponse(OAuth2UserRequest userRe
164227
}
165228
}
166229

167-
private String getUserNameAttributeName(OAuth2UserRequest userRequest) {
168-
if (!StringUtils
169-
.hasText(userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri())) {
170-
OAuth2Error oauth2Error = new OAuth2Error(MISSING_USER_INFO_URI_ERROR_CODE,
171-
"Missing required UserInfo Uri in UserInfoEndpoint for Client Registration: "
172-
+ userRequest.getClientRegistration().getRegistrationId(),
173-
null);
174-
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
175-
}
176-
String userNameAttributeName = userRequest.getClientRegistration()
177-
.getProviderDetails()
178-
.getUserInfoEndpoint()
179-
.getUserNameAttributeName();
180-
if (!StringUtils.hasText(userNameAttributeName)) {
181-
OAuth2Error oauth2Error = new OAuth2Error(MISSING_USER_NAME_ATTRIBUTE_ERROR_CODE,
182-
"Missing required \"user name\" attribute name in UserInfoEndpoint for Client Registration: "
183-
+ userRequest.getClientRegistration().getRegistrationId(),
184-
null);
185-
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
186-
}
187-
return userNameAttributeName;
188-
}
189-
190230
private Collection<GrantedAuthority> getAuthorities(OAuth2AccessToken token, Map<String, Object> attributes,
191-
String userNameAttributeName) {
231+
String username) {
192232
Collection<GrantedAuthority> authorities = new LinkedHashSet<>();
193-
authorities.add(new OAuth2UserAuthority(attributes, userNameAttributeName));
233+
authorities.add(OAuth2UserAuthority.withUsername(username).attributes(attributes).build());
234+
194235
for (String authority : token.getScopes()) {
195236
authorities.add(new SimpleGrantedAuthority("SCOPE_" + authority));
196237
}

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

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -248,11 +248,12 @@ private static String asJson(OAuth2UserAuthority oauth2UserAuthority) {
248248
return "{\n" +
249249
" \"@class\": \"org.springframework.security.oauth2.core.user.OAuth2UserAuthority\",\n" +
250250
" \"authority\": \"" + oauth2UserAuthority.getAuthority() + "\",\n" +
251-
" \"userNameAttributeName\": \"username\",\n" +
252251
" \"attributes\": {\n" +
253252
" \"@class\": \"java.util.Collections$UnmodifiableMap\",\n" +
254253
" \"username\": \"user\"\n" +
255-
" }\n" +
254+
" },\n" +
255+
" \"userNameAttributeName\": \"username\",\n" +
256+
" \"username\": \"user\"\n" +
256257
" }";
257258
// @formatter:on
258259
}
@@ -262,9 +263,10 @@ private static String asJson(OidcUserAuthority oidcUserAuthority) {
262263
return "{\n" +
263264
" \"@class\": \"org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority\",\n" +
264265
" \"authority\": \"" + oidcUserAuthority.getAuthority() + "\",\n" +
265-
" \"userNameAttributeName\": \"" + oidcUserAuthority.getUserNameAttributeName() + "\",\n" +
266266
" \"idToken\": " + asJson(oidcUserAuthority.getIdToken()) + ",\n" +
267-
" \"userInfo\": " + asJson(oidcUserAuthority.getUserInfo()) + "\n" +
267+
" \"userInfo\": " + asJson(oidcUserAuthority.getUserInfo()) + ",\n" +
268+
" \"userNameAttributeName\": \"" + oidcUserAuthority.getUserNameAttributeName() + "\",\n" +
269+
" \"username\": \"subject\"\n" +
268270
" }";
269271
// @formatter:on
270272
}

0 commit comments

Comments
 (0)