CustomOidcUserService.java
package com.smartsupplypro.inventory.service;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.stream.Collectors;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.stereotype.Service;
import com.smartsupplypro.inventory.model.AppUser;
import com.smartsupplypro.inventory.model.Role;
import com.smartsupplypro.inventory.repository.AppUserRepository;
/**
* OIDC user service for OpenID Connect providers with automatic role assignment.
*
* <p><strong>Characteristics</strong>:
* <ul>
* <li><strong>OIDC Support</strong>: Handles OpenID Connect flow (e.g., Google with openid scope)</li>
* <li><strong>Auto-Provisioning</strong>: Creates local user on first OIDC 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>Why Separate from OAuth2UserService</strong>:
* OIDC requires {@code OAuth2UserService<OidcUserRequest, OidcUser>} parametrization.
* Without this, role mapping ({@code ROLE_ADMIN}/{@code ROLE_USER}) wouldn't apply to OIDC logins.
*
* <p><strong>Configuration</strong>:
* Set {@code APP_ADMIN_EMAILS} environment variable with comma-separated admin emails.
*
* @see AppUser
* @see CustomOAuth2UserService
*/
@Service
public class CustomOidcUserService implements OAuth2UserService<OidcUserRequest, OidcUser> {
private final AppUserRepository userRepository;
public CustomOidcUserService(AppUserRepository userRepository) {
this.userRepository = userRepository;
}
/**
* Reads admin 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 to Spring Security authority format.
*
* @param role user role
* @return ROLE_* authority string
*/
private static String toRoleAuthority(Role role) {
String name = (role == null) ? "USER" : role.name();
return name.startsWith("ROLE_") ? name : "ROLE_" + name;
}
/**
* Loads OIDC user and assigns ROLE_ADMIN or ROLE_USER based on APP_ADMIN_EMAILS.
*
* @param request OIDC user request
* @return OIDC user with role-based authority
* @throws OAuth2AuthenticationException if email missing or user creation fails
*/
@Override
public OidcUser loadUser(OidcUserRequest request) throws OAuth2AuthenticationException {
// Enterprise Comment: OIDC User Loading
// Delegate to OidcUserService to handle ID token validation and claims extraction.
// This ensures OpenID Connect standards (JWT signature, issuer validation, nonce) are enforced.
OidcUser oidc = new OidcUserService().loadUser(request);
final String email = oidc.getEmail(); // same as oidc.getAttribute("email")
final String name = oidc.getFullName(); // falls back to "name" claim if present
if (email == null || email.isBlank()) {
throw new OAuth2AuthenticationException("Email not provided by OAuth2 provider.");
}
// Enterprise Comment: Environment-Based Role Assignment
// APP_ADMIN_EMAILS allows zero-downtime role changes without code deployment.
// Example: APP_ADMIN_EMAILS=admin@corp.com,manager@corp.com
final boolean isAdmin = readAdminAllowlist().contains(email.toLowerCase());
// Enterprise Comment: Auto-Provisioning Pattern
// Create user on first login with role based on email allow-list.
// Race condition handling: If concurrent login creates duplicate, re-fetch by email.
AppUser user = userRepository.findByEmail(email).orElseGet(() -> {
AppUser u = new AppUser(); // let JPA manage UUID id
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) {
// Another request created it concurrently, or it already existed -> re-fetch by EMAIL
return userRepository.findByEmail(email).orElseThrow(() -> e);
}
});
// Enterprise Comment: Role Healing Pattern
// Update role if APP_ADMIN_EMAILS changed since last login (idempotent operation).
final Role desired = isAdmin ? Role.ADMIN : Role.USER;
if (user.getRole() != desired) {
user.setRole(desired);
userRepository.save(user);
}
// Merge ROLE_* into authorities so hasRole(...) works across the app
var authorities = new java.util.ArrayList<GrantedAuthority>(oidc.getAuthorities());
authorities.add(new SimpleGrantedAuthority(toRoleAuthority(user.getRole())));
// Prefer "email" as the principal name (so logs & SecurityContext name show the email)
return new DefaultOidcUser(
authorities,
oidc.getIdToken(),
oidc.getUserInfo(),
"email"
);
}
}