OIDC Federation with External Providers: Configuration and Account Linking
OIDC Federation with External Providers
The Assumption
Social login is solved. Add the Google client ID, configure the redirect, and users can sign in. The assumption: if the provider authenticates the user, the user is who they claim to be in your system.
The provider authenticates the user in the provider’s context. The mapping to your system’s user identity is your problem. And the most common mapping (email address) is an attack vector.
The Attack
Account takeover via unverified email. The SaaS platform links social accounts to internal accounts by email address. User Alice has an account with [email protected]. The platform supports login via Google, GitHub, and a permissive OIDC provider.
- Attacker registers at a permissive provider (one that does not require email verification) using
[email protected]. - Attacker signs in to the SaaS platform via this provider.
- Platform receives the OIDC response:
{email: "[email protected]", email_verified: false}. - Platform links this identity to Alice’s existing account because the email matches.
- Attacker now has access to Alice’s account, all her tenant data, and all her permissions.
The root cause: the platform treated email from the provider as verified identity without checking the email_verified claim.
Pre-registration account squatting. An attacker registers first using the target’s email at a provider that does not verify. When the real user tries to link their account later, they find it already linked to the attacker’s provider identity.
The Spec or Mechanism
OIDC Core Section 5.1 defines the email_verified claim:
True if the End-User’s e-mail address has been verified; otherwise false. When this Claim Value is true, this means that the OP took affirmative steps to ensure that this e-mail address was controlled by the End-User at the time the verification was performed.
The spec makes email_verified optional. Providers may omit it entirely. The safe default: treat a missing email_verified as false.
Account linking strategies:
- Provider subject only (no email linking). Each provider account is a separate identity. Users manually link accounts from settings. Safest but worst UX.
- Verified email linking. Link by email only when
email_verified: truefrom a trusted provider. Unverified emails create new accounts. - Automatic email linking (vulnerable). Link by email regardless of verification. Account takeover risk.
The Implementation
Custom OIDC User Service with Email Verification
// VULNERABLE: Links account by email without checking verification
@Component
public class VulnerableOidcUserService implements OAuth2UserService<OidcUserRequest, OidcUser> {
private final UserRepository userRepository;
private final OidcUserService delegate = new OidcUserService();
@Override
public OidcUser loadUser(OidcUserRequest userRequest) {
OidcUser oidcUser = delegate.loadUser(userRequest);
String email = oidcUser.getEmail();
// DANGER: No email_verified check
User user = userRepository.findByEmail(email)
.orElseGet(() -> createUser(email, oidcUser));
return new CustomOidcUser(user, oidcUser);
}
}
// HARDENED: Checks email_verified before linking
@Component
public class HardenedOidcUserService implements OAuth2UserService<OidcUserRequest, OidcUser> {
private final UserRepository userRepository;
private final FederatedIdentityRepository identityRepository;
private final OidcUserService delegate = new OidcUserService();
@Override
public OidcUser loadUser(OidcUserRequest userRequest) {
OidcUser oidcUser = delegate.loadUser(userRequest);
String registrationId = userRequest.getClientRegistration().getRegistrationId();
String providerSubject = oidcUser.getSubject();
// Step 1: Check for existing federated identity link
Optional<FederatedIdentity> existingLink = identityRepository
.findByProviderAndProviderSubject(registrationId, providerSubject);
if (existingLink.isPresent()) {
User user = existingLink.get().getUser();
return new CustomOidcUser(user, oidcUser);
}
// Step 2: Attempt email-based linking ONLY if email is verified
String email = oidcUser.getEmail();
Boolean emailVerified = oidcUser.getEmailVerified();
if (email != null && Boolean.TRUE.equals(emailVerified)) {
Optional<User> existingUser = userRepository.findByEmail(email);
if (existingUser.isPresent()) {
FederatedIdentity link = new FederatedIdentity();
link.setUser(existingUser.get());
link.setProvider(registrationId);
link.setProviderSubject(providerSubject);
link.setLinkedAt(Instant.now());
identityRepository.save(link);
return new CustomOidcUser(existingUser.get(), oidcUser);
}
}
// Step 3: No existing link, no verified email match. Create new account.
User newUser = new User();
newUser.setEmail(email);
newUser.setEmailVerified(Boolean.TRUE.equals(emailVerified));
newUser.setDisplayName(oidcUser.getFullName());
newUser.setCreatedAt(Instant.now());
userRepository.save(newUser);
FederatedIdentity link = new FederatedIdentity();
link.setUser(newUser);
link.setProvider(registrationId);
link.setProviderSubject(providerSubject);
link.setLinkedAt(Instant.now());
identityRepository.save(link);
return new CustomOidcUser(newUser, oidcUser);
}
}
Federated Identity Entity
@Entity
@Table(name = "federated_identities",
uniqueConstraints = @UniqueConstraint(
columns = {"provider", "provider_subject"}))
public class FederatedIdentity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Column(nullable = false)
private String provider;
@Column(name = "provider_subject", nullable = false)
private String providerSubject;
@Column(name = "linked_at", nullable = false)
private Instant linkedAt;
}
Enterprise Tenant Mapping
For B2B tenants using their own identity provider:
@Component
public class TenantProviderMapping {
private final Map<String, String> providerToTenant = Map.of(
"okta-acme", "acme-corp",
"azure-globex", "globex-inc",
"okta-initech", "initech"
);
public String resolveTenant(String registrationId) {
return providerToTenant.get(registrationId);
}
public boolean isEnterprise(String registrationId) {
return providerToTenant.containsKey(registrationId);
}
}
// In the OIDC User Service: assign tenant based on provider
if (tenantProviderMapping.isEnterprise(registrationId)) {
String tenantId = tenantProviderMapping.resolveTenant(registrationId);
newUser.setTenantId(tenantId);
newUser.setTenantRole("USER");
}
Authentication Success Handler with Tenant Context
@Component
public class FederatedAuthenticationSuccessHandler
implements AuthenticationSuccessHandler {
private final TokenService tokenService;
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response, Authentication authentication)
throws IOException {
CustomOidcUser oidcUser = (CustomOidcUser) authentication.getPrincipal();
User user = oidcUser.getInternalUser();
// Issue platform tokens with tenant context
TokenPair tokens = tokenService.issueTokens(
user.getId(), user.getTenantId(), user.getTenantRole());
// Set session cookie for browser clients
response.sendRedirect("/dashboard?login=success");
}
}
The Verification
@SpringBootTest
class FederatedLoginTest {
@Autowired
private UserRepository userRepository;
@Autowired
private FederatedIdentityRepository identityRepository;
@Autowired
private HardenedOidcUserService oidcUserService;
@Test
void unverifiedEmailDoesNotLinkToExistingAccount() {
User alice = new User();
alice.setEmail("[email protected]");
alice.setEmailVerified(true);
userRepository.save(alice);
OidcUserRequest request = createOidcRequest("evil-provider",
"[email protected]", false);
OidcUser result = oidcUserService.loadUser(request);
assertThat(userRepository.count()).isEqualTo(2); // New account, not linked
}
@Test
void verifiedEmailLinksToExistingAccount() {
User alice = new User();
alice.setEmail("[email protected]");
alice.setEmailVerified(true);
userRepository.save(alice);
OidcUserRequest request = createOidcRequest("google",
"[email protected]", true);
OidcUser result = oidcUserService.loadUser(request);
assertThat(userRepository.count()).isEqualTo(1); // Same account
assertThat(identityRepository.count()).isEqualTo(1);
}
@Test
void missingEmailVerifiedTreatedAsFalse() {
User alice = new User();
alice.setEmail("[email protected]");
alice.setEmailVerified(true);
userRepository.save(alice);
OidcUserRequest request = createOidcRequest("sketchy-provider",
"[email protected]", null);
OidcUser result = oidcUserService.loadUser(request);
assertThat(userRepository.count()).isEqualTo(2); // Separate account
}
@Test
void sameProviderSubjectAlwaysMapsToSameUser() {
OidcUserRequest firstLogin = createOidcRequest("github",
"[email protected]", true);
oidcUserService.loadUser(firstLogin);
OidcUserRequest secondLogin = createOidcRequest("github",
"[email protected]", true);
oidcUserService.loadUser(secondLogin);
assertThat(userRepository.count()).isEqualTo(1);
}
@Test
void enterpriseProviderAssignsTenant() {
OidcUserRequest request = createOidcRequest("okta-acme",
"[email protected]", true);
OidcUser result = oidcUserService.loadUser(request);
User created = userRepository.findAll().get(0);
assertThat(created.getTenantId()).isEqualTo("acme-corp");
}
}
The first test is the account takeover prevention test. If someone removes the email_verified check, this test catches the vulnerability.