This commit is contained in:
yoobin_mion 2025-07-02 06:02:21 +00:00 committed by GitHub
commit 5f4c9bfb08
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1059 additions and 142 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

@ -8,3 +8,9 @@ Below are the highlights of the release, or you can view https://github.com/spri
* Added javadoc:org.springframework.security.web.authentication.preauth.x509.SubjectX500PrincipalExtractor[] * Added javadoc:org.springframework.security.web.authentication.preauth.x509.SubjectX500PrincipalExtractor[]
* Added OAuth2 Support for xref:features/integrations/rest/http-interface.adoc[HTTP Interface Integration] * Added OAuth2 Support for xref:features/integrations/rest/http-interface.adoc[HTTP Interface Integration]
== OAuth 2.0
=== Username Expression Support for Nested Attributes - https://github.com/spring-projects/spring-security/pull/16390[gh-16390]
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.

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.
@ -32,6 +32,7 @@ import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
* This mixin class is used to serialize/deserialize {@link DefaultOAuth2User}. * This mixin class is used to serialize/deserialize {@link DefaultOAuth2User}.
* *
* @author Joe Grandja * @author Joe Grandja
* @author YooBin Yoon
* @since 5.3 * @since 5.3
* @see DefaultOAuth2User * @see DefaultOAuth2User
* @see OAuth2ClientJackson2Module * @see OAuth2ClientJackson2Module
@ -45,7 +46,7 @@ abstract class DefaultOAuth2UserMixin {
@JsonCreator @JsonCreator
DefaultOAuth2UserMixin(@JsonProperty("authorities") Collection<? extends GrantedAuthority> authorities, DefaultOAuth2UserMixin(@JsonProperty("authorities") Collection<? extends GrantedAuthority> authorities,
@JsonProperty("attributes") Map<String, Object> attributes, @JsonProperty("attributes") Map<String, Object> attributes,
@JsonProperty("nameAttributeKey") String nameAttributeKey) { @JsonProperty("nameAttributeKey") String nameAttributeKey, @JsonProperty("username") String username) {
} }
} }

View File

@ -40,7 +40,7 @@ import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser;
@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS) @JsonTypeInfo(use = JsonTypeInfo.Id.CLASS)
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility = JsonAutoDetect.Visibility.NONE, @JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility = JsonAutoDetect.Visibility.NONE,
isGetterVisibility = JsonAutoDetect.Visibility.NONE) isGetterVisibility = JsonAutoDetect.Visibility.NONE)
@JsonIgnoreProperties(value = { "attributes" }, ignoreUnknown = true) @JsonIgnoreProperties(value = { "attributes", "username" }, ignoreUnknown = true)
abstract class DefaultOidcUserMixin { abstract class DefaultOidcUserMixin {
@JsonCreator @JsonCreator

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

@ -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.
@ -194,7 +194,8 @@ public class OAuth2AuthenticationTokenMixinTests {
" \"@class\": \"java.util.Collections$UnmodifiableMap\",\n" + " \"@class\": \"java.util.Collections$UnmodifiableMap\",\n" +
" \"username\": \"user\"\n" + " \"username\": \"user\"\n" +
" },\n" + " },\n" +
" \"nameAttributeKey\": \"username\"\n" + " \"nameAttributeKey\": \"username\",\n" +
" \"username\": \"" + oauth2User.getName() + "\"\n" +
" }"; " }";
// @formatter:on // @formatter:on
} }
@ -247,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
} }
@ -261,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.
@ -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

@ -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.
@ -45,6 +45,7 @@ import org.springframework.util.Assert;
* @author Joe Grandja * @author Joe Grandja
* @author Eddú Meléndez * @author Eddú Meléndez
* @author Park Hyojong * @author Park Hyojong
* @author YooBin Yoon
* @since 5.0 * @since 5.0
* @see OAuth2User * @see OAuth2User
*/ */
@ -58,13 +59,17 @@ public class DefaultOAuth2User implements OAuth2User, Serializable {
private final String nameAttributeKey; private final String nameAttributeKey;
private final String username;
/** /**
* Constructs a {@code DefaultOAuth2User} using the provided parameters. * Constructs a {@code DefaultOAuth2User} using the provided parameters.
* @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()} * {@link #getAttributes()}
* @deprecated Use {@link #withUsername(String)} builder pattern instead
*/ */
@Deprecated
public DefaultOAuth2User(Collection<? extends GrantedAuthority> authorities, Map<String, Object> attributes, public DefaultOAuth2User(Collection<? extends GrantedAuthority> authorities, Map<String, Object> attributes,
String nameAttributeKey) { String nameAttributeKey) {
Assert.notEmpty(attributes, "attributes cannot be empty"); Assert.notEmpty(attributes, "attributes cannot be empty");
@ -77,11 +82,45 @@ 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 = attributes.get(nameAttributeKey).toString();
}
/**
* Constructs a {@code DefaultOAuth2User} using the provided parameters. This
* constructor is used by Jackson for deserialization.
* @param authorities the authorities granted to the user
* @param attributes the attributes about the user
* @param nameAttributeKey the key used to access the user's &quot;name&quot; from
* {@link #getAttributes()} - preserved for backwards compatibility
* @param username the user's name
*/
private DefaultOAuth2User(Collection<? extends GrantedAuthority> authorities, Map<String, Object> attributes,
String nameAttributeKey, String username) {
Assert.notEmpty(attributes, "attributes cannot be empty");
this.authorities = (authorities != null)
? Collections.unmodifiableSet(new LinkedHashSet<>(this.sortAuthorities(authorities)))
: Collections.unmodifiableSet(new LinkedHashSet<>(AuthorityUtils.NO_AUTHORITIES));
this.attributes = Collections.unmodifiableMap(new LinkedHashMap<>(attributes));
this.nameAttributeKey = nameAttributeKey;
this.username = username;
Assert.hasText(this.username, "username cannot be empty");
}
/**
* Creates a new {@code DefaultOAuth2User} 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 String getName() { public String getName() {
return this.getAttribute(this.nameAttributeKey).toString(); return this.username;
} }
@Override @Override
@ -140,4 +179,39 @@ public class DefaultOAuth2User implements OAuth2User, Serializable {
return sb.toString(); return sb.toString();
} }
/**
* A builder for {@link DefaultOAuth2User}.
*
* @since 7.0
*/
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);
}
}
} }

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-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

@ -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.
@ -17,6 +17,7 @@
package org.springframework.security.oauth2.core.user; package org.springframework.security.oauth2.core.user;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
@ -35,6 +36,7 @@ import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException
* @author Vedran Pavic * @author Vedran Pavic
* @author Joe Grandja * @author Joe Grandja
* @author Park Hyojong * @author Park Hyojong
* @author Yoobin Yoon
*/ */
public class DefaultOAuth2UserTests { public class DefaultOAuth2UserTests {
@ -109,4 +111,157 @@ public class DefaultOAuth2UserTests {
SerializationUtils.serialize(user); SerializationUtils.serialize(user);
} }
@Test
public void withUsernameWhenValidParametersThenCreated() {
String directUsername = "directUser";
DefaultOAuth2User user = DefaultOAuth2User.withUsername(directUsername)
.authorities(AUTHORITIES)
.attributes(ATTRIBUTES)
.build();
assertThat(user.getName()).isEqualTo(directUsername);
assertThat(user.getAuthorities()).hasSize(1);
assertThat(user.getAuthorities().iterator().next()).isEqualTo(AUTHORITY);
assertThat(user.getAttributes()).containsOnlyKeys(ATTRIBUTE_NAME_KEY);
assertThat(user.getAttributes().get(ATTRIBUTE_NAME_KEY)).isEqualTo(USERNAME);
}
@Test
public void withUsernameWhenUsernameIsEmptyThenThrowIllegalArgumentException() {
assertThatIllegalArgumentException().isThrownBy(() -> DefaultOAuth2User.withUsername(""));
}
@Test
public void withUsernameWhenAttributesIsNullThenThrowIllegalArgumentException() {
assertThatIllegalArgumentException().isThrownBy(
() -> DefaultOAuth2User.withUsername("username").authorities(AUTHORITIES).attributes(null).build());
}
@Test
public void withUsernameWhenAttributesIsEmptyThenThrowIllegalArgumentException() {
assertThatIllegalArgumentException().isThrownBy(() -> DefaultOAuth2User.withUsername("username")
.authorities(AUTHORITIES)
.attributes(Collections.emptyMap())
.build());
}
@Test
public void withUsernameWhenUsernameNullThenThrowIllegalArgumentException() {
assertThatIllegalArgumentException().isThrownBy(() -> DefaultOAuth2User.withUsername((String) null));
}
@Test
public void withUsernameWhenCreatedThenIsSerializable() {
DefaultOAuth2User user = DefaultOAuth2User.withUsername("directUser")
.authorities(AUTHORITIES)
.attributes(ATTRIBUTES)
.build();
SerializationUtils.serialize(user);
}
@Test
public void withUsernameWhenUsernameProvidedThenTakesPrecedenceOverAttributes() {
Map<String, Object> attributes = new HashMap<>();
attributes.put("username", "fromAttributes");
attributes.put("id", "123");
DefaultOAuth2User user = DefaultOAuth2User.withUsername("directUsername")
.authorities(AUTHORITIES)
.attributes(attributes)
.build();
assertThat(user.getName()).isEqualTo("directUsername");
assertThat((String) user.getAttribute("username")).isEqualTo("fromAttributes");
}
@Test
public void constructorWhenSimpleAttributeKeyThenWorksAsUsual() {
DefaultOAuth2User user = new DefaultOAuth2User(AUTHORITIES, ATTRIBUTES, ATTRIBUTE_NAME_KEY);
assertThat(user.getName()).isEqualTo(USERNAME);
assertThat(user.getAttributes()).containsOnlyKeys(ATTRIBUTE_NAME_KEY);
}
@Test
public void withUsernameAndDeprecatedConstructorWhenSameDataThenEqual() {
DefaultOAuth2User user1 = new DefaultOAuth2User(AUTHORITIES, ATTRIBUTES, ATTRIBUTE_NAME_KEY);
DefaultOAuth2User user2 = DefaultOAuth2User.withUsername(USERNAME)
.authorities(AUTHORITIES)
.attributes(ATTRIBUTES)
.build();
assertThat(user1.getName()).isEqualTo(user2.getName());
assertThat(user1.getAuthorities()).isEqualTo(user2.getAuthorities());
assertThat(user1.getAttributes()).isEqualTo(user2.getAttributes());
assertThat(user1).isEqualTo(user2);
}
@Test
public void withUsernameWhenAuthoritiesIsNullThenCreatedWithEmptyAuthorities() {
DefaultOAuth2User user = DefaultOAuth2User.withUsername("testUser")
.authorities(null)
.attributes(ATTRIBUTES)
.build();
assertThat(user.getName()).isEqualTo("testUser");
assertThat(user.getAuthorities()).isEmpty();
assertThat(user.getAttributes()).isEqualTo(ATTRIBUTES);
}
@Test
public void withUsernameWhenAuthoritiesIsEmptyThenCreated() {
DefaultOAuth2User user = DefaultOAuth2User.withUsername("testUser")
.authorities(Collections.emptySet())
.attributes(ATTRIBUTES)
.build();
assertThat(user.getName()).isEqualTo("testUser");
assertThat(user.getAuthorities()).isEmpty();
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);
}
} }