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 String uri; <14>
private AuthenticationMethod authenticationMethod; <15> private AuthenticationMethod authenticationMethod; <15>
private String userNameAttributeName; <16> private String userNameAttributeName; <16>
private String usernameExpression; <17>
} }
} }
public static final class ClientSettings { 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. <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. <15> `(userInfoEndpoint)authenticationMethod`: The authentication method used when sending the access token to the UserInfo Endpoint.
The supported values are *header*, *form* and *query*. 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. <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> [[oauth2Client-client-registration-requireProofKey]]`requireProofKey`: If `true` or if `authorizationGrantType` is `none`, then PKCE will be enabled by default. <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]. 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

@ -37,12 +37,13 @@ public final class ClientRegistration {
private String uri; <14> private String uri; <14>
private AuthenticationMethod authenticationMethod; <15> private AuthenticationMethod authenticationMethod; <15>
private String userNameAttributeName; <16> private String userNameAttributeName; <16>
private String usernameExpression; <17>
} }
} }
public static final class ClientSettings { 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. <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. <15> `(userInfoEndpoint)authenticationMethod`: The authentication method used when sending the access token to the UserInfo Endpoint.
The supported values are *header*, *form*, and *query*. 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. <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> [[oauth2Client-client-registration-requireProofKey]]`requireProofKey`: If `true` or if `authorizationGrantType` is `none`, then PKCE will be enabled by default. <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]. 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]] [[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. 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`. 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.
Note that this may affect reports that operate on this key name.
== 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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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 Joe Grandja
* @author Michael Sosa * @author Michael Sosa
* @author Yoobin Yoon
* @since 5.0 * @since 5.0
* @see <a target="_blank" href="https://tools.ietf.org/html/rfc6749#section-2">Section 2 * @see <a target="_blank" href="https://tools.ietf.org/html/rfc6749#section-2">Section 2
* Client Registration</a> * Client Registration</a>
@ -299,8 +300,11 @@ public final class ClientRegistration implements Serializable {
private AuthenticationMethod authenticationMethod = AuthenticationMethod.HEADER; private AuthenticationMethod authenticationMethod = AuthenticationMethod.HEADER;
@Deprecated
private String userNameAttributeName; private String userNameAttributeName;
private String usernameExpression;
UserInfoEndpoint() { 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 * @deprecated Use {@link #getUsernameExpression()} instead
* info response.
* @return the attribute name used to access the user's name from the user
* info response
*/ */
@Deprecated
public String getUserNameAttributeName() { public String getUserNameAttributeName() {
return this.userNameAttributeName; 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; private AuthenticationMethod userInfoAuthenticationMethod = AuthenticationMethod.HEADER;
@Deprecated
private String userNameAttributeName; private String userNameAttributeName;
private String usernameExpression;
private String jwkSetUri; private String jwkSetUri;
private String issuerUri; private String issuerUri;
@ -399,6 +414,7 @@ public final class ClientRegistration implements Serializable {
this.userInfoUri = clientRegistration.providerDetails.userInfoEndpoint.uri; this.userInfoUri = clientRegistration.providerDetails.userInfoEndpoint.uri;
this.userInfoAuthenticationMethod = clientRegistration.providerDetails.userInfoEndpoint.authenticationMethod; this.userInfoAuthenticationMethod = clientRegistration.providerDetails.userInfoEndpoint.authenticationMethod;
this.userNameAttributeName = clientRegistration.providerDetails.userInfoEndpoint.userNameAttributeName; this.userNameAttributeName = clientRegistration.providerDetails.userInfoEndpoint.userNameAttributeName;
this.usernameExpression = clientRegistration.providerDetails.userInfoEndpoint.usernameExpression;
this.jwkSetUri = clientRegistration.providerDetails.jwkSetUri; this.jwkSetUri = clientRegistration.providerDetails.jwkSetUri;
this.issuerUri = clientRegistration.providerDetails.issuerUri; this.issuerUri = clientRegistration.providerDetails.issuerUri;
Map<String, Object> configurationMetadata = clientRegistration.providerDetails.configurationMetadata; 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 * Sets the username attribute name. This method automatically converts the
* response. * attribute name to a SpEL expression for backward compatibility.
* @param userNameAttributeName the attribute name used to access the user's name *
* from the user info response * <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} * @return the {@link Builder}
*/ */
public Builder userNameAttributeName(String userNameAttributeName) { public Builder userNameAttributeName(String userNameAttributeName) {
this.userNameAttributeName = 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; return this;
} }
@ -672,7 +717,10 @@ public final class ClientRegistration implements Serializable {
providerDetails.tokenUri = this.tokenUri; providerDetails.tokenUri = this.tokenUri;
providerDetails.userInfoEndpoint.uri = this.userInfoUri; providerDetails.userInfoEndpoint.uri = this.userInfoUri;
providerDetails.userInfoEndpoint.authenticationMethod = this.userInfoAuthenticationMethod; providerDetails.userInfoEndpoint.authenticationMethod = this.userInfoAuthenticationMethod;
providerDetails.userInfoEndpoint.usernameExpression = this.usernameExpression;
providerDetails.userInfoEndpoint.userNameAttributeName = this.userNameAttributeName; providerDetails.userInfoEndpoint.userNameAttributeName = this.userNameAttributeName;
providerDetails.jwkSetUri = this.jwkSetUri; providerDetails.jwkSetUri = this.jwkSetUri;
providerDetails.issuerUri = this.issuerUri; providerDetails.issuerUri = this.issuerUri;
providerDetails.configurationMetadata = Collections.unmodifiableMap(this.configurationMetadata); 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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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.LinkedHashSet;
import java.util.Map; import java.util.Map;
import org.springframework.context.expression.MapAccessor;
import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.convert.converter.Converter; 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.RequestEntity;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.GrantedAuthority; 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 * An implementation of an {@link OAuth2UserService} that supports standard OAuth 2.0
* Provider's. * Provider's.
* <p> * <p>
* For standard OAuth 2.0 Provider's, the attribute name used to access the user's name * For standard OAuth 2.0 Provider's, the username expression used to extract the user's
* from the UserInfo response is required and therefore must be available via * name from the UserInfo response is required and therefore must be available via
* {@link ClientRegistration.ProviderDetails.UserInfoEndpoint#getUserNameAttributeName() * {@link ClientRegistration.ProviderDetails.UserInfoEndpoint#getUsernameExpression()
* UserInfoEndpoint.getUserNameAttributeName()}. * UserInfoEndpoint.getUsernameExpression()}.
* <p> * <p>
* <b>NOTE:</b> Attribute names are <b>not</b> standardized between providers and * <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 * therefore will vary. Please consult the provider's API documentation for the set of
* supported user attribute names. * supported user attribute names.
* *
* @author Joe Grandja * @author Joe Grandja
* @author Yoobin Yoon
* @since 5.0 * @since 5.0
* @see OAuth2UserService * @see OAuth2UserService
* @see OAuth2UserRequest * @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_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<>() { private static final ParameterizedTypeReference<Map<String, Object>> PARAMETERIZED_RESPONSE_TYPE = new ParameterizedTypeReference<>() {
}; };
@ -90,13 +99,67 @@ public class DefaultOAuth2UserService implements OAuth2UserService<OAuth2UserReq
@Override @Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
Assert.notNull(userRequest, "userRequest cannot be null"); Assert.notNull(userRequest, "userRequest cannot be null");
String userNameAttributeName = getUserNameAttributeName(userRequest); String usernameExpression = getUsernameExpression(userRequest);
RequestEntity<?> request = this.requestEntityConverter.convert(userRequest); RequestEntity<?> request = this.requestEntityConverter.convert(userRequest);
ResponseEntity<Map<String, Object>> response = getResponse(userRequest, request); ResponseEntity<Map<String, Object>> response = getResponse(userRequest, request);
OAuth2AccessToken token = userRequest.getAccessToken(); OAuth2AccessToken token = userRequest.getAccessToken();
Map<String, Object> attributes = this.attributesConverter.convert(userRequest).convert(response.getBody()); 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, private Collection<GrantedAuthority> getAuthorities(OAuth2AccessToken token, Map<String, Object> attributes,
String userNameAttributeName) { String username) {
Collection<GrantedAuthority> authorities = new LinkedHashSet<>(); Collection<GrantedAuthority> authorities = new LinkedHashSet<>();
authorities.add(new OAuth2UserAuthority(attributes, userNameAttributeName)); authorities.add(OAuth2UserAuthority.withUsername(username).attributes(attributes).build());
for (String authority : token.getScopes()) { for (String authority : token.getScopes()) {
authorities.add(new SimpleGrantedAuthority("SCOPE_" + authority)); 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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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 net.minidev.json.JSONObject;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import org.springframework.context.expression.MapAccessor;
import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.convert.converter.Converter; 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.HttpHeaders;
import org.springframework.http.HttpStatusCode; import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType; 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 * An implementation of an {@link ReactiveOAuth2UserService} that supports standard OAuth
* 2.0 Provider's. * 2.0 Provider's.
* <p> * <p>
* For standard OAuth 2.0 Provider's, the attribute name used to access the user's name * For standard OAuth 2.0 Provider's, the username expression used to extract the user's
* from the UserInfo response is required and therefore must be available via * name from the UserInfo response is required and therefore must be available via
* {@link org.springframework.security.oauth2.client.registration.ClientRegistration.ProviderDetails.UserInfoEndpoint#getUserNameAttributeName() * {@link org.springframework.security.oauth2.client.registration.ClientRegistration.ProviderDetails.UserInfoEndpoint#getUsernameExpression()
* UserInfoEndpoint.getUserNameAttributeName()}. * UserInfoEndpoint.getUsernameExpression()}.
* <p> * <p>
* <b>NOTE:</b> Attribute names are <b>not</b> standardized between providers and * <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 * therefore will vary. Please consult the provider's API documentation for the set of
* supported user attribute names. * supported user attribute names.
* *
* @author Rob Winch * @author Rob Winch
* @author Yoobin Yoon
* @since 5.1 * @since 5.1
* @see ReactiveOAuth2UserService * @see ReactiveOAuth2UserService
* @see OAuth2UserRequest * @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 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<>() { private static final ParameterizedTypeReference<Map<String, Object>> STRING_OBJECT_MAP = new ParameterizedTypeReference<>() {
}; };
@ -99,17 +108,7 @@ public class DefaultReactiveOAuth2UserService implements ReactiveOAuth2UserServi
null); null);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString()); throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
} }
String userNameAttributeName = userRequest.getClientRegistration() String usernameExpression = getUsernameExpression(userRequest);
.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());
}
AuthenticationMethod authenticationMethod = userRequest.getClientRegistration() AuthenticationMethod authenticationMethod = userRequest.getClientRegistration()
.getProviderDetails() .getProviderDetails()
.getUserInfoEndpoint() .getUserInfoEndpoint()
@ -130,15 +129,20 @@ public class DefaultReactiveOAuth2UserService implements ReactiveOAuth2UserServi
.bodyToMono(DefaultReactiveOAuth2UserService.STRING_OBJECT_MAP) .bodyToMono(DefaultReactiveOAuth2UserService.STRING_OBJECT_MAP)
.mapNotNull((attributes) -> this.attributesConverter.convert(userRequest).convert(attributes)); .mapNotNull((attributes) -> this.attributesConverter.convert(userRequest).convert(attributes));
return userAttributes.map((attrs) -> { return userAttributes.map((attrs) -> {
GrantedAuthority authority = new OAuth2UserAuthority(attrs, userNameAttributeName); String username = evaluateUsername(attrs, usernameExpression);
Set<GrantedAuthority> authorities = new HashSet<>(); Set<GrantedAuthority> authorities = new HashSet<>();
authorities.add(authority); authorities.add(OAuth2UserAuthority.withUsername(username)
.attributes(attrs)
.build());
OAuth2AccessToken token = userRequest.getAccessToken(); OAuth2AccessToken token = userRequest.getAccessToken();
for (String scope : token.getScopes()) { for (String scope : token.getScopes()) {
authorities.add(new SimpleGrantedAuthority("SCOPE_" + scope)); 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 || .onErrorMap((ex) -> (ex instanceof UnsupportedMediaTypeException ||
ex.getCause() instanceof UnsupportedMediaTypeException), (ex) -> { ex.getCause() instanceof UnsupportedMediaTypeException), (ex) -> {
@ -168,6 +172,47 @@ public class DefaultReactiveOAuth2UserService implements ReactiveOAuth2UserServi
// @formatter:on // @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, private WebClient.RequestHeadersSpec<?> getRequestHeaderSpec(OAuth2UserRequest userRequest, String userInfoUri,
AuthenticationMethod authenticationMethod) { AuthenticationMethod authenticationMethod) {
if (AuthenticationMethod.FORM.equals(authenticationMethod)) { if (AuthenticationMethod.FORM.equals(authenticationMethod)) {

View File

@ -248,11 +248,12 @@ public class OAuth2AuthenticationTokenMixinTests {
return "{\n" + return "{\n" +
" \"@class\": \"org.springframework.security.oauth2.core.user.OAuth2UserAuthority\",\n" + " \"@class\": \"org.springframework.security.oauth2.core.user.OAuth2UserAuthority\",\n" +
" \"authority\": \"" + oauth2UserAuthority.getAuthority() + "\",\n" + " \"authority\": \"" + oauth2UserAuthority.getAuthority() + "\",\n" +
" \"userNameAttributeName\": \"username\",\n" +
" \"attributes\": {\n" + " \"attributes\": {\n" +
" \"@class\": \"java.util.Collections$UnmodifiableMap\",\n" + " \"@class\": \"java.util.Collections$UnmodifiableMap\",\n" +
" \"username\": \"user\"\n" + " \"username\": \"user\"\n" +
" }\n" + " },\n" +
" \"userNameAttributeName\": \"username\",\n" +
" \"username\": \"user\"\n" +
" }"; " }";
// @formatter:on // @formatter:on
} }
@ -262,9 +263,10 @@ public class OAuth2AuthenticationTokenMixinTests {
return "{\n" + return "{\n" +
" \"@class\": \"org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority\",\n" + " \"@class\": \"org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority\",\n" +
" \"authority\": \"" + oidcUserAuthority.getAuthority() + "\",\n" + " \"authority\": \"" + oidcUserAuthority.getAuthority() + "\",\n" +
" \"userNameAttributeName\": \"" + oidcUserAuthority.getUserNameAttributeName() + "\",\n" +
" \"idToken\": " + asJson(oidcUserAuthority.getIdToken()) + ",\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 // @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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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()); .isEqualTo(expectedClientRegistration.getProviderDetails().getUserInfoEndpoint().getAuthenticationMethod());
assertThat(clientRegistration.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName()).isEqualTo( assertThat(clientRegistration.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName()).isEqualTo(
expectedClientRegistration.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName()); expectedClientRegistration.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName());
assertThat(clientRegistration.getProviderDetails().getUserInfoEndpoint().getUsernameExpression())
.isEqualTo(expectedClientRegistration.getProviderDetails().getUserInfoEndpoint().getUsernameExpression());
assertThat(clientRegistration.getProviderDetails().getJwkSetUri()) assertThat(clientRegistration.getProviderDetails().getJwkSetUri())
.isEqualTo(expectedClientRegistration.getProviderDetails().getJwkSetUri()); .isEqualTo(expectedClientRegistration.getProviderDetails().getJwkSetUri());
assertThat(clientRegistration.getProviderDetails().getIssuerUri()) assertThat(clientRegistration.getProviderDetails().getIssuerUri())
@ -306,6 +308,8 @@ public class OAuth2AuthorizedClientMixinTests {
.map((key) -> "\"" + key + "\": \"" + providerDetails.getConfigurationMetadata().get(key) + "\"") .map((key) -> "\"" + key + "\": \"" + providerDetails.getConfigurationMetadata().get(key) + "\"")
.collect(Collectors.joining(",")); .collect(Collectors.joining(","));
} }
String usernameExpression = (userInfoEndpoint.getUsernameExpression() != null)
? "\"" + userInfoEndpoint.getUsernameExpression() + "\"" : null;
// @formatter:off // @formatter:off
return "{\n" + return "{\n" +
" \"@class\": \"org.springframework.security.oauth2.client.registration.ClientRegistration\",\n" + " \"@class\": \"org.springframework.security.oauth2.client.registration.ClientRegistration\",\n" +
@ -333,7 +337,8 @@ public class OAuth2AuthorizedClientMixinTests {
" \"authenticationMethod\": {\n" + " \"authenticationMethod\": {\n" +
" \"value\": \"" + userInfoEndpoint.getAuthenticationMethod().getValue() + "\"\n" + " \"value\": \"" + userInfoEndpoint.getAuthenticationMethod().getValue() + "\"\n" +
" },\n" + " },\n" +
" \"userNameAttributeName\": " + ((userInfoEndpoint.getUserNameAttributeName() != null) ? "\"" + userInfoEndpoint.getUserNameAttributeName() + "\"" : null) + "\n" + " \"userNameAttributeName\": " + ((userInfoEndpoint.getUserNameAttributeName() != null) ? "\"" + userInfoEndpoint.getUserNameAttributeName() + "\"" : null) + ",\n" +
" \"usernameExpression\": " + usernameExpression + "\n" +
" },\n" + " },\n" +
" \"jwkSetUri\": " + ((providerDetails.getJwkSetUri() != null) ? "\"" + providerDetails.getJwkSetUri() + "\"" : null) + ",\n" + " \"jwkSetUri\": " + ((providerDetails.getJwkSetUri() != null) ? "\"" + providerDetails.getJwkSetUri() + "\"" : null) + ",\n" +
" \"issuerUri\": " + ((providerDetails.getIssuerUri() != null) ? "\"" + providerDetails.getIssuerUri() + "\"" : 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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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}. * Tests for {@link ClientRegistration}.
* *
* @author Joe Grandja * @author Joe Grandja
* @author Yoobin Yoon
*/ */
public class ClientRegistrationTests { public class ClientRegistrationTests {
@ -716,6 +717,7 @@ public class ClientRegistrationTests {
.isEqualTo(updatedUserInfoEndpoint.getAuthenticationMethod()); .isEqualTo(updatedUserInfoEndpoint.getAuthenticationMethod());
assertThat(userInfoEndpoint.getUserNameAttributeName()) assertThat(userInfoEndpoint.getUserNameAttributeName())
.isEqualTo(updatedUserInfoEndpoint.getUserNameAttributeName()); .isEqualTo(updatedUserInfoEndpoint.getUserNameAttributeName());
assertThat(userInfoEndpoint.getUsernameExpression()).isEqualTo(updatedUserInfoEndpoint.getUsernameExpression());
assertThat(providerDetails.getJwkSetUri()).isEqualTo(updatedProviderDetails.getJwkSetUri()); assertThat(providerDetails.getJwkSetUri()).isEqualTo(updatedProviderDetails.getJwkSetUri());
assertThat(providerDetails.getIssuerUri()).isEqualTo(updatedProviderDetails.getIssuerUri()); assertThat(providerDetails.getIssuerUri()).isEqualTo(updatedProviderDetails.getIssuerUri());
assertThat(providerDetails.getConfigurationMetadata()) assertThat(providerDetails.getConfigurationMetadata())
@ -802,6 +804,84 @@ public class ClientRegistrationTests {
assertThat(clientRegistration.getClientSettings().isRequireProofKey()).isTrue(); 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 @ParameterizedTest
@MethodSource("invalidPkceGrantTypes") @MethodSource("invalidPkceGrantTypes")
void buildWhenInvalidGrantTypeForPkceThenException(AuthorizationGrantType invalidGrantType) { 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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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 Joe Grandja
* @author Eddú Meléndez * @author Eddú Meléndez
* @author Yoobin Yoon
*/ */
public class DefaultOAuth2UserServiceTests { public class DefaultOAuth2UserServiceTests {
@ -121,7 +122,7 @@ public class DefaultOAuth2UserServiceTests {
// @formatter:on // @formatter:on
assertThatExceptionOfType(OAuth2AuthenticationException.class) assertThatExceptionOfType(OAuth2AuthenticationException.class)
.isThrownBy(() -> this.userService.loadUser(new OAuth2UserRequest(clientRegistration, this.accessToken))) .isThrownBy(() -> this.userService.loadUser(new OAuth2UserRequest(clientRegistration, this.accessToken)))
.withMessageContaining("missing_user_name_attribute"); .withMessageContaining("invalid_user_info_response");
} }
@Test @Test
@ -153,10 +154,13 @@ public class DefaultOAuth2UserServiceTests {
assertThat((String) user.getAttribute("email")).isEqualTo("user1@example.com"); assertThat((String) user.getAttribute("email")).isEqualTo("user1@example.com");
assertThat(user.getAuthorities()).hasSize(1); assertThat(user.getAuthorities()).hasSize(1);
assertThat(user.getAuthorities().iterator().next()).isInstanceOf(OAuth2UserAuthority.class); 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.getAuthority()).isEqualTo("OAUTH2_USER");
assertThat(userAuthority.getAttributes()).isEqualTo(user.getAttributes()); assertThat(userAuthority.getAttributes()).isEqualTo(user.getAttributes());
assertThat(userAuthority.getUserNameAttributeName()).isEqualTo("user-name"); assertThat(userAuthority.getUsername()).isEqualTo("user1");
} }
@Test @Test
@ -194,10 +198,13 @@ public class DefaultOAuth2UserServiceTests {
assertThat((String) user.getAttribute("email")).isEqualTo("user1@example.com"); assertThat((String) user.getAttribute("email")).isEqualTo("user1@example.com");
assertThat(user.getAuthorities()).hasSize(1); assertThat(user.getAuthorities()).hasSize(1);
assertThat(user.getAuthorities().iterator().next()).isInstanceOf(OAuth2UserAuthority.class); 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.getAuthority()).isEqualTo("OAUTH2_USER");
assertThat(userAuthority.getAttributes()).isEqualTo(user.getAttributes()); assertThat(userAuthority.getAttributes()).isEqualTo(user.getAttributes());
assertThat(userAuthority.getUserNameAttributeName()).isEqualTo("user-name"); assertThat(userAuthority.getUsername()).isEqualTo("user1");
} }
@Test @Test
@ -421,6 +428,134 @@ public class DefaultOAuth2UserServiceTests {
.isThrownBy(() -> this.userService.setAttributesConverter(null)); .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) { private DefaultOAuth2UserService withMockResponse(Map<String, Object> response) {
ResponseEntity<Map<String, Object>> responseEntity = new ResponseEntity<>(response, HttpStatus.OK); ResponseEntity<Map<String, Object>> responseEntity = new ResponseEntity<>(response, HttpStatus.OK);
Converter<OAuth2UserRequest, RequestEntity<?>> requestEntityConverter = mock(Converter.class); 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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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 Rob Winch
* @author Eddú Meléndez * @author Eddú Meléndez
* @author Yoobin Yoon
* @since 5.1 * @since 5.1
*/ */
public class DefaultReactiveOAuth2UserServiceTests { public class DefaultReactiveOAuth2UserServiceTests {
@ -104,19 +105,6 @@ public class DefaultReactiveOAuth2UserServiceTests {
.verify(); .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 @Test
public void loadUserWhenUserInfoSuccessResponseThenReturnUser() { public void loadUserWhenUserInfoSuccessResponseThenReturnUser() {
// @formatter:off // @formatter:off
@ -141,10 +129,13 @@ public class DefaultReactiveOAuth2UserServiceTests {
assertThat((String) user.getAttribute("email")).isEqualTo("user1@example.com"); assertThat((String) user.getAttribute("email")).isEqualTo("user1@example.com");
assertThat(user.getAuthorities()).hasSize(1); assertThat(user.getAuthorities()).hasSize(1);
assertThat(user.getAuthorities().iterator().next()).isInstanceOf(OAuth2UserAuthority.class); 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.getAuthority()).isEqualTo("OAUTH2_USER");
assertThat(userAuthority.getAttributes()).isEqualTo(user.getAttributes()); assertThat(userAuthority.getAttributes()).isEqualTo(user.getAttributes());
assertThat(userAuthority.getUserNameAttributeName()).isEqualTo("id"); assertThat(userAuthority.getUsername()).isEqualTo("user1");
} }
// gh-9336 // gh-9336
@ -201,10 +192,13 @@ public class DefaultReactiveOAuth2UserServiceTests {
assertThat((String) user.getAttribute("email")).isEqualTo("user1@example.com"); assertThat((String) user.getAttribute("email")).isEqualTo("user1@example.com");
assertThat(user.getAuthorities()).hasSize(1); assertThat(user.getAuthorities()).hasSize(1);
assertThat(user.getAuthorities().iterator().next()).isInstanceOf(OAuth2UserAuthority.class); 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.getAuthority()).isEqualTo("OAUTH2_USER");
assertThat(userAuthority.getAttributes()).isEqualTo(user.getAttributes()); assertThat(userAuthority.getAttributes()).isEqualTo(user.getAttributes());
assertThat(userAuthority.getUserNameAttributeName()).isEqualTo("user-name"); assertThat(userAuthority.getUsername()).isEqualTo("user1");
} }
// gh-5500 // gh-5500
@ -338,6 +332,66 @@ public class DefaultReactiveOAuth2UserServiceTests {
.isThrownBy(() -> this.userService.setAttributesConverter(null)); .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) { private DefaultReactiveOAuth2UserService withMockResponse(Map<String, Object> body) {
WebClient real = WebClient.builder().build(); WebClient real = WebClient.builder().build();
WebClient.RequestHeadersUriSpec spec = spy(real.post()); 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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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.OidcIdToken;
import org.springframework.security.oauth2.core.oidc.OidcUserInfo; import org.springframework.security.oauth2.core.oidc.OidcUserInfo;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User; import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import org.springframework.util.Assert;
/** /**
* The default implementation of an {@link OidcUser}. * The default implementation of an {@link OidcUser}.
@ -35,6 +36,7 @@ import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
* *
* @author Joe Grandja * @author Joe Grandja
* @author Vedran Pavic * @author Vedran Pavic
* @author Yoobin Yoon
* @since 5.0 * @since 5.0
* @see OidcUser * @see OidcUser
* @see DefaultOAuth2User * @see DefaultOAuth2User
@ -54,7 +56,9 @@ public class DefaultOidcUser extends DefaultOAuth2User implements OidcUser {
* Constructs a {@code DefaultOidcUser} using the provided parameters. * Constructs a {@code DefaultOidcUser} using the provided parameters.
* @param authorities the authorities granted to the user * @param authorities the authorities granted to the user
* @param idToken the {@link OidcIdToken ID Token} containing claims about 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) { public DefaultOidcUser(Collection<? extends GrantedAuthority> authorities, OidcIdToken idToken) {
this(authorities, idToken, IdTokenClaimNames.SUB); 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 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 * @param nameAttributeKey the key used to access the user's &quot;name&quot; from
* {@link #getAttributes()} * {@link #getAttributes()}
* @deprecated Use {@link #withUsername(String)} builder pattern instead
*/ */
@Deprecated
public DefaultOidcUser(Collection<? extends GrantedAuthority> authorities, OidcIdToken idToken, public DefaultOidcUser(Collection<? extends GrantedAuthority> authorities, OidcIdToken idToken,
String nameAttributeKey) { String nameAttributeKey) {
this(authorities, idToken, null, 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 idToken the {@link OidcIdToken ID Token} containing claims about the user
* @param userInfo the {@link OidcUserInfo UserInfo} containing claims about the user, * @param userInfo the {@link OidcUserInfo UserInfo} containing claims about the user,
* may be {@code null} * may be {@code null}
* @deprecated Use {@link #withUsername(String)} builder pattern instead
*/ */
@Deprecated
public DefaultOidcUser(Collection<? extends GrantedAuthority> authorities, OidcIdToken idToken, public DefaultOidcUser(Collection<? extends GrantedAuthority> authorities, OidcIdToken idToken,
OidcUserInfo userInfo) { OidcUserInfo userInfo) {
this(authorities, idToken, userInfo, IdTokenClaimNames.SUB); this(authorities, idToken, userInfo, IdTokenClaimNames.SUB);
@ -91,7 +99,9 @@ public class DefaultOidcUser extends DefaultOAuth2User implements OidcUser {
* may be {@code null} * may be {@code null}
* @param nameAttributeKey the key used to access the user's &quot;name&quot; from * @param nameAttributeKey the key used to access the user's &quot;name&quot; from
* {@link #getAttributes()} * {@link #getAttributes()}
* @deprecated Use {@link #withUsername(String)} builder pattern instead
*/ */
@Deprecated
public DefaultOidcUser(Collection<? extends GrantedAuthority> authorities, OidcIdToken idToken, public DefaultOidcUser(Collection<? extends GrantedAuthority> authorities, OidcIdToken idToken,
OidcUserInfo userInfo, String nameAttributeKey) { OidcUserInfo userInfo, String nameAttributeKey) {
super(authorities, OidcUserAuthority.collectClaims(idToken, userInfo), nameAttributeKey); super(authorities, OidcUserAuthority.collectClaims(idToken, userInfo), nameAttributeKey);
@ -99,6 +109,34 @@ public class DefaultOidcUser extends DefaultOAuth2User implements OidcUser {
this.userInfo = userInfo; 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 @Override
public Map<String, Object> getClaims() { public Map<String, Object> getClaims() {
return this.getAttributes(); return this.getAttributes();
@ -114,4 +152,56 @@ public class DefaultOidcUser extends DefaultOAuth2User implements OidcUser {
return this.userInfo; 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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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}. * A {@link GrantedAuthority} that may be associated to an {@link OidcUser}.
* *
* @author Joe Grandja * @author Joe Grandja
* @author Yoobin Yoon
* @since 5.0 * @since 5.0
* @see OidcUser * @see OidcUser
*/ */
@ -47,7 +48,9 @@ public class OidcUserAuthority extends OAuth2UserAuthority {
/** /**
* Constructs a {@code OidcUserAuthority} using the provided parameters. * Constructs a {@code OidcUserAuthority} using the provided parameters.
* @param idToken the {@link OidcIdToken ID Token} containing claims about the user * @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) { public OidcUserAuthority(OidcIdToken idToken) {
this(idToken, null); 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 idToken the {@link OidcIdToken ID Token} containing claims about the user
* @param userInfo the {@link OidcUserInfo UserInfo} containing claims about the user, * @param userInfo the {@link OidcUserInfo UserInfo} containing claims about the user,
* may be {@code null} * may be {@code null}
* @deprecated Use {@link #withUsername(String)} builder pattern instead
*/ */
@Deprecated
public OidcUserAuthority(OidcIdToken idToken, OidcUserInfo userInfo) { public OidcUserAuthority(OidcIdToken idToken, OidcUserInfo userInfo) {
this("OIDC_USER", idToken, 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 * @param userNameAttributeName the attribute name used to access the user's name from
* the attributes * the attributes
* @since 6.4 * @since 6.4
* @deprecated Use {@link #withUsername(String)} builder pattern instead
*/ */
@Deprecated
public OidcUserAuthority(OidcIdToken idToken, OidcUserInfo userInfo, @Nullable String userNameAttributeName) { public OidcUserAuthority(OidcIdToken idToken, OidcUserInfo userInfo, @Nullable String userNameAttributeName) {
this("OIDC_USER", idToken, userInfo, 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 idToken the {@link OidcIdToken ID Token} containing claims about the user
* @param userInfo the {@link OidcUserInfo UserInfo} containing claims about the user, * @param userInfo the {@link OidcUserInfo UserInfo} containing claims about the user,
* may be {@code null} * may be {@code null}
* @deprecated Use {@link #withUsername(String)} builder pattern instead
*/ */
@Deprecated
public OidcUserAuthority(String authority, OidcIdToken idToken, OidcUserInfo userInfo) { public OidcUserAuthority(String authority, OidcIdToken idToken, OidcUserInfo userInfo) {
this(authority, idToken, userInfo, IdTokenClaimNames.SUB); 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 * @param userNameAttributeName the attribute name used to access the user's name from
* the attributes * the attributes
* @since 6.4 * @since 6.4
* @deprecated Use {@link #withUsername(String)} builder pattern instead
*/ */
@Deprecated
public OidcUserAuthority(String authority, OidcIdToken idToken, OidcUserInfo userInfo, public OidcUserAuthority(String authority, OidcIdToken idToken, OidcUserInfo userInfo,
@Nullable String userNameAttributeName) { @Nullable String userNameAttributeName) {
super(authority, collectClaims(idToken, userInfo), userNameAttributeName); super(authority, collectClaims(idToken, userInfo), userNameAttributeName);
@ -105,6 +116,33 @@ public class OidcUserAuthority extends OAuth2UserAuthority {
this.userInfo = userInfo; 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. * Returns the {@link OidcIdToken ID Token} containing claims about the user.
* @return the {@link OidcIdToken} 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; 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}. * The default implementation of an {@link OAuth2User}.
* *
* <p> * <p>
* User attribute names are <b>not</b> standardized between providers and therefore it is * User attribute names are <b>not</b> standardized between providers. The recommended
* required to supply the <i>key</i> for the user's &quot;name&quot; attribute to one of * approach is to use {@link #withUsername(String)} builder pattern to directly specify
* the constructors. The <i>key</i> will be used for accessing the &quot;name&quot; of the * the username, eliminating the need to determine attribute keys. Alternatively, when
* {@code Principal} (user) via {@link #getAttributes()} and returning it from * using the deprecated constructors, it is required to supply the <i>key</i> for the
* {@link #getName()}. * 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 Joe Grandja
* @author Eddú Meléndez * @author Eddú Meléndez
* @author Park Hyojong * @author Park Hyojong
* @author YooBin Yoon * @author Yoobin Yoon
* @since 5.0 * @since 5.0
* @see OAuth2User * @see OAuth2User
*/ */
@ -57,6 +59,7 @@ public class DefaultOAuth2User implements OAuth2User, Serializable {
private final Map<String, Object> attributes; private final Map<String, Object> attributes;
@Deprecated
private final String nameAttributeKey; private final String nameAttributeKey;
private final String username; private final String username;
@ -86,15 +89,14 @@ public class DefaultOAuth2User implements OAuth2User, Serializable {
} }
/** /**
* Constructs a {@code DefaultOAuth2User} using the provided parameters. This * Constructs a {@code DefaultOAuth2User} using the provided parameters.
* constructor is used by Jackson for deserialization.
* @param authorities the authorities granted to the user * @param authorities the authorities granted to the user
* @param attributes the attributes about the user * @param attributes the attributes about the user
* @param nameAttributeKey the key used to access the user's &quot;name&quot; from * @param nameAttributeKey the key used to access the user's &quot;name&quot; from
* {@link #getAttributes()} - preserved for backwards compatibility * {@link #getAttributes()} - preserved for backwards compatibility
* @param username the user's name * @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) { String nameAttributeKey, String username) {
Assert.notEmpty(attributes, "attributes cannot be empty"); Assert.notEmpty(attributes, "attributes cannot be empty");
@ -103,7 +105,7 @@ public class DefaultOAuth2User implements OAuth2User, Serializable {
: Collections.unmodifiableSet(new LinkedHashSet<>(AuthorityUtils.NO_AUTHORITIES)); : Collections.unmodifiableSet(new LinkedHashSet<>(AuthorityUtils.NO_AUTHORITIES));
this.attributes = Collections.unmodifiableMap(new LinkedHashMap<>(attributes)); this.attributes = Collections.unmodifiableMap(new LinkedHashMap<>(attributes));
this.nameAttributeKey = nameAttributeKey; this.nameAttributeKey = nameAttributeKey;
this.username = (username != null) ? username : attributes.get(nameAttributeKey).toString(); this.username = username;
Assert.hasText(this.username, "username cannot be empty"); 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. * Creates a new {@code DefaultOAuth2User} builder with the username.
* @param username the user's name * @param username the user's name
* @return a new {@code Builder} * @return a new {@code Builder}
* @since 6.5 * @since 7.0
*/ */
public static Builder withUsername(String username) { public static Builder withUsername(String username) {
return new Builder(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 @Override
public String getName() { public String getName() {
return this.username; return this.username;
@ -214,4 +181,39 @@ public class DefaultOAuth2User implements OAuth2User, Serializable {
return sb.toString(); 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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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}. * A {@link GrantedAuthority} that may be associated to an {@link OAuth2User}.
* *
* @author Joe Grandja * @author Joe Grandja
* @author Yoobin Yoon
* @since 5.0 * @since 5.0
* @see OAuth2User * @see OAuth2User
*/ */
@ -42,13 +43,18 @@ public class OAuth2UserAuthority implements GrantedAuthority {
private final Map<String, Object> attributes; private final Map<String, Object> attributes;
@Deprecated
private final String userNameAttributeName; private final String userNameAttributeName;
private final String username;
/** /**
* Constructs a {@code OAuth2UserAuthority} using the provided parameters and defaults * Constructs a {@code OAuth2UserAuthority} using the provided parameters and defaults
* {@link #getAuthority()} to {@code OAUTH2_USER}. * {@link #getAuthority()} to {@code OAUTH2_USER}.
* @param attributes the attributes about the user * @param attributes the attributes about the user
* @deprecated Use {@link #withUsername(String)} builder pattern instead
*/ */
@Deprecated
public OAuth2UserAuthority(Map<String, Object> attributes) { public OAuth2UserAuthority(Map<String, Object> attributes) {
this("OAUTH2_USER", 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 * @param userNameAttributeName the attribute name used to access the user's name from
* the attributes * the attributes
* @since 6.4 * @since 6.4
* @deprecated Use {@link #withUsername(String)} builder pattern instead
*/ */
@Deprecated
public OAuth2UserAuthority(Map<String, Object> attributes, @Nullable String userNameAttributeName) { public OAuth2UserAuthority(Map<String, Object> attributes, @Nullable String userNameAttributeName) {
this("OAUTH2_USER", attributes, userNameAttributeName); this("OAUTH2_USER", attributes, userNameAttributeName);
} }
@ -69,7 +77,9 @@ public class OAuth2UserAuthority implements GrantedAuthority {
* Constructs a {@code OAuth2UserAuthority} using the provided parameters. * Constructs a {@code OAuth2UserAuthority} using the provided parameters.
* @param authority the authority granted to the user * @param authority the authority granted to the user
* @param attributes the attributes about 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) { public OAuth2UserAuthority(String authority, Map<String, Object> attributes) {
this(authority, attributes, null); 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 * @param userNameAttributeName the attribute name used to access the user's name from
* the attributes * the attributes
* @since 6.4 * @since 6.4
* @deprecated Use {@link #withUsername(String)} builder pattern instead
*/ */
@Deprecated
public OAuth2UserAuthority(String authority, Map<String, Object> attributes, String userNameAttributeName) { public OAuth2UserAuthority(String authority, Map<String, Object> attributes, String userNameAttributeName) {
Assert.hasText(authority, "authority cannot be empty"); Assert.hasText(authority, "authority cannot be empty");
Assert.notEmpty(attributes, "attributes cannot be empty"); Assert.notEmpty(attributes, "attributes cannot be empty");
this.authority = authority; this.authority = authority;
this.attributes = Collections.unmodifiableMap(new LinkedHashMap<>(attributes)); this.attributes = Collections.unmodifiableMap(new LinkedHashMap<>(attributes));
this.userNameAttributeName = userNameAttributeName; 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 @Override
@ -107,12 +147,26 @@ public class OAuth2UserAuthority implements GrantedAuthority {
* Returns the attribute name used to access the user's name from the attributes. * 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 * @return the attribute name used to access the user's name from the attributes
* @since 6.4 * @since 6.4
* @deprecated Use {@link #getUsername()} instead
*/ */
@Deprecated
@Nullable @Nullable
public String getUserNameAttributeName() { public String getUserNameAttributeName() {
return this.userNameAttributeName; 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 @Override
public boolean equals(Object obj) { public boolean equals(Object obj) {
if (this == obj) { if (this == obj) {
@ -125,6 +179,9 @@ public class OAuth2UserAuthority implements GrantedAuthority {
if (!this.getAuthority().equals(that.getAuthority())) { if (!this.getAuthority().equals(that.getAuthority())) {
return false; return false;
} }
if (!Objects.equals(this.username, that.username)) {
return false;
}
Map<String, Object> thatAttributes = that.getAttributes(); Map<String, Object> thatAttributes = that.getAttributes();
if (getAttributes().size() != thatAttributes.size()) { if (getAttributes().size() != thatAttributes.size()) {
return false; return false;
@ -150,7 +207,7 @@ public class OAuth2UserAuthority implements GrantedAuthority {
@Override @Override
public int hashCode() { public int hashCode() {
int result = this.getAuthority().hashCode(); int result = this.getAuthority().hashCode();
result = 31 * result; result = 31 * result + Objects.hashCode(this.username);
for (Map.Entry<String, Object> e : getAttributes().entrySet()) { for (Map.Entry<String, Object> e : getAttributes().entrySet()) {
Object key = e.getKey(); Object key = e.getKey();
Object value = convertURLIfNecessary(e.getValue()); Object value = convertURLIfNecessary(e.getValue());
@ -172,4 +229,39 @@ public class OAuth2UserAuthority implements GrantedAuthority {
return (value instanceof URL) ? ((URL) value).toExternalForm() : value; 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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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 Vedran Pavic
* @author Joe Grandja * @author Joe Grandja
* @author Yoobin Yoon
*/ */
public class DefaultOidcUserTests { public class DefaultOidcUserTests {
@ -147,4 +148,37 @@ public class DefaultOidcUserTests {
StandardClaimNames.NAME, StandardClaimNames.EMAIL); 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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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}. * Tests for {@link OidcUserAuthority}.
* *
* @author Joe Grandja * @author Joe Grandja
* @author Yoobin Yoon
*/ */
public class OidcUserAuthorityTests { public class OidcUserAuthorityTests {
@ -84,4 +85,43 @@ public class OidcUserAuthorityTests {
StandardClaimNames.NAME, StandardClaimNames.EMAIL); 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()); .build());
} }
@Test
public void withUsernameWhenUsernameNullThenThrowIllegalArgumentException() {
assertThatIllegalArgumentException().isThrownBy(() -> DefaultOAuth2User.withUsername((String) null));
}
@Test @Test
public void withUsernameWhenCreatedThenIsSerializable() { public void withUsernameWhenCreatedThenIsSerializable() {
DefaultOAuth2User user = DefaultOAuth2User.withUsername("directUser") DefaultOAuth2User user = DefaultOAuth2User.withUsername("directUser")
@ -215,4 +220,48 @@ public class DefaultOAuth2UserTests {
assertThat(user.getAttributes()).isEqualTo(ATTRIBUTES); 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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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}. * Tests for {@link OAuth2UserAuthority}.
* *
* @author Joe Grandja * @author Joe Grandja
* @author Yoobin Yoon
*/ */
public class OAuth2UserAuthorityTests { public class OAuth2UserAuthorityTests {
@ -94,4 +95,37 @@ public class OAuth2UserAuthorityTests {
assertThat(AUTHORITY_WITH_STRINGURL.hashCode()).isEqualTo(AUTHORITY_WITH_OBJECTURL.hashCode()); 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);
}
} }