Osteen Omega
Osteen Omega

Reputation: 13

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();
    }
}

Upvotes: 0

Views: 82

Answers (1)

Roar S.
Roar S.

Reputation: 11099

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_ENCODING, MediaType.APPLICATION_JSON_VALUE)
                .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);
    }
}

Upvotes: 0

Related Questions