From bb084842e4e023979da7fa7a7d61048722b8d393 Mon Sep 17 00:00:00 2001 From: yybmion Date: Wed, 11 Jun 2025 19:50:33 +0900 Subject: [PATCH 1/3] Allow injecting the principal name into DefaultOAuth2User - Add username field to DefaultOAuth2User for direct name injection - Add Builder pattern with DefaultOAuth2User.withUsername(String) static factory method - Deprecate constructor that uses nameAttributeKey lookup in favor of Builder pattern - Update Jackson mixins to support username field serialization/deserialization This change prepares for SpEL support in the next commit. Signed-off-by: yybmion --- .../jackson2/DefaultOAuth2UserMixin.java | 3 +- .../client/jackson2/DefaultOidcUserMixin.java | 2 +- .../OAuth2AuthenticationTokenMixinTests.java | 3 +- .../oauth2/core/user/DefaultOAuth2User.java | 76 ++++++++++++- .../core/user/DefaultOAuth2UserTests.java | 106 ++++++++++++++++++ 5 files changed, 186 insertions(+), 4 deletions(-) diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/jackson2/DefaultOAuth2UserMixin.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/jackson2/DefaultOAuth2UserMixin.java index ffe5bfae30..89a9bfb08a 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/jackson2/DefaultOAuth2UserMixin.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/jackson2/DefaultOAuth2UserMixin.java @@ -32,6 +32,7 @@ * This mixin class is used to serialize/deserialize {@link DefaultOAuth2User}. * * @author Joe Grandja + * @author YooBin Yoon * @since 5.3 * @see DefaultOAuth2User * @see OAuth2ClientJackson2Module @@ -45,7 +46,7 @@ abstract class DefaultOAuth2UserMixin { @JsonCreator DefaultOAuth2UserMixin(@JsonProperty("authorities") Collection authorities, @JsonProperty("attributes") Map attributes, - @JsonProperty("nameAttributeKey") String nameAttributeKey) { + @JsonProperty("nameAttributeKey") String nameAttributeKey, @JsonProperty("username") String username) { } } diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/jackson2/DefaultOidcUserMixin.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/jackson2/DefaultOidcUserMixin.java index da2136b0ce..05a142876f 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/jackson2/DefaultOidcUserMixin.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/jackson2/DefaultOidcUserMixin.java @@ -40,7 +40,7 @@ @JsonTypeInfo(use = JsonTypeInfo.Id.CLASS) @JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility = JsonAutoDetect.Visibility.NONE, isGetterVisibility = JsonAutoDetect.Visibility.NONE) -@JsonIgnoreProperties(value = { "attributes" }, ignoreUnknown = true) +@JsonIgnoreProperties(value = { "attributes", "username" }, ignoreUnknown = true) abstract class DefaultOidcUserMixin { @JsonCreator diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/jackson2/OAuth2AuthenticationTokenMixinTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/jackson2/OAuth2AuthenticationTokenMixinTests.java index 8b25c5a559..2fdec9c4e7 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/jackson2/OAuth2AuthenticationTokenMixinTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/jackson2/OAuth2AuthenticationTokenMixinTests.java @@ -194,7 +194,8 @@ private static String asJson(DefaultOAuth2User oauth2User) { " \"@class\": \"java.util.Collections$UnmodifiableMap\",\n" + " \"username\": \"user\"\n" + " },\n" + - " \"nameAttributeKey\": \"username\"\n" + + " \"nameAttributeKey\": \"username\",\n" + + " \"username\": \"" + oauth2User.getName() + "\"\n" + " }"; // @formatter:on } diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/user/DefaultOAuth2User.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/user/DefaultOAuth2User.java index a8d09ec7aa..64404e87c2 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/user/DefaultOAuth2User.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/user/DefaultOAuth2User.java @@ -44,6 +44,7 @@ * @author Joe Grandja * @author Eddú Meléndez * @author Park Hyojong + * @author YooBin Yoon * @since 5.0 * @see OAuth2User */ @@ -57,13 +58,17 @@ public class DefaultOAuth2User implements OAuth2User, Serializable { private final String nameAttributeKey; + private final String username; + /** * Constructs a {@code DefaultOAuth2User} using the provided parameters. * @param authorities the authorities granted to the user * @param attributes the attributes about the user * @param nameAttributeKey the key used to access the user's "name" from * {@link #getAttributes()} + * @deprecated Use {@link #withUsername(String)} builder pattern instead */ + @Deprecated public DefaultOAuth2User(Collection authorities, Map attributes, String nameAttributeKey) { Assert.notEmpty(attributes, "attributes cannot be empty"); @@ -76,11 +81,80 @@ public DefaultOAuth2User(Collection authorities, Map : Collections.unmodifiableSet(new LinkedHashSet<>(AuthorityUtils.NO_AUTHORITIES)); this.attributes = Collections.unmodifiableMap(new LinkedHashMap<>(attributes)); 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 "name" from + * {@link #getAttributes()} - preserved for backwards compatibility + * @param username the user's name + */ + private DefaultOAuth2User(Collection authorities, Map 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 != null) ? username : attributes.get(nameAttributeKey).toString(); + + 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 6.5 + */ + public static Builder withUsername(String username) { + return new Builder(username); + } + + /** + * A builder for {@link DefaultOAuth2User}. + * + * @since 6.5 + */ + public static final class Builder { + + private final String username; + + private Collection authorities; + + private Map attributes; + + private Builder(String username) { + Assert.hasText(username, "username cannot be empty"); + this.username = username; + } + + public Builder authorities(Collection authorities) { + this.authorities = authorities; + return this; + } + + public Builder attributes(Map attributes) { + this.attributes = attributes; + return this; + } + + public DefaultOAuth2User build() { + Assert.notEmpty(this.attributes, "attributes cannot be empty"); + return new DefaultOAuth2User(this.authorities, this.attributes, null, this.username); + } + } @Override public String getName() { - return this.getAttribute(this.nameAttributeKey).toString(); + return this.username; } @Override diff --git a/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/user/DefaultOAuth2UserTests.java b/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/user/DefaultOAuth2UserTests.java index f770784322..e387d049b7 100644 --- a/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/user/DefaultOAuth2UserTests.java +++ b/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/user/DefaultOAuth2UserTests.java @@ -17,6 +17,7 @@ package org.springframework.security.oauth2.core.user; import java.util.Collections; +import java.util.HashMap; import java.util.Map; import java.util.Set; @@ -35,6 +36,7 @@ * @author Vedran Pavic * @author Joe Grandja * @author Park Hyojong + * @author Yoobin Yoon */ public class DefaultOAuth2UserTests { @@ -109,4 +111,108 @@ public void constructorWhenCreatedThenIsSerializable() { 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 withUsernameWhenCreatedThenIsSerializable() { + DefaultOAuth2User user = DefaultOAuth2User.withUsername("directUser") + .authorities(AUTHORITIES) + .attributes(ATTRIBUTES) + .build(); + SerializationUtils.serialize(user); + } + + @Test + public void withUsernameWhenUsernameProvidedThenTakesPrecedenceOverAttributes() { + Map 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); + } + } From beb5a447cae38d2597a3486e667201607b85b4b3 Mon Sep 17 00:00:00 2001 From: yybmion Date: Wed, 2 Jul 2025 14:19:19 +0900 Subject: [PATCH 2/3] Add SpEL support for nested username extraction in OAuth2 - Add usernameExpression property with SpEL evaluation support - Auto-convert userNameAttributeName to SpEL for backward compatibility - Use SimpleEvaluationContext for secure expression evaluation - Pass evaluated username to OAuth2UserAuthority for spring-projectsgh-15012 compatibility - Add Builder pattern to DefaultOAuth2User - Add Builder pattern to OAuth2UserAuthority - Add Builder pattern to OidcUserAuthority with inherance support - Add Builder pattern to DefaultOidcUser with inherance support - Support nested property access (e.g., "data.username") - Add usernameExpression property to ClientRegistration documentation - Update What's New section Fixes gh-16390 Signed-off-by: yybmion --- .../pages/reactive/oauth2/client/core.adoc | 8 +- .../pages/servlet/oauth2/client/core.adoc | 12 +- docs/modules/ROOT/pages/whats-new.adoc | 2 +- .../registration/ClientRegistration.java | 64 ++++++- .../userinfo/DefaultOAuth2UserService.java | 105 +++++++---- .../OAuth2AuthenticationTokenMixinTests.java | 10 +- .../OAuth2AuthorizedClientMixinTests.java | 7 +- .../registration/ClientRegistrationTests.java | 80 +++++++++ .../DefaultOAuth2UserServiceTests.java | 163 ++++++++++++++++-- .../core/oidc/user/DefaultOidcUser.java | 90 ++++++++++ .../core/oidc/user/OidcUserAuthority.java | 100 +++++++++++ .../oauth2/core/user/DefaultOAuth2User.java | 94 +++++----- .../oauth2/core/user/OAuth2UserAuthority.java | 94 +++++++++- .../oidc/user/OidcUserAuthorityTests.java | 40 +++++ .../core/user/DefaultOAuth2UserTests.java | 49 ++++++ .../core/user/OAuth2UserAuthorityTests.java | 34 ++++ 16 files changed, 837 insertions(+), 115 deletions(-) diff --git a/docs/modules/ROOT/pages/reactive/oauth2/client/core.adoc b/docs/modules/ROOT/pages/reactive/oauth2/client/core.adoc index d95a8f35da..2d9bf33262 100644 --- a/docs/modules/ROOT/pages/reactive/oauth2/client/core.adoc +++ b/docs/modules/ROOT/pages/reactive/oauth2/client/core.adoc @@ -36,12 +36,13 @@ public final class ClientRegistration { private String uri; <14> private AuthenticationMethod authenticationMethod; <15> private String userNameAttributeName; <16> + private String usernameExpression; <17> } } public static final class ClientSettings { - private boolean requireProofKey; // <17> + private boolean requireProofKey; // <18> } } ---- @@ -67,8 +68,9 @@ The name may be used in certain scenarios, such as when displaying the name of t <14> `(userInfoEndpoint)uri`: The UserInfo Endpoint URI used to access the claims/attributes of the authenticated end-user. <15> `(userInfoEndpoint)authenticationMethod`: The authentication method used when sending the access token to the UserInfo Endpoint. The supported values are *header*, *form* and *query*. -<16> `userNameAttributeName`: The name of the attribute returned in the UserInfo Response that references the Name or Identifier of the end-user. -<17> [[oauth2Client-client-registration-requireProofKey]]`requireProofKey`: If `true` or if `authorizationGrantType` is `none`, then PKCE will be enabled by default. +<16> `userNameAttributeName`: The name of the attribute returned in the UserInfo Response that references the Name or Identifier of the end-user. *Deprecated* - use `usernameExpression` instead. +<17> `usernameExpression`: A SpEL expression used to extract the username from the UserInfo Response. Supports accessing nested attributes (e.g., `"data.username"`) and complex expressions (e.g., `"preferred_username ?: email"`). +<18> [[oauth2Client-client-registration-requireProofKey]]`requireProofKey`: If `true` or if `authorizationGrantType` is `none`, then PKCE will be enabled by default. A `ClientRegistration` can be initially configured using discovery of an OpenID Connect Provider's https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig[Configuration endpoint] or an Authorization Server's https://tools.ietf.org/html/rfc8414#section-3[Metadata endpoint]. diff --git a/docs/modules/ROOT/pages/servlet/oauth2/client/core.adoc b/docs/modules/ROOT/pages/servlet/oauth2/client/core.adoc index ba0291c068..cf32785603 100644 --- a/docs/modules/ROOT/pages/servlet/oauth2/client/core.adoc +++ b/docs/modules/ROOT/pages/servlet/oauth2/client/core.adoc @@ -31,18 +31,19 @@ public final class ClientRegistration { private UserInfoEndpoint userInfoEndpoint; private String jwkSetUri; <11> private String issuerUri; <12> - private Map configurationMetadata; <13> + private Map configurationMetadata; <13> public class UserInfoEndpoint { private String uri; <14> - private AuthenticationMethod authenticationMethod; <15> + private AuthenticationMethod authenticationMethod; <15> private String userNameAttributeName; <16> + private String usernameExpression; <17> } } public static final class ClientSettings { - private boolean requireProofKey; // <17> + private boolean requireProofKey; // <18> } } ---- @@ -68,8 +69,9 @@ This information is available only if the Spring Boot property `spring.security. <14> `(userInfoEndpoint)uri`: The UserInfo Endpoint URI used to access the claims and attributes of the authenticated end-user. <15> `(userInfoEndpoint)authenticationMethod`: The authentication method used when sending the access token to the UserInfo Endpoint. The supported values are *header*, *form*, and *query*. -<16> `userNameAttributeName`: The name of the attribute returned in the UserInfo Response that references the Name or Identifier of the end-user. -<17> [[oauth2Client-client-registration-requireProofKey]]`requireProofKey`: If `true` or if `authorizationGrantType` is `none`, then PKCE will be enabled by default. +<16> `userNameAttributeName`: The name of the attribute returned in the UserInfo Response that references the Name or Identifier of the end-user. Deprecated - use usernameExpression instead. +<17> `usernameExpression`: A SpEL expression used to extract the username from the UserInfo Response. Supports accessing nested attributes (e.g., "data.username") and complex expressions (e.g., "preferred_username ?: email"). +<18> [[oauth2Client-client-registration-requireProofKey]]`requireProofKey`: If `true` or if `authorizationGrantType` is `none`, then PKCE will be enabled by default. You can initially configure a `ClientRegistration` by using discovery of an OpenID Connect Provider's https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig[Configuration endpoint] or an Authorization Server's https://tools.ietf.org/html/rfc8414#section-3[Metadata endpoint]. diff --git a/docs/modules/ROOT/pages/whats-new.adoc b/docs/modules/ROOT/pages/whats-new.adoc index 67df64182e..016ea882af 100644 --- a/docs/modules/ROOT/pages/whats-new.adoc +++ b/docs/modules/ROOT/pages/whats-new.adoc @@ -36,7 +36,7 @@ http.csrf((csrf) -> csrf.spa()); * Removed `ApacheDsContainer` and related Apache DS support in favor of UnboundID == OAuth 2.0 - +* 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. * Removed support for password grant * Added OAuth2 Support for xref:features/integrations/rest/http-interface.adoc[HTTP Interface Integration] * Added support for custom `JwkSource` in `NimbusJwtDecoder`, allowing usage of Nimbus's `JwkSourceBuilder` API diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/registration/ClientRegistration.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/registration/ClientRegistration.java index 633e318f7c..2b87e0526c 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/registration/ClientRegistration.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/registration/ClientRegistration.java @@ -46,6 +46,7 @@ * * @author Joe Grandja * @author Michael Sosa + * @author Yoobin Yoon * @since 5.0 * @see Section 2 * Client Registration @@ -298,8 +299,11 @@ public class UserInfoEndpoint implements Serializable { private AuthenticationMethod authenticationMethod = AuthenticationMethod.HEADER; + @Deprecated private String userNameAttributeName; + private String usernameExpression; + UserInfoEndpoint() { } @@ -321,15 +325,23 @@ public AuthenticationMethod getAuthenticationMethod() { } /** - * Returns the attribute name used to access the user's name from the user - * info response. - * @return the attribute name used to access the user's name from the user - * info response + * @deprecated Use {@link #getUsernameExpression()} instead */ + @Deprecated public String getUserNameAttributeName() { return this.userNameAttributeName; } + /** + * Returns the SpEL expression used to extract the username from user info + * response. + * @return the SpEL expression for username extraction + * @since 7.0 + */ + public String getUsernameExpression() { + return this.usernameExpression; + } + } } @@ -369,8 +381,11 @@ public static final class Builder implements Serializable { private AuthenticationMethod userInfoAuthenticationMethod = AuthenticationMethod.HEADER; + @Deprecated private String userNameAttributeName; + private String usernameExpression; + private String jwkSetUri; private String issuerUri; @@ -398,6 +413,7 @@ private Builder(ClientRegistration clientRegistration) { this.userInfoUri = clientRegistration.providerDetails.userInfoEndpoint.uri; this.userInfoAuthenticationMethod = clientRegistration.providerDetails.userInfoEndpoint.authenticationMethod; this.userNameAttributeName = clientRegistration.providerDetails.userInfoEndpoint.userNameAttributeName; + this.usernameExpression = clientRegistration.providerDetails.userInfoEndpoint.usernameExpression; this.jwkSetUri = clientRegistration.providerDetails.jwkSetUri; this.issuerUri = clientRegistration.providerDetails.issuerUri; Map configurationMetadata = clientRegistration.providerDetails.configurationMetadata; @@ -551,14 +567,43 @@ public Builder userInfoAuthenticationMethod(AuthenticationMethod userInfoAuthent } /** - * Sets the attribute name used to access the user's name from the user info - * response. - * @param userNameAttributeName the attribute name used to access the user's name - * from the user info response + * Sets the username attribute name. This method automatically converts the + * attribute name to a SpEL expression for backward compatibility. + * + *

+ * This is a convenience method that internally calls + * {@link #usernameExpression(String)} with the attribute name wrapped in bracket + * notation. + * @param userNameAttributeName the username attribute name * @return the {@link Builder} */ public Builder userNameAttributeName(String userNameAttributeName) { this.userNameAttributeName = userNameAttributeName; + if (userNameAttributeName != null) { + this.usernameExpression = "['" + userNameAttributeName + "']"; + } + return this; + } + + /** + * Sets the SpEL expression used to extract the username from user info response. + * + *

+ * Examples: + *

    + *
  • Simple attribute: {@code "['username']"} or {@code "username"}
  • + *
  • Nested attribute: {@code "data.username"}
  • + *
  • Complex expression: {@code "user_info?.name ?: 'anonymous'"}
  • + *
  • Array access: {@code "users[0].name"}
  • + *
  • Conditional: + * {@code "preferred_username != null ? preferred_username : email"}
  • + *
+ * @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; } @@ -668,7 +713,10 @@ private ProviderDetails createProviderDetails(ClientRegistration clientRegistrat providerDetails.tokenUri = this.tokenUri; providerDetails.userInfoEndpoint.uri = this.userInfoUri; providerDetails.userInfoEndpoint.authenticationMethod = this.userInfoAuthenticationMethod; + + providerDetails.userInfoEndpoint.usernameExpression = this.usernameExpression; providerDetails.userInfoEndpoint.userNameAttributeName = this.userNameAttributeName; + providerDetails.jwkSetUri = this.jwkSetUri; providerDetails.issuerUri = this.issuerUri; providerDetails.configurationMetadata = Collections.unmodifiableMap(this.configurationMetadata); diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/userinfo/DefaultOAuth2UserService.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/userinfo/DefaultOAuth2UserService.java index 76248322e0..7d351ccd35 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/userinfo/DefaultOAuth2UserService.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/userinfo/DefaultOAuth2UserService.java @@ -20,8 +20,12 @@ import java.util.LinkedHashSet; import java.util.Map; +import org.springframework.context.expression.MapAccessor; import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.convert.converter.Converter; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.SimpleEvaluationContext; import org.springframework.http.RequestEntity; import org.springframework.http.ResponseEntity; import org.springframework.security.core.GrantedAuthority; @@ -47,16 +51,17 @@ * An implementation of an {@link OAuth2UserService} that supports standard OAuth 2.0 * Provider's. *

- * For standard OAuth 2.0 Provider's, the attribute name used to access the user's name - * from the UserInfo response is required and therefore must be available via - * {@link ClientRegistration.ProviderDetails.UserInfoEndpoint#getUserNameAttributeName() - * UserInfoEndpoint.getUserNameAttributeName()}. + * For standard OAuth 2.0 Provider's, the username expression used to extract the user's + * name from the UserInfo response is required and therefore must be available via + * {@link ClientRegistration.ProviderDetails.UserInfoEndpoint#getUsernameExpression() + * UserInfoEndpoint.getUsernameExpression()}. *

* NOTE: Attribute names are not standardized between providers and * therefore will vary. Please consult the provider's API documentation for the set of * supported user attribute names. * * @author Joe Grandja + * @author Yoobin Yoon * @since 5.0 * @see OAuth2UserService * @see OAuth2UserRequest @@ -71,6 +76,10 @@ public class DefaultOAuth2UserService implements OAuth2UserService> PARAMETERIZED_RESPONSE_TYPE = new ParameterizedTypeReference<>() { }; @@ -90,13 +99,67 @@ public DefaultOAuth2UserService() { @Override public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { Assert.notNull(userRequest, "userRequest cannot be null"); - String userNameAttributeName = getUserNameAttributeName(userRequest); + String usernameExpression = getUsernameExpression(userRequest); RequestEntity request = this.requestEntityConverter.convert(userRequest); ResponseEntity> response = getResponse(userRequest, request); OAuth2AccessToken token = userRequest.getAccessToken(); Map attributes = this.attributesConverter.convert(userRequest).convert(response.getBody()); - Collection authorities = getAuthorities(token, attributes, userNameAttributeName); - return new DefaultOAuth2User(authorities, attributes, userNameAttributeName); + + String evaluatedUsername = evaluateUsername(attributes, usernameExpression); + + Collection 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 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 @@ private ResponseEntity> getResponse(OAuth2UserRequest userRe } } - 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 getAuthorities(OAuth2AccessToken token, Map attributes, - String userNameAttributeName) { + String username) { Collection authorities = new LinkedHashSet<>(); - authorities.add(new OAuth2UserAuthority(attributes, userNameAttributeName)); + authorities.add(OAuth2UserAuthority.withUsername(username).attributes(attributes).build()); + for (String authority : token.getScopes()) { authorities.add(new SimpleGrantedAuthority("SCOPE_" + authority)); } diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/jackson2/OAuth2AuthenticationTokenMixinTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/jackson2/OAuth2AuthenticationTokenMixinTests.java index 2fdec9c4e7..07a4b15b9b 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/jackson2/OAuth2AuthenticationTokenMixinTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/jackson2/OAuth2AuthenticationTokenMixinTests.java @@ -248,11 +248,12 @@ private static String asJson(OAuth2UserAuthority oauth2UserAuthority) { return "{\n" + " \"@class\": \"org.springframework.security.oauth2.core.user.OAuth2UserAuthority\",\n" + " \"authority\": \"" + oauth2UserAuthority.getAuthority() + "\",\n" + - " \"userNameAttributeName\": \"username\",\n" + " \"attributes\": {\n" + " \"@class\": \"java.util.Collections$UnmodifiableMap\",\n" + " \"username\": \"user\"\n" + - " }\n" + + " },\n" + + " \"userNameAttributeName\": \"username\",\n" + + " \"username\": \"user\"\n" + " }"; // @formatter:on } @@ -262,9 +263,10 @@ private static String asJson(OidcUserAuthority oidcUserAuthority) { return "{\n" + " \"@class\": \"org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority\",\n" + " \"authority\": \"" + oidcUserAuthority.getAuthority() + "\",\n" + - " \"userNameAttributeName\": \"" + oidcUserAuthority.getUserNameAttributeName() + "\",\n" + " \"idToken\": " + asJson(oidcUserAuthority.getIdToken()) + ",\n" + - " \"userInfo\": " + asJson(oidcUserAuthority.getUserInfo()) + "\n" + + " \"userInfo\": " + asJson(oidcUserAuthority.getUserInfo()) + ",\n" + + " \"userNameAttributeName\": \"" + oidcUserAuthority.getUserNameAttributeName() + "\",\n" + + " \"username\": \"subject\"\n" + " }"; // @formatter:on } diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/jackson2/OAuth2AuthorizedClientMixinTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/jackson2/OAuth2AuthorizedClientMixinTests.java index 0c511a6805..35fb836c76 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/jackson2/OAuth2AuthorizedClientMixinTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/jackson2/OAuth2AuthorizedClientMixinTests.java @@ -145,6 +145,8 @@ public void deserializeWhenMixinRegisteredThenDeserializes() throws Exception { .isEqualTo(expectedClientRegistration.getProviderDetails().getUserInfoEndpoint().getAuthenticationMethod()); assertThat(clientRegistration.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName()).isEqualTo( expectedClientRegistration.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName()); + assertThat(clientRegistration.getProviderDetails().getUserInfoEndpoint().getUsernameExpression()) + .isEqualTo(expectedClientRegistration.getProviderDetails().getUserInfoEndpoint().getUsernameExpression()); assertThat(clientRegistration.getProviderDetails().getJwkSetUri()) .isEqualTo(expectedClientRegistration.getProviderDetails().getJwkSetUri()); assertThat(clientRegistration.getProviderDetails().getIssuerUri()) @@ -306,6 +308,8 @@ private static String asJson(ClientRegistration clientRegistration) { .map((key) -> "\"" + key + "\": \"" + providerDetails.getConfigurationMetadata().get(key) + "\"") .collect(Collectors.joining(",")); } + String usernameExpression = (userInfoEndpoint.getUsernameExpression() != null) + ? "\"" + userInfoEndpoint.getUsernameExpression() + "\"" : null; // @formatter:off return "{\n" + " \"@class\": \"org.springframework.security.oauth2.client.registration.ClientRegistration\",\n" + @@ -333,7 +337,8 @@ private static String asJson(ClientRegistration clientRegistration) { " \"authenticationMethod\": {\n" + " \"value\": \"" + userInfoEndpoint.getAuthenticationMethod().getValue() + "\"\n" + " },\n" + - " \"userNameAttributeName\": " + ((userInfoEndpoint.getUserNameAttributeName() != null) ? "\"" + userInfoEndpoint.getUserNameAttributeName() + "\"" : null) + "\n" + + " \"userNameAttributeName\": " + ((userInfoEndpoint.getUserNameAttributeName() != null) ? "\"" + userInfoEndpoint.getUserNameAttributeName() + "\"" : null) + ",\n" + + " \"usernameExpression\": " + usernameExpression + "\n" + " },\n" + " \"jwkSetUri\": " + ((providerDetails.getJwkSetUri() != null) ? "\"" + providerDetails.getJwkSetUri() + "\"" : null) + ",\n" + " \"issuerUri\": " + ((providerDetails.getIssuerUri() != null) ? "\"" + providerDetails.getIssuerUri() + "\"" : null) + ",\n" + diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/registration/ClientRegistrationTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/registration/ClientRegistrationTests.java index 16f33e5c3d..11fbf2d736 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/registration/ClientRegistrationTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/registration/ClientRegistrationTests.java @@ -43,6 +43,7 @@ * Tests for {@link ClientRegistration}. * * @author Joe Grandja + * @author Yoobin Yoon */ public class ClientRegistrationTests { @@ -619,6 +620,7 @@ public void buildWhenClientRegistrationProvidedThenEachPropertyMatches() { .isEqualTo(updatedUserInfoEndpoint.getAuthenticationMethod()); assertThat(userInfoEndpoint.getUserNameAttributeName()) .isEqualTo(updatedUserInfoEndpoint.getUserNameAttributeName()); + assertThat(userInfoEndpoint.getUsernameExpression()).isEqualTo(updatedUserInfoEndpoint.getUsernameExpression()); assertThat(providerDetails.getJwkSetUri()).isEqualTo(updatedProviderDetails.getJwkSetUri()); assertThat(providerDetails.getIssuerUri()).isEqualTo(updatedProviderDetails.getIssuerUri()); assertThat(providerDetails.getConfigurationMetadata()) @@ -705,6 +707,84 @@ void buildWhenNewAuthorizationCodeAndPkceThenBuilds() { assertThat(clientRegistration.getClientSettings().isRequireProofKey()).isTrue(); } + @Test + public void buildWhenUsernameExpressionProvidedThenSet() { + String usernameExpression = "data.username"; + // @formatter:off + ClientRegistration registration = ClientRegistration.withRegistrationId(REGISTRATION_ID) + .clientId(CLIENT_ID) + .clientSecret(CLIENT_SECRET) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri(REDIRECT_URI) + .authorizationUri(AUTHORIZATION_URI) + .tokenUri(TOKEN_URI) + .usernameExpression(usernameExpression) + .build(); + // @formatter:on + assertThat(registration.getProviderDetails().getUserInfoEndpoint().getUsernameExpression()) + .isEqualTo(usernameExpression); + } + + @Test + public void buildWhenBothUserNameAttributeNameAndUsernameExpressionProvidedThenUsernameExpressionTakesPrecedence() { + String userNameAttributeName = "username"; + String usernameExpression = "data.username"; + // @formatter:off + ClientRegistration registration = ClientRegistration.withRegistrationId(REGISTRATION_ID) + .clientId(CLIENT_ID) + .clientSecret(CLIENT_SECRET) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri(REDIRECT_URI) + .authorizationUri(AUTHORIZATION_URI) + .tokenUri(TOKEN_URI) + .userNameAttributeName(userNameAttributeName) + .usernameExpression(usernameExpression) + .build(); + // @formatter:on + assertThat(registration.getProviderDetails().getUserInfoEndpoint().getUsernameExpression()) + .isEqualTo(usernameExpression); + assertThat(registration.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName()) + .isEqualTo(userNameAttributeName); + } + + @Test + public void buildWhenOnlyUserNameAttributeNameProvidedThenAutoConvertToSpelExpression() { + String userNameAttributeName = "username"; + // @formatter:off + ClientRegistration registration = ClientRegistration.withRegistrationId(REGISTRATION_ID) + .clientId(CLIENT_ID) + .clientSecret(CLIENT_SECRET) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri(REDIRECT_URI) + .authorizationUri(AUTHORIZATION_URI) + .tokenUri(TOKEN_URI) + .userNameAttributeName(userNameAttributeName) + .build(); + // @formatter:on + assertThat(registration.getProviderDetails().getUserInfoEndpoint().getUsernameExpression()) + .isEqualTo("['" + userNameAttributeName + "']"); + assertThat(registration.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName()) + .isEqualTo(userNameAttributeName); + } + + @Test + public void buildWhenCopyingClientRegistrationWithUsernameExpressionThenPreserved() { + String usernameExpression = "profile.name"; + // @formatter:off + ClientRegistration original = ClientRegistration.withRegistrationId(REGISTRATION_ID) + .clientId(CLIENT_ID) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri(REDIRECT_URI) + .authorizationUri(AUTHORIZATION_URI) + .tokenUri(TOKEN_URI) + .usernameExpression(usernameExpression) + .build(); + // @formatter:on + ClientRegistration copy = ClientRegistration.withClientRegistration(original).build(); + assertThat(copy.getProviderDetails().getUserInfoEndpoint().getUsernameExpression()) + .isEqualTo(usernameExpression); + } + @ParameterizedTest @MethodSource("invalidPkceGrantTypes") void buildWhenInvalidGrantTypeForPkceThenException(AuthorizationGrantType invalidGrantType) { diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/userinfo/DefaultOAuth2UserServiceTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/userinfo/DefaultOAuth2UserServiceTests.java index fc2925232b..d57d5fb311 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/userinfo/DefaultOAuth2UserServiceTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/userinfo/DefaultOAuth2UserServiceTests.java @@ -61,6 +61,7 @@ * * @author Joe Grandja * @author Eddú Meléndez + * @author Yoobin Yoon */ public class DefaultOAuth2UserServiceTests { @@ -121,7 +122,7 @@ public void loadUserWhenUserNameAttributeNameIsNullThenThrowOAuth2Authentication // @formatter:on assertThatExceptionOfType(OAuth2AuthenticationException.class) .isThrownBy(() -> this.userService.loadUser(new OAuth2UserRequest(clientRegistration, this.accessToken))) - .withMessageContaining("missing_user_name_attribute"); + .withMessageContaining("invalid_user_info_response"); } @Test @@ -153,23 +154,26 @@ public void loadUserWhenUserInfoSuccessResponseThenReturnUser() { assertThat((String) user.getAttribute("email")).isEqualTo("user1@example.com"); assertThat(user.getAuthorities()).hasSize(1); assertThat(user.getAuthorities().iterator().next()).isInstanceOf(OAuth2UserAuthority.class); - OAuth2UserAuthority userAuthority = (OAuth2UserAuthority) user.getAuthorities().iterator().next(); + OAuth2UserAuthority userAuthority = OAuth2UserAuthority.withUsername("user1") + .attributes(user.getAttributes()) + .authority("OAUTH2_USER") + .build(); assertThat(userAuthority.getAuthority()).isEqualTo("OAUTH2_USER"); assertThat(userAuthority.getAttributes()).isEqualTo(user.getAttributes()); - assertThat(userAuthority.getUserNameAttributeName()).isEqualTo("user-name"); + assertThat(userAuthority.getUsername()).isEqualTo("user1"); } @Test public void loadUserWhenNestedUserInfoSuccessThenReturnUser() { // @formatter:off String userInfoResponse = "{\n" - + " \"user\": {\"user-name\": \"user1\"},\n" - + " \"first-name\": \"first\",\n" - + " \"last-name\": \"last\",\n" - + " \"middle-name\": \"middle\",\n" - + " \"address\": \"address\",\n" - + " \"email\": \"user1@example.com\"\n" - + "}\n"; + + " \"user\": {\"user-name\": \"user1\"},\n" + + " \"first-name\": \"first\",\n" + + " \"last-name\": \"last\",\n" + + " \"middle-name\": \"middle\",\n" + + " \"address\": \"address\",\n" + + " \"email\": \"user1@example.com\"\n" + + "}\n"; // @formatter:on this.server.enqueue(jsonResponse(userInfoResponse)); String userInfoUri = this.server.url("/user").toString(); @@ -194,10 +198,13 @@ public void loadUserWhenNestedUserInfoSuccessThenReturnUser() { assertThat((String) user.getAttribute("email")).isEqualTo("user1@example.com"); assertThat(user.getAuthorities()).hasSize(1); assertThat(user.getAuthorities().iterator().next()).isInstanceOf(OAuth2UserAuthority.class); - OAuth2UserAuthority userAuthority = (OAuth2UserAuthority) user.getAuthorities().iterator().next(); + OAuth2UserAuthority userAuthority = OAuth2UserAuthority.withUsername("user1") + .attributes(user.getAttributes()) + .authority("OAUTH2_USER") + .build(); assertThat(userAuthority.getAuthority()).isEqualTo("OAUTH2_USER"); assertThat(userAuthority.getAttributes()).isEqualTo(user.getAttributes()); - assertThat(userAuthority.getUserNameAttributeName()).isEqualTo("user-name"); + assertThat(userAuthority.getUsername()).isEqualTo("user1"); } @Test @@ -247,8 +254,8 @@ public void loadUserWhenUserInfoErrorResponseWwwAuthenticateHeaderThenThrowOAuth public void loadUserWhenUserInfoErrorResponseThenThrowOAuth2AuthenticationException() { // @formatter:off String userInfoErrorResponse = "{\n" - + " \"error\": \"invalid_token\"\n" - + "}\n"; + + " \"error\": \"invalid_token\"\n" + + "}\n"; // @formatter:on this.server.enqueue(jsonResponse(userInfoErrorResponse).setResponseCode(400)); String userInfoUri = this.server.url("/user").toString(); @@ -421,6 +428,134 @@ public void setAttributesConverterWhenNullThenException() { .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 response) { ResponseEntity> responseEntity = new ResponseEntity<>(response, HttpStatus.OK); Converter> requestEntityConverter = mock(Converter.class); diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/user/DefaultOidcUser.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/user/DefaultOidcUser.java index 3b99e3e829..d694f10132 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/user/DefaultOidcUser.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/user/DefaultOidcUser.java @@ -25,6 +25,7 @@ import org.springframework.security.oauth2.core.oidc.OidcIdToken; import org.springframework.security.oauth2.core.oidc.OidcUserInfo; import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.util.Assert; /** * The default implementation of an {@link OidcUser}. @@ -35,6 +36,7 @@ * * @author Joe Grandja * @author Vedran Pavic + * @author Yoobin Yoon * @since 5.0 * @see OidcUser * @see DefaultOAuth2User @@ -54,7 +56,9 @@ public class DefaultOidcUser extends DefaultOAuth2User implements OidcUser { * Constructs a {@code DefaultOidcUser} using the provided parameters. * @param authorities the authorities granted to the user * @param idToken the {@link OidcIdToken ID Token} containing claims about the user + * @deprecated Use {@link #withUsername(String)} builder pattern instead */ + @Deprecated public DefaultOidcUser(Collection authorities, OidcIdToken idToken) { this(authorities, idToken, IdTokenClaimNames.SUB); } @@ -65,7 +69,9 @@ public DefaultOidcUser(Collection authorities, OidcI * @param idToken the {@link OidcIdToken ID Token} containing claims about the user * @param nameAttributeKey the key used to access the user's "name" from * {@link #getAttributes()} + * @deprecated Use {@link #withUsername(String)} builder pattern instead */ + @Deprecated public DefaultOidcUser(Collection authorities, OidcIdToken idToken, String nameAttributeKey) { this(authorities, idToken, null, nameAttributeKey); @@ -77,7 +83,9 @@ public DefaultOidcUser(Collection authorities, OidcI * @param idToken the {@link OidcIdToken ID Token} containing claims about the user * @param userInfo the {@link OidcUserInfo UserInfo} containing claims about the user, * may be {@code null} + * @deprecated Use {@link #withUsername(String)} builder pattern instead */ + @Deprecated public DefaultOidcUser(Collection authorities, OidcIdToken idToken, OidcUserInfo userInfo) { this(authorities, idToken, userInfo, IdTokenClaimNames.SUB); @@ -91,7 +99,9 @@ public DefaultOidcUser(Collection authorities, OidcI * may be {@code null} * @param nameAttributeKey the key used to access the user's "name" from * {@link #getAttributes()} + * @deprecated Use {@link #withUsername(String)} builder pattern instead */ + @Deprecated public DefaultOidcUser(Collection authorities, OidcIdToken idToken, OidcUserInfo userInfo, String nameAttributeKey) { super(authorities, OidcUserAuthority.collectClaims(idToken, userInfo), nameAttributeKey); @@ -99,6 +109,34 @@ public DefaultOidcUser(Collection authorities, OidcI this.userInfo = userInfo; } + /** + * Constructs a {@code DefaultOidcUser} using the provided parameters. + * @param authorities the authorities granted to the user + * @param attributes the attributes about the user + * @param nameAttributeKey the key used to access the user's "name" from + * {@link #getAttributes()} - preserved for backwards compatibility + * @param username the user's name + * @param idToken the {@link OidcIdToken ID Token} containing claims about the user + * @param userInfo the {@link OidcUserInfo UserInfo} containing claims about the user, + * may be {@code null} + */ + private DefaultOidcUser(Collection authorities, Map attributes, + String nameAttributeKey, String username, OidcIdToken idToken, OidcUserInfo userInfo) { + super(authorities, attributes, nameAttributeKey, username); + this.idToken = idToken; + this.userInfo = userInfo; + } + + /** + * Creates a new {@code DefaultOidcUser} builder with the username. + * @param username the user's name + * @return a new {@code Builder} + * @since 7.0 + */ + public static Builder withUsername(String username) { + return new Builder(username); + } + @Override public Map getClaims() { return this.getAttributes(); @@ -114,4 +152,56 @@ public OidcUserInfo getUserInfo() { return this.userInfo; } + /** + * A builder for {@link DefaultOidcUser}. + * + * @since 7.0 + */ + public static final class Builder extends DefaultOAuth2User.Builder { + + private OidcIdToken idToken; + + private OidcUserInfo userInfo; + + private Builder(String username) { + super(username); + } + + public Builder idToken(OidcIdToken idToken) { + this.idToken = idToken; + return this; + } + + public Builder userInfo(OidcUserInfo userInfo) { + this.userInfo = userInfo; + return this; + } + + @Override + public Builder authorities(Collection authorities) { + super.authorities(authorities); + return this; + } + + @Override + public Builder attributes(Map attributes) { + super.attributes(attributes); + return this; + } + + @Override + public DefaultOidcUser build() { + Assert.notNull(this.idToken, "idToken cannot be null"); + + if (this.attributes == null) { + this.attributes = OidcUserAuthority.collectClaims(this.idToken, this.userInfo); + } + + Assert.notEmpty(this.attributes, "attributes cannot be empty"); + return new DefaultOidcUser(this.authorities, this.attributes, null, this.username, this.idToken, + this.userInfo); + } + + } + } diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/user/OidcUserAuthority.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/user/OidcUserAuthority.java index 5e0f4fa0b2..402e29344b 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/user/OidcUserAuthority.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/user/OidcUserAuthority.java @@ -32,6 +32,7 @@ * A {@link GrantedAuthority} that may be associated to an {@link OidcUser}. * * @author Joe Grandja + * @author Yoobin Yoon * @since 5.0 * @see OidcUser */ @@ -47,7 +48,9 @@ public class OidcUserAuthority extends OAuth2UserAuthority { /** * Constructs a {@code OidcUserAuthority} using the provided parameters. * @param idToken the {@link OidcIdToken ID Token} containing claims about the user + * @deprecated Use {@link #withUsername(String)} builder pattern instead */ + @Deprecated public OidcUserAuthority(OidcIdToken idToken) { this(idToken, null); } @@ -58,7 +61,9 @@ public OidcUserAuthority(OidcIdToken idToken) { * @param idToken the {@link OidcIdToken ID Token} containing claims about the user * @param userInfo the {@link OidcUserInfo UserInfo} containing claims about the user, * may be {@code null} + * @deprecated Use {@link #withUsername(String)} builder pattern instead */ + @Deprecated public OidcUserAuthority(OidcIdToken idToken, OidcUserInfo userInfo) { this("OIDC_USER", idToken, userInfo); } @@ -72,7 +77,9 @@ public OidcUserAuthority(OidcIdToken idToken, OidcUserInfo userInfo) { * @param userNameAttributeName the attribute name used to access the user's name from * the attributes * @since 6.4 + * @deprecated Use {@link #withUsername(String)} builder pattern instead */ + @Deprecated public OidcUserAuthority(OidcIdToken idToken, OidcUserInfo userInfo, @Nullable String userNameAttributeName) { this("OIDC_USER", idToken, userInfo, userNameAttributeName); } @@ -83,7 +90,9 @@ public OidcUserAuthority(OidcIdToken idToken, OidcUserInfo userInfo, @Nullable S * @param idToken the {@link OidcIdToken ID Token} containing claims about the user * @param userInfo the {@link OidcUserInfo UserInfo} containing claims about the user, * may be {@code null} + * @deprecated Use {@link #withUsername(String)} builder pattern instead */ + @Deprecated public OidcUserAuthority(String authority, OidcIdToken idToken, OidcUserInfo userInfo) { this(authority, idToken, userInfo, IdTokenClaimNames.SUB); } @@ -97,7 +106,9 @@ public OidcUserAuthority(String authority, OidcIdToken idToken, OidcUserInfo use * @param userNameAttributeName the attribute name used to access the user's name from * the attributes * @since 6.4 + * @deprecated Use {@link #withUsername(String)} builder pattern instead */ + @Deprecated public OidcUserAuthority(String authority, OidcIdToken idToken, OidcUserInfo userInfo, @Nullable String userNameAttributeName) { super(authority, collectClaims(idToken, userInfo), userNameAttributeName); @@ -105,6 +116,33 @@ public OidcUserAuthority(String authority, OidcIdToken idToken, OidcUserInfo use 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 attributes, OidcIdToken idToken, + OidcUserInfo userInfo) { + super(username, authority, attributes); + this.idToken = idToken; + this.userInfo = userInfo; + } + + /** + * Creates a new {@code OidcUserAuthority} builder with the username. + * @param username the username + * @return a new {@code Builder} + * @since 7.0 + */ + public static Builder withUsername(String username) { + return new Builder(username); + } + /** * Returns the {@link OidcIdToken ID Token} containing claims about the user. * @return the {@link OidcIdToken} containing claims about the user. @@ -159,4 +197,66 @@ static Map collectClaims(OidcIdToken idToken, OidcUserInfo userI 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 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); + } + + } + } diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/user/DefaultOAuth2User.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/user/DefaultOAuth2User.java index 64404e87c2..2ce929847d 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/user/DefaultOAuth2User.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/user/DefaultOAuth2User.java @@ -35,16 +35,18 @@ * The default implementation of an {@link OAuth2User}. * *

- * User attribute names are not standardized between providers and therefore it is - * required to supply the key for the user's "name" attribute to one of - * the constructors. The key will be used for accessing the "name" of the - * {@code Principal} (user) via {@link #getAttributes()} and returning it from - * {@link #getName()}. + * User attribute names are not standardized between providers. The recommended + * approach is to use {@link #withUsername(String)} builder pattern to directly specify + * the username, eliminating the need to determine attribute keys. Alternatively, when + * using the deprecated constructors, it is required to supply the key for the + * user's "name" attribute, which will be used for accessing the + * "name" of the {@code Principal} (user) via {@link #getAttributes()} and + * returning it from {@link #getName()}. * * @author Joe Grandja * @author Eddú Meléndez * @author Park Hyojong - * @author YooBin Yoon + * @author Yoobin Yoon * @since 5.0 * @see OAuth2User */ @@ -56,6 +58,7 @@ public class DefaultOAuth2User implements OAuth2User, Serializable { private final Map attributes; + @Deprecated private final String nameAttributeKey; private final String username; @@ -85,15 +88,14 @@ public DefaultOAuth2User(Collection authorities, Map } /** - * Constructs a {@code DefaultOAuth2User} using the provided parameters. This - * constructor is used by Jackson for deserialization. + * Constructs a {@code DefaultOAuth2User} using the provided parameters. * @param authorities the authorities granted to the user * @param attributes the attributes about the user * @param nameAttributeKey the key used to access the user's "name" from * {@link #getAttributes()} - preserved for backwards compatibility * @param username the user's name */ - private DefaultOAuth2User(Collection authorities, Map attributes, + protected DefaultOAuth2User(Collection authorities, Map attributes, String nameAttributeKey, String username) { Assert.notEmpty(attributes, "attributes cannot be empty"); @@ -102,7 +104,7 @@ private DefaultOAuth2User(Collection authorities, Ma : Collections.unmodifiableSet(new LinkedHashSet<>(AuthorityUtils.NO_AUTHORITIES)); this.attributes = Collections.unmodifiableMap(new LinkedHashMap<>(attributes)); this.nameAttributeKey = nameAttributeKey; - this.username = (username != null) ? username : attributes.get(nameAttributeKey).toString(); + this.username = username; Assert.hasText(this.username, "username cannot be empty"); } @@ -111,47 +113,12 @@ private DefaultOAuth2User(Collection authorities, Ma * Creates a new {@code DefaultOAuth2User} builder with the username. * @param username the user's name * @return a new {@code Builder} - * @since 6.5 + * @since 7.0 */ public static Builder withUsername(String username) { return new Builder(username); } - /** - * A builder for {@link DefaultOAuth2User}. - * - * @since 6.5 - */ - public static final class Builder { - - private final String username; - - private Collection authorities; - - private Map attributes; - - private Builder(String username) { - Assert.hasText(username, "username cannot be empty"); - this.username = username; - } - - public Builder authorities(Collection authorities) { - this.authorities = authorities; - return this; - } - - public Builder attributes(Map attributes) { - this.attributes = attributes; - return this; - } - - public DefaultOAuth2User build() { - Assert.notEmpty(this.attributes, "attributes cannot be empty"); - return new DefaultOAuth2User(this.authorities, this.attributes, null, this.username); - } - - } - @Override public String getName() { return this.username; @@ -213,4 +180,39 @@ public String toString() { return sb.toString(); } + /** + * A builder for {@link DefaultOAuth2User}. + * + * @since 7.0 + */ + public static class Builder { + + protected final String username; + + protected Collection authorities; + + protected Map attributes; + + protected Builder(String username) { + Assert.hasText(username, "username cannot be empty"); + this.username = username; + } + + public Builder authorities(Collection authorities) { + this.authorities = authorities; + return this; + } + + public Builder attributes(Map 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); + } + + } + } diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/user/OAuth2UserAuthority.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/user/OAuth2UserAuthority.java index 5a94b825d5..3d6ac4a741 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/user/OAuth2UserAuthority.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/user/OAuth2UserAuthority.java @@ -30,6 +30,7 @@ * A {@link GrantedAuthority} that may be associated to an {@link OAuth2User}. * * @author Joe Grandja + * @author Yoobin Yoon * @since 5.0 * @see OAuth2User */ @@ -41,13 +42,18 @@ public class OAuth2UserAuthority implements GrantedAuthority { private final Map attributes; + @Deprecated private final String userNameAttributeName; + private final String username; + /** * Constructs a {@code OAuth2UserAuthority} using the provided parameters and defaults * {@link #getAuthority()} to {@code OAUTH2_USER}. * @param attributes the attributes about the user + * @deprecated Use {@link #withUsername(String)} builder pattern instead */ + @Deprecated public OAuth2UserAuthority(Map attributes) { this("OAUTH2_USER", attributes); } @@ -59,7 +65,9 @@ public OAuth2UserAuthority(Map attributes) { * @param userNameAttributeName the attribute name used to access the user's name from * the attributes * @since 6.4 + * @deprecated Use {@link #withUsername(String)} builder pattern instead */ + @Deprecated public OAuth2UserAuthority(Map attributes, @Nullable String userNameAttributeName) { this("OAUTH2_USER", attributes, userNameAttributeName); } @@ -68,7 +76,9 @@ public OAuth2UserAuthority(Map attributes, @Nullable String user * Constructs a {@code OAuth2UserAuthority} using the provided parameters. * @param authority the authority granted to the user * @param attributes the attributes about the user + * @deprecated Use {@link #withUsername(String)} builder pattern instead */ + @Deprecated public OAuth2UserAuthority(String authority, Map attributes) { this(authority, attributes, null); } @@ -80,13 +90,43 @@ public OAuth2UserAuthority(String authority, Map attributes) { * @param userNameAttributeName the attribute name used to access the user's name from * the attributes * @since 6.4 + * @deprecated Use {@link #withUsername(String)} builder pattern instead */ + @Deprecated public OAuth2UserAuthority(String authority, Map attributes, String userNameAttributeName) { Assert.hasText(authority, "authority cannot be empty"); Assert.notEmpty(attributes, "attributes cannot be empty"); this.authority = authority; this.attributes = Collections.unmodifiableMap(new LinkedHashMap<>(attributes)); this.userNameAttributeName = userNameAttributeName; + this.username = (userNameAttributeName != null && attributes.get(userNameAttributeName) != null) + ? attributes.get(userNameAttributeName).toString() : null; + } + + /** + * Constructs a {@code OAuth2UserAuthority} using the provided parameters. + * @param username the username + * @param authority the authority granted to the user + * @param attributes the attributes about the user + */ + protected OAuth2UserAuthority(String username, String authority, Map 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 @@ -106,12 +146,26 @@ public Map getAttributes() { * Returns the attribute name used to access the user's name from the attributes. * @return the attribute name used to access the user's name from the attributes * @since 6.4 + * @deprecated Use {@link #getUsername()} instead */ + @Deprecated @Nullable public String getUserNameAttributeName() { return this.userNameAttributeName; } + /** + * Returns the username of the OAuth2 user. + *

+ * This method provides direct access to the username without requiring knowledge of + * the attribute structure or SpEL expressions used to extract it. + * @return the username + * @since 7.0 + */ + public String getUsername() { + return this.username; + } + @Override public boolean equals(Object obj) { if (this == obj) { @@ -124,6 +178,9 @@ public boolean equals(Object obj) { if (!this.getAuthority().equals(that.getAuthority())) { return false; } + if (!Objects.equals(this.username, that.username)) { + return false; + } Map thatAttributes = that.getAttributes(); if (getAttributes().size() != thatAttributes.size()) { return false; @@ -149,7 +206,7 @@ public boolean equals(Object obj) { @Override public int hashCode() { int result = this.getAuthority().hashCode(); - result = 31 * result; + result = 31 * result + Objects.hashCode(this.username); for (Map.Entry e : getAttributes().entrySet()) { Object key = e.getKey(); Object value = convertURLIfNecessary(e.getValue()); @@ -171,4 +228,39 @@ private static Object convertURLIfNecessary(Object 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 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 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); + } + + } + } diff --git a/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/oidc/user/OidcUserAuthorityTests.java b/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/oidc/user/OidcUserAuthorityTests.java index 66da110c5a..bddfb9e44a 100644 --- a/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/oidc/user/OidcUserAuthorityTests.java +++ b/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/oidc/user/OidcUserAuthorityTests.java @@ -34,6 +34,7 @@ * Tests for {@link OidcUserAuthority}. * * @author Joe Grandja + * @author Yoobin Yoon */ public class OidcUserAuthorityTests { @@ -84,4 +85,43 @@ public void constructorWhenAllParametersProvidedAndValidThenCreated() { 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); + } + } diff --git a/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/user/DefaultOAuth2UserTests.java b/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/user/DefaultOAuth2UserTests.java index e387d049b7..eebc099d44 100644 --- a/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/user/DefaultOAuth2UserTests.java +++ b/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/user/DefaultOAuth2UserTests.java @@ -145,6 +145,11 @@ public void withUsernameWhenAttributesIsEmptyThenThrowIllegalArgumentException() .build()); } + @Test + public void withUsernameWhenUsernameNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> DefaultOAuth2User.withUsername((String) null)); + } + @Test public void withUsernameWhenCreatedThenIsSerializable() { DefaultOAuth2User user = DefaultOAuth2User.withUsername("directUser") @@ -215,4 +220,48 @@ public void withUsernameWhenAuthoritiesIsEmptyThenCreated() { assertThat(user.getAttributes()).isEqualTo(ATTRIBUTES); } + @Test + public void withUsernameWhenNestedAttributesThenUsernameExtractedCorrectly() { + Map nestedAttributes = new HashMap<>(); + Map 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 attributes = new HashMap<>(); + Map profile = new HashMap<>(); + Map 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"); + } + } diff --git a/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/user/OAuth2UserAuthorityTests.java b/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/user/OAuth2UserAuthorityTests.java index d91ef46e67..5bdf75472d 100644 --- a/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/user/OAuth2UserAuthorityTests.java +++ b/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/user/OAuth2UserAuthorityTests.java @@ -30,6 +30,7 @@ * Tests for {@link OAuth2UserAuthority}. * * @author Joe Grandja + * @author Yoobin Yoon */ public class OAuth2UserAuthorityTests { @@ -94,4 +95,37 @@ public void hashCodeIsSameRegardlessOfUrlType() { 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); + } + } From af30db917cf1c4124c24a4949e5424b5363412f6 Mon Sep 17 00:00:00 2001 From: yybmion Date: Mon, 7 Jul 2025 14:14:19 +0900 Subject: [PATCH 3/3] Remove deprecated constructor/method usage in OAuth2 user services Replace deprecated constructor and method calls with new builder pattern and updated APIs to eliminate deprecated code usage - Update OidcUserRequestUtils to use non-deprecated APIs - Extract OAuth2UsernameExpressionUtils for SpEL evaluation logic - minor fixes for clarity closes gh-16390 Signed-off-by: yybmion --- .../OidcReactiveOAuth2UserService.java | 25 +++--- .../oidc/userinfo/OidcUserRequestUtils.java | 39 +++++++-- .../client/oidc/userinfo/OidcUserService.java | 25 +++--- .../userinfo/DefaultOAuth2UserService.java | 44 +--------- .../DefaultReactiveOAuth2UserService.java | 31 +++++-- .../OAuth2UsernameExpressionUtils.java | 84 +++++++++++++++++++ .../OidcReactiveOAuth2UserServiceTests.java | 30 ------- .../oidc/userinfo/OidcUserServiceTests.java | 2 +- .../DefaultOAuth2UserServiceTests.java | 23 +++++ .../core/user/DefaultOAuth2UserTests.java | 8 -- 10 files changed, 191 insertions(+), 120 deletions(-) create mode 100644 oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/userinfo/OAuth2UsernameExpressionUtils.java diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcReactiveOAuth2UserService.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcReactiveOAuth2UserService.java index 82fec131f9..43e07159a8 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcReactiveOAuth2UserService.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcReactiveOAuth2UserService.java @@ -191,23 +191,22 @@ public final void setRetrieveUserInfo(Predicate retrieveUserInf * var accessToken = userRequest.getAccessToken(); * var grantedAuthorities = new HashSet<GrantedAuthority>(); * // TODO: Map authorities from the access token - * var userNameAttributeName = "preferred_username"; - * return Mono.just(new DefaultOidcUser( - * grantedAuthorities, - * userRequest.getIdToken(), - * userInfo, - * userNameAttributeName - * )); + * var username = "preferred_username"; + * return Mono.just(DefaultOidcUser.withUsername(username) + * .authorities(grantedAuthorities) + * .idToken(userRequest.getIdToken()) + * .userInfo(userInfo) + * .build()); * }; * } * *

- * Note that you can access the {@code userNameAttributeName} via the - * {@link ClientRegistration} as follows:

-	 * 	var userNameAttributeName = userRequest.getClientRegistration()
-	 * 		.getProviderDetails()
-	 * 		.getUserInfoEndpoint()
-	 * 		.getUserNameAttributeName();
+	 * Note that you can access the username expression via the {@link ClientRegistration}
+	 * as follows: 
+	 *  var usernameExpression = userRequest.getClientRegistration()
+	 *  	.getProviderDetails()
+	 *   	.getUserInfoEndpoint()
+	 *   	.getUsernameExpression();
 	 * 
*

* By default, a {@link DefaultOidcUser} is created with authorities mapped as diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcUserRequestUtils.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcUserRequestUtils.java index 0c56e7b172..2e6799af4b 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcUserRequestUtils.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcUserRequestUtils.java @@ -16,18 +16,23 @@ package org.springframework.security.oauth2.client.oidc.userinfo; +import java.util.HashMap; import java.util.LinkedHashSet; +import java.util.Map; import java.util.Set; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.userinfo.OAuth2UsernameExpressionUtils; import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; import org.springframework.security.oauth2.core.oidc.OidcUserInfo; import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; import org.springframework.security.oauth2.core.oidc.user.OidcUser; import org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority; +import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; @@ -35,6 +40,7 @@ * Utilities for working with the {@link OidcUserRequest} * * @author Rob Winch + * @author Yoobin Yoon * @since 5.1 */ final class OidcUserRequestUtils { @@ -81,21 +87,40 @@ static OidcUser getUser(OidcUserSource userMetadata) { OidcUserInfo userInfo = userMetadata.getUserInfo(); Set authorities = new LinkedHashSet<>(); ClientRegistration.ProviderDetails providerDetails = userRequest.getClientRegistration().getProviderDetails(); - String userNameAttributeName = providerDetails.getUserInfoEndpoint().getUserNameAttributeName(); - if (StringUtils.hasText(userNameAttributeName)) { - authorities.add(new OidcUserAuthority(userRequest.getIdToken(), userInfo, userNameAttributeName)); + String usernameExpression = providerDetails.getUserInfoEndpoint().getUsernameExpression(); + + String username; + if (StringUtils.hasText(usernameExpression)) { + Map claims = collectClaims(userRequest.getIdToken(), userInfo); + username = OAuth2UsernameExpressionUtils.evaluateUsername(claims, usernameExpression); } else { - authorities.add(new OidcUserAuthority(userRequest.getIdToken(), userInfo)); + username = userRequest.getIdToken().getSubject(); } + + authorities + .add(OidcUserAuthority.withUsername(username).idToken(userRequest.getIdToken()).userInfo(userInfo).build()); + OAuth2AccessToken token = userRequest.getAccessToken(); for (String scope : token.getScopes()) { authorities.add(new SimpleGrantedAuthority("SCOPE_" + scope)); } - if (StringUtils.hasText(userNameAttributeName)) { - return new DefaultOidcUser(authorities, userRequest.getIdToken(), userInfo, userNameAttributeName); + + return DefaultOidcUser.withUsername(username) + .authorities(authorities) + .idToken(userRequest.getIdToken()) + .userInfo(userInfo) + .build(); + } + + private static Map collectClaims(OidcIdToken idToken, OidcUserInfo userInfo) { + Assert.notNull(idToken, "idToken cannot be null"); + Map claims = new HashMap<>(); + if (userInfo != null) { + claims.putAll(userInfo.getClaims()); } - return new DefaultOidcUser(authorities, userRequest.getIdToken(), userInfo); + claims.putAll(idToken.getClaims()); + return claims; } private OidcUserRequestUtils() { diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcUserService.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcUserService.java index b838ef6f23..2481c1765a 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcUserService.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcUserService.java @@ -264,23 +264,22 @@ public final void setRetrieveUserInfo(Predicate retrieveUserInf * var accessToken = userRequest.getAccessToken(); * var grantedAuthorities = new HashSet<GrantedAuthority>(); * // TODO: Map authorities from the access token - * var userNameAttributeName = "preferred_username"; - * return new DefaultOidcUser( - * grantedAuthorities, - * userRequest.getIdToken(), - * userInfo, - * userNameAttributeName - * ); + * var username = "preferred_username"; + * return DefaultOidcUser.withUsername(username) + * .authorities(grantedAuthorities) + * .idToken(userRequest.getIdToken()) + * .userInfo(userInfo) + * .build(); * }; * } *

*

- * Note that you can access the {@code userNameAttributeName} via the - * {@link ClientRegistration} as follows:

-	 * 	var userNameAttributeName = userRequest.getClientRegistration()
-	 * 		.getProviderDetails()
-	 * 		.getUserInfoEndpoint()
-	 * 		.getUserNameAttributeName();
+	 * Note that you can access the username expression via the {@link ClientRegistration}
+	 * as follows: 
+	 *  var usernameExpression = userRequest.getClientRegistration()
+	 *  	.getProviderDetails()
+	 *   	.getUserInfoEndpoint()
+	 *   	.getUsernameExpression();
 	 * 
*

* By default, a {@link DefaultOidcUser} is created with authorities mapped as diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/userinfo/DefaultOAuth2UserService.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/userinfo/DefaultOAuth2UserService.java index 7d351ccd35..5b90a1bc99 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/userinfo/DefaultOAuth2UserService.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/userinfo/DefaultOAuth2UserService.java @@ -20,12 +20,8 @@ import java.util.LinkedHashSet; import java.util.Map; -import org.springframework.context.expression.MapAccessor; import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.convert.converter.Converter; -import org.springframework.expression.ExpressionParser; -import org.springframework.expression.spel.standard.SpelExpressionParser; -import org.springframework.expression.spel.support.SimpleEvaluationContext; import org.springframework.http.RequestEntity; import org.springframework.http.ResponseEntity; import org.springframework.security.core.GrantedAuthority; @@ -76,10 +72,6 @@ public class DefaultOAuth2UserService implements OAuth2UserService> PARAMETERIZED_RESPONSE_TYPE = new ParameterizedTypeReference<>() { }; @@ -104,15 +96,9 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic ResponseEntity> response = getResponse(userRequest, request); OAuth2AccessToken token = userRequest.getAccessToken(); Map attributes = this.attributesConverter.convert(userRequest).convert(response.getBody()); - - String evaluatedUsername = evaluateUsername(attributes, usernameExpression); - - Collection authorities = getAuthorities(token, attributes, evaluatedUsername); - - return DefaultOAuth2User.withUsername(evaluatedUsername) - .authorities(authorities) - .attributes(attributes) - .build(); + String username = OAuth2UsernameExpressionUtils.evaluateUsername(attributes, usernameExpression); + Collection authorities = getAuthorities(token, attributes, username); + return DefaultOAuth2User.withUsername(username).authorities(authorities).attributes(attributes).build(); } private String getUsernameExpression(OAuth2UserRequest userRequest) { @@ -138,30 +124,6 @@ private String getUsernameExpression(OAuth2UserRequest userRequest) { return usernameExpression; } - private String evaluateUsername(Map 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(); - } - /** * Use this strategy to adapt user attributes into a format understood by Spring * Security; by default, the original attributes are preserved. diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/userinfo/DefaultReactiveOAuth2UserService.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/userinfo/DefaultReactiveOAuth2UserService.java index 3a69c93122..a44e5f3f5a 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/userinfo/DefaultReactiveOAuth2UserService.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/userinfo/DefaultReactiveOAuth2UserService.java @@ -130,13 +130,15 @@ public Mono loadUser(OAuth2UserRequest userRequest) throws OAuth2Aut .bodyToMono(DefaultReactiveOAuth2UserService.STRING_OBJECT_MAP) .mapNotNull((attributes) -> this.attributesConverter.convert(userRequest).convert(attributes)); return userAttributes.map((attrs) -> { - GrantedAuthority authority = new OAuth2UserAuthority(attrs, userNameAttributeName); - Set authorities = new HashSet<>(); - authorities.add(authority); - OAuth2AccessToken token = userRequest.getAccessToken(); - for (String scope : token.getScopes()) { - authorities.add(new SimpleGrantedAuthority("SCOPE_" + scope)); - } + String username = OAuth2UsernameExpressionUtils.evaluateUsername(attrs, usernameExpression); + Set authorities = new HashSet<>(); + authorities.add(OAuth2UserAuthority.withUsername(username) + .attributes(attrs) + .build()); + OAuth2AccessToken token = userRequest.getAccessToken(); + for (String scope : token.getScopes()) { + authorities.add(new SimpleGrantedAuthority("SCOPE_" + scope)); + } return new DefaultOAuth2User(authorities, attrs, userNameAttributeName); }) @@ -168,6 +170,21 @@ public Mono loadUser(OAuth2UserRequest userRequest) throws OAuth2Aut // @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 WebClient.RequestHeadersSpec getRequestHeaderSpec(OAuth2UserRequest userRequest, String userInfoUri, AuthenticationMethod authenticationMethod) { if (AuthenticationMethod.FORM.equals(authenticationMethod)) { diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/userinfo/OAuth2UsernameExpressionUtils.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/userinfo/OAuth2UsernameExpressionUtils.java new file mode 100644 index 0000000000..f1cf3ba43b --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/userinfo/OAuth2UsernameExpressionUtils.java @@ -0,0 +1,84 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.oauth2.client.userinfo; + +import java.util.Map; + +import org.springframework.context.expression.MapAccessor; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.SimpleEvaluationContext; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; + +/** + * Utility class for evaluating username expressions in OAuth2 user information. + * + * @author Yoobin Yoon + * @since 7.0 + */ +public final class OAuth2UsernameExpressionUtils { + + private static final String INVALID_USERNAME_EXPRESSION_ERROR_CODE = "invalid_username_expression"; + + private static final String INVALID_USER_INFO_RESPONSE_ERROR_CODE = "invalid_user_info_response"; + + private static final ExpressionParser expressionParser = new SpelExpressionParser(); + + /** + * Evaluates a SpEL expression to extract the username from user attributes. + * + *

+ * Examples: + *

    + *
  • Simple attribute: {@code "username"} or {@code "['username']"}
  • + *
  • Nested attribute: {@code "data.username"}
  • + *
  • Complex expression: {@code "user_info?.name ?: 'anonymous'"}
  • + *
+ * @param attributes the user attributes (used as SpEL root object) + * @param usernameExpression the SpEL expression to evaluate + * @return the evaluated username (never null) + * @throws OAuth2AuthenticationException if expression is invalid or evaluates to null + */ + public static String evaluateUsername(Map 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 OAuth2UsernameExpressionUtils() { + } + +} diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcReactiveOAuth2UserServiceTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcReactiveOAuth2UserServiceTests.java index d495a640d0..8228f25694 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcReactiveOAuth2UserServiceTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcReactiveOAuth2UserServiceTests.java @@ -316,36 +316,6 @@ public void loadUserWhenTokenDoesNotContainScopesThenNoScopeAuthorities() { assertThat(userAuthority.getUserNameAttributeName()).isEqualTo("id"); } - @Test - public void loadUserWhenCustomOidcUserConverterSetThenUsed() { - ClientRegistration clientRegistration = TestClientRegistrations.clientRegistration() - .userInfoUri("https://example.com/user") - .userInfoAuthenticationMethod(AuthenticationMethod.HEADER) - .userNameAttributeName(StandardClaimNames.SUB) - .build(); - this.accessToken = TestOAuth2AccessTokens.scopes(clientRegistration.getScopes().toArray(new String[0])); - Converter> oidcUserConverter = mock(Converter.class); - String nameAttributeKey = IdTokenClaimNames.SUB; - OidcUser actualUser = new DefaultOidcUser(AuthorityUtils.createAuthorityList("a", "b"), this.idToken, - nameAttributeKey); - OAuth2User oauth2User = new DefaultOAuth2User(actualUser.getAuthorities(), actualUser.getClaims(), - nameAttributeKey); - ReactiveOAuth2UserService oauth2 = mock(ReactiveOAuth2UserService.class); - given(oauth2.loadUser(any())).willReturn(Mono.just(oauth2User)); - given(oidcUserConverter.convert(any())).willReturn(Mono.just(actualUser)); - this.userService.setOauth2UserService(oauth2); - this.userService.setOidcUserConverter(oidcUserConverter); - OidcUserRequest userRequest = new OidcUserRequest(clientRegistration, this.accessToken, this.idToken); - OidcUser user = this.userService.loadUser(userRequest).block(); - assertThat(user).isEqualTo(actualUser); - ArgumentCaptor metadataCptr = ArgumentCaptor.forClass(OidcUserSource.class); - verify(oidcUserConverter).convert(metadataCptr.capture()); - OidcUserSource metadata = metadataCptr.getValue(); - assertThat(metadata.getUserRequest()).isEqualTo(userRequest); - assertThat(metadata.getOauth2User()).isEqualTo(oauth2User); - assertThat(metadata.getUserInfo()).isNotNull(); - } - @Test public void loadUserWhenNestedUserInfoSuccessThenReturnUser() throws IOException { // @formatter:off diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcUserServiceTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcUserServiceTests.java index a6b0fff3c7..23f2c5a4b9 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcUserServiceTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcUserServiceTests.java @@ -655,7 +655,7 @@ public void loadUserWhenNestedUserInfoSuccessThenReturnUser() { OAuth2UserAuthority userAuthority = (OAuth2UserAuthority) user.getAuthorities().iterator().next(); assertThat(userAuthority.getAuthority()).isEqualTo("OIDC_USER"); assertThat(userAuthority.getAttributes()).isEqualTo(user.getAttributes()); - assertThat(userAuthority.getUserNameAttributeName()).isEqualTo("user-name"); + assertThat(userAuthority.getUsername()).isEqualTo("user1"); } private MockResponse jsonResponse(String json) { diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/userinfo/DefaultOAuth2UserServiceTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/userinfo/DefaultOAuth2UserServiceTests.java index d57d5fb311..b20a648dc0 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/userinfo/DefaultOAuth2UserServiceTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/userinfo/DefaultOAuth2UserServiceTests.java @@ -556,6 +556,29 @@ public void loadUserWhenUsernameExpressionWithArrayAccessThenEvaluateCorrectly() assertThat(user.getName()).isEqualTo("primary_user"); } + @Test + public void loadUserWhenUsernameExpressionWithComplexConditionalThenEvaluateCorrectly() { + // @formatter:off + String userInfoResponse = "{\n" + + " \"user_data\": {\n" + + " \"preferred_username\": \"preferredUser\",\n" + + " \"email\": \"user@example.com\",\n" + + " \"is_verified\": true\n" + + " },\n" + + " \"backup_name\": \"backupUser\"\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("user_data?.is_verified == true ? user_data.preferred_username : backup_name") + .build(); + OAuth2User user = this.userService.loadUser(new OAuth2UserRequest(clientRegistration, this.accessToken)); + assertThat(user.getName()).isEqualTo("preferredUser"); + assertThat(user.getAttributes()).hasSize(2); + } + private DefaultOAuth2UserService withMockResponse(Map response) { ResponseEntity> responseEntity = new ResponseEntity<>(response, HttpStatus.OK); Converter> requestEntityConverter = mock(Converter.class); diff --git a/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/user/DefaultOAuth2UserTests.java b/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/user/DefaultOAuth2UserTests.java index eebc099d44..29327e0dce 100644 --- a/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/user/DefaultOAuth2UserTests.java +++ b/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/user/DefaultOAuth2UserTests.java @@ -174,14 +174,6 @@ public void withUsernameWhenUsernameProvidedThenTakesPrecedenceOverAttributes() 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);