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 spring-projectsgh-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 <yunyubin54@gmail.com>
This commit is contained in:
yybmion 2025-07-02 14:19:19 +09:00
parent 0dc9709018
commit 9c60779b29
20 changed files with 1046 additions and 204 deletions

View File

@ -36,12 +36,13 @@ public final class ClientRegistration {
private String uri; <14>
private AuthenticationMethod authenticationMethod; <15>
private String userNameAttributeName; <16>
private String usernameExpression; <17>
}
}
public static final class ClientSettings {
private boolean requireProofKey; // <17>
private boolean requireProofKey; // <18>
}
}
----
@ -67,8 +68,9 @@ The name may be used in certain scenarios, such as when displaying the name of t
<14> `(userInfoEndpoint)uri`: The UserInfo Endpoint URI used to access the claims/attributes of the authenticated end-user.
<15> `(userInfoEndpoint)authenticationMethod`: The authentication method used when sending the access token to the UserInfo Endpoint.
The supported values are *header*, *form* and *query*.
<16> `userNameAttributeName`: The name of the attribute returned in the UserInfo Response that references the Name or Identifier of the end-user.
<17> [[oauth2Client-client-registration-requireProofKey]]`requireProofKey`: If `true` or if `authorizationGrantType` is `none`, then PKCE will be enabled by default.
<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.
<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"`).
<18> [[oauth2Client-client-registration-requireProofKey]]`requireProofKey`: If `true` or if `authorizationGrantType` is `none`, then PKCE will be enabled by default.
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].

View File

@ -31,18 +31,19 @@ public final class ClientRegistration {
private UserInfoEndpoint userInfoEndpoint;
private String jwkSetUri; <11>
private String issuerUri; <12>
private Map<String, Object> configurationMetadata; <13>
private Map<String, Object> configurationMetadata; <13>
public class UserInfoEndpoint {
private String uri; <14>
private AuthenticationMethod authenticationMethod; <15>
private AuthenticationMethod authenticationMethod; <15>
private String userNameAttributeName; <16>
private String usernameExpression; <17>
}
}
public static final class ClientSettings {
private boolean requireProofKey; // <17>
private boolean requireProofKey; // <18>
}
}
----
@ -68,8 +69,9 @@ This information is available only if the Spring Boot property `spring.security.
<14> `(userInfoEndpoint)uri`: The UserInfo Endpoint URI used to access the claims and attributes of the authenticated end-user.
<15> `(userInfoEndpoint)authenticationMethod`: The authentication method used when sending the access token to the UserInfo Endpoint.
The supported values are *header*, *form*, and *query*.
<16> `userNameAttributeName`: The name of the attribute returned in the UserInfo Response that references the Name or Identifier of the end-user.
<17> [[oauth2Client-client-registration-requireProofKey]]`requireProofKey`: If `true` or if `authorizationGrantType` is `none`, then PKCE will be enabled by default.
<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.
<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").
<18> [[oauth2Client-client-registration-requireProofKey]]`requireProofKey`: If `true` or if `authorizationGrantType` is `none`, then PKCE will be enabled by default.
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].

View File

@ -1,30 +1,17 @@
[[new]]
= What's New in Spring Security 6.5
= What's New in Spring Security 7.0
Spring Security 6.5 provides a number of new features.
Spring Security 7.0 provides a number of new features.
Below are the highlights of the release, or you can view https://github.com/spring-projects/spring-security/releases[the release notes] for a detailed listing of each feature and bug fix.
== New Features
== Web
* Support for automatic context-propagation with Micrometer (https://github.com/spring-projects/spring-security/issues/16665[gh-16665])
* Added javadoc:org.springframework.security.web.authentication.preauth.x509.SubjectX500PrincipalExtractor[]
* Added OAuth2 Support for xref:features/integrations/rest/http-interface.adoc[HTTP Interface Integration]
== Breaking Changes
== OAuth 2.0
=== Observability
=== Username Expression Support for Nested Attributes - https://github.com/spring-projects/spring-security/pull/16390[gh-16390]
The `security.security.reached.filter.section` key name was corrected to `spring.security.reached.filter.section`.
Note that this may affect reports that operate on this key name.
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.
== OAuth
* 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
== WebAuthn
* https://github.com/spring-projects/spring-security/pull/16282[gh-16282] - xref:servlet/authentication/passkeys.adoc#passkeys-configuration-persistence[JDBC Persistence] for WebAuthn/Passkeys
* https://github.com/spring-projects/spring-security/pull/16397[gh-16397] - Added the ability to configure a custom `HttpMessageConverter` for Passkeys using the optional xref:servlet/authentication/passkeys.adoc#passkeys-configuration[`messageConverter` property] on the `webAuthn` DSL.
* https://github.com/spring-projects/spring-security/pull/16396[gh-16396] - Added the ability to configure a custom xref:servlet/authentication/passkeys.adoc#passkeys-configuration-pkccor[`PublicKeyCredentialCreationOptionsRepository`]
== One-Time Token Login
* https://github.com/spring-projects/spring-security/issues/16291[gh-16291] - `oneTimeTokenLogin()` now supports customizing GenerateOneTimeTokenRequest xref:servlet/authentication/onetimetoken.adoc#customize-generate-token-request[via GenerateOneTimeTokenRequestResolver]

View File

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

View File

@ -47,6 +47,7 @@ import org.springframework.util.StringUtils;
*
* @author Joe Grandja
* @author Michael Sosa
* @author Yoobin Yoon
* @since 5.0
* @see <a target="_blank" href="https://tools.ietf.org/html/rfc6749#section-2">Section 2
* Client Registration</a>
@ -299,8 +300,11 @@ public final class ClientRegistration implements Serializable {
private AuthenticationMethod authenticationMethod = AuthenticationMethod.HEADER;
@Deprecated
private String userNameAttributeName;
private String usernameExpression;
UserInfoEndpoint() {
}
@ -322,15 +326,23 @@ public final class ClientRegistration implements Serializable {
}
/**
* Returns the attribute name used to access the user's name from the user
* info response.
* @return the attribute name used to access the user's name from the user
* info response
* @deprecated Use {@link #getUsernameExpression()} instead
*/
@Deprecated
public String getUserNameAttributeName() {
return this.userNameAttributeName;
}
/**
* Returns the SpEL expression used to extract the username from user info
* response.
* @return the SpEL expression for username extraction
* @since 7.0
*/
public String getUsernameExpression() {
return this.usernameExpression;
}
}
}
@ -370,8 +382,11 @@ public final class ClientRegistration implements Serializable {
private AuthenticationMethod userInfoAuthenticationMethod = AuthenticationMethod.HEADER;
@Deprecated
private String userNameAttributeName;
private String usernameExpression;
private String jwkSetUri;
private String issuerUri;
@ -399,6 +414,7 @@ public final class ClientRegistration implements Serializable {
this.userInfoUri = clientRegistration.providerDetails.userInfoEndpoint.uri;
this.userInfoAuthenticationMethod = clientRegistration.providerDetails.userInfoEndpoint.authenticationMethod;
this.userNameAttributeName = clientRegistration.providerDetails.userInfoEndpoint.userNameAttributeName;
this.usernameExpression = clientRegistration.providerDetails.userInfoEndpoint.usernameExpression;
this.jwkSetUri = clientRegistration.providerDetails.jwkSetUri;
this.issuerUri = clientRegistration.providerDetails.issuerUri;
Map<String, Object> configurationMetadata = clientRegistration.providerDetails.configurationMetadata;
@ -552,14 +568,43 @@ public final class ClientRegistration implements Serializable {
}
/**
* Sets the attribute name used to access the user's name from the user info
* response.
* @param userNameAttributeName the attribute name used to access the user's name
* from the user info response
* Sets the username attribute name. This method automatically converts the
* attribute name to a SpEL expression for backward compatibility.
*
* <p>
* This is a convenience method that internally calls
* {@link #usernameExpression(String)} with the attribute name wrapped in bracket
* notation.
* @param userNameAttributeName the username attribute name
* @return the {@link Builder}
*/
public Builder userNameAttributeName(String userNameAttributeName) {
this.userNameAttributeName = userNameAttributeName;
if (userNameAttributeName != null) {
this.usernameExpression = "['" + userNameAttributeName + "']";
}
return this;
}
/**
* Sets the SpEL expression used to extract the username from user info response.
*
* <p>
* Examples:
* <ul>
* <li>Simple attribute: {@code "['username']"} or {@code "username"}</li>
* <li>Nested attribute: {@code "data.username"}</li>
* <li>Complex expression: {@code "user_info?.name ?: 'anonymous'"}</li>
* <li>Array access: {@code "users[0].name"}</li>
* <li>Conditional:
* {@code "preferred_username != null ? preferred_username : email"}</li>
* </ul>
* @param usernameExpression the SpEL expression for username extraction
* @return the {@link Builder}
* @since 7.0
*/
public Builder usernameExpression(String usernameExpression) {
this.usernameExpression = usernameExpression;
return this;
}
@ -672,7 +717,10 @@ public final class ClientRegistration implements Serializable {
providerDetails.tokenUri = this.tokenUri;
providerDetails.userInfoEndpoint.uri = this.userInfoUri;
providerDetails.userInfoEndpoint.authenticationMethod = this.userInfoAuthenticationMethod;
providerDetails.userInfoEndpoint.usernameExpression = this.usernameExpression;
providerDetails.userInfoEndpoint.userNameAttributeName = this.userNameAttributeName;
providerDetails.jwkSetUri = this.jwkSetUri;
providerDetails.issuerUri = this.issuerUri;
providerDetails.configurationMetadata = Collections.unmodifiableMap(this.configurationMetadata);

View File

@ -1,5 +1,5 @@
/*
* Copyright 2002-2024 the original author or authors.
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -20,8 +20,12 @@ import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.Map;
import org.springframework.context.expression.MapAccessor;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.convert.converter.Converter;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.SimpleEvaluationContext;
import org.springframework.http.RequestEntity;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.GrantedAuthority;
@ -47,16 +51,17 @@ import org.springframework.web.client.UnknownContentTypeException;
* An implementation of an {@link OAuth2UserService} that supports standard OAuth 2.0
* Provider's.
* <p>
* For standard OAuth 2.0 Provider's, the attribute name used to access the user's name
* from the UserInfo response is required and therefore must be available via
* {@link ClientRegistration.ProviderDetails.UserInfoEndpoint#getUserNameAttributeName()
* UserInfoEndpoint.getUserNameAttributeName()}.
* For standard OAuth 2.0 Provider's, the username expression used to extract the user's
* name from the UserInfo response is required and therefore must be available via
* {@link ClientRegistration.ProviderDetails.UserInfoEndpoint#getUsernameExpression()
* UserInfoEndpoint.getUsernameExpression()}.
* <p>
* <b>NOTE:</b> Attribute names are <b>not</b> standardized between providers and
* therefore will vary. Please consult the provider's API documentation for the set of
* supported user attribute names.
*
* @author Joe Grandja
* @author Yoobin Yoon
* @since 5.0
* @see OAuth2UserService
* @see OAuth2UserRequest
@ -71,6 +76,10 @@ public class DefaultOAuth2UserService implements OAuth2UserService<OAuth2UserReq
private static final String INVALID_USER_INFO_RESPONSE_ERROR_CODE = "invalid_user_info_response";
private static final String INVALID_USERNAME_EXPRESSION_ERROR_CODE = "invalid_username_expression";
private static final ExpressionParser expressionParser = new SpelExpressionParser();
private static final ParameterizedTypeReference<Map<String, Object>> PARAMETERIZED_RESPONSE_TYPE = new ParameterizedTypeReference<>() {
};
@ -90,13 +99,67 @@ public class DefaultOAuth2UserService implements OAuth2UserService<OAuth2UserReq
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
Assert.notNull(userRequest, "userRequest cannot be null");
String userNameAttributeName = getUserNameAttributeName(userRequest);
String usernameExpression = getUsernameExpression(userRequest);
RequestEntity<?> request = this.requestEntityConverter.convert(userRequest);
ResponseEntity<Map<String, Object>> response = getResponse(userRequest, request);
OAuth2AccessToken token = userRequest.getAccessToken();
Map<String, Object> attributes = this.attributesConverter.convert(userRequest).convert(response.getBody());
Collection<GrantedAuthority> authorities = getAuthorities(token, attributes, userNameAttributeName);
return new DefaultOAuth2User(authorities, attributes, userNameAttributeName);
String evaluatedUsername = evaluateUsername(attributes, usernameExpression);
Collection<GrantedAuthority> authorities = getAuthorities(token, attributes, evaluatedUsername);
return DefaultOAuth2User.withUsername(evaluatedUsername)
.authorities(authorities)
.attributes(attributes)
.build();
}
private String getUsernameExpression(OAuth2UserRequest userRequest) {
if (!StringUtils
.hasText(userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri())) {
OAuth2Error oauth2Error = new OAuth2Error(MISSING_USER_INFO_URI_ERROR_CODE,
"Missing required UserInfo Uri in UserInfoEndpoint for Client Registration: "
+ userRequest.getClientRegistration().getRegistrationId(),
null);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
String usernameExpression = userRequest.getClientRegistration()
.getProviderDetails()
.getUserInfoEndpoint()
.getUsernameExpression();
if (!StringUtils.hasText(usernameExpression)) {
OAuth2Error oauth2Error = new OAuth2Error(MISSING_USER_NAME_ATTRIBUTE_ERROR_CODE,
"Missing required \"user name\" attribute name in UserInfoEndpoint for Client Registration: "
+ userRequest.getClientRegistration().getRegistrationId(),
null);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
return usernameExpression;
}
private String evaluateUsername(Map<String, Object> attributes, String usernameExpression) {
Object value = null;
try {
SimpleEvaluationContext context = SimpleEvaluationContext.forPropertyAccessors(new MapAccessor())
.withRootObject(attributes)
.build();
value = expressionParser.parseExpression(usernameExpression).getValue(context);
}
catch (Exception ex) {
OAuth2Error oauth2Error = new OAuth2Error(INVALID_USERNAME_EXPRESSION_ERROR_CODE,
"Invalid username expression or SPEL expression: " + usernameExpression, null);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString(), ex);
}
if (value == null) {
OAuth2Error oauth2Error = new OAuth2Error(INVALID_USER_INFO_RESPONSE_ERROR_CODE,
"An error occurred while attempting to retrieve the UserInfo Resource: username cannot be null",
null);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
return value.toString();
}
/**
@ -164,33 +227,11 @@ public class DefaultOAuth2UserService implements OAuth2UserService<OAuth2UserReq
}
}
private String getUserNameAttributeName(OAuth2UserRequest userRequest) {
if (!StringUtils
.hasText(userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri())) {
OAuth2Error oauth2Error = new OAuth2Error(MISSING_USER_INFO_URI_ERROR_CODE,
"Missing required UserInfo Uri in UserInfoEndpoint for Client Registration: "
+ userRequest.getClientRegistration().getRegistrationId(),
null);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
String userNameAttributeName = userRequest.getClientRegistration()
.getProviderDetails()
.getUserInfoEndpoint()
.getUserNameAttributeName();
if (!StringUtils.hasText(userNameAttributeName)) {
OAuth2Error oauth2Error = new OAuth2Error(MISSING_USER_NAME_ATTRIBUTE_ERROR_CODE,
"Missing required \"user name\" attribute name in UserInfoEndpoint for Client Registration: "
+ userRequest.getClientRegistration().getRegistrationId(),
null);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
return userNameAttributeName;
}
private Collection<GrantedAuthority> getAuthorities(OAuth2AccessToken token, Map<String, Object> attributes,
String userNameAttributeName) {
String username) {
Collection<GrantedAuthority> authorities = new LinkedHashSet<>();
authorities.add(new OAuth2UserAuthority(attributes, userNameAttributeName));
authorities.add(OAuth2UserAuthority.withUsername(username).attributes(attributes).build());
for (String authority : token.getScopes()) {
authorities.add(new SimpleGrantedAuthority("SCOPE_" + authority));
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2002-2024 the original author or authors.
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -25,8 +25,12 @@ import com.nimbusds.openid.connect.sdk.UserInfoErrorResponse;
import net.minidev.json.JSONObject;
import reactor.core.publisher.Mono;
import org.springframework.context.expression.MapAccessor;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.convert.converter.Converter;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.SimpleEvaluationContext;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
@ -49,16 +53,17 @@ import org.springframework.web.reactive.function.client.WebClient;
* An implementation of an {@link ReactiveOAuth2UserService} that supports standard OAuth
* 2.0 Provider's.
* <p>
* For standard OAuth 2.0 Provider's, the attribute name used to access the user's name
* from the UserInfo response is required and therefore must be available via
* {@link org.springframework.security.oauth2.client.registration.ClientRegistration.ProviderDetails.UserInfoEndpoint#getUserNameAttributeName()
* UserInfoEndpoint.getUserNameAttributeName()}.
* For standard OAuth 2.0 Provider's, the username expression used to extract the user's
* name from the UserInfo response is required and therefore must be available via
* {@link org.springframework.security.oauth2.client.registration.ClientRegistration.ProviderDetails.UserInfoEndpoint#getUsernameExpression()
* UserInfoEndpoint.getUsernameExpression()}.
* <p>
* <b>NOTE:</b> Attribute names are <b>not</b> standardized between providers and
* therefore will vary. Please consult the provider's API documentation for the set of
* supported user attribute names.
*
* @author Rob Winch
* @author Yoobin Yoon
* @since 5.1
* @see ReactiveOAuth2UserService
* @see OAuth2UserRequest
@ -73,6 +78,10 @@ public class DefaultReactiveOAuth2UserService implements ReactiveOAuth2UserServi
private static final String MISSING_USER_NAME_ATTRIBUTE_ERROR_CODE = "missing_user_name_attribute";
private static final String INVALID_USERNAME_EXPRESSION_ERROR_CODE = "invalid_username_expression";
private static final ExpressionParser expressionParser = new SpelExpressionParser();
private static final ParameterizedTypeReference<Map<String, Object>> STRING_OBJECT_MAP = new ParameterizedTypeReference<>() {
};
@ -99,17 +108,7 @@ public class DefaultReactiveOAuth2UserService implements ReactiveOAuth2UserServi
null);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
String userNameAttributeName = userRequest.getClientRegistration()
.getProviderDetails()
.getUserInfoEndpoint()
.getUserNameAttributeName();
if (!StringUtils.hasText(userNameAttributeName)) {
OAuth2Error oauth2Error = new OAuth2Error(MISSING_USER_NAME_ATTRIBUTE_ERROR_CODE,
"Missing required \"user name\" attribute name in UserInfoEndpoint for Client Registration: "
+ userRequest.getClientRegistration().getRegistrationId(),
null);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
String usernameExpression = getUsernameExpression(userRequest);
AuthenticationMethod authenticationMethod = userRequest.getClientRegistration()
.getProviderDetails()
.getUserInfoEndpoint()
@ -130,16 +129,21 @@ public class DefaultReactiveOAuth2UserService implements ReactiveOAuth2UserServi
.bodyToMono(DefaultReactiveOAuth2UserService.STRING_OBJECT_MAP)
.mapNotNull((attributes) -> this.attributesConverter.convert(userRequest).convert(attributes));
return userAttributes.map((attrs) -> {
GrantedAuthority authority = new OAuth2UserAuthority(attrs, userNameAttributeName);
Set<GrantedAuthority> authorities = new HashSet<>();
authorities.add(authority);
OAuth2AccessToken token = userRequest.getAccessToken();
for (String scope : token.getScopes()) {
authorities.add(new SimpleGrantedAuthority("SCOPE_" + scope));
}
String username = evaluateUsername(attrs, usernameExpression);
Set<GrantedAuthority> authorities = new HashSet<>();
authorities.add(OAuth2UserAuthority.withUsername(username)
.attributes(attrs)
.build());
OAuth2AccessToken token = userRequest.getAccessToken();
for (String scope : token.getScopes()) {
authorities.add(new SimpleGrantedAuthority("SCOPE_" + scope));
}
return new DefaultOAuth2User(authorities, attrs, userNameAttributeName);
})
return DefaultOAuth2User.withUsername(username)
.authorities(authorities)
.attributes(attrs)
.build();
})
.onErrorMap((ex) -> (ex instanceof UnsupportedMediaTypeException ||
ex.getCause() instanceof UnsupportedMediaTypeException), (ex) -> {
String contentType = (ex instanceof UnsupportedMediaTypeException) ?
@ -168,6 +172,47 @@ public class DefaultReactiveOAuth2UserService implements ReactiveOAuth2UserServi
// @formatter:on
}
private String getUsernameExpression(OAuth2UserRequest userRequest) {
String usernameExpression = userRequest.getClientRegistration()
.getProviderDetails()
.getUserInfoEndpoint()
.getUsernameExpression();
if (!StringUtils.hasText(usernameExpression)) {
OAuth2Error oauth2Error = new OAuth2Error(MISSING_USER_NAME_ATTRIBUTE_ERROR_CODE,
"Missing required \"user name\" attribute name in UserInfoEndpoint for Client Registration: "
+ userRequest.getClientRegistration().getRegistrationId(),
null);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
return usernameExpression;
}
private String evaluateUsername(Map<String, Object> attributes, String usernameExpression) {
Object value = null;
try {
SimpleEvaluationContext context = SimpleEvaluationContext.forPropertyAccessors(new MapAccessor())
.withRootObject(attributes)
.build();
value = expressionParser.parseExpression(usernameExpression).getValue(context);
}
catch (Exception ex) {
OAuth2Error oauth2Error = new OAuth2Error(INVALID_USERNAME_EXPRESSION_ERROR_CODE,
"Invalid username expression or SPEL expression: " + usernameExpression, null);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString(), ex);
}
if (value == null) {
OAuth2Error oauth2Error = new OAuth2Error(INVALID_USER_INFO_RESPONSE_ERROR_CODE,
"An error occurred while attempting to retrieve the UserInfo Resource: username cannot be null",
null);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
return value.toString();
}
private WebClient.RequestHeadersSpec<?> getRequestHeaderSpec(OAuth2UserRequest userRequest, String userInfoUri,
AuthenticationMethod authenticationMethod) {
if (AuthenticationMethod.FORM.equals(authenticationMethod)) {

View File

@ -248,11 +248,12 @@ public class OAuth2AuthenticationTokenMixinTests {
return "{\n" +
" \"@class\": \"org.springframework.security.oauth2.core.user.OAuth2UserAuthority\",\n" +
" \"authority\": \"" + oauth2UserAuthority.getAuthority() + "\",\n" +
" \"userNameAttributeName\": \"username\",\n" +
" \"attributes\": {\n" +
" \"@class\": \"java.util.Collections$UnmodifiableMap\",\n" +
" \"username\": \"user\"\n" +
" }\n" +
" },\n" +
" \"userNameAttributeName\": \"username\",\n" +
" \"username\": \"user\"\n" +
" }";
// @formatter:on
}
@ -262,9 +263,10 @@ public class OAuth2AuthenticationTokenMixinTests {
return "{\n" +
" \"@class\": \"org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority\",\n" +
" \"authority\": \"" + oidcUserAuthority.getAuthority() + "\",\n" +
" \"userNameAttributeName\": \"" + oidcUserAuthority.getUserNameAttributeName() + "\",\n" +
" \"idToken\": " + asJson(oidcUserAuthority.getIdToken()) + ",\n" +
" \"userInfo\": " + asJson(oidcUserAuthority.getUserInfo()) + "\n" +
" \"userInfo\": " + asJson(oidcUserAuthority.getUserInfo()) + ",\n" +
" \"userNameAttributeName\": \"" + oidcUserAuthority.getUserNameAttributeName() + "\",\n" +
" \"username\": \"subject\"\n" +
" }";
// @formatter:on
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2002-2021 the original author or authors.
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -145,6 +145,8 @@ public class OAuth2AuthorizedClientMixinTests {
.isEqualTo(expectedClientRegistration.getProviderDetails().getUserInfoEndpoint().getAuthenticationMethod());
assertThat(clientRegistration.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName()).isEqualTo(
expectedClientRegistration.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName());
assertThat(clientRegistration.getProviderDetails().getUserInfoEndpoint().getUsernameExpression())
.isEqualTo(expectedClientRegistration.getProviderDetails().getUserInfoEndpoint().getUsernameExpression());
assertThat(clientRegistration.getProviderDetails().getJwkSetUri())
.isEqualTo(expectedClientRegistration.getProviderDetails().getJwkSetUri());
assertThat(clientRegistration.getProviderDetails().getIssuerUri())
@ -306,6 +308,8 @@ public class OAuth2AuthorizedClientMixinTests {
.map((key) -> "\"" + key + "\": \"" + providerDetails.getConfigurationMetadata().get(key) + "\"")
.collect(Collectors.joining(","));
}
String usernameExpression = (userInfoEndpoint.getUsernameExpression() != null)
? "\"" + userInfoEndpoint.getUsernameExpression() + "\"" : null;
// @formatter:off
return "{\n" +
" \"@class\": \"org.springframework.security.oauth2.client.registration.ClientRegistration\",\n" +
@ -333,7 +337,8 @@ public class OAuth2AuthorizedClientMixinTests {
" \"authenticationMethod\": {\n" +
" \"value\": \"" + userInfoEndpoint.getAuthenticationMethod().getValue() + "\"\n" +
" },\n" +
" \"userNameAttributeName\": " + ((userInfoEndpoint.getUserNameAttributeName() != null) ? "\"" + userInfoEndpoint.getUserNameAttributeName() + "\"" : null) + "\n" +
" \"userNameAttributeName\": " + ((userInfoEndpoint.getUserNameAttributeName() != null) ? "\"" + userInfoEndpoint.getUserNameAttributeName() + "\"" : null) + ",\n" +
" \"usernameExpression\": " + usernameExpression + "\n" +
" },\n" +
" \"jwkSetUri\": " + ((providerDetails.getJwkSetUri() != null) ? "\"" + providerDetails.getJwkSetUri() + "\"" : null) + ",\n" +
" \"issuerUri\": " + ((providerDetails.getIssuerUri() != null) ? "\"" + providerDetails.getIssuerUri() + "\"" : null) + ",\n" +

View File

@ -1,5 +1,5 @@
/*
* Copyright 2002-2022 the original author or authors.
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -43,6 +43,7 @@ import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
* Tests for {@link ClientRegistration}.
*
* @author Joe Grandja
* @author Yoobin Yoon
*/
public class ClientRegistrationTests {
@ -716,6 +717,7 @@ public class ClientRegistrationTests {
.isEqualTo(updatedUserInfoEndpoint.getAuthenticationMethod());
assertThat(userInfoEndpoint.getUserNameAttributeName())
.isEqualTo(updatedUserInfoEndpoint.getUserNameAttributeName());
assertThat(userInfoEndpoint.getUsernameExpression()).isEqualTo(updatedUserInfoEndpoint.getUsernameExpression());
assertThat(providerDetails.getJwkSetUri()).isEqualTo(updatedProviderDetails.getJwkSetUri());
assertThat(providerDetails.getIssuerUri()).isEqualTo(updatedProviderDetails.getIssuerUri());
assertThat(providerDetails.getConfigurationMetadata())
@ -802,6 +804,84 @@ public class ClientRegistrationTests {
assertThat(clientRegistration.getClientSettings().isRequireProofKey()).isTrue();
}
@Test
public void buildWhenUsernameExpressionProvidedThenSet() {
String usernameExpression = "data.username";
// @formatter:off
ClientRegistration registration = ClientRegistration.withRegistrationId(REGISTRATION_ID)
.clientId(CLIENT_ID)
.clientSecret(CLIENT_SECRET)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri(REDIRECT_URI)
.authorizationUri(AUTHORIZATION_URI)
.tokenUri(TOKEN_URI)
.usernameExpression(usernameExpression)
.build();
// @formatter:on
assertThat(registration.getProviderDetails().getUserInfoEndpoint().getUsernameExpression())
.isEqualTo(usernameExpression);
}
@Test
public void buildWhenBothUserNameAttributeNameAndUsernameExpressionProvidedThenUsernameExpressionTakesPrecedence() {
String userNameAttributeName = "username";
String usernameExpression = "data.username";
// @formatter:off
ClientRegistration registration = ClientRegistration.withRegistrationId(REGISTRATION_ID)
.clientId(CLIENT_ID)
.clientSecret(CLIENT_SECRET)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri(REDIRECT_URI)
.authorizationUri(AUTHORIZATION_URI)
.tokenUri(TOKEN_URI)
.userNameAttributeName(userNameAttributeName)
.usernameExpression(usernameExpression)
.build();
// @formatter:on
assertThat(registration.getProviderDetails().getUserInfoEndpoint().getUsernameExpression())
.isEqualTo(usernameExpression);
assertThat(registration.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName())
.isEqualTo(userNameAttributeName);
}
@Test
public void buildWhenOnlyUserNameAttributeNameProvidedThenAutoConvertToSpelExpression() {
String userNameAttributeName = "username";
// @formatter:off
ClientRegistration registration = ClientRegistration.withRegistrationId(REGISTRATION_ID)
.clientId(CLIENT_ID)
.clientSecret(CLIENT_SECRET)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri(REDIRECT_URI)
.authorizationUri(AUTHORIZATION_URI)
.tokenUri(TOKEN_URI)
.userNameAttributeName(userNameAttributeName)
.build();
// @formatter:on
assertThat(registration.getProviderDetails().getUserInfoEndpoint().getUsernameExpression())
.isEqualTo("['" + userNameAttributeName + "']");
assertThat(registration.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName())
.isEqualTo(userNameAttributeName);
}
@Test
public void buildWhenCopyingClientRegistrationWithUsernameExpressionThenPreserved() {
String usernameExpression = "profile.name";
// @formatter:off
ClientRegistration original = ClientRegistration.withRegistrationId(REGISTRATION_ID)
.clientId(CLIENT_ID)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri(REDIRECT_URI)
.authorizationUri(AUTHORIZATION_URI)
.tokenUri(TOKEN_URI)
.usernameExpression(usernameExpression)
.build();
// @formatter:on
ClientRegistration copy = ClientRegistration.withClientRegistration(original).build();
assertThat(copy.getProviderDetails().getUserInfoEndpoint().getUsernameExpression())
.isEqualTo(usernameExpression);
}
@ParameterizedTest
@MethodSource("invalidPkceGrantTypes")
void buildWhenInvalidGrantTypeForPkceThenException(AuthorizationGrantType invalidGrantType) {

View File

@ -1,5 +1,5 @@
/*
* Copyright 2002-2024 the original author or authors.
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -61,6 +61,7 @@ import static org.mockito.Mockito.mock;
*
* @author Joe Grandja
* @author Eddú Meléndez
* @author Yoobin Yoon
*/
public class DefaultOAuth2UserServiceTests {
@ -121,7 +122,7 @@ public class DefaultOAuth2UserServiceTests {
// @formatter:on
assertThatExceptionOfType(OAuth2AuthenticationException.class)
.isThrownBy(() -> this.userService.loadUser(new OAuth2UserRequest(clientRegistration, this.accessToken)))
.withMessageContaining("missing_user_name_attribute");
.withMessageContaining("invalid_user_info_response");
}
@Test
@ -153,23 +154,26 @@ public class DefaultOAuth2UserServiceTests {
assertThat((String) user.getAttribute("email")).isEqualTo("user1@example.com");
assertThat(user.getAuthorities()).hasSize(1);
assertThat(user.getAuthorities().iterator().next()).isInstanceOf(OAuth2UserAuthority.class);
OAuth2UserAuthority userAuthority = (OAuth2UserAuthority) user.getAuthorities().iterator().next();
OAuth2UserAuthority userAuthority = OAuth2UserAuthority.withUsername("user1")
.attributes(user.getAttributes())
.authority("OAUTH2_USER")
.build();
assertThat(userAuthority.getAuthority()).isEqualTo("OAUTH2_USER");
assertThat(userAuthority.getAttributes()).isEqualTo(user.getAttributes());
assertThat(userAuthority.getUserNameAttributeName()).isEqualTo("user-name");
assertThat(userAuthority.getUsername()).isEqualTo("user1");
}
@Test
public void loadUserWhenNestedUserInfoSuccessThenReturnUser() {
// @formatter:off
String userInfoResponse = "{\n"
+ " \"user\": {\"user-name\": \"user1\"},\n"
+ " \"first-name\": \"first\",\n"
+ " \"last-name\": \"last\",\n"
+ " \"middle-name\": \"middle\",\n"
+ " \"address\": \"address\",\n"
+ " \"email\": \"user1@example.com\"\n"
+ "}\n";
+ " \"user\": {\"user-name\": \"user1\"},\n"
+ " \"first-name\": \"first\",\n"
+ " \"last-name\": \"last\",\n"
+ " \"middle-name\": \"middle\",\n"
+ " \"address\": \"address\",\n"
+ " \"email\": \"user1@example.com\"\n"
+ "}\n";
// @formatter:on
this.server.enqueue(jsonResponse(userInfoResponse));
String userInfoUri = this.server.url("/user").toString();
@ -194,10 +198,13 @@ public class DefaultOAuth2UserServiceTests {
assertThat((String) user.getAttribute("email")).isEqualTo("user1@example.com");
assertThat(user.getAuthorities()).hasSize(1);
assertThat(user.getAuthorities().iterator().next()).isInstanceOf(OAuth2UserAuthority.class);
OAuth2UserAuthority userAuthority = (OAuth2UserAuthority) user.getAuthorities().iterator().next();
OAuth2UserAuthority userAuthority = OAuth2UserAuthority.withUsername("user1")
.attributes(user.getAttributes())
.authority("OAUTH2_USER")
.build();
assertThat(userAuthority.getAuthority()).isEqualTo("OAUTH2_USER");
assertThat(userAuthority.getAttributes()).isEqualTo(user.getAttributes());
assertThat(userAuthority.getUserNameAttributeName()).isEqualTo("user-name");
assertThat(userAuthority.getUsername()).isEqualTo("user1");
}
@Test
@ -247,8 +254,8 @@ public class DefaultOAuth2UserServiceTests {
public void loadUserWhenUserInfoErrorResponseThenThrowOAuth2AuthenticationException() {
// @formatter:off
String userInfoErrorResponse = "{\n"
+ " \"error\": \"invalid_token\"\n"
+ "}\n";
+ " \"error\": \"invalid_token\"\n"
+ "}\n";
// @formatter:on
this.server.enqueue(jsonResponse(userInfoErrorResponse).setResponseCode(400));
String userInfoUri = this.server.url("/user").toString();
@ -421,6 +428,134 @@ public class DefaultOAuth2UserServiceTests {
.isThrownBy(() -> this.userService.setAttributesConverter(null));
}
@Test
public void loadUserWhenBackwardCompatibilityWithUserNameAttributeNameThenWorks() {
// @formatter:off
String userInfoResponse = "{\n"
+ " \"user-name\": \"backwardCompatUser\",\n"
+ " \"email\": \"backward@example.com\"\n"
+ "}\n";
// @formatter:on
this.server.enqueue(jsonResponse(userInfoResponse));
String userInfoUri = this.server.url("/user").toString();
ClientRegistration clientRegistration = this.clientRegistrationBuilder.userInfoUri(userInfoUri)
.userInfoAuthenticationMethod(AuthenticationMethod.HEADER)
.userNameAttributeName("user-name")
.build();
OAuth2User user = this.userService.loadUser(new OAuth2UserRequest(clientRegistration, this.accessToken));
assertThat(user.getName()).isEqualTo("backwardCompatUser");
assertThat(user.getAttributes()).hasSize(2);
}
@Test
public void loadUserWhenUsernameExpressionIsSimpleAttributeThenUseDirectly() {
// @formatter:off
String userInfoResponse = "{\n"
+ " \"username\": \"simpleUser\",\n"
+ " \"id\": \"54321\",\n"
+ " \"email\": \"simple@example.com\"\n"
+ "}\n";
// @formatter:on
this.server.enqueue(jsonResponse(userInfoResponse));
String userInfoUri = this.server.url("/user").toString();
ClientRegistration clientRegistration = this.clientRegistrationBuilder.userInfoUri(userInfoUri)
.userInfoAuthenticationMethod(AuthenticationMethod.HEADER)
.usernameExpression("username")
.build();
OAuth2User user = this.userService.loadUser(new OAuth2UserRequest(clientRegistration, this.accessToken));
assertThat(user.getName()).isEqualTo("simpleUser");
assertThat(user.getAttributes()).hasSize(3);
}
@Test
public void loadUserWhenUsernameExpressionIsSpelThenEvaluateCorrectly() {
// @formatter:off
String userInfoResponse = "{\n"
+ " \"data\": {\n"
+ " \"user\": {\n"
+ " \"username\": \"spelUser\"\n"
+ " }\n"
+ " },\n"
+ " \"id\": \"12345\",\n"
+ " \"email\": \"spel@example.com\"\n"
+ "}\n";
// @formatter:on
this.server.enqueue(jsonResponse(userInfoResponse));
String userInfoUri = this.server.url("/user").toString();
ClientRegistration clientRegistration = this.clientRegistrationBuilder.userInfoUri(userInfoUri)
.userInfoAuthenticationMethod(AuthenticationMethod.HEADER)
.usernameExpression("data.user.username")
.build();
OAuth2User user = this.userService.loadUser(new OAuth2UserRequest(clientRegistration, this.accessToken));
assertThat(user.getName()).isEqualTo("spelUser");
assertThat(user.getAttributes()).hasSize(3);
assertThat((String) user.getAttribute("id")).isEqualTo("12345");
assertThat((String) user.getAttribute("email")).isEqualTo("spel@example.com");
}
@Test
public void loadUserWhenUsernameExpressionInvalidSpelThenThrowOAuth2AuthenticationException() {
// @formatter:off
String userInfoResponse = "{\n"
+ " \"username\": \"testUser\",\n"
+ " \"id\": \"12345\"\n"
+ "}\n";
// @formatter:on
this.server.enqueue(jsonResponse(userInfoResponse));
String userInfoUri = this.server.url("/user").toString();
ClientRegistration clientRegistration = this.clientRegistrationBuilder.userInfoUri(userInfoUri)
.userInfoAuthenticationMethod(AuthenticationMethod.HEADER)
.usernameExpression("nonexistent.invalid.path") // invalid SpEL
.build();
assertThatExceptionOfType(OAuth2AuthenticationException.class)
.isThrownBy(() -> this.userService.loadUser(new OAuth2UserRequest(clientRegistration, this.accessToken)))
.withMessageContaining("invalid_username_expression")
.withMessageContaining("Invalid username expression or SPEL expression");
}
@Test
public void loadUserWhenUsernameExpressionResultsInNullThenThrowOAuth2AuthenticationException() {
// @formatter:off
String userInfoResponse = "{\n"
+ " \"username\": \"testUser\",\n"
+ " \"data\": {\n"
+ " \"username\": null\n"
+ " }\n"
+ "}\n";
// @formatter:on
this.server.enqueue(jsonResponse(userInfoResponse));
String userInfoUri = this.server.url("/user").toString();
ClientRegistration clientRegistration = this.clientRegistrationBuilder.userInfoUri(userInfoUri)
.userInfoAuthenticationMethod(AuthenticationMethod.HEADER)
.usernameExpression("data.username")
.build();
assertThatExceptionOfType(OAuth2AuthenticationException.class)
.isThrownBy(() -> this.userService.loadUser(new OAuth2UserRequest(clientRegistration, this.accessToken)))
.withMessageContaining("invalid_user_info_response")
.withMessageContaining("username cannot be null");
}
@Test
public void loadUserWhenUsernameExpressionWithArrayAccessThenEvaluateCorrectly() {
// @formatter:off
String userInfoResponse = "{\n"
+ " \"accounts\": [\n"
+ " {\"username\": \"primary_user\", \"type\": \"primary\"},\n"
+ " {\"username\": \"secondary_user\", \"type\": \"secondary\"}\n"
+ " ],\n"
+ " \"id\": \"12345\"\n"
+ "}\n";
// @formatter:on
this.server.enqueue(jsonResponse(userInfoResponse));
String userInfoUri = this.server.url("/user").toString();
ClientRegistration clientRegistration = this.clientRegistrationBuilder.userInfoUri(userInfoUri)
.userInfoAuthenticationMethod(AuthenticationMethod.HEADER)
.usernameExpression("accounts[0].username")
.build();
OAuth2User user = this.userService.loadUser(new OAuth2UserRequest(clientRegistration, this.accessToken));
assertThat(user.getName()).isEqualTo("primary_user");
}
private DefaultOAuth2UserService withMockResponse(Map<String, Object> response) {
ResponseEntity<Map<String, Object>> responseEntity = new ResponseEntity<>(response, HttpStatus.OK);
Converter<OAuth2UserRequest, RequestEntity<?>> requestEntityConverter = mock(Converter.class);

View File

@ -1,5 +1,5 @@
/*
* Copyright 2002-2024 the original author or authors.
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -60,6 +60,7 @@ import static org.mockito.Mockito.spy;
/**
* @author Rob Winch
* @author Eddú Meléndez
* @author Yoobin Yoon
* @since 5.1
*/
public class DefaultReactiveOAuth2UserServiceTests {
@ -104,19 +105,6 @@ public class DefaultReactiveOAuth2UserServiceTests {
.verify();
}
@Test
public void loadUserWhenUserNameAttributeNameIsNullThenThrowOAuth2AuthenticationException() {
this.clientRegistration.userNameAttributeName(null);
// @formatter:off
StepVerifier.create(this.userService.loadUser(oauth2UserRequest()))
.expectErrorSatisfies((ex) -> assertThat(ex)
.isInstanceOf(OAuth2AuthenticationException.class)
.hasMessageContaining("missing_user_name_attribute")
)
.verify();
// @formatter:on
}
@Test
public void loadUserWhenUserInfoSuccessResponseThenReturnUser() {
// @formatter:off
@ -141,10 +129,13 @@ public class DefaultReactiveOAuth2UserServiceTests {
assertThat((String) user.getAttribute("email")).isEqualTo("user1@example.com");
assertThat(user.getAuthorities()).hasSize(1);
assertThat(user.getAuthorities().iterator().next()).isInstanceOf(OAuth2UserAuthority.class);
OAuth2UserAuthority userAuthority = (OAuth2UserAuthority) user.getAuthorities().iterator().next();
OAuth2UserAuthority userAuthority = OAuth2UserAuthority.withUsername("user1")
.attributes(user.getAttributes())
.authority("OAUTH2_USER")
.build();
assertThat(userAuthority.getAuthority()).isEqualTo("OAUTH2_USER");
assertThat(userAuthority.getAttributes()).isEqualTo(user.getAttributes());
assertThat(userAuthority.getUserNameAttributeName()).isEqualTo("id");
assertThat(userAuthority.getUsername()).isEqualTo("user1");
}
// gh-9336
@ -152,13 +143,13 @@ public class DefaultReactiveOAuth2UserServiceTests {
public void loadUserWhenUserInfo201CreatedResponseThenReturnUser() {
// @formatter:off
String userInfoResponse = "{\n"
+ " \"id\": \"user1\",\n"
+ " \"first-name\": \"first\",\n"
+ " \"last-name\": \"last\",\n"
+ " \"middle-name\": \"middle\",\n"
+ " \"address\": \"address\",\n"
+ " \"email\": \"user1@example.com\"\n"
+ "}\n";
+ " \"id\": \"user1\",\n"
+ " \"first-name\": \"first\",\n"
+ " \"last-name\": \"last\",\n"
+ " \"middle-name\": \"middle\",\n"
+ " \"address\": \"address\",\n"
+ " \"email\": \"user1@example.com\"\n"
+ "}\n";
// @formatter:on
this.server.enqueue(new MockResponse().setResponseCode(201)
.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
@ -170,13 +161,13 @@ public class DefaultReactiveOAuth2UserServiceTests {
public void loadUserWhenNestedUserInfoSuccessThenReturnUser() {
// @formatter:off
String userInfoResponse = "{\n"
+ " \"user\": {\"user-name\": \"user1\"},\n"
+ " \"first-name\": \"first\",\n"
+ " \"last-name\": \"last\",\n"
+ " \"middle-name\": \"middle\",\n"
+ " \"address\": \"address\",\n"
+ " \"email\": \"user1@example.com\"\n"
+ "}\n";
+ " \"user\": {\"user-name\": \"user1\"},\n"
+ " \"first-name\": \"first\",\n"
+ " \"last-name\": \"last\",\n"
+ " \"middle-name\": \"middle\",\n"
+ " \"address\": \"address\",\n"
+ " \"email\": \"user1@example.com\"\n"
+ "}\n";
// @formatter:on
enqueueApplicationJsonBody(userInfoResponse);
String userInfoUri = this.server.url("/user").toString();
@ -201,10 +192,13 @@ public class DefaultReactiveOAuth2UserServiceTests {
assertThat((String) user.getAttribute("email")).isEqualTo("user1@example.com");
assertThat(user.getAuthorities()).hasSize(1);
assertThat(user.getAuthorities().iterator().next()).isInstanceOf(OAuth2UserAuthority.class);
OAuth2UserAuthority userAuthority = (OAuth2UserAuthority) user.getAuthorities().iterator().next();
OAuth2UserAuthority userAuthority = OAuth2UserAuthority.withUsername("user1")
.attributes(user.getAttributes())
.authority("OAUTH2_USER")
.build();
assertThat(userAuthority.getAuthority()).isEqualTo("OAUTH2_USER");
assertThat(userAuthority.getAttributes()).isEqualTo(user.getAttributes());
assertThat(userAuthority.getUserNameAttributeName()).isEqualTo("user-name");
assertThat(userAuthority.getUsername()).isEqualTo("user1");
}
// gh-5500
@ -257,7 +251,7 @@ public class DefaultReactiveOAuth2UserServiceTests {
public void loadUserWhenUserInfoSuccessResponseInvalidThenThrowOAuth2AuthenticationException() {
// @formatter:off
String userInfoResponse = "{\n"
+ " \"id\": \"user1\",\n"
+ " \"id\": \"user1\",\n"
+ " \"first-name\": \"first\",\n"
+ " \"last-name\": \"last\",\n"
+ " \"middle-name\": \"middle\",\n"
@ -338,6 +332,66 @@ public class DefaultReactiveOAuth2UserServiceTests {
.isThrownBy(() -> this.userService.setAttributesConverter(null));
}
@Test
public void loadUserWhenUsernameExpressionIsSpelThenEvaluateCorrectly() {
// @formatter:off
String userInfoResponse = "{\n"
+ " \"data\": {\n"
+ " \"profile\": {\n"
+ " \"name\": \"reactiveSpelUser\"\n"
+ " }\n"
+ " },\n"
+ " \"id\": \"reactive123\"\n"
+ "}\n";
// @formatter:on
enqueueApplicationJsonBody(userInfoResponse);
ClientRegistration clientRegistration = this.clientRegistration.usernameExpression("data.profile.name").build();
OAuth2User user = this.userService.loadUser(new OAuth2UserRequest(clientRegistration, this.accessToken))
.block();
assertThat(user.getName()).isEqualTo("reactiveSpelUser");
assertThat(user.getAttributes()).hasSize(2);
assertThat((String) user.getAttribute("id")).isEqualTo("reactive123");
}
@Test
public void loadUserWhenUsernameExpressionInvalidSpelThenThrowOAuth2AuthenticationException() {
// @formatter:off
String userInfoResponse = "{\n"
+ " \"id\": \"reactive123\",\n"
+ " \"username\": \"reactiveUser\"\n"
+ "}\n";
// @formatter:on
enqueueApplicationJsonBody(userInfoResponse);
ClientRegistration clientRegistration = this.clientRegistration.usernameExpression("invalid.spel.expression")
.build();
StepVerifier.create(this.userService.loadUser(new OAuth2UserRequest(clientRegistration, this.accessToken)))
.expectErrorSatisfies((ex) -> {
assertThat(ex).isInstanceOf(OAuth2AuthenticationException.class);
assertThat(ex.getMessage()).contains("Invalid username expression or SPEL expression");
})
.verify();
}
@Test
public void loadUserWhenUsernameExpressionResultsInNullThenThrowException() {
// @formatter:off
String userInfoResponse = "{\n"
+ " \"username\": \"testUser\",\n"
+ " \"data\": {\n"
+ " \"username\": null\n"
+ " }\n"
+ "}\n";
// @formatter:on
enqueueApplicationJsonBody(userInfoResponse);
ClientRegistration clientRegistration = this.clientRegistration.usernameExpression("data.username").build();
StepVerifier.create(this.userService.loadUser(new OAuth2UserRequest(clientRegistration, this.accessToken)))
.expectErrorSatisfies((ex) -> {
assertThat(ex).isInstanceOf(OAuth2AuthenticationException.class);
assertThat(ex.getMessage()).contains("username cannot be null");
})
.verify();
}
private DefaultReactiveOAuth2UserService withMockResponse(Map<String, Object> body) {
WebClient real = WebClient.builder().build();
WebClient.RequestHeadersUriSpec spec = spy(real.post());

View File

@ -1,5 +1,5 @@
/*
* Copyright 2002-2024 the original author or authors.
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -25,6 +25,7 @@ import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import org.springframework.security.oauth2.core.oidc.OidcUserInfo;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import org.springframework.util.Assert;
/**
* The default implementation of an {@link OidcUser}.
@ -35,6 +36,7 @@ import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
*
* @author Joe Grandja
* @author Vedran Pavic
* @author Yoobin Yoon
* @since 5.0
* @see OidcUser
* @see DefaultOAuth2User
@ -54,7 +56,9 @@ public class DefaultOidcUser extends DefaultOAuth2User implements OidcUser {
* Constructs a {@code DefaultOidcUser} using the provided parameters.
* @param authorities the authorities granted to the user
* @param idToken the {@link OidcIdToken ID Token} containing claims about the user
* @deprecated Use {@link #withUsername(String)} builder pattern instead
*/
@Deprecated
public DefaultOidcUser(Collection<? extends GrantedAuthority> authorities, OidcIdToken idToken) {
this(authorities, idToken, IdTokenClaimNames.SUB);
}
@ -65,7 +69,9 @@ public class DefaultOidcUser extends DefaultOAuth2User implements OidcUser {
* @param idToken the {@link OidcIdToken ID Token} containing claims about the user
* @param nameAttributeKey the key used to access the user's &quot;name&quot; from
* {@link #getAttributes()}
* @deprecated Use {@link #withUsername(String)} builder pattern instead
*/
@Deprecated
public DefaultOidcUser(Collection<? extends GrantedAuthority> authorities, OidcIdToken idToken,
String nameAttributeKey) {
this(authorities, idToken, null, nameAttributeKey);
@ -77,7 +83,9 @@ public class DefaultOidcUser extends DefaultOAuth2User implements OidcUser {
* @param idToken the {@link OidcIdToken ID Token} containing claims about the user
* @param userInfo the {@link OidcUserInfo UserInfo} containing claims about the user,
* may be {@code null}
* @deprecated Use {@link #withUsername(String)} builder pattern instead
*/
@Deprecated
public DefaultOidcUser(Collection<? extends GrantedAuthority> authorities, OidcIdToken idToken,
OidcUserInfo userInfo) {
this(authorities, idToken, userInfo, IdTokenClaimNames.SUB);
@ -91,7 +99,9 @@ public class DefaultOidcUser extends DefaultOAuth2User implements OidcUser {
* may be {@code null}
* @param nameAttributeKey the key used to access the user's &quot;name&quot; from
* {@link #getAttributes()}
* @deprecated Use {@link #withUsername(String)} builder pattern instead
*/
@Deprecated
public DefaultOidcUser(Collection<? extends GrantedAuthority> authorities, OidcIdToken idToken,
OidcUserInfo userInfo, String nameAttributeKey) {
super(authorities, OidcUserAuthority.collectClaims(idToken, userInfo), nameAttributeKey);
@ -99,6 +109,34 @@ public class DefaultOidcUser extends DefaultOAuth2User implements OidcUser {
this.userInfo = userInfo;
}
/**
* Constructs a {@code DefaultOidcUser} using the provided parameters.
* @param authorities the authorities granted to the user
* @param attributes the attributes about the user
* @param nameAttributeKey the key used to access the user's &quot;name&quot; from
* {@link #getAttributes()} - preserved for backwards compatibility
* @param username the user's name
* @param idToken the {@link OidcIdToken ID Token} containing claims about the user
* @param userInfo the {@link OidcUserInfo UserInfo} containing claims about the user,
* may be {@code null}
*/
private DefaultOidcUser(Collection<? extends GrantedAuthority> authorities, Map<String, Object> attributes,
String nameAttributeKey, String username, OidcIdToken idToken, OidcUserInfo userInfo) {
super(authorities, attributes, nameAttributeKey, username);
this.idToken = idToken;
this.userInfo = userInfo;
}
/**
* Creates a new {@code DefaultOidcUser} builder with the username.
* @param username the user's name
* @return a new {@code Builder}
* @since 7.0
*/
public static Builder withUsername(String username) {
return new Builder(username);
}
@Override
public Map<String, Object> getClaims() {
return this.getAttributes();
@ -114,4 +152,56 @@ public class DefaultOidcUser extends DefaultOAuth2User implements OidcUser {
return this.userInfo;
}
/**
* A builder for {@link DefaultOidcUser}.
*
* @since 7.0
*/
public static final class Builder extends DefaultOAuth2User.Builder {
private OidcIdToken idToken;
private OidcUserInfo userInfo;
private Builder(String username) {
super(username);
}
public Builder idToken(OidcIdToken idToken) {
this.idToken = idToken;
return this;
}
public Builder userInfo(OidcUserInfo userInfo) {
this.userInfo = userInfo;
return this;
}
@Override
public Builder authorities(Collection<? extends GrantedAuthority> authorities) {
super.authorities(authorities);
return this;
}
@Override
public Builder attributes(Map<String, Object> attributes) {
super.attributes(attributes);
return this;
}
@Override
public DefaultOidcUser build() {
Assert.notNull(this.idToken, "idToken cannot be null");
if (this.attributes == null) {
this.attributes = OidcUserAuthority.collectClaims(this.idToken, this.userInfo);
}
Assert.notEmpty(this.attributes, "attributes cannot be empty");
return new DefaultOidcUser(this.authorities, this.attributes, null, this.username, this.idToken,
this.userInfo);
}
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2002-2024 the original author or authors.
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -32,6 +32,7 @@ import org.springframework.util.Assert;
* A {@link GrantedAuthority} that may be associated to an {@link OidcUser}.
*
* @author Joe Grandja
* @author Yoobin Yoon
* @since 5.0
* @see OidcUser
*/
@ -47,7 +48,9 @@ public class OidcUserAuthority extends OAuth2UserAuthority {
/**
* Constructs a {@code OidcUserAuthority} using the provided parameters.
* @param idToken the {@link OidcIdToken ID Token} containing claims about the user
* @deprecated Use {@link #withUsername(String)} builder pattern instead
*/
@Deprecated
public OidcUserAuthority(OidcIdToken idToken) {
this(idToken, null);
}
@ -58,7 +61,9 @@ public class OidcUserAuthority extends OAuth2UserAuthority {
* @param idToken the {@link OidcIdToken ID Token} containing claims about the user
* @param userInfo the {@link OidcUserInfo UserInfo} containing claims about the user,
* may be {@code null}
* @deprecated Use {@link #withUsername(String)} builder pattern instead
*/
@Deprecated
public OidcUserAuthority(OidcIdToken idToken, OidcUserInfo userInfo) {
this("OIDC_USER", idToken, userInfo);
}
@ -72,7 +77,9 @@ public class OidcUserAuthority extends OAuth2UserAuthority {
* @param userNameAttributeName the attribute name used to access the user's name from
* the attributes
* @since 6.4
* @deprecated Use {@link #withUsername(String)} builder pattern instead
*/
@Deprecated
public OidcUserAuthority(OidcIdToken idToken, OidcUserInfo userInfo, @Nullable String userNameAttributeName) {
this("OIDC_USER", idToken, userInfo, userNameAttributeName);
}
@ -83,7 +90,9 @@ public class OidcUserAuthority extends OAuth2UserAuthority {
* @param idToken the {@link OidcIdToken ID Token} containing claims about the user
* @param userInfo the {@link OidcUserInfo UserInfo} containing claims about the user,
* may be {@code null}
* @deprecated Use {@link #withUsername(String)} builder pattern instead
*/
@Deprecated
public OidcUserAuthority(String authority, OidcIdToken idToken, OidcUserInfo userInfo) {
this(authority, idToken, userInfo, IdTokenClaimNames.SUB);
}
@ -97,7 +106,9 @@ public class OidcUserAuthority extends OAuth2UserAuthority {
* @param userNameAttributeName the attribute name used to access the user's name from
* the attributes
* @since 6.4
* @deprecated Use {@link #withUsername(String)} builder pattern instead
*/
@Deprecated
public OidcUserAuthority(String authority, OidcIdToken idToken, OidcUserInfo userInfo,
@Nullable String userNameAttributeName) {
super(authority, collectClaims(idToken, userInfo), userNameAttributeName);
@ -105,6 +116,33 @@ public class OidcUserAuthority extends OAuth2UserAuthority {
this.userInfo = userInfo;
}
/**
* Constructs a {@code OidcUserAuthority} using the provided parameters. This
* constructor is used by the Builder pattern.
* @param username the username
* @param authority the authority granted to the user
* @param attributes the attributes about the user
* @param idToken the {@link OidcIdToken ID Token} containing claims about the user
* @param userInfo the {@link OidcUserInfo UserInfo} containing claims about the user,
* may be {@code null}
*/
private OidcUserAuthority(String username, String authority, Map<String, Object> attributes, OidcIdToken idToken,
OidcUserInfo userInfo) {
super(username, authority, attributes);
this.idToken = idToken;
this.userInfo = userInfo;
}
/**
* Creates a new {@code OidcUserAuthority} builder with the username.
* @param username the username
* @return a new {@code Builder}
* @since 7.0
*/
public static Builder withUsername(String username) {
return new Builder(username);
}
/**
* Returns the {@link OidcIdToken ID Token} containing claims about the user.
* @return the {@link OidcIdToken} containing claims about the user.
@ -159,4 +197,66 @@ public class OidcUserAuthority extends OAuth2UserAuthority {
return claims;
}
/**
* A builder for {@link OidcUserAuthority}.
*
* @since 7.0
*/
public static final class Builder extends OAuth2UserAuthority.Builder {
private OidcIdToken idToken;
private OidcUserInfo userInfo;
private Builder(String username) {
super(username);
this.authority = "OIDC_USER";
}
/**
* Sets the {@link OidcIdToken ID Token} containing claims about the user.
* @param idToken the {@link OidcIdToken ID Token}
* @return the {@link Builder}
*/
public Builder idToken(OidcIdToken idToken) {
this.idToken = idToken;
return this;
}
/**
* Sets the {@link OidcUserInfo UserInfo} containing claims about the user.
* @param userInfo the {@link OidcUserInfo UserInfo}
* @return the {@link Builder}
*/
public Builder userInfo(OidcUserInfo userInfo) {
this.userInfo = userInfo;
return this;
}
@Override
public Builder authority(String authority) {
super.authority(authority);
return this;
}
@Override
public Builder attributes(Map<String, Object> attributes) {
super.attributes(attributes);
return this;
}
@Override
public OidcUserAuthority build() {
Assert.notNull(this.idToken, "idToken cannot be null");
if (this.attributes == null) {
this.attributes = collectClaims(this.idToken, this.userInfo);
}
Assert.notEmpty(this.attributes, "attributes cannot be empty");
return new OidcUserAuthority(this.username, this.authority, this.attributes, this.idToken, this.userInfo);
}
}
}

View File

@ -36,16 +36,18 @@ import org.springframework.util.Assert;
* The default implementation of an {@link OAuth2User}.
*
* <p>
* User attribute names are <b>not</b> standardized between providers and therefore it is
* required to supply the <i>key</i> for the user's &quot;name&quot; attribute to one of
* the constructors. The <i>key</i> will be used for accessing the &quot;name&quot; of the
* {@code Principal} (user) via {@link #getAttributes()} and returning it from
* {@link #getName()}.
* User attribute names are <b>not</b> standardized between providers. The recommended
* approach is to use {@link #withUsername(String)} builder pattern to directly specify
* the username, eliminating the need to determine attribute keys. Alternatively, when
* using the deprecated constructors, it is required to supply the <i>key</i> for the
* user's &quot;name&quot; attribute, which will be used for accessing the
* &quot;name&quot; of the {@code Principal} (user) via {@link #getAttributes()} and
* returning it from {@link #getName()}.
*
* @author Joe Grandja
* @author Eddú Meléndez
* @author Park Hyojong
* @author YooBin Yoon
* @author Yoobin Yoon
* @since 5.0
* @see OAuth2User
*/
@ -57,6 +59,7 @@ public class DefaultOAuth2User implements OAuth2User, Serializable {
private final Map<String, Object> attributes;
@Deprecated
private final String nameAttributeKey;
private final String username;
@ -86,15 +89,14 @@ public class DefaultOAuth2User implements OAuth2User, Serializable {
}
/**
* Constructs a {@code DefaultOAuth2User} using the provided parameters. This
* constructor is used by Jackson for deserialization.
* Constructs a {@code DefaultOAuth2User} using the provided parameters.
* @param authorities the authorities granted to the user
* @param attributes the attributes about the user
* @param nameAttributeKey the key used to access the user's &quot;name&quot; from
* {@link #getAttributes()} - preserved for backwards compatibility
* @param username the user's name
*/
private DefaultOAuth2User(Collection<? extends GrantedAuthority> authorities, Map<String, Object> attributes,
protected DefaultOAuth2User(Collection<? extends GrantedAuthority> authorities, Map<String, Object> attributes,
String nameAttributeKey, String username) {
Assert.notEmpty(attributes, "attributes cannot be empty");
@ -103,7 +105,7 @@ public class DefaultOAuth2User implements OAuth2User, Serializable {
: Collections.unmodifiableSet(new LinkedHashSet<>(AuthorityUtils.NO_AUTHORITIES));
this.attributes = Collections.unmodifiableMap(new LinkedHashMap<>(attributes));
this.nameAttributeKey = nameAttributeKey;
this.username = (username != null) ? username : attributes.get(nameAttributeKey).toString();
this.username = username;
Assert.hasText(this.username, "username cannot be empty");
}
@ -112,47 +114,12 @@ public class DefaultOAuth2User implements OAuth2User, Serializable {
* Creates a new {@code DefaultOAuth2User} builder with the username.
* @param username the user's name
* @return a new {@code Builder}
* @since 6.5
* @since 7.0
*/
public static Builder withUsername(String username) {
return new Builder(username);
}
/**
* A builder for {@link DefaultOAuth2User}.
*
* @since 6.5
*/
public static final class Builder {
private final String username;
private Collection<? extends GrantedAuthority> authorities;
private Map<String, Object> attributes;
private Builder(String username) {
Assert.hasText(username, "username cannot be empty");
this.username = username;
}
public Builder authorities(Collection<? extends GrantedAuthority> authorities) {
this.authorities = authorities;
return this;
}
public Builder attributes(Map<String, Object> attributes) {
this.attributes = attributes;
return this;
}
public DefaultOAuth2User build() {
Assert.notEmpty(this.attributes, "attributes cannot be empty");
return new DefaultOAuth2User(this.authorities, this.attributes, null, this.username);
}
}
@Override
public String getName() {
return this.username;
@ -214,4 +181,39 @@ public class DefaultOAuth2User implements OAuth2User, Serializable {
return sb.toString();
}
/**
* A builder for {@link DefaultOAuth2User}.
*
* @since 7.0
*/
public static class Builder {
protected final String username;
protected Collection<? extends GrantedAuthority> authorities;
protected Map<String, Object> attributes;
protected Builder(String username) {
Assert.hasText(username, "username cannot be empty");
this.username = username;
}
public Builder authorities(Collection<? extends GrantedAuthority> authorities) {
this.authorities = authorities;
return this;
}
public Builder attributes(Map<String, Object> attributes) {
this.attributes = attributes;
return this;
}
public DefaultOAuth2User build() {
Assert.notEmpty(this.attributes, "attributes cannot be empty");
return new DefaultOAuth2User(this.authorities, this.attributes, null, this.username);
}
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2002-2024 the original author or authors.
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -31,6 +31,7 @@ import org.springframework.util.Assert;
* A {@link GrantedAuthority} that may be associated to an {@link OAuth2User}.
*
* @author Joe Grandja
* @author Yoobin Yoon
* @since 5.0
* @see OAuth2User
*/
@ -42,13 +43,18 @@ public class OAuth2UserAuthority implements GrantedAuthority {
private final Map<String, Object> attributes;
@Deprecated
private final String userNameAttributeName;
private final String username;
/**
* Constructs a {@code OAuth2UserAuthority} using the provided parameters and defaults
* {@link #getAuthority()} to {@code OAUTH2_USER}.
* @param attributes the attributes about the user
* @deprecated Use {@link #withUsername(String)} builder pattern instead
*/
@Deprecated
public OAuth2UserAuthority(Map<String, Object> attributes) {
this("OAUTH2_USER", attributes);
}
@ -60,7 +66,9 @@ public class OAuth2UserAuthority implements GrantedAuthority {
* @param userNameAttributeName the attribute name used to access the user's name from
* the attributes
* @since 6.4
* @deprecated Use {@link #withUsername(String)} builder pattern instead
*/
@Deprecated
public OAuth2UserAuthority(Map<String, Object> attributes, @Nullable String userNameAttributeName) {
this("OAUTH2_USER", attributes, userNameAttributeName);
}
@ -69,7 +77,9 @@ public class OAuth2UserAuthority implements GrantedAuthority {
* Constructs a {@code OAuth2UserAuthority} using the provided parameters.
* @param authority the authority granted to the user
* @param attributes the attributes about the user
* @deprecated Use {@link #withUsername(String)} builder pattern instead
*/
@Deprecated
public OAuth2UserAuthority(String authority, Map<String, Object> attributes) {
this(authority, attributes, null);
}
@ -81,13 +91,43 @@ public class OAuth2UserAuthority implements GrantedAuthority {
* @param userNameAttributeName the attribute name used to access the user's name from
* the attributes
* @since 6.4
* @deprecated Use {@link #withUsername(String)} builder pattern instead
*/
@Deprecated
public OAuth2UserAuthority(String authority, Map<String, Object> attributes, String userNameAttributeName) {
Assert.hasText(authority, "authority cannot be empty");
Assert.notEmpty(attributes, "attributes cannot be empty");
this.authority = authority;
this.attributes = Collections.unmodifiableMap(new LinkedHashMap<>(attributes));
this.userNameAttributeName = userNameAttributeName;
this.username = (userNameAttributeName != null && attributes.get(userNameAttributeName) != null)
? attributes.get(userNameAttributeName).toString() : null;
}
/**
* Constructs a {@code OAuth2UserAuthority} using the provided parameters.
* @param username the username
* @param authority the authority granted to the user
* @param attributes the attributes about the user
*/
protected OAuth2UserAuthority(String username, String authority, Map<String, Object> attributes) {
Assert.hasText(username, "username cannot be empty");
Assert.hasText(authority, "authority cannot be empty");
Assert.notEmpty(attributes, "attributes cannot be empty");
this.username = username;
this.authority = authority;
this.attributes = Collections.unmodifiableMap(new LinkedHashMap<>(attributes));
this.userNameAttributeName = null;
}
/**
* Creates a new {@code OAuth2UserAuthority} builder with the username.
* @param username the username
* @return a new {@code Builder}
* @since 7.0
*/
public static Builder withUsername(String username) {
return new Builder(username);
}
@Override
@ -107,12 +147,26 @@ public class OAuth2UserAuthority implements GrantedAuthority {
* Returns the attribute name used to access the user's name from the attributes.
* @return the attribute name used to access the user's name from the attributes
* @since 6.4
* @deprecated Use {@link #getUsername()} instead
*/
@Deprecated
@Nullable
public String getUserNameAttributeName() {
return this.userNameAttributeName;
}
/**
* Returns the username of the OAuth2 user.
* <p>
* This method provides direct access to the username without requiring knowledge of
* the attribute structure or SpEL expressions used to extract it.
* @return the username
* @since 7.0
*/
public String getUsername() {
return this.username;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
@ -125,6 +179,9 @@ public class OAuth2UserAuthority implements GrantedAuthority {
if (!this.getAuthority().equals(that.getAuthority())) {
return false;
}
if (!Objects.equals(this.username, that.username)) {
return false;
}
Map<String, Object> thatAttributes = that.getAttributes();
if (getAttributes().size() != thatAttributes.size()) {
return false;
@ -150,7 +207,7 @@ public class OAuth2UserAuthority implements GrantedAuthority {
@Override
public int hashCode() {
int result = this.getAuthority().hashCode();
result = 31 * result;
result = 31 * result + Objects.hashCode(this.username);
for (Map.Entry<String, Object> e : getAttributes().entrySet()) {
Object key = e.getKey();
Object value = convertURLIfNecessary(e.getValue());
@ -172,4 +229,39 @@ public class OAuth2UserAuthority implements GrantedAuthority {
return (value instanceof URL) ? ((URL) value).toExternalForm() : value;
}
/**
* A builder for {@link OAuth2UserAuthority}.
*
* @since 7.0
*/
public static class Builder {
protected final String username;
protected String authority = "OAUTH2_USER";
protected Map<String, Object> attributes;
protected Builder(String username) {
Assert.hasText(username, "username cannot be empty");
this.username = username;
}
public Builder authority(String authority) {
this.authority = authority;
return this;
}
public Builder attributes(Map<String, Object> attributes) {
this.attributes = attributes;
return this;
}
public OAuth2UserAuthority build() {
Assert.notEmpty(this.attributes, "attributes cannot be empty");
return new OAuth2UserAuthority(this.username, this.authority, this.attributes);
}
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2002-2021 the original author or authors.
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -40,6 +40,7 @@ import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException
*
* @author Vedran Pavic
* @author Joe Grandja
* @author Yoobin Yoon
*/
public class DefaultOidcUserTests {
@ -147,4 +148,37 @@ public class DefaultOidcUserTests {
StandardClaimNames.NAME, StandardClaimNames.EMAIL);
}
@Test
public void withUsernameWhenUsernameIsNullThenThrowIllegalArgumentException() {
assertThatIllegalArgumentException().isThrownBy(() -> DefaultOidcUser.withUsername(null));
}
@Test
public void withUsernameWhenUsernameIsEmptyThenThrowIllegalArgumentException() {
assertThatIllegalArgumentException().isThrownBy(() -> DefaultOidcUser.withUsername(""));
}
@Test
public void builderWhenIdTokenIsNotSetThenThrowIllegalArgumentException() {
assertThatIllegalArgumentException().isThrownBy(() -> DefaultOidcUser.withUsername(SUBJECT).build());
}
@Test
public void builderWhenAllParametersProvidedAndValidThenCreated() {
String username = "custom-username";
DefaultOidcUser user = DefaultOidcUser.withUsername(username)
.authorities(AUTHORITIES)
.idToken(ID_TOKEN)
.userInfo(USER_INFO)
.build();
assertThat(user.getName()).isEqualTo(username);
assertThat(user.getIdToken()).isEqualTo(ID_TOKEN);
assertThat(user.getUserInfo()).isEqualTo(USER_INFO);
assertThat(user.getAuthorities()).hasSize(1);
assertThat(user.getAuthorities().iterator().next()).isEqualTo(AUTHORITY);
assertThat(user.getAttributes()).containsOnlyKeys(IdTokenClaimNames.ISS, IdTokenClaimNames.SUB,
StandardClaimNames.NAME, StandardClaimNames.EMAIL);
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2002-2017 the original author or authors.
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -34,6 +34,7 @@ import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException
* Tests for {@link OidcUserAuthority}.
*
* @author Joe Grandja
* @author Yoobin Yoon
*/
public class OidcUserAuthorityTests {
@ -84,4 +85,43 @@ public class OidcUserAuthorityTests {
StandardClaimNames.NAME, StandardClaimNames.EMAIL);
}
@Test
public void withUsernameWhenUsernameIsNullThenThrowIllegalArgumentException() {
assertThatIllegalArgumentException().isThrownBy(() -> OidcUserAuthority.withUsername(null));
}
@Test
public void withUsernameWhenUsernameIsEmptyThenThrowIllegalArgumentException() {
assertThatIllegalArgumentException().isThrownBy(() -> OidcUserAuthority.withUsername(""));
}
@Test
public void builderWhenIdTokenIsNotSetThenThrowIllegalArgumentException() {
assertThatIllegalArgumentException().isThrownBy(() -> OidcUserAuthority.withUsername(SUBJECT).build());
}
@Test
public void builderWhenAllParametersProvidedAndValidThenCreated() {
String username = SUBJECT;
OidcUserAuthority authority = OidcUserAuthority.withUsername(username)
.idToken(ID_TOKEN)
.userInfo(USER_INFO)
.build();
assertThat(authority.getUsername()).isEqualTo(username);
assertThat(authority.getAuthority()).isEqualTo("OIDC_USER");
assertThat(authority.getIdToken()).isEqualTo(ID_TOKEN);
assertThat(authority.getUserInfo()).isEqualTo(USER_INFO);
assertThat(authority.getAttributes()).containsOnlyKeys(IdTokenClaimNames.ISS, IdTokenClaimNames.SUB,
StandardClaimNames.NAME, StandardClaimNames.EMAIL);
}
@Test
public void getUsernameWhenBuiltWithUsernameThenReturnsUsername() {
String username = SUBJECT;
OidcUserAuthority authority = OidcUserAuthority.withUsername(username).idToken(ID_TOKEN).build();
assertThat(authority.getUsername()).isEqualTo(username);
}
}

View File

@ -145,6 +145,11 @@ public class DefaultOAuth2UserTests {
.build());
}
@Test
public void withUsernameWhenUsernameNullThenThrowIllegalArgumentException() {
assertThatIllegalArgumentException().isThrownBy(() -> DefaultOAuth2User.withUsername((String) null));
}
@Test
public void withUsernameWhenCreatedThenIsSerializable() {
DefaultOAuth2User user = DefaultOAuth2User.withUsername("directUser")
@ -215,4 +220,48 @@ public class DefaultOAuth2UserTests {
assertThat(user.getAttributes()).isEqualTo(ATTRIBUTES);
}
@Test
public void withUsernameWhenNestedAttributesThenUsernameExtractedCorrectly() {
Map<String, Object> nestedAttributes = new HashMap<>();
Map<String, Object> userData = new HashMap<>();
userData.put("name", "nestedUser");
userData.put("id", "123");
nestedAttributes.put("data", userData);
nestedAttributes.put("other", "value");
DefaultOAuth2User user = DefaultOAuth2User.withUsername("nestedUser")
.authorities(AUTHORITIES)
.attributes(nestedAttributes)
.build();
assertThat(user.getName()).isEqualTo("nestedUser");
assertThat(user.getAttributes()).hasSize(2);
assertThat(user.getAttributes().get("data")).isEqualTo(userData);
assertThat(user.getAttributes().get("other")).isEqualTo("value");
}
@Test
public void withUsernameWhenComplexNestedAttributesThenCorrectlyHandled() {
Map<String, Object> attributes = new HashMap<>();
Map<String, Object> profile = new HashMap<>();
Map<String, Object> socialMedia = new HashMap<>();
socialMedia.put("twitter", "twitterUser");
socialMedia.put("github", "githubUser");
profile.put("social", socialMedia);
profile.put("email", "user@example.com");
attributes.put("profile", profile);
attributes.put("id", "user123");
DefaultOAuth2User user = DefaultOAuth2User.withUsername("customUsername")
.authorities(AUTHORITIES)
.attributes(attributes)
.build();
assertThat(user.getName()).isEqualTo("customUsername");
assertThat(user.getAttributes()).isEqualTo(attributes);
assertThat(((Map<?, ?>) ((Map<?, ?>) user.getAttribute("profile")).get("social")).get("twitter"))
.isEqualTo("twitterUser");
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2002-2022 the original author or authors.
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -30,6 +30,7 @@ import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException
* Tests for {@link OAuth2UserAuthority}.
*
* @author Joe Grandja
* @author Yoobin Yoon
*/
public class OAuth2UserAuthorityTests {
@ -94,4 +95,37 @@ public class OAuth2UserAuthorityTests {
assertThat(AUTHORITY_WITH_STRINGURL.hashCode()).isEqualTo(AUTHORITY_WITH_OBJECTURL.hashCode());
}
@Test
public void withUsernameWhenUsernameIsNullThenThrowIllegalArgumentException() {
assertThatIllegalArgumentException().isThrownBy(() -> OAuth2UserAuthority.withUsername(null));
}
@Test
public void withUsernameWhenUsernameIsEmptyThenThrowIllegalArgumentException() {
assertThatIllegalArgumentException().isThrownBy(() -> OAuth2UserAuthority.withUsername(""));
}
@Test
public void builderWhenAttributesIsNotSetThenThrowIllegalArgumentException() {
assertThatIllegalArgumentException().isThrownBy(() -> OAuth2UserAuthority.withUsername("john_doe").build());
}
@Test
public void builderWhenAllParametersProvidedAndValidThenCreated() {
String username = "john_doe";
OAuth2UserAuthority authority = OAuth2UserAuthority.withUsername(username).attributes(ATTRIBUTES).build();
assertThat(authority.getUsername()).isEqualTo(username);
assertThat(authority.getAuthority()).isEqualTo("OAUTH2_USER");
assertThat(authority.getAttributes()).isEqualTo(ATTRIBUTES);
}
@Test
public void getUsernameWhenBuiltWithUsernameThenReturnsUsername() {
String username = "john_doe";
OAuth2UserAuthority authority = OAuth2UserAuthority.withUsername(username).attributes(ATTRIBUTES).build();
assertThat(authority.getUsername()).isEqualTo(username);
}
}