CustomOAuth2UserService.java
package com.smartsupplypro.inventory.service;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
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.core.user.DefaultOAuth2User;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
import com.smartsupplypro.inventory.model.AppUser;
import com.smartsupplypro.inventory.model.Role;
import com.smartsupplypro.inventory.repository.AppUserRepository;
/**
* OAuth2 user service for social login with automatic role assignment.
*
* <p><strong>Characteristics</strong>:
* <ul>
* <li><strong>Auto-Provisioning</strong>: Creates local user on first OAuth2 login</li>
* <li><strong>Role Assignment</strong>: ADMIN via APP_ADMIN_EMAILS env var, otherwise USER</li>
* <li><strong>Email-Based Identity</strong>: Uses email as principal for security context</li>
* <li><strong>Role Healing</strong>: Updates role if allow-list changes</li>
* </ul>
*
* <p><strong>Configuration</strong>:
* Set {@code APP_ADMIN_EMAILS} environment variable with comma-separated admin emails.
*
* @see AppUser
* @see CustomOidcUserService
*/
@Service
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final AppUserRepository userRepository;
public CustomOAuth2UserService(AppUserRepository userRepository) {
this.userRepository = userRepository;
}
/**
* Reads admin email allow-list from APP_ADMIN_EMAILS environment variable.
* @return set of lowercase admin emails
*/
private static Set<String> readAdminAllowlist() {
String raw = System.getenv().getOrDefault("APP_ADMIN_EMAILS", "");
if (raw == null || raw.isBlank()) return Collections.emptySet();
return Arrays.stream(raw.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.map(String::toLowerCase)
.collect(Collectors.toCollection(LinkedHashSet::new));
}
/**
* Normalizes role name to Spring Security ROLE_* authority format.
* @param roleName role name
* @return ROLE_* prefixed authority
*/
private static String toRoleAuthority(String roleName) {
if (roleName == null || roleName.isBlank()) return "ROLE_USER";
return roleName.startsWith("ROLE_") ? roleName : "ROLE_" + roleName;
}
/**
* Loads OAuth2 user and provisions/updates local user with role assignment.
* @param request OAuth2 user request
* @return OAuth2 user with ROLE_* authorities
* @throws OAuth2AuthenticationException if email not provided
*/
@Override
public OAuth2User loadUser(OAuth2UserRequest request) throws OAuth2AuthenticationException {
// Enterprise Comment: OAuth2 User Loading
// Delegates to default service for upstream provider communication
OAuth2User oauthUser = new DefaultOAuth2UserService().loadUser(request);
final String email = oauthUser.getAttribute("email");
final String name = oauthUser.getAttribute("name");
if (email == null || email.isBlank()) {
throw new OAuth2AuthenticationException("Email not provided by OAuth2 provider.");
}
// Enterprise Comment: Environment-Based Role Assignment
// Admin emails configured via APP_ADMIN_EMAILS for operational flexibility
// Decide role from env allow-list (minimal change; no AppProperties wiring needed)
final boolean isAdmin = readAdminAllowlist().contains(email.toLowerCase());
// Enterprise Comment: Auto-Provisioning Pattern
// Creates local user on first login with race condition handling
// Find or create local user
AppUser user = userRepository.findByEmail(email).orElseGet(() -> {
AppUser u = new AppUser(); // use no-arg ctor; id stays a UUID
u.setEmail(email);
u.setName((name == null || name.isBlank()) ? email : name);
u.setRole(isAdmin ? Role.ADMIN : Role.USER);
u.setCreatedAt(LocalDateTime.now());
try {
return userRepository.save(u);
} catch (DataIntegrityViolationException e) {
// If unique(email) tripped, fetch the existing row by EMAIL
return userRepository.findByEmail(email).orElseThrow(() -> e);
}
});
// Enterprise Comment: Role Healing Pattern
// Updates role dynamically if allow-list changes (idempotent operation)
// Heal role if the allow-list changed since last login (idempotent)
final Role desired = isAdmin ? Role.ADMIN : Role.USER;
if (user.getRole() != desired) {
user.setRole(desired);
userRepository.save(user);
}
// Build ROLE_* authority so hasRole(...) checks keep working
final String roleName = user.getRole().name();
final SimpleGrantedAuthority roleAuthority = new SimpleGrantedAuthority(toRoleAuthority(roleName));
// Copy provider attributes and add a helpful "appRole" for the frontend
Map<String, Object> attributes = new HashMap<>(oauthUser.getAttributes());
attributes.put("appRole", roleName);
return new DefaultOAuth2User(Collections.singletonList(roleAuthority), attributes, "email");
}
}