From 0dc9709018359843b5fa01c61354f79c87676c01 Mon Sep 17 00:00:00 2001 From: yybmion Date: Wed, 11 Jun 2025 19:50:33 +0900 Subject: [PATCH 1/2] 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 | 5 +- .../client/jackson2/DefaultOidcUserMixin.java | 2 +- .../OAuth2AuthenticationTokenMixinTests.java | 5 +- .../oauth2/core/user/DefaultOAuth2User.java | 78 ++++++++++++- .../core/user/DefaultOAuth2UserTests.java | 108 +++++++++++++++++- 5 files changed, 190 insertions(+), 8 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 917062c905..562c17e942 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -32,6 +32,7 @@ import org.springframework.security.oauth2.core.user.DefaultOAuth2User; * 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 5b46dc9396..f3ada58055 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 @@ import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; @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 6fe7d05b50..a64caaa06d 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -194,7 +194,8 @@ public class OAuth2AuthenticationTokenMixinTests { " \"@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 6c80d7b64a..3b60a771e1 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -45,6 +45,7 @@ import org.springframework.util.Assert; * @author Joe Grandja * @author Eddú Meléndez * @author Park Hyojong + * @author YooBin Yoon * @since 5.0 * @see OAuth2User */ @@ -58,13 +59,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"); @@ -77,11 +82,80 @@ public class DefaultOAuth2User implements OAuth2User, Serializable { : Collections.unmodifiableSet(new LinkedHashSet<>(AuthorityUtils.NO_AUTHORITIES)); this.attributes = Collections.unmodifiableMap(new LinkedHashMap<>(attributes)); this.nameAttributeKey = nameAttributeKey; + this.username = 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 a56c5bcf6a..f55fea1d8a 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -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 @@ import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException * @author Vedran Pavic * @author Joe Grandja * @author Park Hyojong + * @author Yoobin Yoon */ public class DefaultOAuth2UserTests { @@ -109,4 +111,108 @@ public class DefaultOAuth2UserTests { 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 0ca4bfdc1f9ff9b4ee620dc9323cb2405ecb3430 Mon Sep 17 00:00:00 2001 From: yybmion Date: Wed, 2 Jul 2025 14:19:19 +0900 Subject: [PATCH 2/2] 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 - 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 | 29 +-- .../registration/ClientRegistration.java | 64 ++++++- .../userinfo/DefaultOAuth2UserService.java | 107 ++++++++---- .../DefaultReactiveOAuth2UserService.java | 95 +++++++--- .../OAuth2AuthenticationTokenMixinTests.java | 10 +- .../OAuth2AuthorizedClientMixinTests.java | 9 +- .../registration/ClientRegistrationTests.java | 82 ++++++++- .../DefaultOAuth2UserServiceTests.java | 165 ++++++++++++++++-- ...DefaultReactiveOAuth2UserServiceTests.java | 120 +++++++++---- .../core/oidc/user/OidcUserAuthority.java | 102 ++++++++++- .../oauth2/core/user/DefaultOAuth2User.java | 74 ++++---- .../oauth2/core/user/OAuth2UserAuthority.java | 96 +++++++++- .../oidc/user/OidcUserAuthorityTests.java | 42 ++++- .../core/user/DefaultOAuth2UserTests.java | 49 ++++++ .../core/user/OAuth2UserAuthorityTests.java | 36 +++- 17 files changed, 908 insertions(+), 192 deletions(-) diff --git a/docs/modules/ROOT/pages/reactive/oauth2/client/core.adoc b/docs/modules/ROOT/pages/reactive/oauth2/client/core.adoc index e1ca19df49..30821b2938 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 0418877371..ccfdc0f99a 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 03368827eb..c8152e81a1 100644 --- a/docs/modules/ROOT/pages/whats-new.adoc +++ b/docs/modules/ROOT/pages/whats-new.adoc @@ -1,30 +1,17 @@ [[new]] -= What's New in Spring Security 6.5 += What's New in Spring Security 7.0 -Spring Security 6.5 provides a number of new features. +Spring Security 7.0 provides a number of new features. Below are the highlights of the release, or you can view https://github.com/spring-projects/spring-security/releases[the release notes] for a detailed listing of each feature and bug fix. -== New Features +== Web -* Support for automatic context-propagation with Micrometer (https://github.com/spring-projects/spring-security/issues/16665[gh-16665]) +* Added javadoc:org.springframework.security.web.authentication.preauth.x509.SubjectX500PrincipalExtractor[] +* Added OAuth2 Support for xref:features/integrations/rest/http-interface.adoc[HTTP Interface Integration] -== Breaking Changes +== OAuth 2.0 -=== Observability +=== Username Expression Support for Nested Attributes - https://github.com/spring-projects/spring-security/pull/16390[gh-16390] -The `security.security.reached.filter.section` key name was corrected to `spring.security.reached.filter.section`. -Note that this may affect reports that operate on this key name. +OAuth2 Client now supports SpEL expressions for extracting usernames from nested UserInfo responses, eliminating the need for custom `OAuth2UserService` implementations in many cases. This is particularly useful for APIs like Twitter API v2 that return nested user data. -== OAuth - -* https://github.com/spring-projects/spring-security/pull/16386[gh-16386] - Enable PKCE for confidential clients using `ClientRegistration.clientSettings.requireProofKey=true` for xref:servlet/oauth2/client/core.adoc#oauth2Client-client-registration-requireProofKey[servlet] and xref:reactive/oauth2/client/core.adoc#oauth2Client-client-registration-requireProofKey[reactive] applications - -== WebAuthn - -* https://github.com/spring-projects/spring-security/pull/16282[gh-16282] - xref:servlet/authentication/passkeys.adoc#passkeys-configuration-persistence[JDBC Persistence] for WebAuthn/Passkeys -* https://github.com/spring-projects/spring-security/pull/16397[gh-16397] - Added the ability to configure a custom `HttpMessageConverter` for Passkeys using the optional xref:servlet/authentication/passkeys.adoc#passkeys-configuration[`messageConverter` property] on the `webAuthn` DSL. -* https://github.com/spring-projects/spring-security/pull/16396[gh-16396] - Added the ability to configure a custom xref:servlet/authentication/passkeys.adoc#passkeys-configuration-pkccor[`PublicKeyCredentialCreationOptionsRepository`] - -== One-Time Token Login - -* https://github.com/spring-projects/spring-security/issues/16291[gh-16291] - `oneTimeTokenLogin()` now supports customizing GenerateOneTimeTokenRequest xref:servlet/authentication/onetimetoken.adoc#customize-generate-token-request[via GenerateOneTimeTokenRequestResolver] 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 b492a6d801..0d2c46849c 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 @@ -47,6 +47,7 @@ import org.springframework.util.StringUtils; * * @author Joe Grandja * @author Michael Sosa + * @author Yoobin Yoon * @since 5.0 * @see Section 2 * Client Registration @@ -299,8 +300,11 @@ public final class ClientRegistration implements Serializable { private AuthenticationMethod authenticationMethod = AuthenticationMethod.HEADER; + @Deprecated private String userNameAttributeName; + private String usernameExpression; + UserInfoEndpoint() { } @@ -322,15 +326,23 @@ public final class ClientRegistration implements Serializable { } /** - * Returns the attribute name used to access the user's name from the user - * info response. - * @return the attribute name used to access the user's name from the user - * info response + * @deprecated Use {@link #getUsernameExpression()} instead */ + @Deprecated public String getUserNameAttributeName() { return this.userNameAttributeName; } + /** + * Returns the SpEL expression used to extract the username from user info + * response. + * @return the SpEL expression for username extraction + * @since 7.0 + */ + public String getUsernameExpression() { + return this.usernameExpression; + } + } } @@ -370,8 +382,11 @@ public final class ClientRegistration implements Serializable { private AuthenticationMethod userInfoAuthenticationMethod = AuthenticationMethod.HEADER; + @Deprecated private String userNameAttributeName; + private String usernameExpression; + private String jwkSetUri; private String issuerUri; @@ -399,6 +414,7 @@ public final class ClientRegistration implements Serializable { this.userInfoUri = clientRegistration.providerDetails.userInfoEndpoint.uri; this.userInfoAuthenticationMethod = clientRegistration.providerDetails.userInfoEndpoint.authenticationMethod; this.userNameAttributeName = clientRegistration.providerDetails.userInfoEndpoint.userNameAttributeName; + this.usernameExpression = clientRegistration.providerDetails.userInfoEndpoint.usernameExpression; this.jwkSetUri = clientRegistration.providerDetails.jwkSetUri; this.issuerUri = clientRegistration.providerDetails.issuerUri; Map configurationMetadata = clientRegistration.providerDetails.configurationMetadata; @@ -552,14 +568,43 @@ public final class ClientRegistration implements Serializable { } /** - * Sets the attribute name used to access the user's name from the user info - * response. - * @param userNameAttributeName the attribute name used to access the user's name - * from the user info response + * Sets the username attribute name. This method automatically converts the + * attribute name to a SpEL expression for backward compatibility. + * + *

+ * 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; } @@ -672,7 +717,10 @@ public final class ClientRegistration implements Serializable { providerDetails.tokenUri = this.tokenUri; providerDetails.userInfoEndpoint.uri = this.userInfoUri; providerDetails.userInfoEndpoint.authenticationMethod = this.userInfoAuthenticationMethod; + + providerDetails.userInfoEndpoint.usernameExpression = this.usernameExpression; providerDetails.userInfoEndpoint.userNameAttributeName = this.userNameAttributeName; + providerDetails.jwkSetUri = this.jwkSetUri; providerDetails.issuerUri = this.issuerUri; providerDetails.configurationMetadata = Collections.unmodifiableMap(this.configurationMetadata); 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 02930047b1..8251c010f1 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,8 +20,12 @@ import java.util.Collection; import java.util.LinkedHashSet; import java.util.Map; +import org.springframework.context.expression.MapAccessor; import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.convert.converter.Converter; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.SimpleEvaluationContext; import org.springframework.http.RequestEntity; import org.springframework.http.ResponseEntity; import org.springframework.security.core.GrantedAuthority; @@ -47,16 +51,17 @@ import org.springframework.web.client.UnknownContentTypeException; * An implementation of an {@link OAuth2UserService} that supports standard OAuth 2.0 * Provider's. *

- * 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 class DefaultOAuth2UserService implements OAuth2UserService 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 @@ public class DefaultOAuth2UserService implements OAuth2UserService 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/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 ae3a65b52c..f821b2b29a 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -25,8 +25,12 @@ import com.nimbusds.openid.connect.sdk.UserInfoErrorResponse; import net.minidev.json.JSONObject; import reactor.core.publisher.Mono; +import org.springframework.context.expression.MapAccessor; import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.convert.converter.Converter; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.SimpleEvaluationContext; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; @@ -49,16 +53,17 @@ import org.springframework.web.reactive.function.client.WebClient; * An implementation of an {@link ReactiveOAuth2UserService} that supports standard OAuth * 2.0 Provider's. *

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

* 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 Rob Winch + * @author Yoobin Yoon * @since 5.1 * @see ReactiveOAuth2UserService * @see OAuth2UserRequest @@ -73,6 +78,10 @@ public class DefaultReactiveOAuth2UserService implements ReactiveOAuth2UserServi private static final String MISSING_USER_NAME_ATTRIBUTE_ERROR_CODE = "missing_user_name_attribute"; + private static final String INVALID_USERNAME_EXPRESSION_ERROR_CODE = "invalid_username_expression"; + + private static final ExpressionParser expressionParser = new SpelExpressionParser(); + private static final ParameterizedTypeReference> STRING_OBJECT_MAP = new ParameterizedTypeReference<>() { }; @@ -99,17 +108,7 @@ public class DefaultReactiveOAuth2UserService implements ReactiveOAuth2UserServi null); throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString()); } - String userNameAttributeName = userRequest.getClientRegistration() - .getProviderDetails() - .getUserInfoEndpoint() - .getUserNameAttributeName(); - if (!StringUtils.hasText(userNameAttributeName)) { - OAuth2Error oauth2Error = new OAuth2Error(MISSING_USER_NAME_ATTRIBUTE_ERROR_CODE, - "Missing required \"user name\" attribute name in UserInfoEndpoint for Client Registration: " - + userRequest.getClientRegistration().getRegistrationId(), - null); - throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString()); - } + String usernameExpression = getUsernameExpression(userRequest); AuthenticationMethod authenticationMethod = userRequest.getClientRegistration() .getProviderDetails() .getUserInfoEndpoint() @@ -130,16 +129,21 @@ public class DefaultReactiveOAuth2UserService implements ReactiveOAuth2UserServi .bodyToMono(DefaultReactiveOAuth2UserService.STRING_OBJECT_MAP) .mapNotNull((attributes) -> this.attributesConverter.convert(userRequest).convert(attributes)); return userAttributes.map((attrs) -> { - GrantedAuthority authority = new OAuth2UserAuthority(attrs, userNameAttributeName); - Set authorities = new HashSet<>(); - authorities.add(authority); - OAuth2AccessToken token = userRequest.getAccessToken(); - for (String scope : token.getScopes()) { - authorities.add(new SimpleGrantedAuthority("SCOPE_" + scope)); - } + String username = evaluateUsername(attrs, usernameExpression); + Set authorities = new HashSet<>(); + authorities.add(OAuth2UserAuthority.withUsername(username) + .attributes(attrs) + .build()); + OAuth2AccessToken token = userRequest.getAccessToken(); + for (String scope : token.getScopes()) { + authorities.add(new SimpleGrantedAuthority("SCOPE_" + scope)); + } - return new DefaultOAuth2User(authorities, attrs, userNameAttributeName); - }) + return DefaultOAuth2User.withUsername(username) + .authorities(authorities) + .attributes(attrs) + .build(); + }) .onErrorMap((ex) -> (ex instanceof UnsupportedMediaTypeException || ex.getCause() instanceof UnsupportedMediaTypeException), (ex) -> { String contentType = (ex instanceof UnsupportedMediaTypeException) ? @@ -168,6 +172,47 @@ public class DefaultReactiveOAuth2UserService implements ReactiveOAuth2UserServi // @formatter:on } + private String getUsernameExpression(OAuth2UserRequest userRequest) { + String usernameExpression = userRequest.getClientRegistration() + .getProviderDetails() + .getUserInfoEndpoint() + .getUsernameExpression(); + if (!StringUtils.hasText(usernameExpression)) { + OAuth2Error oauth2Error = new OAuth2Error(MISSING_USER_NAME_ATTRIBUTE_ERROR_CODE, + "Missing required \"user name\" attribute name in UserInfoEndpoint for Client Registration: " + + userRequest.getClientRegistration().getRegistrationId(), + null); + throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString()); + } + return usernameExpression; + } + + private String evaluateUsername(Map attributes, String usernameExpression) { + Object value = null; + + try { + SimpleEvaluationContext context = SimpleEvaluationContext.forPropertyAccessors(new MapAccessor()) + .withRootObject(attributes) + .build(); + value = expressionParser.parseExpression(usernameExpression).getValue(context); + } + catch (Exception ex) { + OAuth2Error oauth2Error = new OAuth2Error(INVALID_USERNAME_EXPRESSION_ERROR_CODE, + "Invalid username expression or SPEL expression: " + usernameExpression, null); + throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString(), ex); + + } + + if (value == null) { + OAuth2Error oauth2Error = new OAuth2Error(INVALID_USER_INFO_RESPONSE_ERROR_CODE, + "An error occurred while attempting to retrieve the UserInfo Resource: username cannot be null", + null); + throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString()); + } + + return value.toString(); + } + private WebClient.RequestHeadersSpec getRequestHeaderSpec(OAuth2UserRequest userRequest, String userInfoUri, AuthenticationMethod authenticationMethod) { if (AuthenticationMethod.FORM.equals(authenticationMethod)) { 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 a64caaa06d..be90452d04 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 @@ public class OAuth2AuthenticationTokenMixinTests { return "{\n" + " \"@class\": \"org.springframework.security.oauth2.core.user.OAuth2UserAuthority\",\n" + " \"authority\": \"" + oauth2UserAuthority.getAuthority() + "\",\n" + - " \"userNameAttributeName\": \"username\",\n" + " \"attributes\": {\n" + " \"@class\": \"java.util.Collections$UnmodifiableMap\",\n" + " \"username\": \"user\"\n" + - " }\n" + + " },\n" + + " \"userNameAttributeName\": \"username\",\n" + + " \"username\": \"user\"\n" + " }"; // @formatter:on } @@ -262,9 +263,10 @@ public class OAuth2AuthenticationTokenMixinTests { return "{\n" + " \"@class\": \"org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority\",\n" + " \"authority\": \"" + oidcUserAuthority.getAuthority() + "\",\n" + - " \"userNameAttributeName\": \"" + oidcUserAuthority.getUserNameAttributeName() + "\",\n" + " \"idToken\": " + asJson(oidcUserAuthority.getIdToken()) + ",\n" + - " \"userInfo\": " + asJson(oidcUserAuthority.getUserInfo()) + "\n" + + " \"userInfo\": " + asJson(oidcUserAuthority.getUserInfo()) + ",\n" + + " \"userNameAttributeName\": \"" + oidcUserAuthority.getUserNameAttributeName() + "\",\n" + + " \"username\": \"subject\"\n" + " }"; // @formatter:on } 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 d6d0e81927..aa812c24f8 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 the original author or authors. + * Copyright 2002-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -145,6 +145,8 @@ public class OAuth2AuthorizedClientMixinTests { .isEqualTo(expectedClientRegistration.getProviderDetails().getUserInfoEndpoint().getAuthenticationMethod()); assertThat(clientRegistration.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName()).isEqualTo( expectedClientRegistration.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName()); + assertThat(clientRegistration.getProviderDetails().getUserInfoEndpoint().getUsernameExpression()) + .isEqualTo(expectedClientRegistration.getProviderDetails().getUserInfoEndpoint().getUsernameExpression()); assertThat(clientRegistration.getProviderDetails().getJwkSetUri()) .isEqualTo(expectedClientRegistration.getProviderDetails().getJwkSetUri()); assertThat(clientRegistration.getProviderDetails().getIssuerUri()) @@ -306,6 +308,8 @@ public class OAuth2AuthorizedClientMixinTests { .map((key) -> "\"" + key + "\": \"" + providerDetails.getConfigurationMetadata().get(key) + "\"") .collect(Collectors.joining(",")); } + String usernameExpression = (userInfoEndpoint.getUsernameExpression() != null) + ? "\"" + userInfoEndpoint.getUsernameExpression() + "\"" : null; // @formatter:off return "{\n" + " \"@class\": \"org.springframework.security.oauth2.client.registration.ClientRegistration\",\n" + @@ -333,7 +337,8 @@ public class OAuth2AuthorizedClientMixinTests { " \"authenticationMethod\": {\n" + " \"value\": \"" + userInfoEndpoint.getAuthenticationMethod().getValue() + "\"\n" + " },\n" + - " \"userNameAttributeName\": " + ((userInfoEndpoint.getUserNameAttributeName() != null) ? "\"" + userInfoEndpoint.getUserNameAttributeName() + "\"" : null) + "\n" + + " \"userNameAttributeName\": " + ((userInfoEndpoint.getUserNameAttributeName() != null) ? "\"" + userInfoEndpoint.getUserNameAttributeName() + "\"" : null) + ",\n" + + " \"usernameExpression\": " + usernameExpression + "\n" + " },\n" + " \"jwkSetUri\": " + ((providerDetails.getJwkSetUri() != null) ? "\"" + providerDetails.getJwkSetUri() + "\"" : null) + ",\n" + " \"issuerUri\": " + ((providerDetails.getIssuerUri() != null) ? "\"" + providerDetails.getIssuerUri() + "\"" : null) + ",\n" + 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 9dbcbd5a5c..35f41b5a7d 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -43,6 +43,7 @@ import static org.assertj.core.api.Assertions.assertThatIllegalStateException; * Tests for {@link ClientRegistration}. * * @author Joe Grandja + * @author Yoobin Yoon */ public class ClientRegistrationTests { @@ -716,6 +717,7 @@ public class ClientRegistrationTests { .isEqualTo(updatedUserInfoEndpoint.getAuthenticationMethod()); assertThat(userInfoEndpoint.getUserNameAttributeName()) .isEqualTo(updatedUserInfoEndpoint.getUserNameAttributeName()); + assertThat(userInfoEndpoint.getUsernameExpression()).isEqualTo(updatedUserInfoEndpoint.getUsernameExpression()); assertThat(providerDetails.getJwkSetUri()).isEqualTo(updatedProviderDetails.getJwkSetUri()); assertThat(providerDetails.getIssuerUri()).isEqualTo(updatedProviderDetails.getIssuerUri()); assertThat(providerDetails.getConfigurationMetadata()) @@ -802,6 +804,84 @@ public class ClientRegistrationTests { assertThat(clientRegistration.getClientSettings().isRequireProofKey()).isTrue(); } + @Test + public void buildWhenUsernameExpressionProvidedThenSet() { + String usernameExpression = "data.username"; + // @formatter:off + ClientRegistration registration = ClientRegistration.withRegistrationId(REGISTRATION_ID) + .clientId(CLIENT_ID) + .clientSecret(CLIENT_SECRET) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri(REDIRECT_URI) + .authorizationUri(AUTHORIZATION_URI) + .tokenUri(TOKEN_URI) + .usernameExpression(usernameExpression) + .build(); + // @formatter:on + assertThat(registration.getProviderDetails().getUserInfoEndpoint().getUsernameExpression()) + .isEqualTo(usernameExpression); + } + + @Test + public void buildWhenBothUserNameAttributeNameAndUsernameExpressionProvidedThenUsernameExpressionTakesPrecedence() { + String userNameAttributeName = "username"; + String usernameExpression = "data.username"; + // @formatter:off + ClientRegistration registration = ClientRegistration.withRegistrationId(REGISTRATION_ID) + .clientId(CLIENT_ID) + .clientSecret(CLIENT_SECRET) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri(REDIRECT_URI) + .authorizationUri(AUTHORIZATION_URI) + .tokenUri(TOKEN_URI) + .userNameAttributeName(userNameAttributeName) + .usernameExpression(usernameExpression) + .build(); + // @formatter:on + assertThat(registration.getProviderDetails().getUserInfoEndpoint().getUsernameExpression()) + .isEqualTo(usernameExpression); + assertThat(registration.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName()) + .isEqualTo(userNameAttributeName); + } + + @Test + public void buildWhenOnlyUserNameAttributeNameProvidedThenAutoConvertToSpelExpression() { + String userNameAttributeName = "username"; + // @formatter:off + ClientRegistration registration = ClientRegistration.withRegistrationId(REGISTRATION_ID) + .clientId(CLIENT_ID) + .clientSecret(CLIENT_SECRET) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri(REDIRECT_URI) + .authorizationUri(AUTHORIZATION_URI) + .tokenUri(TOKEN_URI) + .userNameAttributeName(userNameAttributeName) + .build(); + // @formatter:on + assertThat(registration.getProviderDetails().getUserInfoEndpoint().getUsernameExpression()) + .isEqualTo("['" + userNameAttributeName + "']"); + assertThat(registration.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName()) + .isEqualTo(userNameAttributeName); + } + + @Test + public void buildWhenCopyingClientRegistrationWithUsernameExpressionThenPreserved() { + String usernameExpression = "profile.name"; + // @formatter:off + ClientRegistration original = ClientRegistration.withRegistrationId(REGISTRATION_ID) + .clientId(CLIENT_ID) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri(REDIRECT_URI) + .authorizationUri(AUTHORIZATION_URI) + .tokenUri(TOKEN_URI) + .usernameExpression(usernameExpression) + .build(); + // @formatter:on + ClientRegistration copy = ClientRegistration.withClientRegistration(original).build(); + assertThat(copy.getProviderDetails().getUserInfoEndpoint().getUsernameExpression()) + .isEqualTo(usernameExpression); + } + @ParameterizedTest @MethodSource("invalidPkceGrantTypes") void buildWhenInvalidGrantTypeForPkceThenException(AuthorizationGrantType invalidGrantType) { 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 e210019f48..89e9831d71 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -61,6 +61,7 @@ import static org.mockito.Mockito.mock; * * @author Joe Grandja * @author Eddú Meléndez + * @author Yoobin Yoon */ public class DefaultOAuth2UserServiceTests { @@ -121,7 +122,7 @@ public class DefaultOAuth2UserServiceTests { // @formatter:on assertThatExceptionOfType(OAuth2AuthenticationException.class) .isThrownBy(() -> this.userService.loadUser(new OAuth2UserRequest(clientRegistration, this.accessToken))) - .withMessageContaining("missing_user_name_attribute"); + .withMessageContaining("invalid_user_info_response"); } @Test @@ -153,23 +154,26 @@ public class DefaultOAuth2UserServiceTests { assertThat((String) user.getAttribute("email")).isEqualTo("user1@example.com"); assertThat(user.getAuthorities()).hasSize(1); assertThat(user.getAuthorities().iterator().next()).isInstanceOf(OAuth2UserAuthority.class); - OAuth2UserAuthority userAuthority = (OAuth2UserAuthority) user.getAuthorities().iterator().next(); + OAuth2UserAuthority userAuthority = OAuth2UserAuthority.withUsername("user1") + .attributes(user.getAttributes()) + .authority("OAUTH2_USER") + .build(); assertThat(userAuthority.getAuthority()).isEqualTo("OAUTH2_USER"); assertThat(userAuthority.getAttributes()).isEqualTo(user.getAttributes()); - assertThat(userAuthority.getUserNameAttributeName()).isEqualTo("user-name"); + assertThat(userAuthority.getUsername()).isEqualTo("user1"); } @Test public void loadUserWhenNestedUserInfoSuccessThenReturnUser() { // @formatter:off String userInfoResponse = "{\n" - + " \"user\": {\"user-name\": \"user1\"},\n" - + " \"first-name\": \"first\",\n" - + " \"last-name\": \"last\",\n" - + " \"middle-name\": \"middle\",\n" - + " \"address\": \"address\",\n" - + " \"email\": \"user1@example.com\"\n" - + "}\n"; + + " \"user\": {\"user-name\": \"user1\"},\n" + + " \"first-name\": \"first\",\n" + + " \"last-name\": \"last\",\n" + + " \"middle-name\": \"middle\",\n" + + " \"address\": \"address\",\n" + + " \"email\": \"user1@example.com\"\n" + + "}\n"; // @formatter:on this.server.enqueue(jsonResponse(userInfoResponse)); String userInfoUri = this.server.url("/user").toString(); @@ -194,10 +198,13 @@ public class DefaultOAuth2UserServiceTests { assertThat((String) user.getAttribute("email")).isEqualTo("user1@example.com"); assertThat(user.getAuthorities()).hasSize(1); assertThat(user.getAuthorities().iterator().next()).isInstanceOf(OAuth2UserAuthority.class); - OAuth2UserAuthority userAuthority = (OAuth2UserAuthority) user.getAuthorities().iterator().next(); + OAuth2UserAuthority userAuthority = OAuth2UserAuthority.withUsername("user1") + .attributes(user.getAttributes()) + .authority("OAUTH2_USER") + .build(); assertThat(userAuthority.getAuthority()).isEqualTo("OAUTH2_USER"); assertThat(userAuthority.getAttributes()).isEqualTo(user.getAttributes()); - assertThat(userAuthority.getUserNameAttributeName()).isEqualTo("user-name"); + assertThat(userAuthority.getUsername()).isEqualTo("user1"); } @Test @@ -247,8 +254,8 @@ public class DefaultOAuth2UserServiceTests { public void loadUserWhenUserInfoErrorResponseThenThrowOAuth2AuthenticationException() { // @formatter:off String userInfoErrorResponse = "{\n" - + " \"error\": \"invalid_token\"\n" - + "}\n"; + + " \"error\": \"invalid_token\"\n" + + "}\n"; // @formatter:on this.server.enqueue(jsonResponse(userInfoErrorResponse).setResponseCode(400)); String userInfoUri = this.server.url("/user").toString(); @@ -421,6 +428,134 @@ public class DefaultOAuth2UserServiceTests { .isThrownBy(() -> this.userService.setAttributesConverter(null)); } + @Test + public void loadUserWhenBackwardCompatibilityWithUserNameAttributeNameThenWorks() { + // @formatter:off + String userInfoResponse = "{\n" + + " \"user-name\": \"backwardCompatUser\",\n" + + " \"email\": \"backward@example.com\"\n" + + "}\n"; + // @formatter:on + this.server.enqueue(jsonResponse(userInfoResponse)); + String userInfoUri = this.server.url("/user").toString(); + ClientRegistration clientRegistration = this.clientRegistrationBuilder.userInfoUri(userInfoUri) + .userInfoAuthenticationMethod(AuthenticationMethod.HEADER) + .userNameAttributeName("user-name") + .build(); + OAuth2User user = this.userService.loadUser(new OAuth2UserRequest(clientRegistration, this.accessToken)); + assertThat(user.getName()).isEqualTo("backwardCompatUser"); + assertThat(user.getAttributes()).hasSize(2); + } + + @Test + public void loadUserWhenUsernameExpressionIsSimpleAttributeThenUseDirectly() { + // @formatter:off + String userInfoResponse = "{\n" + + " \"username\": \"simpleUser\",\n" + + " \"id\": \"54321\",\n" + + " \"email\": \"simple@example.com\"\n" + + "}\n"; + // @formatter:on + this.server.enqueue(jsonResponse(userInfoResponse)); + String userInfoUri = this.server.url("/user").toString(); + ClientRegistration clientRegistration = this.clientRegistrationBuilder.userInfoUri(userInfoUri) + .userInfoAuthenticationMethod(AuthenticationMethod.HEADER) + .usernameExpression("username") + .build(); + OAuth2User user = this.userService.loadUser(new OAuth2UserRequest(clientRegistration, this.accessToken)); + assertThat(user.getName()).isEqualTo("simpleUser"); + assertThat(user.getAttributes()).hasSize(3); + } + + @Test + public void loadUserWhenUsernameExpressionIsSpelThenEvaluateCorrectly() { + // @formatter:off + String userInfoResponse = "{\n" + + " \"data\": {\n" + + " \"user\": {\n" + + " \"username\": \"spelUser\"\n" + + " }\n" + + " },\n" + + " \"id\": \"12345\",\n" + + " \"email\": \"spel@example.com\"\n" + + "}\n"; + // @formatter:on + this.server.enqueue(jsonResponse(userInfoResponse)); + String userInfoUri = this.server.url("/user").toString(); + ClientRegistration clientRegistration = this.clientRegistrationBuilder.userInfoUri(userInfoUri) + .userInfoAuthenticationMethod(AuthenticationMethod.HEADER) + .usernameExpression("data.user.username") + .build(); + OAuth2User user = this.userService.loadUser(new OAuth2UserRequest(clientRegistration, this.accessToken)); + assertThat(user.getName()).isEqualTo("spelUser"); + assertThat(user.getAttributes()).hasSize(3); + assertThat((String) user.getAttribute("id")).isEqualTo("12345"); + assertThat((String) user.getAttribute("email")).isEqualTo("spel@example.com"); + } + + @Test + public void loadUserWhenUsernameExpressionInvalidSpelThenThrowOAuth2AuthenticationException() { + // @formatter:off + String userInfoResponse = "{\n" + + " \"username\": \"testUser\",\n" + + " \"id\": \"12345\"\n" + + "}\n"; + // @formatter:on + this.server.enqueue(jsonResponse(userInfoResponse)); + String userInfoUri = this.server.url("/user").toString(); + ClientRegistration clientRegistration = this.clientRegistrationBuilder.userInfoUri(userInfoUri) + .userInfoAuthenticationMethod(AuthenticationMethod.HEADER) + .usernameExpression("nonexistent.invalid.path") // invalid SpEL + .build(); + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.userService.loadUser(new OAuth2UserRequest(clientRegistration, this.accessToken))) + .withMessageContaining("invalid_username_expression") + .withMessageContaining("Invalid username expression or SPEL expression"); + } + + @Test + public void loadUserWhenUsernameExpressionResultsInNullThenThrowOAuth2AuthenticationException() { + // @formatter:off + String userInfoResponse = "{\n" + + " \"username\": \"testUser\",\n" + + " \"data\": {\n" + + " \"username\": null\n" + + " }\n" + + "}\n"; + // @formatter:on + this.server.enqueue(jsonResponse(userInfoResponse)); + String userInfoUri = this.server.url("/user").toString(); + ClientRegistration clientRegistration = this.clientRegistrationBuilder.userInfoUri(userInfoUri) + .userInfoAuthenticationMethod(AuthenticationMethod.HEADER) + .usernameExpression("data.username") + .build(); + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.userService.loadUser(new OAuth2UserRequest(clientRegistration, this.accessToken))) + .withMessageContaining("invalid_user_info_response") + .withMessageContaining("username cannot be null"); + } + + @Test + public void loadUserWhenUsernameExpressionWithArrayAccessThenEvaluateCorrectly() { + // @formatter:off + String userInfoResponse = "{\n" + + " \"accounts\": [\n" + + " {\"username\": \"primary_user\", \"type\": \"primary\"},\n" + + " {\"username\": \"secondary_user\", \"type\": \"secondary\"}\n" + + " ],\n" + + " \"id\": \"12345\"\n" + + "}\n"; + // @formatter:on + this.server.enqueue(jsonResponse(userInfoResponse)); + String userInfoUri = this.server.url("/user").toString(); + ClientRegistration clientRegistration = this.clientRegistrationBuilder.userInfoUri(userInfoUri) + .userInfoAuthenticationMethod(AuthenticationMethod.HEADER) + .usernameExpression("accounts[0].username") + .build(); + OAuth2User user = this.userService.loadUser(new OAuth2UserRequest(clientRegistration, this.accessToken)); + assertThat(user.getName()).isEqualTo("primary_user"); + } + private DefaultOAuth2UserService withMockResponse(Map response) { ResponseEntity> responseEntity = new ResponseEntity<>(response, HttpStatus.OK); Converter> requestEntityConverter = mock(Converter.class); diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/userinfo/DefaultReactiveOAuth2UserServiceTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/userinfo/DefaultReactiveOAuth2UserServiceTests.java index a8614842c6..7a80d0d661 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/userinfo/DefaultReactiveOAuth2UserServiceTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/userinfo/DefaultReactiveOAuth2UserServiceTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -60,6 +60,7 @@ import static org.mockito.Mockito.spy; /** * @author Rob Winch * @author Eddú Meléndez + * @author Yoobin Yoon * @since 5.1 */ public class DefaultReactiveOAuth2UserServiceTests { @@ -104,19 +105,6 @@ public class DefaultReactiveOAuth2UserServiceTests { .verify(); } - @Test - public void loadUserWhenUserNameAttributeNameIsNullThenThrowOAuth2AuthenticationException() { - this.clientRegistration.userNameAttributeName(null); - // @formatter:off - StepVerifier.create(this.userService.loadUser(oauth2UserRequest())) - .expectErrorSatisfies((ex) -> assertThat(ex) - .isInstanceOf(OAuth2AuthenticationException.class) - .hasMessageContaining("missing_user_name_attribute") - ) - .verify(); - // @formatter:on - } - @Test public void loadUserWhenUserInfoSuccessResponseThenReturnUser() { // @formatter:off @@ -141,10 +129,13 @@ public class DefaultReactiveOAuth2UserServiceTests { assertThat((String) user.getAttribute("email")).isEqualTo("user1@example.com"); assertThat(user.getAuthorities()).hasSize(1); assertThat(user.getAuthorities().iterator().next()).isInstanceOf(OAuth2UserAuthority.class); - OAuth2UserAuthority userAuthority = (OAuth2UserAuthority) user.getAuthorities().iterator().next(); + OAuth2UserAuthority userAuthority = OAuth2UserAuthority.withUsername("user1") + .attributes(user.getAttributes()) + .authority("OAUTH2_USER") + .build(); assertThat(userAuthority.getAuthority()).isEqualTo("OAUTH2_USER"); assertThat(userAuthority.getAttributes()).isEqualTo(user.getAttributes()); - assertThat(userAuthority.getUserNameAttributeName()).isEqualTo("id"); + assertThat(userAuthority.getUsername()).isEqualTo("user1"); } // gh-9336 @@ -152,13 +143,13 @@ public class DefaultReactiveOAuth2UserServiceTests { public void loadUserWhenUserInfo201CreatedResponseThenReturnUser() { // @formatter:off String userInfoResponse = "{\n" - + " \"id\": \"user1\",\n" - + " \"first-name\": \"first\",\n" - + " \"last-name\": \"last\",\n" - + " \"middle-name\": \"middle\",\n" - + " \"address\": \"address\",\n" - + " \"email\": \"user1@example.com\"\n" - + "}\n"; + + " \"id\": \"user1\",\n" + + " \"first-name\": \"first\",\n" + + " \"last-name\": \"last\",\n" + + " \"middle-name\": \"middle\",\n" + + " \"address\": \"address\",\n" + + " \"email\": \"user1@example.com\"\n" + + "}\n"; // @formatter:on this.server.enqueue(new MockResponse().setResponseCode(201) .setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) @@ -170,13 +161,13 @@ public class DefaultReactiveOAuth2UserServiceTests { public void loadUserWhenNestedUserInfoSuccessThenReturnUser() { // @formatter:off String userInfoResponse = "{\n" - + " \"user\": {\"user-name\": \"user1\"},\n" - + " \"first-name\": \"first\",\n" - + " \"last-name\": \"last\",\n" - + " \"middle-name\": \"middle\",\n" - + " \"address\": \"address\",\n" - + " \"email\": \"user1@example.com\"\n" - + "}\n"; + + " \"user\": {\"user-name\": \"user1\"},\n" + + " \"first-name\": \"first\",\n" + + " \"last-name\": \"last\",\n" + + " \"middle-name\": \"middle\",\n" + + " \"address\": \"address\",\n" + + " \"email\": \"user1@example.com\"\n" + + "}\n"; // @formatter:on enqueueApplicationJsonBody(userInfoResponse); String userInfoUri = this.server.url("/user").toString(); @@ -201,10 +192,13 @@ public class DefaultReactiveOAuth2UserServiceTests { assertThat((String) user.getAttribute("email")).isEqualTo("user1@example.com"); assertThat(user.getAuthorities()).hasSize(1); assertThat(user.getAuthorities().iterator().next()).isInstanceOf(OAuth2UserAuthority.class); - OAuth2UserAuthority userAuthority = (OAuth2UserAuthority) user.getAuthorities().iterator().next(); + OAuth2UserAuthority userAuthority = OAuth2UserAuthority.withUsername("user1") + .attributes(user.getAttributes()) + .authority("OAUTH2_USER") + .build(); assertThat(userAuthority.getAuthority()).isEqualTo("OAUTH2_USER"); assertThat(userAuthority.getAttributes()).isEqualTo(user.getAttributes()); - assertThat(userAuthority.getUserNameAttributeName()).isEqualTo("user-name"); + assertThat(userAuthority.getUsername()).isEqualTo("user1"); } // gh-5500 @@ -257,7 +251,7 @@ public class DefaultReactiveOAuth2UserServiceTests { public void loadUserWhenUserInfoSuccessResponseInvalidThenThrowOAuth2AuthenticationException() { // @formatter:off String userInfoResponse = "{\n" - + " \"id\": \"user1\",\n" + + " \"id\": \"user1\",\n" + " \"first-name\": \"first\",\n" + " \"last-name\": \"last\",\n" + " \"middle-name\": \"middle\",\n" @@ -338,6 +332,66 @@ public class DefaultReactiveOAuth2UserServiceTests { .isThrownBy(() -> this.userService.setAttributesConverter(null)); } + @Test + public void loadUserWhenUsernameExpressionIsSpelThenEvaluateCorrectly() { + // @formatter:off + String userInfoResponse = "{\n" + + " \"data\": {\n" + + " \"profile\": {\n" + + " \"name\": \"reactiveSpelUser\"\n" + + " }\n" + + " },\n" + + " \"id\": \"reactive123\"\n" + + "}\n"; + // @formatter:on + enqueueApplicationJsonBody(userInfoResponse); + ClientRegistration clientRegistration = this.clientRegistration.usernameExpression("data.profile.name").build(); + OAuth2User user = this.userService.loadUser(new OAuth2UserRequest(clientRegistration, this.accessToken)) + .block(); + assertThat(user.getName()).isEqualTo("reactiveSpelUser"); + assertThat(user.getAttributes()).hasSize(2); + assertThat((String) user.getAttribute("id")).isEqualTo("reactive123"); + } + + @Test + public void loadUserWhenUsernameExpressionInvalidSpelThenThrowOAuth2AuthenticationException() { + // @formatter:off + String userInfoResponse = "{\n" + + " \"id\": \"reactive123\",\n" + + " \"username\": \"reactiveUser\"\n" + + "}\n"; + // @formatter:on + enqueueApplicationJsonBody(userInfoResponse); + ClientRegistration clientRegistration = this.clientRegistration.usernameExpression("invalid.spel.expression") + .build(); + StepVerifier.create(this.userService.loadUser(new OAuth2UserRequest(clientRegistration, this.accessToken))) + .expectErrorSatisfies((ex) -> { + assertThat(ex).isInstanceOf(OAuth2AuthenticationException.class); + assertThat(ex.getMessage()).contains("Invalid username expression or SPEL expression"); + }) + .verify(); + } + + @Test + public void loadUserWhenUsernameExpressionResultsInNullThenThrowException() { + // @formatter:off + String userInfoResponse = "{\n" + + " \"username\": \"testUser\",\n" + + " \"data\": {\n" + + " \"username\": null\n" + + " }\n" + + "}\n"; + // @formatter:on + enqueueApplicationJsonBody(userInfoResponse); + ClientRegistration clientRegistration = this.clientRegistration.usernameExpression("data.username").build(); + StepVerifier.create(this.userService.loadUser(new OAuth2UserRequest(clientRegistration, this.accessToken))) + .expectErrorSatisfies((ex) -> { + assertThat(ex).isInstanceOf(OAuth2AuthenticationException.class); + assertThat(ex.getMessage()).contains("username cannot be null"); + }) + .verify(); + } + private DefaultReactiveOAuth2UserService withMockResponse(Map body) { WebClient real = WebClient.builder().build(); WebClient.RequestHeadersUriSpec spec = spy(real.post()); 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 4d07ad136c..0834d23b9b 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -32,6 +32,7 @@ import org.springframework.util.Assert; * A {@link GrantedAuthority} that may be associated to an {@link OidcUser}. * * @author Joe Grandja + * @author Yoobin Yoon * @since 5.0 * @see OidcUser */ @@ -47,7 +48,9 @@ public class OidcUserAuthority extends OAuth2UserAuthority { /** * Constructs a {@code OidcUserAuthority} using the provided parameters. * @param idToken the {@link OidcIdToken ID Token} containing claims about the user + * @deprecated Use {@link #withUsername(String)} builder pattern instead */ + @Deprecated public OidcUserAuthority(OidcIdToken idToken) { this(idToken, null); } @@ -58,7 +61,9 @@ public class OidcUserAuthority extends OAuth2UserAuthority { * @param idToken the {@link OidcIdToken ID Token} containing claims about the user * @param userInfo the {@link OidcUserInfo UserInfo} containing claims about the user, * may be {@code null} + * @deprecated Use {@link #withUsername(String)} builder pattern instead */ + @Deprecated public OidcUserAuthority(OidcIdToken idToken, OidcUserInfo userInfo) { this("OIDC_USER", idToken, userInfo); } @@ -72,7 +77,9 @@ public class OidcUserAuthority extends OAuth2UserAuthority { * @param userNameAttributeName the attribute name used to access the user's name from * the attributes * @since 6.4 + * @deprecated Use {@link #withUsername(String)} builder pattern instead */ + @Deprecated public OidcUserAuthority(OidcIdToken idToken, OidcUserInfo userInfo, @Nullable String userNameAttributeName) { this("OIDC_USER", idToken, userInfo, userNameAttributeName); } @@ -83,7 +90,9 @@ public class OidcUserAuthority extends OAuth2UserAuthority { * @param idToken the {@link OidcIdToken ID Token} containing claims about the user * @param userInfo the {@link OidcUserInfo UserInfo} containing claims about the user, * may be {@code null} + * @deprecated Use {@link #withUsername(String)} builder pattern instead */ + @Deprecated public OidcUserAuthority(String authority, OidcIdToken idToken, OidcUserInfo userInfo) { this(authority, idToken, userInfo, IdTokenClaimNames.SUB); } @@ -97,7 +106,9 @@ public class OidcUserAuthority extends OAuth2UserAuthority { * @param userNameAttributeName the attribute name used to access the user's name from * the attributes * @since 6.4 + * @deprecated Use {@link #withUsername(String)} builder pattern instead */ + @Deprecated public OidcUserAuthority(String authority, OidcIdToken idToken, OidcUserInfo userInfo, @Nullable String userNameAttributeName) { super(authority, collectClaims(idToken, userInfo), userNameAttributeName); @@ -105,6 +116,33 @@ public class OidcUserAuthority extends OAuth2UserAuthority { this.userInfo = userInfo; } + /** + * Constructs a {@code OidcUserAuthority} using the provided parameters. This + * constructor is used by the Builder pattern. + * @param username the username + * @param authority the authority granted to the user + * @param attributes the attributes about the user + * @param idToken the {@link OidcIdToken ID Token} containing claims about the user + * @param userInfo the {@link OidcUserInfo UserInfo} containing claims about the user, + * may be {@code null} + */ + private OidcUserAuthority(String username, String authority, Map attributes, OidcIdToken idToken, + OidcUserInfo userInfo) { + super(username, authority, attributes); + this.idToken = idToken; + this.userInfo = userInfo; + } + + /** + * Creates a new {@code OidcUserAuthority} builder with the username. + * @param username the username + * @return a new {@code Builder} + * @since 7.0 + */ + public static Builder withUsername(String username) { + return new Builder(username); + } + /** * Returns the {@link OidcIdToken ID Token} containing claims about the user. * @return the {@link OidcIdToken} containing claims about the user. @@ -159,4 +197,66 @@ public class OidcUserAuthority extends OAuth2UserAuthority { return claims; } + /** + * A builder for {@link OidcUserAuthority}. + * + * @since 7.0 + */ + public static final class Builder extends OAuth2UserAuthority.Builder { + + private OidcIdToken idToken; + + private OidcUserInfo userInfo; + + private Builder(String username) { + super(username); + this.authority = "OIDC_USER"; + } + + /** + * Sets the {@link OidcIdToken ID Token} containing claims about the user. + * @param idToken the {@link OidcIdToken ID Token} + * @return the {@link Builder} + */ + public Builder idToken(OidcIdToken idToken) { + this.idToken = idToken; + return this; + } + + /** + * Sets the {@link OidcUserInfo UserInfo} containing claims about the user. + * @param userInfo the {@link OidcUserInfo UserInfo} + * @return the {@link Builder} + */ + public Builder userInfo(OidcUserInfo userInfo) { + this.userInfo = userInfo; + return this; + } + + @Override + public Builder authority(String authority) { + super.authority(authority); + return this; + } + + @Override + public Builder attributes(Map 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 3b60a771e1..ef803270c7 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 @@ -103,7 +103,7 @@ public class DefaultOAuth2User implements OAuth2User, Serializable { : Collections.unmodifiableSet(new LinkedHashSet<>(AuthorityUtils.NO_AUTHORITIES)); this.attributes = Collections.unmodifiableMap(new LinkedHashMap<>(attributes)); this.nameAttributeKey = nameAttributeKey; - this.username = (username != null) ? username : attributes.get(nameAttributeKey).toString(); + this.username = username; Assert.hasText(this.username, "username cannot be empty"); } @@ -112,47 +112,12 @@ public class DefaultOAuth2User implements OAuth2User, Serializable { * Creates a new {@code DefaultOAuth2User} builder with the username. * @param username the user's name * @return a new {@code Builder} - * @since 6.5 + * @since 7.0 */ public static Builder withUsername(String username) { return new Builder(username); } - /** - * A builder for {@link DefaultOAuth2User}. - * - * @since 6.5 - */ - public static final class Builder { - - private final String username; - - private Collection 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; @@ -214,4 +179,39 @@ public class DefaultOAuth2User implements OAuth2User, Serializable { return sb.toString(); } + /** + * A builder for {@link DefaultOAuth2User}. + * + * @since 7.0 + */ + 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); + } + + } + } 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 c9edc42a81..b411a4e6fd 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -31,6 +31,7 @@ import org.springframework.util.Assert; * A {@link GrantedAuthority} that may be associated to an {@link OAuth2User}. * * @author Joe Grandja + * @author Yoobin Yoon * @since 5.0 * @see OAuth2User */ @@ -42,13 +43,18 @@ public class OAuth2UserAuthority implements GrantedAuthority { private final Map 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); } @@ -60,7 +66,9 @@ public class OAuth2UserAuthority implements GrantedAuthority { * @param userNameAttributeName the attribute name used to access the user's name from * the attributes * @since 6.4 + * @deprecated Use {@link #withUsername(String)} builder pattern instead */ + @Deprecated public OAuth2UserAuthority(Map attributes, @Nullable String userNameAttributeName) { this("OAUTH2_USER", attributes, userNameAttributeName); } @@ -69,7 +77,9 @@ public class OAuth2UserAuthority implements GrantedAuthority { * Constructs a {@code OAuth2UserAuthority} using the provided parameters. * @param authority the authority granted to the user * @param attributes the attributes about the user + * @deprecated Use {@link #withUsername(String)} builder pattern instead */ + @Deprecated public OAuth2UserAuthority(String authority, Map attributes) { this(authority, attributes, null); } @@ -81,13 +91,43 @@ public class OAuth2UserAuthority implements GrantedAuthority { * @param userNameAttributeName the attribute name used to access the user's name from * the attributes * @since 6.4 + * @deprecated Use {@link #withUsername(String)} builder pattern instead */ + @Deprecated public OAuth2UserAuthority(String authority, Map 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 @@ -107,12 +147,26 @@ public class OAuth2UserAuthority implements GrantedAuthority { * Returns the attribute name used to access the user's name from the attributes. * @return the attribute name used to access the user's name from the attributes * @since 6.4 + * @deprecated Use {@link #getUsername()} instead */ + @Deprecated @Nullable public String getUserNameAttributeName() { return this.userNameAttributeName; } + /** + * Returns the username of the OAuth2 user. + *

+ * This method provides direct access to the username without requiring knowledge of + * the attribute structure or SpEL expressions used to extract it. + * @return the username + * @since 7.0 + */ + public String getUsername() { + return this.username; + } + @Override public boolean equals(Object obj) { if (this == obj) { @@ -125,6 +179,9 @@ public class OAuth2UserAuthority implements GrantedAuthority { if (!this.getAuthority().equals(that.getAuthority())) { return false; } + if (!Objects.equals(this.username, that.username)) { + return false; + } Map thatAttributes = that.getAttributes(); if (getAttributes().size() != thatAttributes.size()) { return false; @@ -150,7 +207,7 @@ public class OAuth2UserAuthority implements GrantedAuthority { @Override public int hashCode() { int result = this.getAuthority().hashCode(); - result = 31 * result; + result = 31 * result + Objects.hashCode(this.username); for (Map.Entry e : getAttributes().entrySet()) { Object key = e.getKey(); Object value = convertURLIfNecessary(e.getValue()); @@ -172,4 +229,39 @@ public class OAuth2UserAuthority implements GrantedAuthority { return (value instanceof URL) ? ((URL) value).toExternalForm() : value; } + /** + * A builder for {@link OAuth2UserAuthority}. + * + * @since 7.0 + */ + public static class Builder { + + protected final String username; + + protected String authority = "OAUTH2_USER"; + + protected Map 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 369e0c91d1..f1ba286b1f 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2017 the original author or authors. + * Copyright 2002-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -34,6 +34,7 @@ import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException * Tests for {@link OidcUserAuthority}. * * @author Joe Grandja + * @author Yoobin Yoon */ public class OidcUserAuthorityTests { @@ -84,4 +85,43 @@ public class OidcUserAuthorityTests { StandardClaimNames.NAME, StandardClaimNames.EMAIL); } + @Test + public void withUsernameWhenUsernameIsNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> OidcUserAuthority.withUsername(null)); + } + + @Test + public void withUsernameWhenUsernameIsEmptyThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> OidcUserAuthority.withUsername("")); + } + + @Test + public void builderWhenIdTokenIsNotSetThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> OidcUserAuthority.withUsername(SUBJECT).build()); + } + + @Test + public void builderWhenAllParametersProvidedAndValidThenCreated() { + String username = SUBJECT; + OidcUserAuthority authority = OidcUserAuthority.withUsername(username) + .idToken(ID_TOKEN) + .userInfo(USER_INFO) + .build(); + + assertThat(authority.getUsername()).isEqualTo(username); + assertThat(authority.getAuthority()).isEqualTo("OIDC_USER"); + assertThat(authority.getIdToken()).isEqualTo(ID_TOKEN); + assertThat(authority.getUserInfo()).isEqualTo(USER_INFO); + assertThat(authority.getAttributes()).containsOnlyKeys(IdTokenClaimNames.ISS, IdTokenClaimNames.SUB, + StandardClaimNames.NAME, StandardClaimNames.EMAIL); + } + + @Test + public void getUsernameWhenBuiltWithUsernameThenReturnsUsername() { + String username = SUBJECT; + OidcUserAuthority authority = OidcUserAuthority.withUsername(username).idToken(ID_TOKEN).build(); + + assertThat(authority.getUsername()).isEqualTo(username); + } + } 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 f55fea1d8a..1eac7a24eb 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 class DefaultOAuth2UserTests { .build()); } + @Test + public void withUsernameWhenUsernameNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> DefaultOAuth2User.withUsername((String) null)); + } + @Test public void withUsernameWhenCreatedThenIsSerializable() { DefaultOAuth2User user = DefaultOAuth2User.withUsername("directUser") @@ -215,4 +220,48 @@ public class DefaultOAuth2UserTests { assertThat(user.getAttributes()).isEqualTo(ATTRIBUTES); } + @Test + public void withUsernameWhenNestedAttributesThenUsernameExtractedCorrectly() { + Map 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 e7386db77a..82c4c1a793 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -30,6 +30,7 @@ import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException * Tests for {@link OAuth2UserAuthority}. * * @author Joe Grandja + * @author Yoobin Yoon */ public class OAuth2UserAuthorityTests { @@ -94,4 +95,37 @@ public class OAuth2UserAuthorityTests { assertThat(AUTHORITY_WITH_STRINGURL.hashCode()).isEqualTo(AUTHORITY_WITH_OBJECTURL.hashCode()); } + @Test + public void withUsernameWhenUsernameIsNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> OAuth2UserAuthority.withUsername(null)); + } + + @Test + public void withUsernameWhenUsernameIsEmptyThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> OAuth2UserAuthority.withUsername("")); + } + + @Test + public void builderWhenAttributesIsNotSetThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> OAuth2UserAuthority.withUsername("john_doe").build()); + } + + @Test + public void builderWhenAllParametersProvidedAndValidThenCreated() { + String username = "john_doe"; + OAuth2UserAuthority authority = OAuth2UserAuthority.withUsername(username).attributes(ATTRIBUTES).build(); + + assertThat(authority.getUsername()).isEqualTo(username); + assertThat(authority.getAuthority()).isEqualTo("OAUTH2_USER"); + assertThat(authority.getAttributes()).isEqualTo(ATTRIBUTES); + } + + @Test + public void getUsernameWhenBuiltWithUsernameThenReturnsUsername() { + String username = "john_doe"; + OAuth2UserAuthority authority = OAuth2UserAuthority.withUsername(username).attributes(ATTRIBUTES).build(); + + assertThat(authority.getUsername()).isEqualTo(username); + } + }