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 917062c905c..562c17e9429 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 @@ * 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 5b46dc9396f..f3ada580557 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/jackson2/DefaultOidcUserMixin.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/jackson2/DefaultOidcUserMixin.java @@ -40,7 +40,7 @@ @JsonTypeInfo(use = JsonTypeInfo.Id.CLASS) @JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility = JsonAutoDetect.Visibility.NONE, isGetterVisibility = JsonAutoDetect.Visibility.NONE) -@JsonIgnoreProperties(value = { "attributes" }, ignoreUnknown = true) +@JsonIgnoreProperties(value = { "attributes", "username" }, ignoreUnknown = true) abstract class DefaultOidcUserMixin { @JsonCreator diff --git a/oauth2/oauth2-client/src/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 b492a6d8015..131ac9aaba7 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 @@ * * @author Joe Grandja * @author Michael Sosa + * @author Yoobin Yoon * @since 5.0 * @see Section 2 * Client Registration @@ -299,8 +300,11 @@ public class UserInfoEndpoint implements Serializable { private AuthenticationMethod authenticationMethod = AuthenticationMethod.HEADER; + @Deprecated private String userNameAttributeName; + private String usernameExpression; + UserInfoEndpoint() { } @@ -322,15 +326,23 @@ public AuthenticationMethod getAuthenticationMethod() { } /** - * Returns the attribute name used to access the user's name from the user - * info response. - * @return the attribute name used to access the user's name from the user - * info response + * @deprecated Use {@link #getUsernameExpression()} instead */ + @Deprecated public String getUserNameAttributeName() { return this.userNameAttributeName; } + /** + * Returns the SpEL expression used to extract the username from user info + * response. + * @return the SpEL expression for username extraction + * @since 6.5 + */ + public String getUsernameExpression() { + return this.usernameExpression; + } + } } @@ -370,8 +382,11 @@ public static final class Builder implements Serializable { private AuthenticationMethod userInfoAuthenticationMethod = AuthenticationMethod.HEADER; + @Deprecated private String userNameAttributeName; + private String usernameExpression; + private String jwkSetUri; private String issuerUri; @@ -399,6 +414,7 @@ private Builder(ClientRegistration clientRegistration) { this.userInfoUri = clientRegistration.providerDetails.userInfoEndpoint.uri; this.userInfoAuthenticationMethod = clientRegistration.providerDetails.userInfoEndpoint.authenticationMethod; this.userNameAttributeName = clientRegistration.providerDetails.userInfoEndpoint.userNameAttributeName; + this.usernameExpression = clientRegistration.providerDetails.userInfoEndpoint.usernameExpression; this.jwkSetUri = clientRegistration.providerDetails.jwkSetUri; this.issuerUri = clientRegistration.providerDetails.issuerUri; Map configurationMetadata = clientRegistration.providerDetails.configurationMetadata; @@ -552,14 +568,43 @@ public Builder userInfoAuthenticationMethod(AuthenticationMethod userInfoAuthent } /** - * Sets the attribute name used to access the user's name from the user info - * response. - * @param userNameAttributeName the attribute name used to access the user's name - * from the user info response + * Sets the username attribute name. This method automatically converts the + * attribute name to a SpEL expression for backward compatibility. + * + *

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

+ * Examples: + *

+ * @param usernameExpression the SpEL expression for username extraction + * @return the {@link Builder} + * @since 6.5 + */ + public Builder usernameExpression(String usernameExpression) { + this.usernameExpression = usernameExpression; return this; } @@ -672,7 +717,10 @@ private ProviderDetails createProviderDetails(ClientRegistration clientRegistrat providerDetails.tokenUri = this.tokenUri; providerDetails.userInfoEndpoint.uri = this.userInfoUri; providerDetails.userInfoEndpoint.authenticationMethod = this.userInfoAuthenticationMethod; + + providerDetails.userInfoEndpoint.usernameExpression = this.usernameExpression; providerDetails.userInfoEndpoint.userNameAttributeName = this.userNameAttributeName; + providerDetails.jwkSetUri = this.jwkSetUri; providerDetails.issuerUri = this.issuerUri; providerDetails.configurationMetadata = Collections.unmodifiableMap(this.configurationMetadata); diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/userinfo/DefaultOAuth2UserService.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/userinfo/DefaultOAuth2UserService.java index 02930047b16..8251c010f14 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.LinkedHashSet; import java.util.Map; +import org.springframework.context.expression.MapAccessor; import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.convert.converter.Converter; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.SimpleEvaluationContext; import org.springframework.http.RequestEntity; import org.springframework.http.ResponseEntity; import org.springframework.security.core.GrantedAuthority; @@ -47,16 +51,17 @@ * An implementation of an {@link OAuth2UserService} that supports standard OAuth 2.0 * Provider's. *

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

* NOTE: Attribute names are not standardized between providers and * therefore will vary. Please consult the provider's API documentation for the set of * supported user attribute names. * * @author Joe Grandja + * @author Yoobin Yoon * @since 5.0 * @see OAuth2UserService * @see OAuth2UserRequest @@ -71,6 +76,10 @@ public class DefaultOAuth2UserService implements OAuth2UserService> PARAMETERIZED_RESPONSE_TYPE = new ParameterizedTypeReference<>() { }; @@ -90,13 +99,67 @@ public DefaultOAuth2UserService() { @Override public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { Assert.notNull(userRequest, "userRequest cannot be null"); - String userNameAttributeName = getUserNameAttributeName(userRequest); + String usernameExpression = getUsernameExpression(userRequest); RequestEntity request = this.requestEntityConverter.convert(userRequest); ResponseEntity> response = getResponse(userRequest, request); OAuth2AccessToken token = userRequest.getAccessToken(); Map attributes = this.attributesConverter.convert(userRequest).convert(response.getBody()); - Collection authorities = getAuthorities(token, attributes, userNameAttributeName); - return new DefaultOAuth2User(authorities, attributes, userNameAttributeName); + + String evaluatedUsername = evaluateUsername(attributes, usernameExpression); + + Collection authorities = getAuthorities(token, attributes, evaluatedUsername); + + return DefaultOAuth2User.withUsername(evaluatedUsername) + .authorities(authorities) + .attributes(attributes) + .build(); + } + + private String getUsernameExpression(OAuth2UserRequest userRequest) { + if (!StringUtils + .hasText(userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri())) { + OAuth2Error oauth2Error = new OAuth2Error(MISSING_USER_INFO_URI_ERROR_CODE, + "Missing required UserInfo Uri in UserInfoEndpoint for Client Registration: " + + userRequest.getClientRegistration().getRegistrationId(), + null); + throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString()); + } + String usernameExpression = userRequest.getClientRegistration() + .getProviderDetails() + .getUserInfoEndpoint() + .getUsernameExpression(); + if (!StringUtils.hasText(usernameExpression)) { + OAuth2Error oauth2Error = new OAuth2Error(MISSING_USER_NAME_ATTRIBUTE_ERROR_CODE, + "Missing required \"user name\" attribute name in UserInfoEndpoint for Client Registration: " + + userRequest.getClientRegistration().getRegistrationId(), + null); + throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString()); + } + return usernameExpression; + } + + private String evaluateUsername(Map attributes, String usernameExpression) { + Object value = null; + + try { + SimpleEvaluationContext context = SimpleEvaluationContext.forPropertyAccessors(new MapAccessor()) + .withRootObject(attributes) + .build(); + value = expressionParser.parseExpression(usernameExpression).getValue(context); + } + catch (Exception ex) { + OAuth2Error oauth2Error = new OAuth2Error(INVALID_USERNAME_EXPRESSION_ERROR_CODE, + "Invalid username expression or SPEL expression: " + usernameExpression, null); + throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString(), ex); + } + + if (value == null) { + OAuth2Error oauth2Error = new OAuth2Error(INVALID_USER_INFO_RESPONSE_ERROR_CODE, + "An error occurred while attempting to retrieve the UserInfo Resource: username cannot be null", + null); + throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString()); + } + return value.toString(); } /** @@ -164,33 +227,11 @@ private ResponseEntity> getResponse(OAuth2UserRequest userRe } } - private String getUserNameAttributeName(OAuth2UserRequest userRequest) { - if (!StringUtils - .hasText(userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri())) { - OAuth2Error oauth2Error = new OAuth2Error(MISSING_USER_INFO_URI_ERROR_CODE, - "Missing required UserInfo Uri in UserInfoEndpoint for Client Registration: " - + userRequest.getClientRegistration().getRegistrationId(), - null); - throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString()); - } - String userNameAttributeName = userRequest.getClientRegistration() - .getProviderDetails() - .getUserInfoEndpoint() - .getUserNameAttributeName(); - if (!StringUtils.hasText(userNameAttributeName)) { - OAuth2Error oauth2Error = new OAuth2Error(MISSING_USER_NAME_ATTRIBUTE_ERROR_CODE, - "Missing required \"user name\" attribute name in UserInfoEndpoint for Client Registration: " - + userRequest.getClientRegistration().getRegistrationId(), - null); - throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString()); - } - return userNameAttributeName; - } - private Collection getAuthorities(OAuth2AccessToken token, Map attributes, - String userNameAttributeName) { + String username) { Collection authorities = new LinkedHashSet<>(); - authorities.add(new OAuth2UserAuthority(attributes, userNameAttributeName)); + authorities.add(OAuth2UserAuthority.withUsername(username).attributes(attributes).build()); + for (String authority : token.getScopes()) { authorities.add(new SimpleGrantedAuthority("SCOPE_" + authority)); } diff --git a/oauth2/oauth2-client/src/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 ae3a65b52c0..f821b2b29a2 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 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 @@ * 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 Mono loadUser(OAuth2UserRequest userRequest) throws OAuth2Aut 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 Mono loadUser(OAuth2UserRequest userRequest) throws OAuth2Aut .bodyToMono(DefaultReactiveOAuth2UserService.STRING_OBJECT_MAP) .mapNotNull((attributes) -> this.attributesConverter.convert(userRequest).convert(attributes)); return userAttributes.map((attrs) -> { - GrantedAuthority authority = new OAuth2UserAuthority(attrs, userNameAttributeName); - Set authorities = new HashSet<>(); - authorities.add(authority); - OAuth2AccessToken token = userRequest.getAccessToken(); - for (String scope : token.getScopes()) { - authorities.add(new SimpleGrantedAuthority("SCOPE_" + scope)); - } - - return new DefaultOAuth2User(authorities, attrs, userNameAttributeName); - }) + 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 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 Mono loadUser(OAuth2UserRequest userRequest) throws OAuth2Aut // @formatter:on } + private String getUsernameExpression(OAuth2UserRequest userRequest) { + String usernameExpression = userRequest.getClientRegistration() + .getProviderDetails() + .getUserInfoEndpoint() + .getUsernameExpression(); + if (!StringUtils.hasText(usernameExpression)) { + OAuth2Error oauth2Error = new OAuth2Error(MISSING_USER_NAME_ATTRIBUTE_ERROR_CODE, + "Missing required \"user name\" attribute name in UserInfoEndpoint for Client Registration: " + + userRequest.getClientRegistration().getRegistrationId(), + null); + throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString()); + } + return usernameExpression; + } + + private 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 6fe7d05b501..be90452d046 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 @@ private static String asJson(DefaultOAuth2User oauth2User) { " \"@class\": \"java.util.Collections$UnmodifiableMap\",\n" + " \"username\": \"user\"\n" + " },\n" + - " \"nameAttributeKey\": \"username\"\n" + + " \"nameAttributeKey\": \"username\",\n" + + " \"username\": \"" + oauth2User.getName() + "\"\n" + " }"; // @formatter:on } @@ -247,11 +248,12 @@ private static String asJson(OAuth2UserAuthority oauth2UserAuthority) { return "{\n" + " \"@class\": \"org.springframework.security.oauth2.core.user.OAuth2UserAuthority\",\n" + " \"authority\": \"" + oauth2UserAuthority.getAuthority() + "\",\n" + - " \"userNameAttributeName\": \"username\",\n" + " \"attributes\": {\n" + " \"@class\": \"java.util.Collections$UnmodifiableMap\",\n" + " \"username\": \"user\"\n" + - " }\n" + + " },\n" + + " \"userNameAttributeName\": \"username\",\n" + + " \"username\": \"user\"\n" + " }"; // @formatter:on } @@ -261,9 +263,10 @@ private static String asJson(OidcUserAuthority oidcUserAuthority) { return "{\n" + " \"@class\": \"org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority\",\n" + " \"authority\": \"" + oidcUserAuthority.getAuthority() + "\",\n" + - " \"userNameAttributeName\": \"" + oidcUserAuthority.getUserNameAttributeName() + "\",\n" + " \"idToken\": " + asJson(oidcUserAuthority.getIdToken()) + ",\n" + - " \"userInfo\": " + asJson(oidcUserAuthority.getUserInfo()) + "\n" + + " \"userInfo\": " + asJson(oidcUserAuthority.getUserInfo()) + ",\n" + + " \"userNameAttributeName\": \"" + oidcUserAuthority.getUserNameAttributeName() + "\",\n" + + " \"username\": \"subject\"\n" + " }"; // @formatter:on } diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/jackson2/OAuth2AuthorizedClientMixinTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/jackson2/OAuth2AuthorizedClientMixinTests.java index d6d0e819276..aa812c24f88 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 void deserializeWhenMixinRegisteredThenDeserializes() throws Exception { .isEqualTo(expectedClientRegistration.getProviderDetails().getUserInfoEndpoint().getAuthenticationMethod()); assertThat(clientRegistration.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName()).isEqualTo( expectedClientRegistration.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName()); + assertThat(clientRegistration.getProviderDetails().getUserInfoEndpoint().getUsernameExpression()) + .isEqualTo(expectedClientRegistration.getProviderDetails().getUserInfoEndpoint().getUsernameExpression()); assertThat(clientRegistration.getProviderDetails().getJwkSetUri()) .isEqualTo(expectedClientRegistration.getProviderDetails().getJwkSetUri()); assertThat(clientRegistration.getProviderDetails().getIssuerUri()) @@ -306,6 +308,8 @@ private static String asJson(ClientRegistration clientRegistration) { .map((key) -> "\"" + key + "\": \"" + providerDetails.getConfigurationMetadata().get(key) + "\"") .collect(Collectors.joining(",")); } + String usernameExpression = (userInfoEndpoint.getUsernameExpression() != null) + ? "\"" + userInfoEndpoint.getUsernameExpression() + "\"" : null; // @formatter:off return "{\n" + " \"@class\": \"org.springframework.security.oauth2.client.registration.ClientRegistration\",\n" + @@ -333,7 +337,8 @@ private static String asJson(ClientRegistration clientRegistration) { " \"authenticationMethod\": {\n" + " \"value\": \"" + userInfoEndpoint.getAuthenticationMethod().getValue() + "\"\n" + " },\n" + - " \"userNameAttributeName\": " + ((userInfoEndpoint.getUserNameAttributeName() != null) ? "\"" + userInfoEndpoint.getUserNameAttributeName() + "\"" : null) + "\n" + + " \"userNameAttributeName\": " + ((userInfoEndpoint.getUserNameAttributeName() != null) ? "\"" + userInfoEndpoint.getUserNameAttributeName() + "\"" : null) + ",\n" + + " \"usernameExpression\": " + usernameExpression + "\n" + " },\n" + " \"jwkSetUri\": " + ((providerDetails.getJwkSetUri() != null) ? "\"" + providerDetails.getJwkSetUri() + "\"" : null) + ",\n" + " \"issuerUri\": " + ((providerDetails.getIssuerUri() != null) ? "\"" + providerDetails.getIssuerUri() + "\"" : null) + ",\n" + diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/registration/ClientRegistrationTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/registration/ClientRegistrationTests.java index 9dbcbd5a5c8..35f41b5a7d8 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 @@ * Tests for {@link ClientRegistration}. * * @author Joe Grandja + * @author Yoobin Yoon */ public class ClientRegistrationTests { @@ -716,6 +717,7 @@ public void buildWhenClientRegistrationProvidedThenEachPropertyMatches() { .isEqualTo(updatedUserInfoEndpoint.getAuthenticationMethod()); assertThat(userInfoEndpoint.getUserNameAttributeName()) .isEqualTo(updatedUserInfoEndpoint.getUserNameAttributeName()); + assertThat(userInfoEndpoint.getUsernameExpression()).isEqualTo(updatedUserInfoEndpoint.getUsernameExpression()); assertThat(providerDetails.getJwkSetUri()).isEqualTo(updatedProviderDetails.getJwkSetUri()); assertThat(providerDetails.getIssuerUri()).isEqualTo(updatedProviderDetails.getIssuerUri()); assertThat(providerDetails.getConfigurationMetadata()) @@ -802,6 +804,84 @@ void buildWhenNewAuthorizationCodeAndPkceThenBuilds() { assertThat(clientRegistration.getClientSettings().isRequireProofKey()).isTrue(); } + @Test + public void buildWhenUsernameExpressionProvidedThenSet() { + String usernameExpression = "data.username"; + // @formatter:off + ClientRegistration registration = ClientRegistration.withRegistrationId(REGISTRATION_ID) + .clientId(CLIENT_ID) + .clientSecret(CLIENT_SECRET) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri(REDIRECT_URI) + .authorizationUri(AUTHORIZATION_URI) + .tokenUri(TOKEN_URI) + .usernameExpression(usernameExpression) + .build(); + // @formatter:on + assertThat(registration.getProviderDetails().getUserInfoEndpoint().getUsernameExpression()) + .isEqualTo(usernameExpression); + } + + @Test + public void buildWhenBothUserNameAttributeNameAndUsernameExpressionProvidedThenUsernameExpressionTakesPrecedence() { + String userNameAttributeName = "username"; + String usernameExpression = "data.username"; + // @formatter:off + ClientRegistration registration = ClientRegistration.withRegistrationId(REGISTRATION_ID) + .clientId(CLIENT_ID) + .clientSecret(CLIENT_SECRET) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri(REDIRECT_URI) + .authorizationUri(AUTHORIZATION_URI) + .tokenUri(TOKEN_URI) + .userNameAttributeName(userNameAttributeName) + .usernameExpression(usernameExpression) + .build(); + // @formatter:on + assertThat(registration.getProviderDetails().getUserInfoEndpoint().getUsernameExpression()) + .isEqualTo(usernameExpression); + assertThat(registration.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName()) + .isEqualTo(userNameAttributeName); + } + + @Test + public void buildWhenOnlyUserNameAttributeNameProvidedThenAutoConvertToSpelExpression() { + String userNameAttributeName = "username"; + // @formatter:off + ClientRegistration registration = ClientRegistration.withRegistrationId(REGISTRATION_ID) + .clientId(CLIENT_ID) + .clientSecret(CLIENT_SECRET) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri(REDIRECT_URI) + .authorizationUri(AUTHORIZATION_URI) + .tokenUri(TOKEN_URI) + .userNameAttributeName(userNameAttributeName) + .build(); + // @formatter:on + assertThat(registration.getProviderDetails().getUserInfoEndpoint().getUsernameExpression()) + .isEqualTo("['" + userNameAttributeName + "']"); + assertThat(registration.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName()) + .isEqualTo(userNameAttributeName); + } + + @Test + public void buildWhenCopyingClientRegistrationWithUsernameExpressionThenPreserved() { + String usernameExpression = "profile.name"; + // @formatter:off + ClientRegistration original = ClientRegistration.withRegistrationId(REGISTRATION_ID) + .clientId(CLIENT_ID) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri(REDIRECT_URI) + .authorizationUri(AUTHORIZATION_URI) + .tokenUri(TOKEN_URI) + .usernameExpression(usernameExpression) + .build(); + // @formatter:on + ClientRegistration copy = ClientRegistration.withClientRegistration(original).build(); + assertThat(copy.getProviderDetails().getUserInfoEndpoint().getUsernameExpression()) + .isEqualTo(usernameExpression); + } + @ParameterizedTest @MethodSource("invalidPkceGrantTypes") void buildWhenInvalidGrantTypeForPkceThenException(AuthorizationGrantType invalidGrantType) { diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/userinfo/DefaultOAuth2UserServiceTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/userinfo/DefaultOAuth2UserServiceTests.java index e210019f48e..89e9831d714 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 @@ * * @author Joe Grandja * @author Eddú Meléndez + * @author Yoobin Yoon */ public class DefaultOAuth2UserServiceTests { @@ -121,7 +122,7 @@ public void loadUserWhenUserNameAttributeNameIsNullThenThrowOAuth2Authentication // @formatter:on assertThatExceptionOfType(OAuth2AuthenticationException.class) .isThrownBy(() -> this.userService.loadUser(new OAuth2UserRequest(clientRegistration, this.accessToken))) - .withMessageContaining("missing_user_name_attribute"); + .withMessageContaining("invalid_user_info_response"); } @Test @@ -153,23 +154,26 @@ public void loadUserWhenUserInfoSuccessResponseThenReturnUser() { assertThat((String) user.getAttribute("email")).isEqualTo("user1@example.com"); assertThat(user.getAuthorities()).hasSize(1); assertThat(user.getAuthorities().iterator().next()).isInstanceOf(OAuth2UserAuthority.class); - OAuth2UserAuthority userAuthority = (OAuth2UserAuthority) user.getAuthorities().iterator().next(); + OAuth2UserAuthority userAuthority = OAuth2UserAuthority.withUsername("user1") + .attributes(user.getAttributes()) + .authority("OAUTH2_USER") + .build(); assertThat(userAuthority.getAuthority()).isEqualTo("OAUTH2_USER"); assertThat(userAuthority.getAttributes()).isEqualTo(user.getAttributes()); - assertThat(userAuthority.getUserNameAttributeName()).isEqualTo("user-name"); + assertThat(userAuthority.getUsername()).isEqualTo("user1"); } @Test public void loadUserWhenNestedUserInfoSuccessThenReturnUser() { // @formatter:off String userInfoResponse = "{\n" - + " \"user\": {\"user-name\": \"user1\"},\n" - + " \"first-name\": \"first\",\n" - + " \"last-name\": \"last\",\n" - + " \"middle-name\": \"middle\",\n" - + " \"address\": \"address\",\n" - + " \"email\": \"user1@example.com\"\n" - + "}\n"; + + " \"user\": {\"user-name\": \"user1\"},\n" + + " \"first-name\": \"first\",\n" + + " \"last-name\": \"last\",\n" + + " \"middle-name\": \"middle\",\n" + + " \"address\": \"address\",\n" + + " \"email\": \"user1@example.com\"\n" + + "}\n"; // @formatter:on this.server.enqueue(jsonResponse(userInfoResponse)); String userInfoUri = this.server.url("/user").toString(); @@ -194,10 +198,13 @@ public void loadUserWhenNestedUserInfoSuccessThenReturnUser() { assertThat((String) user.getAttribute("email")).isEqualTo("user1@example.com"); assertThat(user.getAuthorities()).hasSize(1); assertThat(user.getAuthorities().iterator().next()).isInstanceOf(OAuth2UserAuthority.class); - OAuth2UserAuthority userAuthority = (OAuth2UserAuthority) user.getAuthorities().iterator().next(); + OAuth2UserAuthority userAuthority = OAuth2UserAuthority.withUsername("user1") + .attributes(user.getAttributes()) + .authority("OAUTH2_USER") + .build(); assertThat(userAuthority.getAuthority()).isEqualTo("OAUTH2_USER"); assertThat(userAuthority.getAttributes()).isEqualTo(user.getAttributes()); - assertThat(userAuthority.getUserNameAttributeName()).isEqualTo("user-name"); + assertThat(userAuthority.getUsername()).isEqualTo("user1"); } @Test @@ -247,8 +254,8 @@ public void loadUserWhenUserInfoErrorResponseWwwAuthenticateHeaderThenThrowOAuth public void loadUserWhenUserInfoErrorResponseThenThrowOAuth2AuthenticationException() { // @formatter:off String userInfoErrorResponse = "{\n" - + " \"error\": \"invalid_token\"\n" - + "}\n"; + + " \"error\": \"invalid_token\"\n" + + "}\n"; // @formatter:on this.server.enqueue(jsonResponse(userInfoErrorResponse).setResponseCode(400)); String userInfoUri = this.server.url("/user").toString(); @@ -421,6 +428,134 @@ public void setAttributesConverterWhenNullThenException() { .isThrownBy(() -> this.userService.setAttributesConverter(null)); } + @Test + public void loadUserWhenBackwardCompatibilityWithUserNameAttributeNameThenWorks() { + // @formatter:off + String userInfoResponse = "{\n" + + " \"user-name\": \"backwardCompatUser\",\n" + + " \"email\": \"backward@example.com\"\n" + + "}\n"; + // @formatter:on + this.server.enqueue(jsonResponse(userInfoResponse)); + String userInfoUri = this.server.url("/user").toString(); + ClientRegistration clientRegistration = this.clientRegistrationBuilder.userInfoUri(userInfoUri) + .userInfoAuthenticationMethod(AuthenticationMethod.HEADER) + .userNameAttributeName("user-name") + .build(); + OAuth2User user = this.userService.loadUser(new OAuth2UserRequest(clientRegistration, this.accessToken)); + assertThat(user.getName()).isEqualTo("backwardCompatUser"); + assertThat(user.getAttributes()).hasSize(2); + } + + @Test + public void loadUserWhenUsernameExpressionIsSimpleAttributeThenUseDirectly() { + // @formatter:off + String userInfoResponse = "{\n" + + " \"username\": \"simpleUser\",\n" + + " \"id\": \"54321\",\n" + + " \"email\": \"simple@example.com\"\n" + + "}\n"; + // @formatter:on + this.server.enqueue(jsonResponse(userInfoResponse)); + String userInfoUri = this.server.url("/user").toString(); + ClientRegistration clientRegistration = this.clientRegistrationBuilder.userInfoUri(userInfoUri) + .userInfoAuthenticationMethod(AuthenticationMethod.HEADER) + .usernameExpression("username") + .build(); + OAuth2User user = this.userService.loadUser(new OAuth2UserRequest(clientRegistration, this.accessToken)); + assertThat(user.getName()).isEqualTo("simpleUser"); + assertThat(user.getAttributes()).hasSize(3); + } + + @Test + public void loadUserWhenUsernameExpressionIsSpelThenEvaluateCorrectly() { + // @formatter:off + String userInfoResponse = "{\n" + + " \"data\": {\n" + + " \"user\": {\n" + + " \"username\": \"spelUser\"\n" + + " }\n" + + " },\n" + + " \"id\": \"12345\",\n" + + " \"email\": \"spel@example.com\"\n" + + "}\n"; + // @formatter:on + this.server.enqueue(jsonResponse(userInfoResponse)); + String userInfoUri = this.server.url("/user").toString(); + ClientRegistration clientRegistration = this.clientRegistrationBuilder.userInfoUri(userInfoUri) + .userInfoAuthenticationMethod(AuthenticationMethod.HEADER) + .usernameExpression("data.user.username") + .build(); + OAuth2User user = this.userService.loadUser(new OAuth2UserRequest(clientRegistration, this.accessToken)); + assertThat(user.getName()).isEqualTo("spelUser"); + assertThat(user.getAttributes()).hasSize(3); + assertThat((String) user.getAttribute("id")).isEqualTo("12345"); + assertThat((String) user.getAttribute("email")).isEqualTo("spel@example.com"); + } + + @Test + public void loadUserWhenUsernameExpressionInvalidSpelThenThrowOAuth2AuthenticationException() { + // @formatter:off + String userInfoResponse = "{\n" + + " \"username\": \"testUser\",\n" + + " \"id\": \"12345\"\n" + + "}\n"; + // @formatter:on + this.server.enqueue(jsonResponse(userInfoResponse)); + String userInfoUri = this.server.url("/user").toString(); + ClientRegistration clientRegistration = this.clientRegistrationBuilder.userInfoUri(userInfoUri) + .userInfoAuthenticationMethod(AuthenticationMethod.HEADER) + .usernameExpression("nonexistent.invalid.path") // invalid SpEL + .build(); + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.userService.loadUser(new OAuth2UserRequest(clientRegistration, this.accessToken))) + .withMessageContaining("invalid_username_expression") + .withMessageContaining("Invalid username expression or SPEL expression"); + } + + @Test + public void loadUserWhenUsernameExpressionResultsInNullThenThrowOAuth2AuthenticationException() { + // @formatter:off + String userInfoResponse = "{\n" + + " \"username\": \"testUser\",\n" + + " \"data\": {\n" + + " \"username\": null\n" + + " }\n" + + "}\n"; + // @formatter:on + this.server.enqueue(jsonResponse(userInfoResponse)); + String userInfoUri = this.server.url("/user").toString(); + ClientRegistration clientRegistration = this.clientRegistrationBuilder.userInfoUri(userInfoUri) + .userInfoAuthenticationMethod(AuthenticationMethod.HEADER) + .usernameExpression("data.username") + .build(); + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.userService.loadUser(new OAuth2UserRequest(clientRegistration, this.accessToken))) + .withMessageContaining("invalid_user_info_response") + .withMessageContaining("username cannot be null"); + } + + @Test + public void loadUserWhenUsernameExpressionWithArrayAccessThenEvaluateCorrectly() { + // @formatter:off + String userInfoResponse = "{\n" + + " \"accounts\": [\n" + + " {\"username\": \"primary_user\", \"type\": \"primary\"},\n" + + " {\"username\": \"secondary_user\", \"type\": \"secondary\"}\n" + + " ],\n" + + " \"id\": \"12345\"\n" + + "}\n"; + // @formatter:on + this.server.enqueue(jsonResponse(userInfoResponse)); + String userInfoUri = this.server.url("/user").toString(); + ClientRegistration clientRegistration = this.clientRegistrationBuilder.userInfoUri(userInfoUri) + .userInfoAuthenticationMethod(AuthenticationMethod.HEADER) + .usernameExpression("accounts[0].username") + .build(); + OAuth2User user = this.userService.loadUser(new OAuth2UserRequest(clientRegistration, this.accessToken)); + assertThat(user.getName()).isEqualTo("primary_user"); + } + private DefaultOAuth2UserService withMockResponse(Map response) { ResponseEntity> responseEntity = new ResponseEntity<>(response, HttpStatus.OK); Converter> requestEntityConverter = mock(Converter.class); diff --git a/oauth2/oauth2-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 a8614842c6d..7a80d0d6615 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 @@ /** * @author Rob Winch * @author Eddú Meléndez + * @author Yoobin Yoon * @since 5.1 */ public class DefaultReactiveOAuth2UserServiceTests { @@ -104,19 +105,6 @@ public void loadUserWhenUserInfoUriIsNullThenThrowOAuth2AuthenticationException( .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 void loadUserWhenUserInfoSuccessResponseThenReturnUser() { assertThat((String) user.getAttribute("email")).isEqualTo("user1@example.com"); assertThat(user.getAuthorities()).hasSize(1); assertThat(user.getAuthorities().iterator().next()).isInstanceOf(OAuth2UserAuthority.class); - OAuth2UserAuthority userAuthority = (OAuth2UserAuthority) user.getAuthorities().iterator().next(); + OAuth2UserAuthority userAuthority = OAuth2UserAuthority.withUsername("user1") + .attributes(user.getAttributes()) + .authority("OAUTH2_USER") + .build(); assertThat(userAuthority.getAuthority()).isEqualTo("OAUTH2_USER"); assertThat(userAuthority.getAttributes()).isEqualTo(user.getAttributes()); - assertThat(userAuthority.getUserNameAttributeName()).isEqualTo("id"); + assertThat(userAuthority.getUsername()).isEqualTo("user1"); } // gh-9336 @@ -152,13 +143,13 @@ public void loadUserWhenUserInfoSuccessResponseThenReturnUser() { 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 void loadUserWhenUserInfo201CreatedResponseThenReturnUser() { 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 void loadUserWhenNestedUserInfoSuccessThenReturnUser() { assertThat((String) user.getAttribute("email")).isEqualTo("user1@example.com"); assertThat(user.getAuthorities()).hasSize(1); assertThat(user.getAuthorities().iterator().next()).isInstanceOf(OAuth2UserAuthority.class); - OAuth2UserAuthority userAuthority = (OAuth2UserAuthority) user.getAuthorities().iterator().next(); + OAuth2UserAuthority userAuthority = OAuth2UserAuthority.withUsername("user1") + .attributes(user.getAttributes()) + .authority("OAUTH2_USER") + .build(); assertThat(userAuthority.getAuthority()).isEqualTo("OAUTH2_USER"); assertThat(userAuthority.getAttributes()).isEqualTo(user.getAttributes()); - assertThat(userAuthority.getUserNameAttributeName()).isEqualTo("user-name"); + assertThat(userAuthority.getUsername()).isEqualTo("user1"); } // gh-5500 @@ -257,7 +251,7 @@ public void loadUserWhenAuthenticationMethodFormSuccessResponseThenHttpMethodPos 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 void setAttributesConverterWhenNullThenException() { .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/user/DefaultOAuth2User.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/user/DefaultOAuth2User.java index 6c80d7b64a2..e8b6fe1a470 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 @@ * @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,45 @@ public DefaultOAuth2User(Collection authorities, Map : Collections.unmodifiableSet(new LinkedHashSet<>(AuthorityUtils.NO_AUTHORITIES)); this.attributes = Collections.unmodifiableMap(new LinkedHashMap<>(attributes)); this.nameAttributeKey = nameAttributeKey; + this.username = attributes.get(nameAttributeKey).toString(); + } + + /** + * Constructs a {@code DefaultOAuth2User} using the provided parameters. This + * constructor is used by Jackson for deserialization. + * @param authorities the authorities granted to the user + * @param attributes the attributes about the user + * @param nameAttributeKey the key used to access the user's "name" from + * {@link #getAttributes()} - preserved for backwards compatibility + * @param username the user's name + */ + private DefaultOAuth2User(Collection authorities, Map attributes, + String nameAttributeKey, String username) { + Assert.notEmpty(attributes, "attributes cannot be empty"); + + this.authorities = (authorities != null) + ? Collections.unmodifiableSet(new LinkedHashSet<>(this.sortAuthorities(authorities))) + : Collections.unmodifiableSet(new LinkedHashSet<>(AuthorityUtils.NO_AUTHORITIES)); + this.attributes = Collections.unmodifiableMap(new LinkedHashMap<>(attributes)); + this.nameAttributeKey = nameAttributeKey; + this.username = username; + + 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); } @Override public String getName() { - return this.getAttribute(this.nameAttributeKey).toString(); + return this.username; } @Override @@ -140,4 +179,39 @@ public String toString() { return sb.toString(); } + /** + * 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); + } + + } + } 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 c9edc42a81d..933323b9112 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 @@ * A {@link GrantedAuthority} that may be associated to an {@link OAuth2User}. * * @author Joe Grandja + * @author Yoobin Yoon * @since 5.0 * @see OAuth2User */ @@ -44,6 +45,8 @@ public class OAuth2UserAuthority implements GrantedAuthority { private final String userNameAttributeName; + private final String username; + /** * Constructs a {@code OAuth2UserAuthority} using the provided parameters and defaults * {@link #getAuthority()} to {@code OAUTH2_USER}. @@ -88,6 +91,39 @@ public OAuth2UserAuthority(String authority, Map attributes, Str 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. This + * constructor is used by Jackson for deserialization. + * @param authority the authority granted to the user + * @param attributes the attributes about the user + * @param userNameAttributeName the attribute name used to access the user's name from + * the attributes - preserved for backwards compatibility + * @param username the username + */ + private OAuth2UserAuthority(String authority, Map attributes, String userNameAttributeName, + String username) { + 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 = username; + + Assert.hasText(this.username, "username cannot be empty"); + } + + /** + * Creates a new {@code OAuth2UserAuthority} builder with the username. + * @param username the username + * @return a new {@code Builder} + * @since 6.5 + */ + public static Builder withUsername(String username) { + return new Builder(username); } @Override @@ -113,6 +149,18 @@ 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 6.5 + */ + public String getUsername() { + return this.username; + } + @Override public boolean equals(Object obj) { if (this == obj) { @@ -125,6 +173,9 @@ public boolean equals(Object obj) { if (!this.getAuthority().equals(that.getAuthority())) { return false; } + if (!Objects.equals(this.username, that.username)) { + return false; + } Map thatAttributes = that.getAttributes(); if (getAttributes().size() != thatAttributes.size()) { return false; @@ -150,7 +201,7 @@ public boolean equals(Object obj) { @Override public int hashCode() { int result = this.getAuthority().hashCode(); - result = 31 * result; + result = 31 * result + Objects.hashCode(this.username); for (Map.Entry e : getAttributes().entrySet()) { Object key = e.getKey(); Object value = convertURLIfNecessary(e.getValue()); @@ -172,4 +223,39 @@ private static Object convertURLIfNecessary(Object value) { return (value instanceof URL) ? ((URL) value).toExternalForm() : value; } + /** + * A builder for {@link OAuth2UserAuthority}. + * + * @since 6.5 + */ + public static final class Builder { + + private final String username; + + private String authority = "OAUTH2_USER"; + + private Map attributes; + + private 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.authority, this.attributes, null, this.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 a56c5bcf6a2..1eac7a24eb0 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 @@ * @author Vedran Pavic * @author Joe Grandja * @author Park Hyojong + * @author Yoobin Yoon */ public class DefaultOAuth2UserTests { @@ -109,4 +111,157 @@ public void constructorWhenCreatedThenIsSerializable() { SerializationUtils.serialize(user); } + @Test + public void withUsernameWhenValidParametersThenCreated() { + String directUsername = "directUser"; + DefaultOAuth2User user = DefaultOAuth2User.withUsername(directUsername) + .authorities(AUTHORITIES) + .attributes(ATTRIBUTES) + .build(); + + assertThat(user.getName()).isEqualTo(directUsername); + assertThat(user.getAuthorities()).hasSize(1); + assertThat(user.getAuthorities().iterator().next()).isEqualTo(AUTHORITY); + assertThat(user.getAttributes()).containsOnlyKeys(ATTRIBUTE_NAME_KEY); + assertThat(user.getAttributes().get(ATTRIBUTE_NAME_KEY)).isEqualTo(USERNAME); + } + + @Test + public void withUsernameWhenUsernameIsEmptyThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> DefaultOAuth2User.withUsername("")); + } + + @Test + public void withUsernameWhenAttributesIsNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy( + () -> DefaultOAuth2User.withUsername("username").authorities(AUTHORITIES).attributes(null).build()); + } + + @Test + public void withUsernameWhenAttributesIsEmptyThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> DefaultOAuth2User.withUsername("username") + .authorities(AUTHORITIES) + .attributes(Collections.emptyMap()) + .build()); + } + + @Test + public void withUsernameWhenUsernameNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> DefaultOAuth2User.withUsername((String) null)); + } + + @Test + public void withUsernameWhenCreatedThenIsSerializable() { + DefaultOAuth2User user = DefaultOAuth2User.withUsername("directUser") + .authorities(AUTHORITIES) + .attributes(ATTRIBUTES) + .build(); + SerializationUtils.serialize(user); + } + + @Test + public void withUsernameWhenUsernameProvidedThenTakesPrecedenceOverAttributes() { + Map attributes = new HashMap<>(); + attributes.put("username", "fromAttributes"); + attributes.put("id", "123"); + + DefaultOAuth2User user = DefaultOAuth2User.withUsername("directUsername") + .authorities(AUTHORITIES) + .attributes(attributes) + .build(); + + assertThat(user.getName()).isEqualTo("directUsername"); + assertThat((String) user.getAttribute("username")).isEqualTo("fromAttributes"); + } + + @Test + public void constructorWhenSimpleAttributeKeyThenWorksAsUsual() { + DefaultOAuth2User user = new DefaultOAuth2User(AUTHORITIES, ATTRIBUTES, ATTRIBUTE_NAME_KEY); + + assertThat(user.getName()).isEqualTo(USERNAME); + assertThat(user.getAttributes()).containsOnlyKeys(ATTRIBUTE_NAME_KEY); + } + + @Test + public void withUsernameAndDeprecatedConstructorWhenSameDataThenEqual() { + DefaultOAuth2User user1 = new DefaultOAuth2User(AUTHORITIES, ATTRIBUTES, ATTRIBUTE_NAME_KEY); + DefaultOAuth2User user2 = DefaultOAuth2User.withUsername(USERNAME) + .authorities(AUTHORITIES) + .attributes(ATTRIBUTES) + .build(); + + assertThat(user1.getName()).isEqualTo(user2.getName()); + assertThat(user1.getAuthorities()).isEqualTo(user2.getAuthorities()); + assertThat(user1.getAttributes()).isEqualTo(user2.getAttributes()); + assertThat(user1).isEqualTo(user2); + } + + @Test + public void withUsernameWhenAuthoritiesIsNullThenCreatedWithEmptyAuthorities() { + DefaultOAuth2User user = DefaultOAuth2User.withUsername("testUser") + .authorities(null) + .attributes(ATTRIBUTES) + .build(); + + assertThat(user.getName()).isEqualTo("testUser"); + assertThat(user.getAuthorities()).isEmpty(); + assertThat(user.getAttributes()).isEqualTo(ATTRIBUTES); + } + + @Test + public void withUsernameWhenAuthoritiesIsEmptyThenCreated() { + DefaultOAuth2User user = DefaultOAuth2User.withUsername("testUser") + .authorities(Collections.emptySet()) + .attributes(ATTRIBUTES) + .build(); + + assertThat(user.getName()).isEqualTo("testUser"); + assertThat(user.getAuthorities()).isEmpty(); + assertThat(user.getAttributes()).isEqualTo(ATTRIBUTES); + } + + @Test + public void withUsernameWhenNestedAttributesThenUsernameExtractedCorrectly() { + Map 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 e7386db77a3..82c4c1a793f 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 @@ * Tests for {@link OAuth2UserAuthority}. * * @author Joe Grandja + * @author Yoobin Yoon */ public class OAuth2UserAuthorityTests { @@ -94,4 +95,37 @@ public void hashCodeIsSameRegardlessOfUrlType() { assertThat(AUTHORITY_WITH_STRINGURL.hashCode()).isEqualTo(AUTHORITY_WITH_OBJECTURL.hashCode()); } + @Test + public void withUsernameWhenUsernameIsNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> OAuth2UserAuthority.withUsername(null)); + } + + @Test + public void withUsernameWhenUsernameIsEmptyThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> OAuth2UserAuthority.withUsername("")); + } + + @Test + public void builderWhenAttributesIsNotSetThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> OAuth2UserAuthority.withUsername("john_doe").build()); + } + + @Test + public void builderWhenAllParametersProvidedAndValidThenCreated() { + String username = "john_doe"; + OAuth2UserAuthority authority = OAuth2UserAuthority.withUsername(username).attributes(ATTRIBUTES).build(); + + assertThat(authority.getUsername()).isEqualTo(username); + assertThat(authority.getAuthority()).isEqualTo("OAUTH2_USER"); + assertThat(authority.getAttributes()).isEqualTo(ATTRIBUTES); + } + + @Test + public void getUsernameWhenBuiltWithUsernameThenReturnsUsername() { + String username = "john_doe"; + OAuth2UserAuthority authority = OAuth2UserAuthority.withUsername(username).attributes(ATTRIBUTES).build(); + + assertThat(authority.getUsername()).isEqualTo(username); + } + }