javaspring-bootauthenticationspring-security

Spring Boot 3 OAuth2 GitHub Login: Email Attribute Returns Null Even After Setting Scope and Public email


I’m implementing OAuth2 login in my Spring Boot 3 application using GitHub as an authentication provider. However, I’m unable to retrieve the user's email from GitHub—it always returns null. i'm building a job board application and i want users to first register with email and password, or with github, and they'll be directed to the onboarding page where they'll input details such as usertype-recruiter or jobseeker etc.

What I've Tried Configured application.yml with the correct scope:

yaml
Copy
Edit
security:
  oauth2:
    client:
      registration:
        github:
          client-id: xxx
          client-secret: xxx
          redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
          scope: user:email

Checked GitHub Profile Settings:

My email is set to "Public" in GitHub. I've Logged OAuth2 Attributes: The email field is missing (null), but other details like name and id are available. Confirmed GitHub Provides Email via API:

My Questions Why is GitHub’s OAuth2 response not including the email, even when my email is public? Is there a way to configure Spring Security to fetch the email directly, or do I need a separate API request?

Here is how I've tried to fetch user details from the access token

@Service
@RequiredArgsConstructor
public class CustomOauth2UserService extends DefaultOAuth2UserService {
    private static final Logger log = LoggerFactory.getLogger(CustomOauth2UserService.class);
    private final UserConnectedAccountRepository userConnectedAccountRepository;
    private final UserRepository userRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest);
        String provider = userRequest.getClientRegistration().getRegistrationId();
        String providerId = oAuth2User.getName();
        String email = oAuth2User.getAttribute("email");
        oAuth2User.getAttributes().forEach((key, value) -> log.info("key {}, value {}", key, value));

        Optional<UserConnectedAccount> userConnectedAccount = userConnectedAccountRepository.findByProviderIdAndProvider(providerId, provider);
        if (userConnectedAccount.isEmpty()) {
            userRepository.findUserByEmail(email)
                    .ifPresentOrElse(user -> connectAccount(providerId, provider, user),
                            () -> createUser(providerId, provider, oAuth2User)
                    );
        }
        return new DefaultOAuth2User(
                Collections.singleton(new SimpleGrantedAuthority(null)),
                oAuth2User.getAttributes(),
                "email"
        );
    }

    public void createUser(String providerId, String provider, OAuth2User oAuth2User) {
        AppUser appUser = new AppUser(oAuth2User);
        appUser = userRepository.save(appUser);
        connectAccount(providerId, provider, appUser);
    }

    private void connectAccount(String providerId, String provider, AppUser appUser) {
        UserConnectedAccount newUserConnectedAccount = new UserConnectedAccount(providerId, provider, appUser);
        userConnectedAccountRepository.save(newUserConnectedAccount);
    }
}

and here is my security configuration. I'm using session based authentication.

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {
    private final UserService userService;
    private final PasswordEncoder passwordEncoder;
    private final GlobalAuthenticationEntryPoint globalAuthenticationEntryPoint;
    private final Oauth2LoginSuccessHandler oauth2LoginSuccessHandler;
    private final CustomOauth2UserService customOauth2UserService;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(request -> request
                .requestMatchers("/api/v1/auth/**", "/login/**", "/**", "/oauth2/authorization/**").permitAll()
                .anyRequest().authenticated());
        http.csrf(AbstractHttpConfigurer::disable)
                .cors(AbstractHttpConfigurer::disable)
                .oauth2Login(oauth2 -> oauth2
                        .userInfoEndpoint(userInfo -> userInfo.userService(customOauth2UserService))
                        .successHandler(oauth2LoginSuccessHandler)
                );

        http.exceptionHandling(customizer ->
                customizer.authenticationEntryPoint(globalAuthenticationEntryPoint)
        );

        return http.build();
    }

    @Bean
    public AuthenticationManager authenticationManager() {
        var authenticationProvider = new DaoAuthenticationProvider(passwordEncoder);
        authenticationProvider.setUserDetailsService(userService);
        return new ProviderManager(authenticationProvider);
    }

    @Bean
    public SecurityContextRepository SecurityContextRepository() {
        return new HttpSessionSecurityContextRepository();
    }

    @Bean
    public SecurityContextLogoutHandler securityContextLogoutHandler() {
        return new SecurityContextLogoutHandler();
    }
}

Solution

  • TL;DR

    We need to fetch email addresses from GitHub, identify the primary email address, and add it to the OAuth2User if present. A custom OAuth2UserService<OAuth2UserRequest, OAuth2User> is responsible for handling this flow. Please note that scope = "user:email" is required.

    Code is verified with Spring Boot 3.4.3.

    RestClientConfig

    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    
    import org.springframework.web.client.RestClient;
    
    @Configuration
    public class RestClientConfig {
    
        @Bean
        public RestClient restClient(RestClient.Builder builder) {
            return builder.build();
        }
    }
    

    GitHubEmailFetcher

    import org.springframework.core.ParameterizedTypeReference;
    import org.springframework.http.HttpHeaders;
    import org.springframework.http.MediaType;
    import org.springframework.stereotype.Service;
    import org.springframework.web.client.RestClient;
    
    import java.util.List;
    
    @Service
    public record GitHubEmailFetcher(RestClient restClient) {
        private static final String EMAILS_URL = "https://api.github.com/user/emails";
        private static final String BEARER_PREFIX = "Bearer ";
    
        public String fetchPrimaryEmailAddress(String token) {
    
            List<GitHubEmailVm> emailVmList = restClient
                    .get()
                    .uri(EMAILS_URL)
                    .header(HttpHeaders.AUTHORIZATION, BEARER_PREFIX + token)
                    .header(HttpHeaders.ACCEPT, "application/vnd.github+json")
                    .retrieve()
                    .body(new ParameterizedTypeReference<>() {
                    });
    
            if (emailVmList == null || emailVmList.isEmpty()) {
                return null;
            }
    
            return emailVmList.stream()
                    .filter(GitHubEmailVm::primary)
                    .findFirst()
                    .map(GitHubEmailVm::email)
                    .orElse(null);
        }
    
        private record GitHubEmailVm(String email, Boolean primary) {
        }
    }
    

    CustomOAuth2UserService

    This class is equivalent to your CustomOauth2UserService, but instead of using inheritance, I’m opting for composition (favoring a "has-a" relationship over an "is-a" relationship).

    import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
    import org.springframework.security.oauth2.core.user.OAuth2User;
    import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
    import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
    import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
    import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
    import org.springframework.stereotype.Component;
    
    import java.util.HashMap;
    import java.util.Map;
    
    @Component
    public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
    
        // "login" is default for GitHub, change to "email" if that's what you want
        private static final String NAME_ATTRIBUTE = "login";
        private static final String EMAIL_KEY = "email";
    
        private final GitHubEmailFetcher emailFetcher;
        private final OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
    
        public CustomOAuth2UserService(GitHubEmailFetcher emailFetcher) {
            this.emailFetcher = emailFetcher;
        }
    
        @Override
        public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
            OAuth2User oauth2User = delegate.loadUser(userRequest);
    
            String primaryEmailAddress = extractPrimaryEmailAddress(
                    oauth2User,
                    userRequest.getAccessToken().getTokenValue());
    
            // return oauth2User if primaryEmailAddress is null
            // alternative: Throw exception
            if (primaryEmailAddress == null) {
                return oauth2User;
            }
    
            // insert your createUser code here
    
            // Clone the original attributes into a mutable map
            Map<String, Object> updatedAttributes = new HashMap<>(oauth2User.getAttributes());
    
            // Add the fetched email to the attributes map
            updatedAttributes.put(EMAIL_KEY, primaryEmailAddress);
    
            // Return a new DefaultOAuth2User with the updated attributes
            return new DefaultOAuth2User(
                    oauth2User.getAuthorities(), // or Collections.emptyList()
                    updatedAttributes,
                    NAME_ATTRIBUTE);
        }
    
        private String extractPrimaryEmailAddress(
                OAuth2User oauth2User,
                String token) {
            String primaryEmailAddress = oauth2User.getAttribute(EMAIL_KEY);
    
            if (!(primaryEmailAddress == null || primaryEmailAddress.isBlank())) {
                return primaryEmailAddress;
            }
    
            return emailFetcher.fetchPrimaryEmailAddress(token);
        }
    }