Spring无法从Entra的用户请求中获取首选用户名

前端开发 2026-07-11

Spring Boot在对Entra进行身份验证时,无法找到preferred_username属性。

我已经在我们的Entra租户中注册了一个应用,情况如下:

Optional Claim - add 'acct' and 'preferred_username'

API permissions - add Microsoft Graph email, openid, profile with type: Delegated

Not relevant, but we also have App roles configured

Spring is configured as follows:

spring:
  security:
    oauth2:
      client:
        registration:
          entra:
            authorization-grant-type: authorization_code
            scope: openid,profile,email
            client-id: ${ENTRA_CLIENT_ID}
            client-secret: ${ENTRA_CLIENT_SECRET}
        provider:
          entra:
            tenantId: ${ENTRA_TENANT_ID}
            issuer-uri: https://login.microsoftonline.com/${spring.security.oauth2.client.provider.entra.tenantId}/v2.0
            user-name-attribute: preferred_username

      resourceserver:
        jwt:
          issuer-uri: https://login.microsoftonline.com/${spring.security.oauth2.client.provider.entra.tenantId}/v2.0

Here's how I have Spring Security set up:

    @Bean
    public SecurityFilterChain filterChainWithOAuth2Security(HttpSecurity http) throws Exception {
        configureStandardHttpSecurity(http)
            .oauth2Login(
                    oauth -> {
                        oauth.userInfoEndpoint(
                            userInfo -> {
                                userInfo.userAuthoritiesMapper(userAuthoritiesMapperForEntra());
                            });
                    })
            .oauth2ResourceServer(oauth -> {
                oauth.jwt(jwt -> {});
            });
        return http.build();    
    }

    @Bean
    public GrantedAuthoritiesMapper userAuthoritiesMapperForEntra() {
        return authorities -> {
            final String ROLES_CLAIM = "roles";

            Set<GrantedAuthority> mappedAuthorities = new HashSet<>();

            authorities.forEach(authority -> {
                    if (authority instanceof OidcUserAuthority) {
                        var claims = ((OidcUserAuthority) authority).getUserInfo().getClaims();
                    var roles = (Collection<String>) claims.get(ROLES_CLAIM);
                    mappedAuthorities.addAll(generateAuthoritiesFromClaim(roles));
                    }
            });

            log.debug("Got authorities from OAuth2: " + mappedAuthorities);
            return mappedAuthorities;
        };
    }

    private Collection<GrantedAuthority> generateAuthoritiesFromClaim(Collection<String> roles) {
        return roles.stream().map(role -> new SimpleGrantedAuthority("ROLE_" + role)).collect(Collectors.toList());
    }

I've checked the token returned from Entra, and it does contain the preferred_username claim.

However, Spring's OAuth2UserRequestEntityConverter makes another call to Entra (using the providerDetails.userInfoEndpoint which is https://graph.microsoft.com/oidc/userinfo) to retrieve the user information. That user information does not include the preferred_username, so Spring chokes.

Is this just a "tough, deal with it" situation - i.e. I need to set user-name-attribute to something that is returned by the userinfo endpoint (e.g. 'name' or 'email')? Or is there something that I'm doing wrong?


Update

In the end, I wound up setting the user-name-attribute to 'email', using defaults for the security setup:

        configureStandardHttpSecurity(http)
            .oauth2Login(Customizer.withDefaults())
            .oauth2ResourceServer(oauth -> {
                oauth.jwt(Customizer.withDefaults());
            })
            ;

and registered a OidcUserService to fix up the roles claim to be compatible with Spring's authorities:

public class EntraOidcSchedulingUserService extends OidcUserService{

    @Override
    public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException {
        OidcUser oidcUser = super.loadUser(userRequest);

        // Extract roles from the Entra ID 'roles' claim
        Collection<GrantedAuthority> authorities = new HashSet<>();
        if (oidcUser.getClaims().containsKey("roles")) {
            List<String> roles = (List<String>) oidcUser.getClaims().get("roles");
            for (String role : roles) {
                authorities.add(new SimpleGrantedAuthority("ROLE_" + role));
            }
        }

        // Return a new OidcUser with the fixed up authorities
        return new MyOidcUser(oidcUser, authorities);
    }

Not sure if this is best practices, but it's working.

解决方案

Not sure if this is best practices, but it's working.

As I wrote many times here and in my tutorials like this Baeldung article (that you should read carefully), no, it is not a best practice to configure a single SecurityFilterChain with oauth2Login and oauth2ResourceServer. The 1st is stateful (based on sessions) and the 2nd stateless (no session, security context built for each request from an access token).

As you are sending requests with a browser, only the oauth2Login configuration is useful. Browsers natively only know how to handle session cookies, not OAuth2 tokens and flows, and anyway, it's your Spring backend that is configured as an OAuth2 (confidential) client and is issued tokens.

For a SPA (single-page application) in the browser to be the OAuth2 (public) client, it would have to drive the OAuth2 flows (authorization-code and refresh-token) and to store tokens. But, as explained in the article linked above, this is unsafe.

However, Spring's OAuth2UserRequestEntityConverter makes another call to Entra (using the providerDetails.userInfoEndpoint ...)

You may configure oauth2Login with your own OAuth2UserService<OidcUserRequest, OidcUser> using only the ID token. Actually, you should to save the useless network call to the userinfo endpoint.

OAuth2UserService<OidcUserRequest, OidcUser> userService() {
  return (OidcUserRequest userRequest) -> {
    var idToken = userRequest.getIdToken();
    var roles = (List<String>) idToken.getClaims().get("roles");
    var authorities = roles.stream().map(r -> new SimpleGrantedAuthority("ROLE_" + r)).toList();
    return new DefaultOidcUser(authorities, idToken, StandardClaimNames.PREFERRED_USERNAME);
  };
}

with:

http.oauth2Login(client -> {
  client.userInfoEndpoint(userInfo -> {
    userInfo.oidcUserService(userService());
  });
});

But if I were you, I would not configure a REST API with oauth2Login. As described in the article linked above, I'd configure it as an oauth2ResourceServer with a BFF in front to avoid configuring your SPA as a public client.

站内所有文章版权归属LeftHeroAI导航站,无授权禁止任何主体转载、抄袭、复制内容,亦不得私自架设镜像站点。一经侵权,本站将通过法律途径追责。

相关文章