Skip to content

Commit adb119d

Browse files
committed
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 - Support nested property access (e.g., "data.username") Fixes gh-16390 Signed-off-by: yybmion <yunyubin54@gmail.com>
1 parent 0dc9709 commit adb119d

File tree

12 files changed

+740
-161
lines changed

12 files changed

+740
-161
lines changed

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
@@ -47,6 +47,7 @@
4747
*
4848
* @author Joe Grandja
4949
* @author Michael Sosa
50+
* @author Yoobin Yoon
5051
* @since 5.0
5152
* @see <a target="_blank" href="https://tools.ietf.org/html/rfc6749#section-2">Section 2
5253
* Client Registration</a>
@@ -299,8 +300,11 @@ public class UserInfoEndpoint implements Serializable {
299300

300301
private AuthenticationMethod authenticationMethod = AuthenticationMethod.HEADER;
301302

303+
@Deprecated
302304
private String userNameAttributeName;
303305

306+
private String usernameExpression;
307+
304308
UserInfoEndpoint() {
305309
}
306310

@@ -322,15 +326,23 @@ public AuthenticationMethod getAuthenticationMethod() {
322326
}
323327

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

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

336348
}
@@ -370,8 +382,11 @@ public static final class Builder implements Serializable {
370382

371383
private AuthenticationMethod userInfoAuthenticationMethod = AuthenticationMethod.HEADER;
372384

385+
@Deprecated
373386
private String userNameAttributeName;
374387

388+
private String usernameExpression;
389+
375390
private String jwkSetUri;
376391

377392
private String issuerUri;
@@ -399,6 +414,7 @@ private Builder(ClientRegistration clientRegistration) {
399414
this.userInfoUri = clientRegistration.providerDetails.userInfoEndpoint.uri;
400415
this.userInfoAuthenticationMethod = clientRegistration.providerDetails.userInfoEndpoint.authenticationMethod;
401416
this.userNameAttributeName = clientRegistration.providerDetails.userInfoEndpoint.userNameAttributeName;
417+
this.usernameExpression = clientRegistration.providerDetails.userInfoEndpoint.usernameExpression;
402418
this.jwkSetUri = clientRegistration.providerDetails.jwkSetUri;
403419
this.issuerUri = clientRegistration.providerDetails.issuerUri;
404420
Map<String, Object> configurationMetadata = clientRegistration.providerDetails.configurationMetadata;
@@ -552,14 +568,43 @@ public Builder userInfoAuthenticationMethod(AuthenticationMethod userInfoAuthent
552568
}
553569

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

@@ -672,7 +717,10 @@ private ProviderDetails createProviderDetails(ClientRegistration clientRegistrat
672717
providerDetails.tokenUri = this.tokenUri;
673718
providerDetails.userInfoEndpoint.uri = this.userInfoUri;
674719
providerDetails.userInfoEndpoint.authenticationMethod = this.userInfoAuthenticationMethod;
720+
721+
providerDetails.userInfoEndpoint.usernameExpression = this.usernameExpression;
675722
providerDetails.userInfoEndpoint.userNameAttributeName = this.userNameAttributeName;
723+
676724
providerDetails.jwkSetUri = this.jwkSetUri;
677725
providerDetails.issuerUri = this.issuerUri;
678726
providerDetails.configurationMetadata = Collections.unmodifiableMap(this.configurationMetadata);

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

Lines changed: 74 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2024 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.
@@ -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
}

0 commit comments

Comments
 (0)