Skip to content

Add SpEL support for nested username extraction in OAuth2 user info #16857

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -45,7 +46,7 @@ abstract class DefaultOAuth2UserMixin {
@JsonCreator
DefaultOAuth2UserMixin(@JsonProperty("authorities") Collection<? extends GrantedAuthority> authorities,
@JsonProperty("attributes") Map<String, Object> attributes,
@JsonProperty("nameAttributeKey") String nameAttributeKey) {
@JsonProperty("nameAttributeKey") String nameAttributeKey, @JsonProperty("username") String username) {
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
*
* @author Joe Grandja
* @author Michael Sosa
* @author Yoobin Yoon
* @since 5.0
* @see <a target="_blank" href="https://tools.ietf.org/html/rfc6749#section-2">Section 2
* Client Registration</a>
Expand Down Expand Up @@ -299,8 +300,11 @@ public class UserInfoEndpoint implements Serializable {

private AuthenticationMethod authenticationMethod = AuthenticationMethod.HEADER;

@Deprecated
private String userNameAttributeName;

private String usernameExpression;

UserInfoEndpoint() {
}

Expand All @@ -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;
}

}

}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<String, Object> configurationMetadata = clientRegistration.providerDetails.configurationMetadata;
Expand Down Expand Up @@ -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.
*
* <p>
* This is a convenience method that internally calls
* {@link #usernameExpression(String)} with the attribute name wrapped in bracket
* notation.
* @param userNameAttributeName the username attribute name
* @return the {@link Builder}
*/
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.
*
* <p>
* Examples:
* <ul>
* <li>Simple attribute: {@code "['username']"} or {@code "username"}</li>
* <li>Nested attribute: {@code "data.username"}</li>
* <li>Complex expression: {@code "user_info?.name ?: 'anonymous'"}</li>
* <li>Array access: {@code "users[0].name"}</li>
* <li>Conditional:
* {@code "preferred_username != null ? preferred_username : email"}</li>
* </ul>
* @param usernameExpression the SpEL expression for username extraction
* @return the {@link Builder}
* @since 6.5
*/
public Builder usernameExpression(String usernameExpression) {
this.usernameExpression = usernameExpression;
return this;
}

Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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;
Expand All @@ -47,16 +51,17 @@
* An implementation of an {@link OAuth2UserService} that supports standard OAuth 2.0
* Provider's.
* <p>
* 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()}.
* <p>
* <b>NOTE:</b> Attribute names are <b>not</b> standardized between providers and
* therefore will vary. Please consult the provider's API documentation for the set of
* supported user attribute names.
*
* @author Joe Grandja
* @author Yoobin Yoon
* @since 5.0
* @see OAuth2UserService
* @see OAuth2UserRequest
Expand All @@ -71,6 +76,10 @@ public class DefaultOAuth2UserService implements OAuth2UserService<OAuth2UserReq

private static final String INVALID_USER_INFO_RESPONSE_ERROR_CODE = "invalid_user_info_response";

private static final String INVALID_USERNAME_EXPRESSION_ERROR_CODE = "invalid_username_expression";

private static final ExpressionParser expressionParser = new SpelExpressionParser();

private static final ParameterizedTypeReference<Map<String, Object>> PARAMETERIZED_RESPONSE_TYPE = new ParameterizedTypeReference<>() {
};

Expand All @@ -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<Map<String, Object>> response = getResponse(userRequest, request);
OAuth2AccessToken token = userRequest.getAccessToken();
Map<String, Object> attributes = this.attributesConverter.convert(userRequest).convert(response.getBody());
Collection<GrantedAuthority> authorities = getAuthorities(token, attributes, userNameAttributeName);
return new DefaultOAuth2User(authorities, attributes, userNameAttributeName);

String evaluatedUsername = evaluateUsername(attributes, usernameExpression);

Collection<GrantedAuthority> authorities = getAuthorities(token, attributes, evaluatedUsername);

return DefaultOAuth2User.withUsername(evaluatedUsername)
.authorities(authorities)
.attributes(attributes)
.build();
}

private String getUsernameExpression(OAuth2UserRequest userRequest) {
if (!StringUtils
.hasText(userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri())) {
OAuth2Error oauth2Error = new OAuth2Error(MISSING_USER_INFO_URI_ERROR_CODE,
"Missing required UserInfo Uri in UserInfoEndpoint for Client Registration: "
+ userRequest.getClientRegistration().getRegistrationId(),
null);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
String usernameExpression = userRequest.getClientRegistration()
.getProviderDetails()
.getUserInfoEndpoint()
.getUsernameExpression();
if (!StringUtils.hasText(usernameExpression)) {
OAuth2Error oauth2Error = new OAuth2Error(MISSING_USER_NAME_ATTRIBUTE_ERROR_CODE,
"Missing required \"user name\" attribute name in UserInfoEndpoint for Client Registration: "
+ userRequest.getClientRegistration().getRegistrationId(),
null);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
return usernameExpression;
}

private String evaluateUsername(Map<String, Object> attributes, String usernameExpression) {
Object value = null;

try {
SimpleEvaluationContext context = SimpleEvaluationContext.forPropertyAccessors(new MapAccessor())
.withRootObject(attributes)
.build();
value = expressionParser.parseExpression(usernameExpression).getValue(context);
}
catch (Exception ex) {
OAuth2Error oauth2Error = new OAuth2Error(INVALID_USERNAME_EXPRESSION_ERROR_CODE,
"Invalid username expression or SPEL expression: " + usernameExpression, null);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString(), ex);
}

if (value == null) {
OAuth2Error oauth2Error = new OAuth2Error(INVALID_USER_INFO_RESPONSE_ERROR_CODE,
"An error occurred while attempting to retrieve the UserInfo Resource: username cannot be null",
null);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
return value.toString();
}

/**
Expand Down Expand Up @@ -164,33 +227,11 @@ private ResponseEntity<Map<String, Object>> 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<GrantedAuthority> getAuthorities(OAuth2AccessToken token, Map<String, Object> attributes,
String userNameAttributeName) {
String username) {
Collection<GrantedAuthority> 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));
}
Expand Down
Loading
Loading